diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 395f45e..e9df189 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -325,8 +325,10 @@ "serviceOverdueDays": "Service overskredet med {days} d", "dueInDays": "Forfalder om {days} d", "okDays": "OK · {days} d", + "okIn": "OK · {parts}", "noKm": "Ingen km", "serviceOverdueKm": "Service overskredet med {km} km", + "serviceOverdueBy": "Service overskredet med {parts}", "inKm": "Om {km} km", "kmLeft": "{km} km tilbage", "expiredAgo": "Udløb for {days} d siden", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 7a0a659..4bef423 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -325,8 +325,10 @@ "serviceOverdueDays": "Service Overdue {days}d", "dueInDays": "Due in {days}d", "okDays": "OK · {days}d", + "okIn": "OK · {parts}", "noKm": "No km", "serviceOverdueKm": "Service Overdue {km} km", + "serviceOverdueBy": "Service Overdue {parts}", "inKm": "In {km} km", "kmLeft": "{km} km left", "expiredAgo": "Expired {days}d ago", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 781a4a3..2d9991c 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -329,8 +329,10 @@ "serviceOverdueDays": "Serwis zaległy {days} dni", "dueInDays": "Termin za {days} dni", "okDays": "OK · {days} dni", + "okIn": "OK · {parts}", "noKm": "Brak przebiegu", "serviceOverdueKm": "Serwis zaległy {km} km", + "serviceOverdueBy": "Serwis zaległy {parts}", "inKm": "Za {km} km", "kmLeft": "Pozostało {km} km", "expiredAgo": "Wygasło {days} dni temu", diff --git a/Phone App/lib/format.dart b/Phone App/lib/format.dart index 72ecc34..b7a8d2e 100644 --- a/Phone App/lib/format.dart +++ b/Phone App/lib/format.dart @@ -117,23 +117,40 @@ int _rank(StatusKey k) => switch (k) { StatusKey.overdue => 3, }; -Status _dateSignal(DateTime? nextDate) { - if (nextDate == null) return Status(StatusKey.unknown, t("status.noData")); +/// One of the two service triggers: its badge state and its own wording, plus +/// the number behind that wording so a badge holding both can quote the two +/// side by side. +typedef _Signal = ({StatusKey key, String label, int? value}); + +_Signal _dateSignal(DateTime? nextDate) { + if (nextDate == null) return (key: StatusKey.unknown, label: t("status.noData"), value: null); final today = DateTime.now(); final days = DateTime(nextDate.year, nextDate.month, nextDate.day) .difference(DateTime(today.year, today.month, today.day)) .inDays; - if (days < 0) return Status(StatusKey.overdue, t("status.serviceOverdueDays", params: {"days": days.abs()})); - if (days <= 30) return Status(StatusKey.soon, t("status.dueInDays", params: {"days": days})); - return Status(StatusKey.ok, t("status.okDays", params: {"days": days})); + if (days < 0) { + return (key: StatusKey.overdue, label: t("status.serviceOverdueDays", params: {"days": days.abs()}), value: days); + } + if (days <= 30) { + return (key: StatusKey.soon, label: t("status.dueInDays", params: {"days": days}), value: days); + } + return (key: StatusKey.ok, label: t("status.okDays", params: {"days": days}), value: days); } -Status _kmSignal(int currentKm, int? nextKm) { - if (nextKm == null) return Status(StatusKey.unknown, t("status.noKm")); +_Signal _kmSignal(int currentKm, int? nextKm) { + if (nextKm == null) return (key: StatusKey.unknown, label: t("status.noKm"), value: null); final remaining = nextKm - currentKm; - if (remaining < 0) return Status(StatusKey.overdue, t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())})); - if (remaining <= _kmSoon) return Status(StatusKey.soon, t("status.inKm", params: {"km": _num(remaining)})); - return Status(StatusKey.ok, t("status.kmLeft", params: {"km": _num(remaining)})); + if (remaining < 0) { + return ( + key: StatusKey.overdue, + label: t("status.serviceOverdueKm", params: {"km": _num(remaining.abs())}), + value: remaining, + ); + } + if (remaining <= _kmSoon) { + return (key: StatusKey.soon, label: t("status.inKm", params: {"km": _num(remaining)}), value: remaining); + } + return (key: StatusKey.ok, label: t("status.kmLeft", params: {"km": _num(remaining)}), value: remaining); } /// Maps the server's expiry/reminder state names onto the badge palette. The @@ -203,7 +220,32 @@ Status serviceStatus(ServiceRecord? latest, Car car) { final date = _dateSignal(latest?.nextServiceDate); final km = _kmSignal(car.currentKm, latest?.nextServiceKm); final worse = _rank(km.key) > _rank(date.key) ? km : date; - if (date.key == StatusKey.unknown && km.key != StatusKey.unknown) return km; - if (km.key == StatusKey.unknown && date.key != StatusKey.unknown) return date; - return worse; + + if (date.key == StatusKey.unknown && km.key == StatusKey.unknown) { + return Status(StatusKey.unknown, t("status.noData")); + } + // With one signal to go on, that signal's own sentence says it best. + if (date.key == StatusKey.unknown) return Status(km.key, km.label); + if (km.key == StatusKey.unknown) return Status(date.key, date.label); + return Status(worse.key, _bothSignals(date, km, worse.key)); +} + +/// Words a badge that has a due date AND an odometer target. A service falls +/// due on whichever arrives first, so "OK · 354d" on its own left out half of +/// what the badge is watching: the distance still to run belongs beside the +/// days. One headline for the severity, then each trigger as a bare quantity - +/// the shape reminderStatus already uses, it having had the two-trigger +/// problem first. +String _bothSignals(_Signal date, _Signal km, StatusKey key) { + final parts = []; + if (key == StatusKey.overdue) { + // Only what has actually passed. The other trigger is not late, and its + // comfortable remainder under an "Overdue" headline would read as one. + if (date.key == StatusKey.overdue) parts.add(t("status.days", params: {"days": date.value!.abs()})); + if (km.key == StatusKey.overdue) parts.add(t("status.km", params: {"km": _num(km.value!.abs())})); + return t("status.serviceOverdueBy", params: {"parts": parts.join(" · ")}); + } + parts.add(t("status.days", params: {"days": date.value})); + parts.add(t("status.km", params: {"km": _num(km.value!)})); + return t(key == StatusKey.soon ? "status.dueIn" : "status.okIn", params: {"parts": parts.join(" · ")}); } diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 9a59d4b..417d395 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -900,8 +900,10 @@ "serviceOverdueDays": "Service overskredet med {days} d", "dueInDays": "Forfalder om {days} d", "okDays": "OK · {days} d", + "okIn": "OK · {parts}", "noKm": "Ingen km", "serviceOverdueKm": "Service overskredet med {km} km", + "serviceOverdueBy": "Service overskredet med {parts}", "inKm": "Om {km} km", "kmLeft": "{km} km tilbage", "expiredAgo": "Udløb for {days} d siden", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index f10216e..d443d38 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -899,8 +899,10 @@ "serviceOverdueDays": "Service Overdue {days}d", "dueInDays": "Due in {days}d", "okDays": "OK · {days}d", + "okIn": "OK · {parts}", "noKm": "No km", "serviceOverdueKm": "Service Overdue {km} km", + "serviceOverdueBy": "Service Overdue {parts}", "inKm": "In {km} km", "kmLeft": "{km} km left", "expiredAgo": "Expired {days}d ago", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 493cd09..a8b0bd0 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -914,8 +914,10 @@ "serviceOverdueDays": "Serwis zaległy {days} dni", "dueInDays": "Termin za {days} dni", "okDays": "OK · {days} dni", + "okIn": "OK · {parts}", "noKm": "Brak przebiegu", "serviceOverdueKm": "Serwis zaległy {km} km", + "serviceOverdueBy": "Serwis zaległy {parts}", "inKm": "Za {km} km", "kmLeft": "Pozostało {km} km", "expiredAgo": "Wygasło {days} dni temu", diff --git a/Web App/web/src/lib/format.js b/Web App/web/src/lib/format.js index 5e6223b..a81d3cd 100644 --- a/Web App/web/src/lib/format.js +++ b/Web App/web/src/lib/format.js @@ -87,9 +87,9 @@ const STYLE = { function dateSignal(nextServiceDate) { const days = daysUntil(nextServiceDate); if (days == null) return { key: "unknown", label: t("status.noData") }; - if (days < 0) return { key: "overdue", label: t("status.serviceOverdueDays", { days: Math.abs(days) }) }; - if (days <= 30) return { key: "soon", label: t("status.dueInDays", { days }) }; - return { key: "ok", label: t("status.okDays", { days }) }; + if (days < 0) return { key: "overdue", days, label: t("status.serviceOverdueDays", { days: Math.abs(days) }) }; + if (days <= 30) return { key: "soon", days, label: t("status.dueInDays", { days }) }; + return { key: "ok", days, label: t("status.okDays", { days }) }; } // kmSignal classifies the current odometer against the next-due km. Both values @@ -98,9 +98,9 @@ function dateSignal(nextServiceDate) { function kmSignal(currentKm, nextServiceKm) { if (currentKm == null || nextServiceKm == null) return { key: "unknown", label: t("status.noKm") }; const remaining = nextServiceKm - currentKm; - if (remaining < 0) return { key: "overdue", label: t("status.serviceOverdueKm", { km: num(Math.abs(remaining)) }) }; - if (remaining <= KM_SOON) return { key: "soon", label: t("status.inKm", { km: num(remaining) }) }; - return { key: "ok", label: t("status.kmLeft", { km: num(remaining) }) }; + if (remaining < 0) return { key: "overdue", remaining, label: t("status.serviceOverdueKm", { km: num(Math.abs(remaining)) }) }; + if (remaining <= KM_SOON) return { key: "soon", remaining, label: t("status.inKm", { km: num(remaining) }) }; + return { key: "ok", remaining, label: t("status.kmLeft", { km: num(remaining) }) }; } // serviceStatus combines the date- and km-based signals, returning the worse of @@ -227,10 +227,33 @@ export function serviceStatus(latest, car = null) { const km = kmSignal(car?.currentKm, latest?.nextServiceKm); const worse = RANK[km.key] > RANK[date.key] ? km : date; - // If only one signal has data, use that one's label. - let label = worse.label; - if (date.key === "unknown" && km.key !== "unknown") label = km.label; - else if (km.key === "unknown" && date.key !== "unknown") label = date.label; + + let label; + if (date.key === "unknown" && km.key === "unknown") label = t("status.noData"); + // With one signal to go on, that signal's own sentence says it best. + else if (date.key === "unknown") label = km.label; + else if (km.key === "unknown") label = date.label; + else label = bothSignals(date, km, worse.key); return { key: worse.key, label, classes: STYLE[worse.key], date, km }; } + +// bothSignals words a badge that has a due date AND an odometer target. A +// service falls due on whichever arrives first, so "OK · 354d" on its own left +// out half of what the badge is watching: the distance still to run belongs +// beside the days. One headline for the severity, then each trigger as a bare +// quantity - the shape reminderStatus already uses, it having had the +// two-trigger problem first. +function bothSignals(date, km, key) { + const parts = []; + if (key === "overdue") { + // Only what has actually passed. The other trigger is not late, and its + // comfortable remainder under an "Overdue" headline would read as one. + if (date.key === "overdue") parts.push(t("status.days", { days: Math.abs(date.days) })); + if (km.key === "overdue") parts.push(t("status.km", { km: num(Math.abs(km.remaining)) })); + return t("status.serviceOverdueBy", { parts: parts.join(" · ") }); + } + parts.push(t("status.days", { days: date.days })); + parts.push(t("status.km", { km: num(km.remaining) })); + return t(key === "soon" ? "status.dueIn" : "status.okIn", { parts: parts.join(" · ") }); +}