feat(settings): every date picker starts its week on the day you pick

Settings > General > Language & region gets "Week starts on" (Monday,
Sunday or Saturday), and Admin > User Defaults a default for everyone who
has not picked one. Monday stays the built-in value, so nothing changes
for anyone who leaves it alone.

Both calendar implementations follow it: CustomDatePicker, used by trip
dates, bookings, costs, to-dos and the admin school holidays on desktop
and phone, and the journey date picker, whose weekday headers were
hardcoded English and now speak the app language. The grid math and the
header row live in one helper (utils/calendarWeek.ts). The day names on
the options come from Intl, so only the label and a hint needed
translating. Vacay keeps its own per-plan week start.

The value is validated like the other regional settings: VALID_VALUES for
admin defaults, the display-settings MCP tool, and the managed key list.

Addresses discussion #2029.
This commit is contained in:
Maurice
2026-09-24 21:56:10 +02:00
parent 196dc12762
commit 7ce31756a3
84 changed files with 511 additions and 106 deletions
@@ -787,3 +787,35 @@ describe('CustomDateTimePicker branches', () => {
expect(onChange).toHaveBeenCalledWith('2026-03-15');
});
});
// ─── Week start (#2029) ───────────────────────────────────────────────────────
describe('CustomDatePicker week start (#2029)', () => {
const onChange = vi.fn();
const original = useSettingsStore.getState().settings;
afterEach(() => useSettingsStore.setState({ settings: original }));
/** The weekday header row and how many blanks precede the 1st, read off the open calendar. */
async function openMarch2026(weekStart?: 'monday' | 'sunday' | 'saturday') {
useSettingsStore.setState({ settings: { ...original, week_start: weekStart } });
const user = userEvent.setup();
render(<CustomDatePicker value="2026-03-15" onChange={onChange} />);
await user.click(screen.getAllByRole('button')[0]);
const day1 = screen.getAllByRole('button').find((b) => b.textContent?.trim() === '1')!;
const grid = day1.parentElement!;
return { header: grid.previousElementSibling!.textContent, blanks: Array.from(grid.children).indexOf(day1) };
}
it('FE-COMP-DATEPICKER-033: rows start on Monday when nothing is set', async () => {
// 1 March 2026 is a Sunday: the last column of a Monday-first row.
expect(await openMarch2026(undefined)).toEqual({ header: 'MTWTFSS', blanks: 6 });
});
it('FE-COMP-DATEPICKER-034: rows start on the day the user picked', async () => {
expect(await openMarch2026('sunday')).toEqual({ header: 'SMTWTFS', blanks: 0 });
});
it('FE-COMP-DATEPICKER-035: a Saturday start puts the Sunday 1st in the second column', async () => {
expect(await openMarch2026('saturday')).toEqual({ header: 'SSMTWTF', blanks: 1 });
});
});
@@ -4,13 +4,12 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '../../i18n';
import { useRemeasureSignal } from '../../hooks/useAnchoredPosition';
import { useWeekStartDay } from '../../hooks/useWeekStartDay';
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek';
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getWeekday(year: number, month: number, day: number): number {
return new Date(year, month, day).getDay();
}
const YEAR_PAGE_SIZE = 12;
type CalendarView = 'days' | 'months' | 'years';
@@ -41,6 +40,7 @@ export function CustomDatePicker({
max,
}: CustomDatePickerProps) {
const { locale, t } = useTranslation();
const weekStart = useWeekStartDay();
const [open, setOpen] = useState(false);
const [view, setView] = useState<CalendarView>('days');
const [yearPageStart, setYearPageStart] = useState(0);
@@ -140,10 +140,8 @@ export function CustomDatePicker({
};
const days = daysInMonth(viewYear, viewMonth);
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7;
const weekdays = Array.from({ length: 7 }, (_, i) =>
new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' })
);
const startDay = leadingBlanks(viewYear, viewMonth, weekStart);
const weekdays = weekdayLabels(locale, weekStart);
const monthNames = Array.from({ length: 12 }, (_, i) =>
new Date(viewYear, i).toLocaleDateString(locale, { month: 'short' })