Say how far the next service is, not only how long
The service badge has always watched two triggers - the next-due date and the next-due odometer reading - and shown one of them. It ranked the two and printed the worse one's sentence, so a car comfortable on both read "OK · 354d" and never said that the odometer target was 13.612 km away, even though the Information card right under it prints the 15.000 km the badge is counting towards. Whichever trigger arrives first ends the interval, so naming only one of them describes half the thing. Both are named now. With both signals known the label is a severity headline followed by each trigger as a bare quantity - "OK · 354d · 13.612 km", "Due in 12d · 13.612 km" - which is the shape reminderStatus in the same file already uses, it having had the two-trigger problem first. The signals gained the number behind their own wording to make that possible; they were returning only a formatted sentence. Wording is unchanged wherever only one signal has data, which is the case this rewrite most risked disturbing: a car with no odometer target still reads "OK · 354d" exactly as before, one with no service date still reads "13.612 km left", and neither still reads "No data". The new keys are only reached when there are genuinely two numbers to print. An overdue badge lists only the triggers that have actually passed. "Service Overdue 30d · 13.612 km" would read as overdue by 13.612 km, which is the opposite of what that number means, so the trigger that is still comfortable stays out of a sentence headed "Overdue". It costs the remaining distance on a date-overdue badge; the alternative costs the reader's trust in the number. The phone carried a line-for-line copy of this logic and gets the same treatment rather than being left a version behind - the two would otherwise disagree about the same car on the same day. Its signals become a private record type, since Status is public and shared with the expiry, reminder and warranty badges that have no second trigger and no use for the field. Two new keys (status.okIn, status.serviceOverdueBy) in all three languages in both apps. The day and km fragments they interpolate were already translated for the reminder badge, so the parts assemble in Polish and Danish without new wording: "OK · 354 dni · 13 612 km", "OK · 354 d · 13.612 km", each with its own grouping separator. Verified by flutter analyze (clean), flutter test - 21 pass, including the key-parity test that would have caught a key added in English alone - and npm run build for the web. The web function was driven through the real module in a browser over ten cases: both signals known at each severity, each of the two overdue alone, both overdue together, either signal missing, neither, and a zero-odometer car, in all three languages. Not verified: no new automated test covers this. The web app has no test runner and the phone's format tests cover the catalogue lookups rather than the badge, so the ten cases above were checked by hand and are not guarded against the next edit. The deployed Web App still serves the previous build and will keep reading "OK · 354d" until it is redeployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b4e99240c6
commit
e5759df52c
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+55
-13
@@ -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 = <String>[];
|
||||
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(" · ")});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(" · ") });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user