Files
DriverVault/Web App/web/src/lib/format.js
T
tajniak81andClaude Opus 5 9abb03ee4f Service history: 0 km is a reading, not a blank
A car collected new sits at 0 km, and every km calculation in the app
quietly refused to work for it. ComputeDerived only filled NextServiceKm
when Km > 0, so a service record entered at 0 produced no next-due
distance at all — the date side worked, because it guards on IsZero(),
which is genuine absence rather than a number that happens to be low.

The same conflation had been copied outward from there. The reminder's
km signal wanted currentKm > 0 before it would count anything down, the
web badge and the service-life ring tested the odometer for truthiness,
formatKm printed an em dash for zero, and fuel and charging rejected a
0 km entry as "odometer (km) is required" — which is the first charge
of an EV on the driveway on delivery day. The phone app carried its own
copy of each. Editing such a car offered an empty odometer box, since
the forms only prefilled a reading above zero.

Everywhere the odometer is a measurement, absence is now tested as
absence: null in the clients, negative on the server, and the required
fields check that the box was filled rather than that the number cleared
zero. Fuel and charging validate Km < 0 instead, and their inputs drop
min="1". Completing a repeating km reminder rolls from the car's actual
reading in every case; the old fallback to the previous target existed
to keep an untracked car off a due date in the past, but CurrentKm +
RepeatKm is ahead of the car by construction, so it could not have
happened.

Left as it was: dueKm, repeatKm and the service intervals, where zero
really does encode "no trigger" and "use the default", and the liters
and kwh checks, since a zero fill is not a fill.

Maintenance is the exception. Its odometer is the one that is genuinely
optional, so zero there still has to mean "not recorded" and those three
sites keep the truthiness test, commented. Fixing that properly wants a
nullable field rather than an int, which is a schema change and its own
commit — the same shape of problem as the latency em dash in 3c4eba8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:26:56 +02:00

237 lines
9.4 KiB
JavaScript

