A week that starts where the person reading it starts theirs

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 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 23:12:10 +02:00
co-authored by Claude Opus 5
parent 5a4515978f
commit 0f48093d1a
12 changed files with 154 additions and 32 deletions
+16
View File
@@ -30,6 +30,7 @@ type userRecord struct {
Locale string `json:"locale"` Locale string `json:"locale"`
DateFormat string `json:"date_format"` DateFormat string `json:"date_format"`
TimeFormat string `json:"time_format"` TimeFormat string `json:"time_format"`
WeekStart string `json:"week_start"`
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"`
@@ -99,6 +100,7 @@ func (rec userRecord) toModel() models.User {
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"), TimeFormat: orDefault(rec.TimeFormat, "auto"),
WeekStart: orDefault(rec.WeekStart, "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,
@@ -164,6 +166,7 @@ type updateMeRequest struct {
Locale *string `json:"locale"` Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"` DateFormat *string `json:"dateFormat"`
TimeFormat *string `json:"timeFormat"` TimeFormat *string `json:"timeFormat"`
WeekStart *string `json:"weekStart"`
Currency *string `json:"currency"` Currency *string `json:"currency"`
FontSize *string `json:"fontSize"` FontSize *string `json:"fontSize"`
DragLocked *bool `json:"dragLocked"` 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 app did before there was a setting; the other two say it outright, for
// the people whose region and habit disagree. // the people whose region and habit disagree.
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true} 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} 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:
@@ -333,6 +342,13 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
} }
payload["time_format"] = *in.TimeFormat 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 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")
+4
View File
@@ -262,6 +262,10 @@ var collectionsSchema = map[string][]fieldDef{
// "auto" is the region's own convention, which is what every clock in the // "auto" is the region's own convention, which is what every clock in the
// app read before this field existed. // app read before this field existed.
fSelect("time_format", []string{"auto", "24", "12"}, false), 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{ 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",
+6 -3
View File
@@ -519,9 +519,12 @@ type User struct {
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 TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR" // The day a week is drawn as starting on, wherever a client lays weekdays
FontSize string `json:"fontSize"` // small | medium | large // out in a row — the scheduler's day picker today.
Role string `json:"role"` // user | admin 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 // DragLocked holds every arrangement on this account's pages still: the
// garage, a car's tabs, its Information rows, the provider's readings. A // garage, a car's tabs, its Information rows, the provider's readings. A
+4
View File
@@ -517,6 +517,10 @@ const DESIRED = {
// "auto" is the region's own convention. Kept in step with // "auto" is the region's own convention. Kept in step with
// validTimeFormats in internal/api/me.go. // validTimeFormats in internal/api/me.go.
F.select("time_format", ["auto", "24", "12"]), 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. // 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.
@@ -16,6 +16,7 @@
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { api } from "../api"; import { api } from "../api";
import { t } from "../i18n"; import { t } from "../i18n";
import { weekdaysInOrder, weekdayShortName } from "../lib/format.js";
import Modal from "./Modal.vue"; import Modal from "./Modal.vue";
import TimeField from "./TimeField.vue"; import TimeField from "./TimeField.vue";
@@ -51,19 +52,13 @@ const error = ref("");
// it fires — same as the buttons on the page behind this. // it fires — same as the buttons on the page behind this.
const ACTIONS = ["start", "stop", "limit", "boost"]; const ACTIONS = ["start", "stop", "limit", "boost"];
// Sunday first, as Intl numbers the weekdays — the names come from the user's // The row starts on whichever day this account reads a week as starting on —
// own locale, so the row reads Pn Wt Śr… in Polish without a table here. // Settings Appearance First day of the week, following the region unless it
const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; // 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
function weekdayLabel(day) { // week. Both of these come from lib/format.js, which owns the rule for every
// 2024-01-07 was a Sunday, so this offset lands each index on its own day. // weekday row in the app.
const date = new Date(Date.UTC(2024, 0, 7 + day)); const weekdays = computed(() => weekdaysInOrder());
try {
return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(date);
} catch {
return String(day);
}
}
function toggleDay(day) { function toggleDay(day) {
everyDay.value = false; everyDay.value = false;
@@ -209,7 +204,7 @@ function browserZone() {
</label> </label>
<div class="mt-1 flex flex-wrap gap-1.5"> <div class="mt-1 flex flex-wrap gap-1.5">
<button <button
v-for="d in WEEKDAYS" v-for="d in weekdays"
:key="d" :key="d"
type="button" type="button"
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors" class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
@@ -218,7 +213,7 @@ function browserZone() {
: 'border-subtle text-muted hover:bg-sunken'" : 'border-subtle text-muted hover:bg-sunken'"
@click="toggleDay(d)" @click="toggleDay(d)"
> >
{{ weekdayLabel(d) }} {{ weekdayShortName(d) }}
</button> </button>
</div> </div>
</div> </div>
+5
View File
@@ -555,6 +555,11 @@
"time24": "24-timers", "time24": "24-timers",
"time12": "12-timers", "time12": "12-timers",
"timeExample": "Eksempel: {example}", "timeExample": "Eksempel: {example}",
"weekStart": "Første dag i ugen",
"weekAuto": "Følg regionen",
"weekMonday": "Mandag",
"weekSunday": "Søndag",
"weekExample": "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
@@ -554,6 +554,11 @@
"time24": "24-hour", "time24": "24-hour",
"time12": "12-hour", "time12": "12-hour",
"timeExample": "Example: {example}", "timeExample": "Example: {example}",
"weekStart": "First day of the week",
"weekAuto": "Follow the region",
"weekMonday": "Monday",
"weekSunday": "Sunday",
"weekExample": "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
@@ -561,6 +561,11 @@
"time24": "24-godzinny", "time24": "24-godzinny",
"time12": "12-godzinny", "time12": "12-godzinny",
"timeExample": "Przykład: {example}", "timeExample": "Przykład: {example}",
"weekStart": "Pierwszy dzień tygodnia",
"weekAuto": "Zgodnie z regionem",
"weekMonday": "Poniedziałek",
"weekSunday": "Niedziela",
"weekExample": "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",
+72
View File
@@ -151,6 +151,78 @@ function regionReadsTwelveHour() {
return twelveHourRegions.get(locale); 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 // 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 // the user's chosen region rather than the browser's own locale — otherwise the
// odometer disagrees with the dates and costs beside it. // odometer disagrees with the dates and costs beside it.
+5
View File
@@ -7,6 +7,10 @@ export const prefs = reactive({
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 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 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
@@ -58,6 +62,7 @@ export function applyProfilePrefs(profile) {
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.timeFormat = profile.timeFormat || "auto";
prefs.weekStart = profile.weekStart || "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;
+5 -13
View File
@@ -4,7 +4,8 @@ import { t } from "../i18n";
import { prefs } from "../prefs"; import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js"; import { askConfirm } from "../lib/confirm.js";
import { api } from "../api"; 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 TimeField from "../components/TimeField.vue";
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js"; import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
import ChargerImportModal from "../components/ChargerImportModal.vue"; import ChargerImportModal from "../components/ChargerImportModal.vue";
@@ -2053,18 +2054,9 @@ function taskChargersLabel(task) {
function taskDaysLabel(task) { function taskDaysLabel(task) {
const days = task.days || []; const days = task.days || [];
if (days.length === 0) return t("charging.scheduler.everyDay"); if (days.length === 0) return t("charging.scheduler.everyDay");
return [...days].sort((a, b) => a - b).map(weekdayShort).join(" "); // 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 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);
}
} }
// The task's time, on the clock the user chose. It is stored as 24-hour "HH:MM" // The task's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
+17 -1
View File
@@ -4,7 +4,8 @@ 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, formatTime } from "../lib/format.js"; import { formatDate, formatMoney, formatTime, weekdaysInOrder, weekdayShortName }
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";
@@ -197,6 +198,9 @@ const timeFormatExample = computed(() => {
return formatTime(d); return formatTime(d);
}); });
const currencyExample = computed(() => formatMoney(1234.5)); 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 // 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 // the pair can be mixed freely (English in Poland, say) rather than being
@@ -1161,6 +1165,18 @@ onBeforeUnmount(() => {
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p> <p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p>
</div> </div>
<!-- Under the clock, as the last of the three questions a region is
asked and the one it is least often asked out loud. -->
<div>
<label class="dh-label">{{ t("settings.appearance.weekStart") }}</label>
<select :value="prefs.weekStart" class="dh-input" @change="saveAppearance({ weekStart: $event.target.value })">
<option value="auto">{{ t("settings.appearance.weekAuto") }}</option>
<option value="monday">{{ t("settings.appearance.weekMonday") }}</option>
<option value="sunday">{{ t("settings.appearance.weekSunday") }}</option>
</select>
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.weekExample", { example: weekStartExample }) }}</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 })">