// 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}`; } } // 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); } export function formatKm(value) { if (value == null || value === "" || value === 0) 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. function kmSignal(currentKm, nextServiceKm) { if (!currentKm || !nextServiceKm) 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"; } // 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 }; }