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:
tajniak81
2026-07-17 11:32:40 +02:00
co-authored by Claude Opus 4.8
parent 07192f1238
commit 03738f08dc
8 changed files with 152 additions and 24 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ other users `read` or `write` access. Every car/service/part handler is gated by
| `parts` | per-car parts catalog | car, name, part_number, category |
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
| `organizations` | tenants | name (unique) |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, font_size, deletion_requested_at |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, currency, font_size, deletion_requested_at |
**Spreadsheet formulas** (from the original `Car Service.xlsx`), reproduced by
the API on read:
+32
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -28,6 +29,7 @@ type userRecord struct {
Theme string `json:"theme"`
Locale string `json:"locale"`
DateFormat string `json:"date_format"`
Currency string `json:"currency"`
FontSize string `json:"font_size"`
DeletionRequestedAt string `json:"deletion_requested_at"`
Role string `json:"role"`
@@ -45,6 +47,7 @@ func (rec userRecord) toModel() models.User {
Theme: orDefault(rec.Theme, "system"),
Locale: orDefault(rec.Locale, "en-US"),
DateFormat: orDefault(rec.DateFormat, "YMD"),
Currency: orDefault(rec.Currency, "USD"),
FontSize: orDefault(rec.FontSize, "medium"),
Role: orDefault(rec.Role, "user"),
Created: rec.Created,
@@ -90,6 +93,7 @@ type updateMeRequest struct {
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
Currency *string `json:"currency"`
FontSize *string `json:"fontSize"`
}
@@ -97,6 +101,23 @@ var validThemes = map[string]bool{"light": true, "dark": true, "system": true}
var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true}
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
// PocketBase rejects anything outside its own list, so accepting a wider set
// here would only turn a clear 400 into a confusing upstream error.
var validCurrencies = map[string]bool{
"EUR": true, "GBP": true, "CHF": true, "PLN": true, "CZK": true, "HUF": true,
"RON": true, "BGN": true, "DKK": true, "SEK": true, "NOK": true, "ISK": true,
"ALL": true, "AMD": true, "AZN": true, "BAM": true, "BYN": true, "GEL": true,
"MDL": true, "MKD": true, "RSD": true, "RUB": true, "TRY": true, "UAH": true,
"USD": true, "CAD": true, "AUD": true, "JPY": true,
}
// The clients pick language and region separately and join them into this tag,
// so the stored value is only ever language-REGION. Enforcing that shape here
// keeps a bad tag out of the record: the web app feeds the locale straight to
// Intl, which throws on a malformed one rather than falling back.
var localePattern = regexp.MustCompile(`^[a-z]{2}-[A-Z]{2}$`)
// handleUpdateMe applies a partial update — only fields present in the request
// body are touched, so the Account/Profile/Appearance sections of the settings
// panel can each save independently without clobbering the others.
@@ -127,6 +148,10 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
payload["theme"] = *in.Theme
}
if in.Locale != nil {
if !localePattern.MatchString(*in.Locale) {
writeError(w, http.StatusBadRequest, "locale must look like en-US")
return
}
payload["locale"] = *in.Locale
}
if in.DateFormat != nil {
@@ -136,6 +161,13 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
payload["date_format"] = *in.DateFormat
}
if in.Currency != nil {
if !validCurrencies[*in.Currency] {
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
return
}
payload["currency"] = *in.Currency
}
if in.FontSize != nil {
if !validFontSizes[*in.FontSize] {
writeError(w, http.StatusBadRequest, "fontSize must be small, medium, or large")
+1
View File
@@ -283,6 +283,7 @@ type User struct {
Theme string `json:"theme"` // light | dark | system
Locale string `json:"locale"` // e.g. "en-US"
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
FontSize string `json:"fontSize"` // small | medium | large
Role string `json:"role"` // user | admin
+8
View File
@@ -361,6 +361,14 @@ const DESIRED = {
F.select("theme", ["light", "dark", "system"]),
F.text("locale"),
F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
// European currencies plus the non-European ones the panel already offered.
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
// in the web app's Settings.vue.
F.select("currency", [
"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",
]),
F.select("font_size", ["small", "medium", "large"]),
F.date("deletion_requested_at"),
// Access role. Empty value is treated as "user" by the API.
+21 -10
View File
@@ -28,9 +28,16 @@ export function formatDate(value) {
}
}
// 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 Number(value).toLocaleString() + " km";
return num(value) + " km";
}
const DAY = 24 * 60 * 60 * 1000;
@@ -72,9 +79,9 @@ function dateSignal(nextServiceDate) {
function kmSignal(currentKm, nextServiceKm) {
if (!currentKm || !nextServiceKm) return { key: "unknown", label: "No km" };
const remaining = nextServiceKm - currentKm;
if (remaining < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(remaining).toLocaleString()} km` };
if (remaining <= KM_SOON) return { key: "soon", label: `In ${remaining.toLocaleString()} km` };
return { key: "ok", label: `${remaining.toLocaleString()} km left` };
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` };
}
// serviceStatus combines the date- and km-based signals, returning the worse of
@@ -88,13 +95,17 @@ export function formatLiters(value) {
return Number(value).toFixed(2) + " L";
}
// Amounts are unit-less on purpose: the project stores plain numbers and has no
// currency setting, so imposing a symbol here would be a guess.
// 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, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: "currency",
currency: prefs.currency || "USD",
});
}
@@ -162,13 +173,13 @@ export function reminderStatus(rem) {
else if (state === "overdue") {
const parts = [];
if (days != null && days < 0) parts.push(`${Math.abs(days)}d`);
if (km != null && km < 0) parts.push(`${Math.abs(km).toLocaleString()} km`);
if (km != null && km < 0) parts.push(`${num(Math.abs(km))} km`);
label = parts.length ? `Overdue ${parts.join(" · ")}` : "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(`${km.toLocaleString()} km`);
if (km != null && km >= 0) parts.push(`${num(km)} km`);
label = parts.length ? `Due in ${parts.join(" · ")}` : "Upcoming";
}
return { key: state, label, classes: REMINDER_STYLE[state] || REMINDER_STYLE.no_trigger };
+3 -1
View File
@@ -1,4 +1,4 @@
// Appearance preferences (theme / locale / date format / font size), applied
// Appearance preferences (theme / locale / date format / currency / font size), applied
// to the document so every view — not just the Settings page — reflects them.
import { reactive } from "vue";
@@ -6,6 +6,7 @@ export const prefs = reactive({
theme: "system", // light | dark | system
locale: "en-US",
dateFormat: "YMD", // YMD | DMY | MDY
currency: "USD", // ISO 4217 code
fontSize: "medium", // small | medium | large
});
@@ -41,6 +42,7 @@ export function applyProfilePrefs(profile) {
prefs.theme = profile.theme || "system";
prefs.locale = profile.locale || "en-US";
prefs.dateFormat = profile.dateFormat || "YMD";
prefs.currency = profile.currency || "USD";
prefs.fontSize = profile.fontSize || "medium";
applyTheme();
applyFontSize();
+3 -3
View File
@@ -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">
+83 -9
View File
@@ -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 &amp; 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>