Files
DriverVault/Web App/web/src/views/Dashboard.vue
T
tajniak81andClaude Opus 4.8 b6bb6b1df0 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>
2026-07-17 20:07:48 +02:00

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>