Compare commits

...
3 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 e190364c77 One ceiling, one slider, in the card that owns it
The current limit sat in the control card and in the settings card at
once. Over the cloud those were not two settings that happen to agree:
the "limit" command builds its frame from the same table entry the
maxCurrentA setting does, so it was one wire field with two controls.
The control card gives it up wherever a settings card can take it —
which, now that the cloud has one, is both transports that read the
charger.

OCPP keeps its slider. A charging profile is not a setting the charger
reports, so there is no settings card to move it to, and clearing a
limit is OCPP's alone: the cloud sets a ceiling and has no message for
"no ceiling". Taking the control away there would have left those modes
unable to set a limit at all.

It arrives in the settings card as the slider it was, not as the number
box the table gave it. Slider rows stack — label and the value it is at
on one line, the track beneath, the floor hint under that — because a
ceiling is a thing you slide between two known ends rather than type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:53:48 +02:00
tajniak81andClaude Opus 5 dde5410788 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>
2026-09-03 18:32:52 +02:00
tajniak81andClaude Opus 5 3fddab6815 The control card says which charger the buttons move
The product shot was already in the account's charger list and already
relayed; only the information card ever drew it. The card whose buttons
start a session had no sign at all of which charger it meant — the name
lives two cards further down, and two A5191s on one account look alike in
a dropdown.

Identified by the serial in force rather than by the record highlighted
in the list beside it. They are usually the same charger, and when they
are not, a picture of the other one is worse than no picture. Either
source will do: the account's own list, or the live half held per
provider, which carry the same image.

