diff --git a/API Server/README.md b/API Server/README.md index ef73156..6353a6c 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -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: diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index 8a0dbcd..afbb9d9 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -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") diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 660b605..0fd3cad 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -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 diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 14b7cf4..ba94367 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -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. diff --git a/Web App/web/src/lib/format.js b/Web App/web/src/lib/format.js index dab58c0..a69ee71 100644 --- a/Web App/web/src/lib/format.js +++ b/Web App/web/src/lib/format.js @@ -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 }; diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js index 7370223..f7122e3 100644 --- a/Web App/web/src/prefs.js +++ b/Web App/web/src/prefs.js @@ -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(); diff --git a/Web App/web/src/views/CarDetail.vue b/Web App/web/src/views/CarDetail.vue index 6ccc8bf..8676bf7 100644 --- a/Web App/web/src/views/CarDetail.vue +++ b/Web App/web/src/views/CarDetail.vue @@ -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); - +
-

Maintenance log

+

Maintenance

Workshop visits and repairs. Routine servicing lives under Service history.