From 0f48093d1a5ea239d724a4ff585bc5de0812df10 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:12:10 +0200 Subject: [PATCH] A week that starts where the person reading it starts theirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler's day picker began on Sunday because that is where Intl numbers the days from, which is a fact about the API and not about anybody's week. Monday leads it across most of Europe. A row of seven buttons in the wrong order is not just odd to read — it is easy to misclick, and a misclicked day in a schedule is a car charging on the wrong night. So Settings › Appearance asks, beneath the date and the clock, as the third question a region gets: first day of the week, following the region unless it is answered outright. The same shape the time format already had, and the same "auto" default, so nothing changes for an account that never opens it. The rule lives in lib/format.js beside the clock's, with the ordering, the day names and the sort all coming from there. The two places that lay weekdays out — the picker and the line each task is summarised on — read it rather than each keeping an opinion, so a day set is written and read back in the same order. Co-Authored-By: Claude Opus 5 --- API Server/internal/api/me.go | 16 +++++ API Server/internal/bootstrap/schema.go | 4 ++ API Server/internal/models/models.go | 9 ++- API Server/scripts/setup-pocketbase.mjs | 4 ++ .../web/src/components/ChargingTaskModal.vue | 25 +++---- Web App/web/src/i18n/da.json | 5 ++ Web App/web/src/i18n/en.json | 5 ++ Web App/web/src/i18n/pl.json | 5 ++ Web App/web/src/lib/format.js | 72 +++++++++++++++++++ Web App/web/src/prefs.js | 5 ++ Web App/web/src/views/Charging.vue | 18 ++--- Web App/web/src/views/Settings.vue | 18 ++++- 12 files changed, 154 insertions(+), 32 deletions(-) diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index a79bbdf..cff9dbb 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -30,6 +30,7 @@ type userRecord struct { Locale string `json:"locale"` DateFormat string `json:"date_format"` TimeFormat string `json:"time_format"` + WeekStart string `json:"week_start"` Currency string `json:"currency"` FontSize string `json:"font_size"` DragLocked bool `json:"drag_locked"` @@ -99,6 +100,7 @@ func (rec userRecord) toModel() models.User { Locale: orDefault(rec.Locale, "en-US"), DateFormat: orDefault(rec.DateFormat, "YMD"), TimeFormat: orDefault(rec.TimeFormat, "auto"), + WeekStart: orDefault(rec.WeekStart, "auto"), Currency: orDefault(rec.Currency, "USD"), FontSize: orDefault(rec.FontSize, "medium"), DragLocked: rec.DragLocked, @@ -164,6 +166,7 @@ type updateMeRequest struct { Locale *string `json:"locale"` DateFormat *string `json:"dateFormat"` TimeFormat *string `json:"timeFormat"` + WeekStart *string `json:"weekStart"` Currency *string `json:"currency"` FontSize *string `json:"fontSize"` DragLocked *bool `json:"dragLocked"` @@ -264,6 +267,12 @@ var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true // 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} + +// Which day a week is drawn as starting on. "auto" is the chosen region's own +// convention — Monday across most of Europe, Sunday in the US — and is what +// every weekday row read before there was a setting; the other two say it +// outright, for the people whose region and habit disagree. +var validWeekStarts = map[string]bool{"auto": true, "monday": true, "sunday": 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: @@ -333,6 +342,13 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) { } payload["time_format"] = *in.TimeFormat } + if in.WeekStart != nil { + if !validWeekStarts[*in.WeekStart] { + writeError(w, http.StatusBadRequest, "weekStart must be auto, monday, or sunday") + return + } + payload["week_start"] = *in.WeekStart + } 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 c07c5f4..1fce83f 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -262,6 +262,10 @@ var collectionsSchema = map[string][]fieldDef{ // "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), + // The day a week is drawn as starting on, wherever weekdays are laid out + // in a row. "auto" is the region's own convention, which is what the + // scheduler's day picker read before this field existed. + fSelect("week_start", []string{"auto", "monday", "sunday"}, 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 f8116c4..b527cd9 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -519,9 +519,12 @@ type User struct { 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 + // The day a week is drawn as starting on, wherever a client lays weekdays + // out in a row — the scheduler's day picker today. + WeekStart string `json:"weekStart"` // auto (the region's own) | monday | sunday + Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR" + FontSize string `json:"fontSize"` // small | medium | large + Role string `json:"role"` // user | admin // DragLocked holds every arrangement on this account's pages still: the // garage, a car's tabs, its Information rows, the provider's readings. A diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 61e2fbb..0e3dc19 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -517,6 +517,10 @@ const DESIRED = { // "auto" is the region's own convention. Kept in step with // validTimeFormats in internal/api/me.go. F.select("time_format", ["auto", "24", "12"]), + // The day a week is drawn as starting on, wherever a client lays weekdays + // out in a row. "auto" is the region's own convention. Kept in step with + // validWeekStarts in internal/api/me.go. + F.select("week_start", ["auto", "monday", "sunday"]), // 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/components/ChargingTaskModal.vue b/Web App/web/src/components/ChargingTaskModal.vue index 389bba7..dc958b9 100644 --- a/Web App/web/src/components/ChargingTaskModal.vue +++ b/Web App/web/src/components/ChargingTaskModal.vue @@ -16,6 +16,7 @@ import { ref, computed, watch } from "vue"; import { api } from "../api"; import { t } from "../i18n"; +import { weekdaysInOrder, weekdayShortName } from "../lib/format.js"; import Modal from "./Modal.vue"; import TimeField from "./TimeField.vue"; @@ -51,19 +52,13 @@ const error = ref(""); // it fires — same as the buttons on the page behind this. const ACTIONS = ["start", "stop", "limit", "boost"]; -// Sunday first, as Intl numbers the weekdays — the names come from the user's -// own locale, so the row reads Pn Wt Śr… in Polish without a table here. -const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; - -function weekdayLabel(day) { - // 2024-01-07 was a Sunday, so this offset lands each index on its own day. - const date = new Date(Date.UTC(2024, 0, 7 + day)); - try { - return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(date); - } catch { - return String(day); - } -} +// The row starts on whichever day this account reads a week as starting on — +// Settings › Appearance › First day of the week, following the region unless it +// was answered outright. A computed rather than a constant, so changing the +// setting in another tab re-lays the row out instead of leaving it on the old +// week. Both of these come from lib/format.js, which owns the rule for every +// weekday row in the app. +const weekdays = computed(() => weekdaysInOrder()); function toggleDay(day) { everyDay.value = false; @@ -209,7 +204,7 @@ function browserZone() {
diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 3961a0c..ff36d78 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -555,6 +555,11 @@ "time24": "24-timers", "time12": "12-timers", "timeExample": "Eksempel: {example}", + "weekStart": "Første dag i ugen", + "weekAuto": "Følg regionen", + "weekMonday": "Mandag", + "weekSunday": "Søndag", + "weekExample": "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 4ad506b..13e17de 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -554,6 +554,11 @@ "time24": "24-hour", "time12": "12-hour", "timeExample": "Example: {example}", + "weekStart": "First day of the week", + "weekAuto": "Follow the region", + "weekMonday": "Monday", + "weekSunday": "Sunday", + "weekExample": "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 1c4980f..b855717 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -561,6 +561,11 @@ "time24": "24-godzinny", "time12": "12-godzinny", "timeExample": "Przykład: {example}", + "weekStart": "Pierwszy dzień tygodnia", + "weekAuto": "Zgodnie z regionem", + "weekMonday": "Poniedziałek", + "weekSunday": "Niedziela", + "weekExample": "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 3a38a78..d410b74 100644 --- a/Web App/web/src/lib/format.js +++ b/Web App/web/src/lib/format.js @@ -151,6 +151,78 @@ function regionReadsTwelveHour() { return twelveHourRegions.get(locale); } +// --- Weekdays -------------------------------------------------------------- +// +// A week does not start on the same day everywhere: Monday across most of +// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons +// that always begins on Sunday reads wrong to half the people looking at it, +// and reads wrong in a way that is easy to misclick — Settings › Appearance › +// First day of the week is the answer, with "auto" following the chosen region +// the way the clock setting does. +// +// Everything that lays weekdays out in a row goes through these two, so there +// is one answer to "which day comes first" rather than one per screen. Days are +// numbered the way Date.getDay() and the scheduler's stored tasks number them: +// 0 = Sunday … 6 = Saturday. + +// Whether weeks are drawn as starting on Monday right now: what the setting +// says outright, or what the region says when it is left on auto. +export function weekStartsOnMonday() { + const mode = prefs.weekStart; + if (mode === "monday") return true; + if (mode === "sunday") return false; + return regionStartsOnMonday(); +} + +// The one question "auto" asks the region. Cached per locale like the clock's, +// and for the same reason — it is asked once per weekday button. +const mondayRegions = new Map(); + +function regionStartsOnMonday() { + const locale = prefs.locale || ""; + if (!mondayRegions.has(locale)) { + // ISO 8601 numbers the days 1 = Monday … 7 = Sunday, which is what weekInfo + // reports. Browsers expose it as a method on some engines and a property on + // others, hence both. + let monday = true; + try { + const info = new Intl.Locale(locale || "en-US"); + const first = (info.getWeekInfo?.() || info.weekInfo)?.firstDay; + if (first) monday = first === 1; + } catch { + // An engine without week information, or an unusable locale. Monday is + // the safer default: it is ISO 8601's, and the convention in every region + // this app's own currency list covers bar one. + } + mondayRegions.set(locale, monday); + } + return mondayRegions.get(locale); +} + +// The seven days in the order they should be drawn, as day numbers. +export function weekdaysInOrder() { + return weekStartsOnMonday() ? [1, 2, 3, 4, 5, 6, 0] : [0, 1, 2, 3, 4, 5, 6]; +} + +// One day's short name in the user's own language, so a row reads Pn Wt Śr in +// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0 +// sits, so the offset lands each number on its own day. +export function weekdayShortName(day) { + try { + return new Intl.DateTimeFormat(prefs.locale || undefined, { weekday: "short", timeZone: "UTC" }) + .format(new Date(Date.UTC(2024, 0, 7 + day))); + } catch { + return String(day); + } +} + +// A set of days, listed in the order this account reads a week in — so the same +// three days always come out in the same order wherever they are shown. +export function sortWeekdays(days) { + const order = weekdaysInOrder(); + return [...(days || [])].sort((a, b) => order.indexOf(a) - order.indexOf(b)); +} + // 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. diff --git a/Web App/web/src/prefs.js b/Web App/web/src/prefs.js index 5725461..daef697 100644 --- a/Web App/web/src/prefs.js +++ b/Web App/web/src/prefs.js @@ -7,6 +7,10 @@ export const prefs = reactive({ locale: "en-US", dateFormat: "YMD", // YMD | DMY | MDY timeFormat: "auto", // auto (the region's own convention) | 24 | 12 + // The day a week is drawn as starting on, wherever weekdays are laid out in a + // row — the charging scheduler's day picker today. See lib/format.js, which + // owns the rule so every such row reads the same. + weekStart: "auto", // auto (the region's own convention) | monday | sunday currency: "USD", // ISO 4217 code fontSize: "medium", // small | medium | large // Holds every arrangement still: the garage, a car's tabs, its Information @@ -58,6 +62,7 @@ export function applyProfilePrefs(profile) { prefs.locale = profile.locale || "en-US"; prefs.dateFormat = profile.dateFormat || "YMD"; prefs.timeFormat = profile.timeFormat || "auto"; + prefs.weekStart = profile.weekStart || "auto"; prefs.currency = profile.currency || "USD"; prefs.fontSize = profile.fontSize || "medium"; prefs.dragLocked = !!profile.dragLocked; diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 80651de..3263771 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -4,7 +4,8 @@ import { t } from "../i18n"; import { prefs } from "../prefs"; import { askConfirm } from "../lib/confirm.js"; import { api } from "../api"; -import { formatDateTime, clockIsTwelveHour } from "../lib/format.js"; +import { formatDateTime, clockIsTwelveHour, weekdayShortName, sortWeekdays } + from "../lib/format.js"; import TimeField from "../components/TimeField.vue"; import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js"; import ChargerImportModal from "../components/ChargerImportModal.vue"; @@ -2053,18 +2054,9 @@ function taskChargersLabel(task) { function taskDaysLabel(task) { const days = task.days || []; if (days.length === 0) return t("charging.scheduler.everyDay"); - return [...days].sort((a, b) => a - b).map(weekdayShort).join(" "); -} - -// The weekday in the user's own language. 2024-01-07 was a Sunday, which is -// where Intl starts counting, so the offset lands each number on its own day. -function weekdayShort(day) { - try { - return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }) - .format(new Date(Date.UTC(2024, 0, 7 + day))); - } catch { - return String(day); - } + // Listed in the order this account reads a week in, so "Mon Fri" and the + // picker that wrote it agree about which end of the week comes first. + return sortWeekdays(days).map(weekdayShortName).join(" "); } // The task's time, on the clock the user chose. It is stored as 24-hour "HH:MM" diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index b8546ac..79fc574 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -4,7 +4,8 @@ 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, formatTime } from "../lib/format.js"; +import { formatDate, formatMoney, formatTime, weekdaysInOrder, weekdayShortName } + 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"; @@ -197,6 +198,9 @@ const timeFormatExample = computed(() => { return formatTime(d); }); const currencyExample = computed(() => formatMoney(1234.5)); +// The week as this account will now see it drawn — the clearest possible +// example, because the setting has no other visible effect on this page. +const weekStartExample = computed(() => weekdaysInOrder().map(weekdayShortName).join(" ")); // Language and region are two controls over the one stored BCP-47 locale, so // the pair can be mixed freely (English in Poland, say) rather than being @@ -1161,6 +1165,18 @@ onBeforeUnmount(() => {

{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}

+ +
+ + +

{{ t("settings.appearance.weekExample", { example: weekStartExample }) }}

+
+