// Formatting + maintenance-status helpers. The status logic follows the
// spreadsheet idea: a service is "due" when its computed next-service date
// (service date + interval) approaches/passes today, OR when the car's current
// odometer approaches/passes the computed next-service km.
import { prefs } from "../prefs.js";
import { t } from "../i18n/index.js";
export function formatDate(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d)) return "—";
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const monthName = d.toLocaleDateString(prefs.locale || undefined, { month: "short" });
const year = d.getFullYear();
switch (prefs.dateFormat) {
case "DMY_NUM":
return `${day}-${month}-${year}`;
case "DMY":
return `${day} ${monthName} ${year}`;
case "MDY":
return `${monthName} ${day}, ${year}`;
case "YMD":
default:
return `${year}-${month}-${day}`;
}
}
// A timestamp rather than a date: the date in the user's chosen format plus the
// clock time in their region's convention. For the places where freshness is the
// whole point — a live reading pulled from a manufacturer service means little
// without the minute it was taken.
export function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d)) return "—";
const time = d.toLocaleTimeString(prefs.locale || undefined, { hour: "2-digit", minute: "2-digit" });
return `${formatDate(value)} ${time}`;
}
// Every number we render goes through here so the grouping separator follows
// the user's chosen region rather than the browser's own locale — otherwise the
// odometer disagrees with the dates and costs beside it.
function num(value) {
return Number(value).toLocaleString(prefs.locale || undefined);
}
// 0 prints as "0 km", not "—": a car picked up new has an odometer reading, and
// blanking it hides the very number the first service interval counts from.
// Nothing stores a placeholder 0 for the intervals — the server defaults those
// (applyCarDefaults) — so a zero reaching here is a real reading.
export function formatKm(value) {
if (value == null || value === "") return "—";
return num(value) + " km";
}
const DAY = 24 * 60 * 60 * 1000;
const KM_SOON = 1000; // within 1000 km of due => "soon"
// daysUntil returns whole days from today to the given date (negative = past).
export function daysUntil(value) {
if (!value) return null;
const target = new Date(value);
if (isNaN(target)) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
target.setHours(0, 0, 0, 0);
return Math.round((target - today) / DAY);
}
// Severity ranking so we can pick the worst of the date/km signals.
const RANK = { unknown: 0, ok: 1, soon: 2, overdue: 3 };
// DriverVault status language: On track (green) / Due soon (amber) / Action
// needed (red). Uses the shared badge recipes from style.css so tints flip in
// dark mode automatically.
const STYLE = {
unknown: "dh-badge dh-badge-neutral",
ok: "dh-badge dh-badge-success",
soon: "dh-badge dh-badge-warning",
overdue: "dh-badge dh-badge-danger",
};
// dateSignal classifies the next-due date relative to today.
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 }) };
}
// kmSignal classifies the current odometer against the next-due km. Both values
// are tested for absence rather than truthiness: 0 km is where a new car starts,
// and reading that as "no data" strands the km signal until the first drive.
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) }) };
}
// serviceStatus combines the date- and km-based signals, returning the worse of
// the two for the badge. `latest` is the most recent service record (with
// nextServiceDate/nextServiceKm); `car` carries the current odometer.
// formatLiters / formatMoney / formatConsumption render the fuel figures. The
// server sends null for anything it could not derive (a window with a missed
// fill, a first-ever tank), which reads as "—" rather than a misleading zero.
export function formatLiters(value) {
if (value == null || value === "") return "—";
return Number(value).toFixed(2) + " L";
}
// Amounts are stored as plain numbers; the user's currency setting only decides
// how they are displayed. Nothing is converted — a figure entered as 40 reads as
// 40 in whichever currency is selected.
export function formatMoney(value) {
if (value == null || value === "") return "—";
// No explicit fraction digits: currency style already pins them to the
// currency's minor unit, which keeps the 2 decimals the fuel figures were
// written for while still rendering yen without phantom sen.
return Number(value).toLocaleString(prefs.locale || undefined, {
style: "currency",
currency: prefs.currency || "USD",
});
}
// One decimal: the interesting differences between tanks live in tenths, and
// rounding to whole litres collapses a best of 6.8 and a worst of 7.0 into the
// same number.
export function formatConsumption(value) {
if (value == null) return "—";
return Number(value).toFixed(1) + " L/100km";
}
export function formatKmPerLiter(value) {
if (value == null) return "—";
return Number(value).toFixed(2) + " km/L";
}
// The charging equivalents of the three above. Energy keeps two decimals like
// litres — a 7.35 kWh top-up is a real number off a charge point — and the
// consumption figure one, for the same reason tanks do.
export function formatKwh(value) {
if (value == null || value === "") return "—";
return Number(value).toFixed(2) + " kWh";
}
export function formatConsumptionKwh(value) {
if (value == null) return "—";
return Number(value).toFixed(1) + " kWh/100km";
}
export function formatKmPerKwh(value) {
if (value == null) return "—";
return Number(value).toFixed(2) + " km/kWh";
}
// Document renewal badge, driven by the server's expiry assessment so the client
// never re-derives the date maths.
const EXPIRY_STYLE = {
no_expiry: "dh-badge dh-badge-neutral",
valid: "dh-badge dh-badge-success",
expiring_soon: "dh-badge dh-badge-warning",
expired: "dh-badge dh-badge-danger",
};
export function expiryStatus(doc) {
const state = doc?.expiry?.state || "no_expiry";
const days = doc?.expiry?.daysUntilExpiry;
let label;
switch (state) {
case "expired":
label = t("status.expiredAgo", { days: Math.abs(days) });
break;
case "expiring_soon":
label = days === 0 ? t("status.expiresToday") : t("status.renewInDays", { days });
break;
case "valid":
label = t("status.validDays", { days });
break;
default:
label = t("status.noExpiry");
}
return { key: state, label, classes: EXPIRY_STYLE[state] || EXPIRY_STYLE.no_expiry };
}
// Reminder badge. The server has already picked the worse of the date and
// odometer signals; this only chooses the wording, preferring whichever trigger
// is actually driving the status.
const REMINDER_STYLE = {
done: "dh-badge dh-badge-neutral",
no_trigger: "dh-badge dh-badge-neutral",
upcoming: "dh-badge dh-badge-success",
due_soon: "dh-badge dh-badge-warning",
overdue: "dh-badge dh-badge-danger",
};
export function reminderStatus(rem) {
const state = rem?.status || "no_trigger";
const days = rem?.daysLeft;
const km = rem?.kmLeft;
let label;
if (state === "done") label = t("status.done");
else if (state === "no_trigger") label = t("status.noTrigger");
else if (state === "overdue") {
const parts = [];
if (days != null && days < 0) parts.push(t("status.days", { days: Math.abs(days) }));
if (km != null && km < 0) parts.push(t("status.km", { km: num(Math.abs(km)) }));
label = parts.length ? t("status.overdueBy", { parts: parts.join(" · ") }) : t("status.overdue");
} else {
// Lead with the trigger that is closest to firing.
const parts = [];
if (days != null && days >= 0) parts.push(days === 0 ? t("status.today") : t("status.days", { days }));
if (km != null && km >= 0) parts.push(t("status.km", { km: num(km) }));
label = parts.length ? t("status.dueIn", { parts: parts.join(" · ") }) : t("status.upcoming");
}
return { key: state, label, classes: REMINDER_STYLE[state] || REMINDER_STYLE.no_trigger };
}
export function serviceStatus(latest, car = null) {
const date = dateSignal(latest?.nextServiceDate);
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;
return { key: worse.key, label, classes: STYLE[worse.key], date, km };
}