diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index d313aed..a79bbdf 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -29,6 +29,7 @@ type userRecord struct { Theme string `json:"theme"` Locale string `json:"locale"` DateFormat string `json:"date_format"` + TimeFormat string `json:"time_format"` Currency string `json:"currency"` FontSize string `json:"font_size"` DragLocked bool `json:"drag_locked"` @@ -97,6 +98,7 @@ func (rec userRecord) toModel() models.User { Theme: orDefault(rec.Theme, "system"), Locale: orDefault(rec.Locale, "en-US"), DateFormat: orDefault(rec.DateFormat, "YMD"), + TimeFormat: orDefault(rec.TimeFormat, "auto"), Currency: orDefault(rec.Currency, "USD"), FontSize: orDefault(rec.FontSize, "medium"), DragLocked: rec.DragLocked, @@ -161,6 +163,7 @@ type updateMeRequest struct { Theme *string `json:"theme"` Locale *string `json:"locale"` DateFormat *string `json:"dateFormat"` + TimeFormat *string `json:"timeFormat"` Currency *string `json:"currency"` FontSize *string `json:"fontSize"` DragLocked *bool `json:"dragLocked"` @@ -256,6 +259,11 @@ func normalizeOrder(field string, in []string, max int) ([]string, error) { 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} + +// "auto" reads the clock the way the chosen region writes it, which is what +// the app did before there was a setting; the other two say it outright, for +// the people whose region and habit disagree. +var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": 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: @@ -318,6 +326,13 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) { } payload["date_format"] = *in.DateFormat } + if in.TimeFormat != nil { + if !validTimeFormats[*in.TimeFormat] { + writeError(w, http.StatusBadRequest, "timeFormat must be auto, 24, or 12") + return + } + payload["time_format"] = *in.TimeFormat + } if in.Currency != nil { if !validCurrencies[*in.Currency] { writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code") diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index ef1b08b..a087718 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -231,6 +231,9 @@ var collectionsSchema = map[string][]fieldDef{ fSelect("theme", []string{"light", "dark", "system"}, false), fText("locale", false), fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false), + // "auto" is the region's own convention, which is what every clock in the + // app read before this field existed. + fSelect("time_format", []string{"auto", "24", "12"}, false), fSelect("currency", []string{ "EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK", "ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB", diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 4d34468..cbc4bed 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -476,6 +476,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 + TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12 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 3018f29..1cc69a4 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -490,6 +490,9 @@ const DESIRED = { F.select("theme", ["light", "dark", "system"]), F.text("locale"), F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]), + // "auto" is the region's own convention. Kept in step with + // validTimeFormats in internal/api/me.go. + F.select("time_format", ["auto", "24", "12"]), // 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. diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index acea4b3..2171386 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -520,6 +520,11 @@ "regionHint": "Tal- og valutaformat.", "dateFormat": "Datoformat", "dateExample": "Eksempel: {example}", + "timeFormat": "Tidsformat", + "timeAuto": "Følg regionen", + "time24": "24-timers", + "time12": "12-timers", + "timeExample": "Eksempel: {example}", "currency": "Valuta", "currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.", "fontSize": "Skriftstørrelse", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 181b9cc..c8427e4 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -519,6 +519,11 @@ "regionHint": "Number and currency layout.", "dateFormat": "Date format", "dateExample": "Example: {example}", + "timeFormat": "Time format", + "timeAuto": "Follow the region", + "time24": "24-hour", + "time12": "12-hour", + "timeExample": "Example: {example}", "currency": "Currency", "currencyExample": "Example: {example} — display only, no amounts are converted.", "fontSize": "Font size", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 6748875..7f60d41 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -524,6 +524,11 @@ "regionHint": "Format liczb i waluty.", "dateFormat": "Format daty", "dateExample": "Przykład: {example}", + "timeFormat": "Format godziny", + "timeAuto": "Jak w regionie", + "time24": "24-godzinny", + "time12": "12-godzinny", + "timeExample": "Przykład: {example}", "currency": "Waluta", "currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.", "fontSize": "Rozmiar czcionki", diff --git a/Web App/web/src/lib/format.js b/Web App/web/src/lib/format.js index b638798..f5cda20 100644 --- a/Web App/web/src/lib/format.js +++ b/Web App/web/src/lib/format.js @@ -76,8 +76,25 @@ export function formatDateTime(value) { if (!value) return "—"; const d = new Date(value); if (isNaN(d)) return "—"; - const time = d.toLocaleTimeString(prefs.locale || undefined, { hour: "2-digit", minute: "2-digit" }); - return `${formatDate(value)} ${time}`; + return `${formatDate(value)} ${formatTime(d)}`; +} + +// The clock alone. "auto" leaves the reading to the region, which is what every +// time in the app said before there was a setting; the other two are for the +// people whose region and habit disagree — plenty of Poles read 12-hour clocks +// and plenty of Americans read 24-hour ones, and the region picker also decides +// how money and numbers are grouped, so it is the wrong lever to reach for. +// +// hourCycle rather than hour12: with hour12:false the en-US formatter prints +// midnight as 24:00. +export function formatTime(value) { + if (!value) return "—"; + const d = value instanceof Date ? value : new Date(value); + if (isNaN(d)) return "—"; + const opts = { hour: "2-digit", minute: "2-digit" }; + if (prefs.timeFormat === "24") opts.hourCycle = "h23"; + else if (prefs.timeFormat === "12") opts.hourCycle = "h12"; + return d.toLocaleTimeString(prefs.locale || undefined, opts); } // Every number we render goes through here so the grouping separator follows diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js index 22bf4a3..5725461 100644 --- a/Web App/web/src/prefs.js +++ b/Web App/web/src/prefs.js @@ -6,6 +6,7 @@ export const prefs = reactive({ theme: "system", // light | dark | system locale: "en-US", dateFormat: "YMD", // YMD | DMY | MDY + timeFormat: "auto", // auto (the region's own convention) | 24 | 12 currency: "USD", // ISO 4217 code fontSize: "medium", // small | medium | large // Holds every arrangement still: the garage, a car's tabs, its Information @@ -56,6 +57,7 @@ export function applyProfilePrefs(profile) { prefs.theme = profile.theme || "system"; prefs.locale = profile.locale || "en-US"; prefs.dateFormat = profile.dateFormat || "YMD"; + prefs.timeFormat = profile.timeFormat || "auto"; prefs.currency = profile.currency || "USD"; prefs.fontSize = profile.fontSize || "medium"; prefs.dragLocked = !!profile.dragLocked; diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 6a2c530..3075a56 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router"; import { api } from "../api"; import { state, isAdmin, logout, refreshProfile } from "../auth"; import { prefs, applyProfilePrefs } from "../prefs"; -import { formatDate, formatMoney } from "../lib/format.js"; +import { formatDate, formatMoney, formatTime } from "../lib/format.js"; import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n"; import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js"; import { askConfirm } from "../lib/confirm.js"; @@ -189,6 +189,13 @@ function saveDefaultTab(surface, key) { } const dateFormatExample = computed(() => formatDate(new Date().toISOString())); +// Thirteen-something rather than now: an example at 09:00 reads the same in +// both conventions, which is the one time of day that cannot show the choice. +const timeFormatExample = computed(() => { + const d = new Date(); + d.setHours(13, 45, 0, 0); + return formatTime(d); +}); const currencyExample = computed(() => formatMoney(1234.5)); // Language and region are two controls over the one stored BCP-47 locale, so @@ -1127,6 +1134,18 @@ onBeforeUnmount(() => {
{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}
+ +{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}
+