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 }) }}

+
+