Compare commits
3
Commits
9d4aecb668
...
e190364c77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e190364c77 | ||
|
|
dde5410788 | ||
|
|
3fddab6815 |
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -602,7 +602,10 @@ const MQTT_SETTING_BLOCKS = [
|
||||
id: "charging",
|
||||
title: "blockCharging",
|
||||
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: "randomDelay", at: "settings.randomDelay", label: "randomDelay", 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;
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
function sourcesLabel(sources) {
|
||||
if (!sources?.length) return "";
|
||||
@@ -1929,6 +1957,26 @@ onMounted(async () => {
|
||||
|
||||
<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="rounded-control bg-sunken px-3 py-2">
|
||||
<div class="data text-sm font-semibold text-strong">{{ ctlStatusLabel }}</div>
|
||||
@@ -1968,14 +2016,18 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The limit, everywhere but the local path. There the settings card
|
||||
owns it: it is the charger's own ceiling register, read back and
|
||||
written, and the same slider in two cards was the same register
|
||||
twice. Neither OCPP nor the cloud has a settings card to move it to
|
||||
— a charging profile is not a setting the charger reports — so here
|
||||
it stays. Clearing it is OCPP's alone: the cloud sets a ceiling and
|
||||
has no message for "no ceiling". -->
|
||||
<div v-if="!ctlIsModbus" class="mt-3">
|
||||
<!-- The limit, only where nothing else owns it. Both transports that
|
||||
read the charger have a settings card now, and on both the limit is
|
||||
the charger's own ceiling — the same register over Modbus, the same
|
||||
wire field over the cloud, where "limit" and the maxCurrentA
|
||||
setting are built from one table. The same slider in two cards was
|
||||
that one value twice.
|
||||
|
||||
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">
|
||||
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
|
||||
</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">
|
||||
<h4 class="eyebrow">{{ t(`charging.modbus.${block.title}`) }}</h4>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="f in block.fields"
|
||||
:key="f.key || f.from"
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<template v-for="f in block.fields" :key="f.key || f.from">
|
||||
<!-- A slider needs the width, so its row stacks: the label and
|
||||
the value it is at on one line, the track under them. -->
|
||||
<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}`">
|
||||
{{ t(`charging.modbus.${f.label}`) }}
|
||||
</label>
|
||||
@@ -2433,6 +2503,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="block.warning" class="mt-2 text-[11px]" style="color: var(--warning-600)">
|
||||
|
||||
@@ -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(() => {
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.dateExample", { example: dateFormatExample }) }}</p>
|
||||
</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>
|
||||
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
||||
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
||||
|
||||
Reference in New Issue
Block a user