Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
7.8 KiB
JavaScript
201 lines
7.8 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}`;
|
|
}
|
|
}
|
|
|
|
// 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 };
|
|
}
|