The clock stops being a side effect of the region
Whether a time read as 13.45 or 01.45 pm was decided by the region picker, which also sets the decimal separator and the currency layout — so a Dane who wanted a 12-hour clock had to move their numbers to get one. Time format is its own setting now, beside the date format it is the other half of. It defaults to "auto", the region's own convention, which is what every timestamp in the app already said: nothing moves until somebody picks something. The 24-hour setting asks for hourCycle h23 rather than hour12:false, because with hour12 the en-US formatter prints midnight as 24:00. One helper, so it reaches everywhere at once: formatDateTime now calls formatTime, and every clock the app draws goes through it — the charger's telemetry and settings, a session's start, when a charger was linked, the provider panel's own timestamp. The users collection gains a time_format select in both places the schema is declared; it is in reconcileOrder, so a restart adds the field and nothing has to be migrated by hand. The native time inputs in the charger's settings card are left alone: the browser renders those in the OS convention whatever this says, and a text box that respected the setting would be the worse control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3fddab6815
commit
dde5410788
@@ -29,6 +29,7 @@ type userRecord struct {
|
|||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
DateFormat string `json:"date_format"`
|
DateFormat string `json:"date_format"`
|
||||||
|
TimeFormat string `json:"time_format"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
FontSize string `json:"font_size"`
|
FontSize string `json:"font_size"`
|
||||||
DragLocked bool `json:"drag_locked"`
|
DragLocked bool `json:"drag_locked"`
|
||||||
@@ -97,6 +98,7 @@ func (rec userRecord) toModel() models.User {
|
|||||||
Theme: orDefault(rec.Theme, "system"),
|
Theme: orDefault(rec.Theme, "system"),
|
||||||
Locale: orDefault(rec.Locale, "en-US"),
|
Locale: orDefault(rec.Locale, "en-US"),
|
||||||
DateFormat: orDefault(rec.DateFormat, "YMD"),
|
DateFormat: orDefault(rec.DateFormat, "YMD"),
|
||||||
|
TimeFormat: orDefault(rec.TimeFormat, "auto"),
|
||||||
Currency: orDefault(rec.Currency, "USD"),
|
Currency: orDefault(rec.Currency, "USD"),
|
||||||
FontSize: orDefault(rec.FontSize, "medium"),
|
FontSize: orDefault(rec.FontSize, "medium"),
|
||||||
DragLocked: rec.DragLocked,
|
DragLocked: rec.DragLocked,
|
||||||
@@ -161,6 +163,7 @@ type updateMeRequest struct {
|
|||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
DateFormat *string `json:"dateFormat"`
|
DateFormat *string `json:"dateFormat"`
|
||||||
|
TimeFormat *string `json:"timeFormat"`
|
||||||
Currency *string `json:"currency"`
|
Currency *string `json:"currency"`
|
||||||
FontSize *string `json:"fontSize"`
|
FontSize *string `json:"fontSize"`
|
||||||
DragLocked *bool `json:"dragLocked"`
|
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 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 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}
|
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
|
||||||
|
|
||||||
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
|
// 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
|
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 in.Currency != nil {
|
||||||
if !validCurrencies[*in.Currency] {
|
if !validCurrencies[*in.Currency] {
|
||||||
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
|
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ var collectionsSchema = map[string][]fieldDef{
|
|||||||
fSelect("theme", []string{"light", "dark", "system"}, false),
|
fSelect("theme", []string{"light", "dark", "system"}, false),
|
||||||
fText("locale", false),
|
fText("locale", false),
|
||||||
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, 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{
|
fSelect("currency", []string{
|
||||||
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
||||||
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ type User struct {
|
|||||||
Theme string `json:"theme"` // light | dark | system
|
Theme string `json:"theme"` // light | dark | system
|
||||||
Locale string `json:"locale"` // e.g. "en-US"
|
Locale string `json:"locale"` // e.g. "en-US"
|
||||||
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
|
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"
|
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
||||||
FontSize string `json:"fontSize"` // small | medium | large
|
FontSize string `json:"fontSize"` // small | medium | large
|
||||||
Role string `json:"role"` // user | admin
|
Role string `json:"role"` // user | admin
|
||||||
|
|||||||
@@ -490,6 +490,9 @@ const DESIRED = {
|
|||||||
F.select("theme", ["light", "dark", "system"]),
|
F.select("theme", ["light", "dark", "system"]),
|
||||||
F.text("locale"),
|
F.text("locale"),
|
||||||
F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
|
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.
|
// European currencies plus the non-European ones the panel already offered.
|
||||||
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
|
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
|
||||||
// in the web app's Settings.vue.
|
// in the web app's Settings.vue.
|
||||||
|
|||||||
@@ -520,6 +520,11 @@
|
|||||||
"regionHint": "Tal- og valutaformat.",
|
"regionHint": "Tal- og valutaformat.",
|
||||||
"dateFormat": "Datoformat",
|
"dateFormat": "Datoformat",
|
||||||
"dateExample": "Eksempel: {example}",
|
"dateExample": "Eksempel: {example}",
|
||||||
|
"timeFormat": "Tidsformat",
|
||||||
|
"timeAuto": "Følg regionen",
|
||||||
|
"time24": "24-timers",
|
||||||
|
"time12": "12-timers",
|
||||||
|
"timeExample": "Eksempel: {example}",
|
||||||
"currency": "Valuta",
|
"currency": "Valuta",
|
||||||
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
|
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
|
||||||
"fontSize": "Skriftstørrelse",
|
"fontSize": "Skriftstørrelse",
|
||||||
|
|||||||
@@ -519,6 +519,11 @@
|
|||||||
"regionHint": "Number and currency layout.",
|
"regionHint": "Number and currency layout.",
|
||||||
"dateFormat": "Date format",
|
"dateFormat": "Date format",
|
||||||
"dateExample": "Example: {example}",
|
"dateExample": "Example: {example}",
|
||||||
|
"timeFormat": "Time format",
|
||||||
|
"timeAuto": "Follow the region",
|
||||||
|
"time24": "24-hour",
|
||||||
|
"time12": "12-hour",
|
||||||
|
"timeExample": "Example: {example}",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"currencyExample": "Example: {example} — display only, no amounts are converted.",
|
"currencyExample": "Example: {example} — display only, no amounts are converted.",
|
||||||
"fontSize": "Font size",
|
"fontSize": "Font size",
|
||||||
|
|||||||
@@ -524,6 +524,11 @@
|
|||||||
"regionHint": "Format liczb i waluty.",
|
"regionHint": "Format liczb i waluty.",
|
||||||
"dateFormat": "Format daty",
|
"dateFormat": "Format daty",
|
||||||
"dateExample": "Przykład: {example}",
|
"dateExample": "Przykład: {example}",
|
||||||
|
"timeFormat": "Format godziny",
|
||||||
|
"timeAuto": "Jak w regionie",
|
||||||
|
"time24": "24-godzinny",
|
||||||
|
"time12": "12-godzinny",
|
||||||
|
"timeExample": "Przykład: {example}",
|
||||||
"currency": "Waluta",
|
"currency": "Waluta",
|
||||||
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
|
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
|
||||||
"fontSize": "Rozmiar czcionki",
|
"fontSize": "Rozmiar czcionki",
|
||||||
|
|||||||
@@ -76,8 +76,25 @@ export function formatDateTime(value) {
|
|||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (isNaN(d)) return "—";
|
if (isNaN(d)) return "—";
|
||||||
const time = d.toLocaleTimeString(prefs.locale || undefined, { hour: "2-digit", minute: "2-digit" });
|
return `${formatDate(value)} ${formatTime(d)}`;
|
||||||
return `${formatDate(value)} ${time}`;
|
}
|
||||||
|
|
||||||
|
// 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
|
// Every number we render goes through here so the grouping separator follows
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const prefs = reactive({
|
|||||||
theme: "system", // light | dark | system
|
theme: "system", // light | dark | system
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
dateFormat: "YMD", // YMD | DMY | MDY
|
dateFormat: "YMD", // YMD | DMY | MDY
|
||||||
|
timeFormat: "auto", // auto (the region's own convention) | 24 | 12
|
||||||
currency: "USD", // ISO 4217 code
|
currency: "USD", // ISO 4217 code
|
||||||
fontSize: "medium", // small | medium | large
|
fontSize: "medium", // small | medium | large
|
||||||
// Holds every arrangement still: the garage, a car's tabs, its Information
|
// 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.theme = profile.theme || "system";
|
||||||
prefs.locale = profile.locale || "en-US";
|
prefs.locale = profile.locale || "en-US";
|
||||||
prefs.dateFormat = profile.dateFormat || "YMD";
|
prefs.dateFormat = profile.dateFormat || "YMD";
|
||||||
|
prefs.timeFormat = profile.timeFormat || "auto";
|
||||||
prefs.currency = profile.currency || "USD";
|
prefs.currency = profile.currency || "USD";
|
||||||
prefs.fontSize = profile.fontSize || "medium";
|
prefs.fontSize = profile.fontSize || "medium";
|
||||||
prefs.dragLocked = !!profile.dragLocked;
|
prefs.dragLocked = !!profile.dragLocked;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { state, isAdmin, logout, refreshProfile } from "../auth";
|
import { state, isAdmin, logout, refreshProfile } from "../auth";
|
||||||
import { prefs, applyProfilePrefs } from "../prefs";
|
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 { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
||||||
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
|
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
|
||||||
import { askConfirm } from "../lib/confirm.js";
|
import { askConfirm } from "../lib/confirm.js";
|
||||||
@@ -189,6 +189,13 @@ function saveDefaultTab(surface, key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dateFormatExample = computed(() => formatDate(new Date().toISOString()));
|
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));
|
const currencyExample = computed(() => formatMoney(1234.5));
|
||||||
|
|
||||||
// Language and region are two controls over the one stored BCP-47 locale, so
|
// Language and region are two controls over the one stored BCP-47 locale, so
|
||||||
@@ -1127,6 +1134,18 @@ onBeforeUnmount(() => {
|
|||||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Beside the date rather than under the region, because it is the
|
||||||
|
same question asked about the other half of a timestamp. -->
|
||||||
|
<div>
|
||||||
|
<label class="dh-label">{{ t("settings.appearance.timeFormat") }}</label>
|
||||||
|
<select :value="prefs.timeFormat" class="dh-input" @change="saveAppearance({ timeFormat: $event.target.value })">
|
||||||
|
<option value="auto">{{ t("settings.appearance.timeAuto") }}</option>
|
||||||
|
<option value="24">{{ t("settings.appearance.time24") }}</option>
|
||||||
|
<option value="12">{{ t("settings.appearance.time12") }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
||||||
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
||||||
|
|||||||
Reference in New Issue
Block a user