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>
146 lines
5.9 KiB
Vue
146 lines
5.9 KiB
Vue
<script setup>
|
|
import { ref, onMounted } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { api } from "../api";
|
|
import { formatDate, formatKm, serviceStatus } from "../lib/format.js";
|
|
import { t, tSplit } from "../i18n";
|
|
import CarFormModal from "../components/CarFormModal.vue";
|
|
|
|
const router = useRouter();
|
|
const cars = ref([]);
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const showAdd = ref(false);
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const list = await api.listCars();
|
|
// For each car, fetch its latest service record to derive next-due status.
|
|
cars.value = await Promise.all(
|
|
list.map(async (car) => {
|
|
const services = await api.listCarServices(car.id);
|
|
const latest = services[0] || null; // API sorts newest-first
|
|
return { ...car, latest, count: services.length };
|
|
})
|
|
);
|
|
} catch (e) {
|
|
error.value = e.message;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function onSaved(car) {
|
|
showAdd.value = false;
|
|
router.push({ name: "car", params: { id: car.id } });
|
|
}
|
|
|
|
// Service-life progress: how far the car is through its km service interval.
|
|
// Returns a { pct, tone } or null when there isn't enough data to compute it.
|
|
const TONE_COLOR = {
|
|
ok: "var(--success-600)",
|
|
soon: "var(--warning-600)",
|
|
overdue: "var(--danger-600)",
|
|
unknown: "var(--ink-300)",
|
|
};
|
|
function serviceLife(car) {
|
|
const interval = Number(car.serviceIntervalKm);
|
|
const nextKm = Number(car.latest?.nextServiceKm);
|
|
const currentKm = Number(car.currentKm);
|
|
if (!interval || !nextKm || !currentKm) return null;
|
|
const remaining = nextKm - currentKm;
|
|
const pct = Math.max(0, Math.min(100, Math.round((1 - remaining / interval) * 100)));
|
|
return { pct, tone: TONE_COLOR[serviceStatus(car.latest, car).key] || TONE_COLOR.unknown };
|
|
}
|
|
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="mb-6 flex items-end justify-between gap-4">
|
|
<div>
|
|
<p class="eyebrow">{{ t("dashboard.eyebrow") }}</p>
|
|
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("dashboard.title") }}</h1>
|
|
<p class="mt-1 text-sm text-muted">{{ t("dashboard.subtitle") }}</p>
|
|
</div>
|
|
<button class="dh-btn dh-btn-primary" @click="showAdd = true">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
|
{{ t("dashboard.addCar") }}
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
|
|
<p v-if="loading" class="text-muted">{{ t("common.loading") }}</p>
|
|
|
|
<div v-else-if="cars.length === 0" class="rounded-card border border-dashed border-default p-12 text-center text-muted">
|
|
{{ tSplit("dashboard.empty", "action").before
|
|
}}<strong class="text-strong">{{ t("dashboard.addCar") }}</strong>{{ tSplit("dashboard.empty", "action").after }}
|
|
</div>
|
|
|
|
<div v-else class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
<RouterLink
|
|
v-for="car in cars"
|
|
:key="car.id"
|
|
:to="{ name: 'car', params: { id: car.id } }"
|
|
class="dh-card group block p-5 transition-shadow duration-150 hover:shadow-pop"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<h2 class="truncate text-lg font-bold tracking-[-0.02em] text-strong">{{ car.name }}</h2>
|
|
<p class="truncate text-xs text-muted">{{ [car.make, car.model, car.year || ''].filter(Boolean).join(' ') }}</p>
|
|
</div>
|
|
<span :class="serviceStatus(car.latest, car).classes">
|
|
{{ serviceStatus(car.latest, car).label }}
|
|
</span>
|
|
</div>
|
|
|
|
<span
|
|
v-if="car.access && car.access !== 'owner'"
|
|
class="dh-badge dh-badge-neutral mt-2"
|
|
>
|
|
{{ car.access === 'read' ? t("dashboard.sharedReadOnly") : t("dashboard.shared") }}
|
|
</span>
|
|
|
|
<!-- Service-life bar: fraction of the km interval used up. -->
|
|
<div v-if="serviceLife(car)" class="mt-4">
|
|
<div class="mb-1.5 flex items-center justify-between">
|
|
<span class="eyebrow">{{ t("dashboard.serviceLife") }}</span>
|
|
<span class="data text-xs font-medium text-strong">{{ serviceLife(car).pct }}%</span>
|
|
</div>
|
|
<div class="h-2 overflow-hidden rounded-pill bg-sunken">
|
|
<div class="h-full rounded-pill" :style="{ width: serviceLife(car).pct + '%', background: serviceLife(car).tone }" />
|
|
</div>
|
|
</div>
|
|
|
|
<dl class="mt-4 space-y-2 text-sm">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="eyebrow">{{ t("dashboard.lastService") }}</dt>
|
|
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.date) }}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="eyebrow">{{ t("dashboard.odometer") }}</dt>
|
|
<dd class="data font-medium text-strong">{{ formatKm(car.currentKm) }}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="eyebrow">{{ t("dashboard.nextDue") }}</dt>
|
|
<dd class="data font-medium text-strong">{{ formatDate(car.latest?.nextServiceDate) }}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<dt class="eyebrow">{{ t("dashboard.nextDueKm") }}</dt>
|
|
<dd class="data font-medium text-strong">{{ formatKm(car.latest?.nextServiceKm) }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<p class="mt-4 border-t border-subtle pt-3 text-xs text-muted">
|
|
{{ t("dashboard.serviceRecords", { n: car.count }) }}
|
|
</p>
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<CarFormModal v-if="showAdd" @saved="onSaved" @close="showAdd = false" />
|
|
</div>
|
|
</template>
|