A shot that will not load is remembered by its URL, so the picture stops
being drawn and comes back on its own if the service starts answering for
it again — nothing to reset when the serial changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:20:33 +02:00
11 changed files with 163 additions and 17 deletions
+15
View File
@@ -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")
+3
View File
@@ -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",
+1
View File
@@ -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
+3
View File
@@ -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.
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+19 -2
View File
@@ -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
+2
View File
@@ -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;
+85 -14
View File
@@ -602,7 +602,10 @@ const MQTT_SETTING_BLOCKS = [
id: "charging", id: "charging",
title: "blockCharging", title: "blockCharging",
fields: [ fields: [
{ key: "maxCurrentA", at: "settings.maxCurrentA", label: "maxCurrentSet", type: "number", min: LIMIT_FLOOR, max: null, step: 1, unit: "A" }, // A slider rather than a box, like the control card had and the Modbus
// settings card still has: it is the same value they set, and a ceiling
// is a thing you slide between two known ends rather than type.
{ key: "maxCurrentA", at: "settings.maxCurrentA", label: "maxCurrentSet", type: "slider", min: LIMIT_FLOOR, max: null, step: 1, unit: "A", hint: "limitFloorHint" },
{ key: "autoStart", at: "settings.autoStart", label: "autoStart", type: "switch" }, { key: "autoStart", at: "settings.autoStart", label: "autoStart", type: "switch" },
{ key: "randomDelay", at: "settings.randomDelay", label: "randomDelay", type: "switch" }, { key: "randomDelay", at: "settings.randomDelay", label: "randomDelay", type: "switch" },
{ key: "plugLock", at: "settings.plugLock", label: "plugLock", type: "switch" }, { key: "plugLock", at: "settings.plugLock", label: "plugLock", type: "switch" },
@@ -1000,6 +1003,31 @@ function liveFor(c) {
return chargerLive.value[c.providerChargerId] || chargerLive.value[c.serial] || null; return chargerLive.value[c.providerChargerId] || chargerLive.value[c.serial] || null;
} }
// --- The charger the buttons act on, as a picture and a name ------------------
//
// The control card drives whichever serial is in force, which is not always the
// record highlighted in the list beside it, so it identifies its charger by that
// serial rather than by the selection. Two sources carry the same product shot:
// the account's own charger list, and the live half held per provider. Either
// will do; the account's is the one that arrives without the home tab having
// been opened.
//
// It lives here rather than up with the other ctl* values because both of its
// sources do, and a lookup reads better next to the map it reads from.
function accountCharger(sn) {
if (!sn) return null;
return chargers.value.find((c) => c.sn === sn) || chargerLive.value[sn] || null;
}
const ctlImageUrl = computed(() => accountCharger(ctlSerial.value.trim())?.imageUrl || "");
const ctlChargerName = computed(() => accountCharger(ctlSerial.value.trim())?.name || "");
// A product shot is a URL from the service, and a URL can 404. Remembered per
// URL rather than per charger, so the picture simply stops being drawn and comes
// back on its own if the service starts answering for it again — no reset to
// forget when the serial changes.
const imageFailed = ref({});
// How the charger is registered on the account, in the service's own terms. // How the charger is registered on the account, in the service's own terms.
function sourcesLabel(sources) { function sourcesLabel(sources) {
if (!sources?.length) return ""; if (!sources?.length) return "";
@@ -1929,6 +1957,26 @@ onMounted(async () => {
<div v-show="isOpen('control')"> <div v-show="isOpen('control')">
<!-- Which charger these buttons act on. The card said nothing about
that before: the name is two cards further down, and the picture
is the fastest way to tell two chargers on one account apart. -->
<div
v-if="ctlImageUrl || ctlChargerName"
class="mt-3 flex items-center gap-3 rounded-control bg-sunken p-3"
>
<img
v-if="ctlImageUrl && !imageFailed[ctlImageUrl]"
:src="ctlImageUrl"
alt=""
class="h-16 w-16 shrink-0 rounded object-contain"
@error="imageFailed[ctlImageUrl] = true"
/>
<div class="min-w-0">
<p v-if="ctlChargerName" class="truncate text-sm font-semibold text-strong">{{ ctlChargerName }}</p>
<p class="data truncate text-[11px] text-muted">{{ ctlSerial }}</p>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2"> <div class="mt-3 grid grid-cols-2 gap-2">
<div class="rounded-control bg-sunken px-3 py-2"> <div class="rounded-control bg-sunken px-3 py-2">
<div class="data text-sm font-semibold text-strong">{{ ctlStatusLabel }}</div> <div class="data text-sm font-semibold text-strong">{{ ctlStatusLabel }}</div>
@@ -1968,14 +2016,18 @@ onMounted(async () => {
</button> </button>
</div> </div>
<!-- The limit, everywhere but the local path. There the settings card <!-- The limit, only where nothing else owns it. Both transports that
owns it: it is the charger's own ceiling register, read back and read the charger have a settings card now, and on both the limit is
written, and the same slider in two cards was the same register the charger's own ceiling the same register over Modbus, the same
twice. Neither OCPP nor the cloud has a settings card to move it to wire field over the cloud, where "limit" and the maxCurrentA
a charging profile is not a setting the charger reports so here setting are built from one table. The same slider in two cards was
it stays. Clearing it is OCPP's alone: the cloud sets a ceiling and that one value twice.
has no message for "no ceiling". -->
<div v-if="!ctlIsModbus" class="mt-3"> OCPP is the exception and keeps it: a charging profile is not a
setting the charger reports, so there is no settings card to move
it to. Clearing it is OCPP's alone too — the cloud sets a ceiling
and has no message for "no ceiling". -->
<div v-if="!ctlReadsDevice" class="mt-3">
<label class="dh-label flex justify-between"> <label class="dh-label flex justify-between">
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span> <span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
</label> </label>
@@ -2370,11 +2422,29 @@ onMounted(async () => {
<section v-for="block in mqttSettingBlocks" :key="block.id" class="mt-2 rounded-control bg-sunken p-3"> <section v-for="block in mqttSettingBlocks" :key="block.id" class="mt-2 rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t(`charging.modbus.${block.title}`) }}</h4> <h4 class="eyebrow">{{ t(`charging.modbus.${block.title}`) }}</h4>
<div class="mt-2 flex flex-col gap-2"> <div class="mt-2 flex flex-col gap-2">
<div <template v-for="f in block.fields" :key="f.key || f.from">
v-for="f in block.fields" <!-- A slider needs the width, so its row stacks: the label and
:key="f.key || f.from" the value it is at on one line, the track under them. -->
class="flex items-center justify-between gap-3" <div v-if="f.type === 'slider'">
> <label class="dh-label flex justify-between" :for="`set-${f.key}`">
<span>{{ t(`charging.modbus.${f.label}`) }}</span>
<span class="data text-body">{{ mqttDraft[f.key] }} {{ f.unit }}</span>
</label>
<input
:id="`set-${f.key}`"
v-model.number="mqttDraft[f.key]"
type="range"
:min="f.min"
:max="fieldMax(f)"
:step="f.step"
class="w-full accent-[var(--accent)]"
/>
<p v-if="f.hint" class="mt-1 text-[11px] text-muted">
{{ t(`charging.modbus.${f.hint}`, { amps: f.min }) }}
</p>
</div>
<div v-else class="flex items-center justify-between gap-3">
<label class="dh-label !mb-0 min-w-0 grow" :for="`set-${f.key || f.from}`"> <label class="dh-label !mb-0 min-w-0 grow" :for="`set-${f.key || f.from}`">
{{ t(`charging.modbus.${f.label}`) }} {{ t(`charging.modbus.${f.label}`) }}
</label> </label>
@@ -2433,6 +2503,7 @@ onMounted(async () => {
/> />
</span> </span>
</div> </div>
</template>
</div> </div>
<p v-if="block.warning" class="mt-2 text-[11px]" style="color: var(--warning-600)"> <p v-if="block.warning" class="mt-2 text-[11px]" style="color: var(--warning-600)">
+20 -1
View File
@@ -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 })">