Add currency setting and split locale into language + region
Costs rendered as bare numbers because the project had no currency to render them with. Add one to the profile, beside the existing appearance preferences: - users.currency in PocketBase, exposed via /api/me, validated against the same 28 codes in the schema, the API and the web app. - Settings offers the European currencies plus the non-European ones the panel already had. Labels come from Intl.DisplayNames rather than a hand-kept table, so the lists read in the user's own language and sort by what is actually on screen. The single "Language & region" picker becomes two controls over the one stored BCP-47 tag, covering European languages and countries, so the two can be mixed (English in Poland). The API now enforces the language-REGION shape: the web app feeds the tag straight to Intl, which throws on a malformed one rather than falling back. formatKm and the km-count labels passed no locale, so kilometres followed the browser while the dates and costs beside them followed the saved preference. They now share one helper that uses the preference. Rename the "Maintenance log" tab to "Maintenance", the name the reminder-type list already used. Amounts are display-only: nothing is converted, so changing currency reinterprets existing records rather than recalculating them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
07192f1238
commit
03738f08dc
8 files changed
+152
-24
No files matched your search
@@ -80,7 +80,7 @@ const dueReminders = computed(
|
||||
const TABS = [
|
||||
{ key: "info", label: "Information" },
|
||||
{ key: "services", label: "Service history" },
|
||||
{ key: "maintenance", label: "Maintenance log" },
|
||||
{ key: "maintenance", label: "Maintenance" },
|
||||
{ key: "fuel", label: "Fuel" },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "parts", label: "Parts catalog" },
|
||||
@@ -538,11 +538,11 @@ onMounted(load);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Maintenance log -->
|
||||
<!-- Maintenance -->
|
||||
<section v-else-if="activeTab === 'maintenance'">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Maintenance log</h2>
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Maintenance</h2>
|
||||
<p class="text-sm text-muted">Workshop visits and repairs. Routine servicing lives under Service history.</p>
|
||||
</div>
|
||||
<button v-if="canWrite" class="dh-btn dh-btn-primary !px-3 !py-1.5" @click="openAddMaintenance">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { state, logout, refreshProfile } from "../auth";
|
||||
import { prefs, applyProfilePrefs } from "../prefs";
|
||||
import { formatDate } from "../lib/format.js";
|
||||
import { formatDate, formatMoney } from "../lib/format.js";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -133,6 +133,66 @@ async function saveAppearance(patch) {
|
||||
}
|
||||
|
||||
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
|
||||
const currencyExample = computed(() => formatMoney(1234.5));
|
||||
|
||||
// Language and region are two controls over the one stored BCP-47 locale, so
|
||||
// the pair can be mixed freely (English in Poland, say) rather than being
|
||||
// limited to the handful of combinations a single list could offer.
|
||||
//
|
||||
// Europe here means the sovereign states of the Council of Europe, plus Belarus,
|
||||
// Russia, Vatican City and Kosovo — geographically European but not members.
|
||||
// Dependencies (Gibraltar, Faroes, Åland) are left out: they are not countries,
|
||||
// and the languages/currencies they would add are already covered. US stays on
|
||||
// for the region list because it was there before this became a Europe list.
|
||||
const LANGUAGE_CODES = [
|
||||
"sq", "hy", "az", "eu", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en",
|
||||
"et", "fi", "fr", "gl", "ka", "de", "el", "hu", "is", "ga", "it", "lv", "lt",
|
||||
"lb", "mk", "mt", "no", "pl", "pt", "ro", "rm", "ru", "sr", "sk", "sl", "es",
|
||||
"sv", "tr", "uk", "cy",
|
||||
];
|
||||
const REGION_CODES = [
|
||||
"AD", "AL", "AM", "AT", "AZ", "BA", "BE", "BG", "BY", "CH", "CY", "CZ", "DE",
|
||||
"DK", "EE", "ES", "FI", "FR", "GB", "GE", "GR", "HR", "HU", "IE", "IS", "IT",
|
||||
"LI", "LT", "LU", "LV", "MC", "MD", "ME", "MK", "MT", "NL", "NO", "PL", "PT",
|
||||
"RO", "RS", "RU", "SE", "SI", "SK", "SM", "TR", "UA", "VA", "XK", "US",
|
||||
];
|
||||
// Mirrors validCurrencies in the API's me.go and the users.currency select in
|
||||
// setup-pocketbase.mjs — all three have to list the same codes.
|
||||
const CURRENCY_CODES = [
|
||||
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
||||
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
||||
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
|
||||
];
|
||||
|
||||
// Labels come from Intl rather than a hand-kept translation table, so the lists
|
||||
// read in the user's own language ("Deutschland" once German is picked) and
|
||||
// sort by what is actually on screen. If a runtime cannot name a code it falls
|
||||
// back to the code itself, which is still selectable.
|
||||
function named(codes, type, withCode = false) {
|
||||
let dn = null;
|
||||
try {
|
||||
dn = new Intl.DisplayNames([prefs.locale || "en-US"], { type });
|
||||
} catch {
|
||||
dn = null;
|
||||
}
|
||||
return codes
|
||||
.map((code) => {
|
||||
const name = dn?.of(code) || code;
|
||||
return { code, label: withCode && name !== code ? `${name} (${code})` : name };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, prefs.locale || undefined));
|
||||
}
|
||||
|
||||
const LANGUAGES = computed(() => named(LANGUAGE_CODES, "language"));
|
||||
const REGIONS = computed(() => named(REGION_CODES, "region"));
|
||||
const CURRENCIES = computed(() => named(CURRENCY_CODES, "currency", true));
|
||||
|
||||
const language = computed(() => (prefs.locale || "en-US").split("-")[0]);
|
||||
const region = computed(() => (prefs.locale || "en-US").split("-")[1] || "US");
|
||||
|
||||
function saveLocale({ lang = language.value, reg = region.value }) {
|
||||
return saveAppearance({ locale: `${lang}-${reg}` });
|
||||
}
|
||||
|
||||
// --- Profile: avatar + bio ---
|
||||
|
||||
@@ -438,15 +498,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label">Language & region</label>
|
||||
<select :value="prefs.locale" class="dh-input" @change="saveAppearance({ locale: $event.target.value })">
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="en-GB">English (UK)</option>
|
||||
<option value="pl-PL">Polski</option>
|
||||
<option value="de-DE">Deutsch</option>
|
||||
<option value="fr-FR">Français</option>
|
||||
<option value="es-ES">Español</option>
|
||||
<label class="dh-label">Language</label>
|
||||
<select :value="language" class="dh-input" @change="saveLocale({ lang: $event.target.value })">
|
||||
<option v-for="l in LANGUAGES" :key="l.code" :value="l.code">{{ l.label }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Names of months and days.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Region</label>
|
||||
<select :value="region" class="dh-input" @change="saveLocale({ reg: $event.target.value })">
|
||||
<option v-for="r in REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Number and currency layout.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -459,6 +523,16 @@ onBeforeUnmount(() => {
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">Example: <span class="data">{{ dateFormatExample }}</span></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Currency</label>
|
||||
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
||||
<option v-for="c in CURRENCIES" :key="c.code" :value="c.code">{{ c.label }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Example: <span class="data">{{ currencyExample }}</span> — display only, no amounts are converted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in new issue
Block a user