Add a language-switch system with per-language files
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ee28b522c7
commit
b6bb6b1df0
@@ -4,6 +4,7 @@
|
||||
// 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 "—";
|
||||
@@ -69,19 +70,19 @@ const STYLE = {
|
||||
// dateSignal classifies the next-due date relative to today.
|
||||
function dateSignal(nextServiceDate) {
|
||||
const days = daysUntil(nextServiceDate);
|
||||
if (days == null) return { key: "unknown", label: "No data" };
|
||||
if (days < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(days)}d` };
|
||||
if (days <= 30) return { key: "soon", label: `Due in ${days}d` };
|
||||
return { key: "ok", label: `OK · ${days}d` };
|
||||
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: "No km" };
|
||||
if (!currentKm || !nextServiceKm) return { key: "unknown", label: t("status.noKm") };
|
||||
const remaining = nextServiceKm - currentKm;
|
||||
if (remaining < 0) return { key: "overdue", label: `Service Overdue ${num(Math.abs(remaining))} km` };
|
||||
if (remaining <= KM_SOON) return { key: "soon", label: `In ${num(remaining)} km` };
|
||||
return { key: "ok", label: `${num(remaining)} km left` };
|
||||
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
|
||||
@@ -137,16 +138,16 @@ export function expiryStatus(doc) {
|
||||
let label;
|
||||
switch (state) {
|
||||
case "expired":
|
||||
label = `Expired ${Math.abs(days)}d ago`;
|
||||
label = t("status.expiredAgo", { days: Math.abs(days) });
|
||||
break;
|
||||
case "expiring_soon":
|
||||
label = days === 0 ? "Expires today" : `Renew in ${days}d`;
|
||||
label = days === 0 ? t("status.expiresToday") : t("status.renewInDays", { days });
|
||||
break;
|
||||
case "valid":
|
||||
label = `Valid · ${days}d`;
|
||||
label = t("status.validDays", { days });
|
||||
break;
|
||||
default:
|
||||
label = "No expiry";
|
||||
label = t("status.noExpiry");
|
||||
}
|
||||
return { key: state, label, classes: EXPIRY_STYLE[state] || EXPIRY_STYLE.no_expiry };
|
||||
}
|
||||
@@ -168,19 +169,19 @@ export function reminderStatus(rem) {
|
||||
const km = rem?.kmLeft;
|
||||
|
||||
let label;
|
||||
if (state === "done") label = "Done";
|
||||
else if (state === "no_trigger") label = "No trigger";
|
||||
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(`${Math.abs(days)}d`);
|
||||
if (km != null && km < 0) parts.push(`${num(Math.abs(km))} km`);
|
||||
label = parts.length ? `Overdue ${parts.join(" · ")}` : "Overdue";
|
||||
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 ? "today" : `${days}d`);
|
||||
if (km != null && km >= 0) parts.push(`${num(km)} km`);
|
||||
label = parts.length ? `Due in ${parts.join(" · ")}` : "Upcoming";
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user