From 2b592abd5fcd78baa8a8bd45442772792011b87e Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 18 Jul 2026 11:31:33 +0200 Subject: [PATCH] client_v2: make DateTimeField week-start and clock locale-aware The custom date/time picker used Intl only for month/weekday names but hardcoded a Monday-first week and a 24-hour clock. Drive both from the active locale via Intl instead: - Week start comes from Intl.Locale weekInfo.firstDay (Sunday-first for zh-TW/pt-BR/ja-JP/pt-PT/ta-IN/th-TH, Saturday for fa-IR, Monday otherwise), reordering the weekday header and calendar grid. Falls back to Monday on browsers without week info. - Locales that prefer a 12-hour clock get a 12/1..11 hour dropdown plus a localized AM/PM meridiem selector; the emitted value stays a 0-23 hour so nothing downstream changes. - Trigger label formats its time part through Intl too. Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64 --- .../src/lib/components/DateTimeField.svelte | 109 ++++++++++++++++-- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/client_v2/src/lib/components/DateTimeField.svelte b/client_v2/src/lib/components/DateTimeField.svelte index 00830795..c3f13b64 100644 --- a/client_v2/src/lib/components/DateTimeField.svelte +++ b/client_v2/src/lib/components/DateTimeField.svelte @@ -20,13 +20,36 @@ let { value, oninput }: Props = $props(); const pad = (n: number) => String(n).padStart(2, '0'); - // Month and weekday names come from Intl in the active locale. Weekdays are - // Monday-first (2021-11-01 was a Monday); months use a mid-month date to dodge - // timezone edge cases. + // Month and weekday names come from Intl in the active locale; months use a + // mid-month date to dodge timezone edge cases. const dtLocale = $derived(languages[getLocale()].code); + + // First day of the week per locale (1 = Monday … 7 = Sunday). Some locales + // (e.g. en-US) start the week on Sunday, others on Saturday. Fall back to + // Monday if the browser lacks Intl.Locale week info. + const firstDay = $derived.by(() => { + try { + const loc = new Intl.Locale(dtLocale) as Intl.Locale & { + weekInfo?: { firstDay: number }; + getWeekInfo?: () => { firstDay: number }; + }; + const info = loc.getWeekInfo?.() ?? loc.weekInfo; + if (info?.firstDay) return info.firstDay; + } catch { + /* older browsers: fall through */ + } + return 1; + }); + // JS getDay() index (0 = Sunday) of the locale's first weekday. + const firstJsDay = $derived(firstDay % 7); + + // Weekday short names, ordered starting from the locale's first day. + // 2021-10-31 was a Sunday, so `Oct 31 + jsDay` yields a date with that weekday. const WEEKDAYS = $derived( Array.from({ length: 7 }, (_v, i) => - new Intl.DateTimeFormat(dtLocale, { weekday: 'short' }).format(new Date(2021, 10, 1 + i)) + new Intl.DateTimeFormat(dtLocale, { weekday: 'short' }).format( + new Date(2021, 9, 31 + ((firstJsDay + i) % 7)) + ) ) ); const MONTHS = $derived( @@ -35,6 +58,23 @@ ) ); + // Whether the active locale prefers a 12-hour clock (AM/PM) over 24-hour. + const hour12 = $derived.by(() => { + try { + return new Intl.DateTimeFormat(dtLocale, { hour: 'numeric' }).resolvedOptions().hour12 ?? false; + } catch { + return false; + } + }); + // Localized AM/PM (day-period) labels for the 12-hour meridiem picker. + const meridiems = $derived.by(() => { + const label = (h: number) => + new Intl.DateTimeFormat(dtLocale, { hour: 'numeric', hour12: true }) + .formatToParts(new Date(2021, 0, 1, h)) + .find((p) => p.type === 'dayPeriod')?.value; + return { am: label(9) ?? 'AM', pm: label(21) ?? 'PM' }; + }); + // --- popover open/close + positioning ------------------------------------ let root = $state(); let pop = $state(); @@ -84,12 +124,18 @@ day: 'numeric', year: 'numeric' }).format(d); - return `${datePart} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + // Time part follows the locale's 12/24-hour preference. + const timePart = new Intl.DateTimeFormat(dtLocale, { + hour: '2-digit', + minute: '2-digit' + }).format(d); + return `${datePart} ${timePart}`; }); - // Calendar grid: 6 weeks × 7 days, Monday-first, null = blank leading cell. + // Calendar grid: 6 weeks × 7 days, ordered from the locale's first weekday, + // null = blank leading cell. let grid = $derived.by(() => { - const firstDow = (new Date(viewYear, viewMonth, 1).getDay() + 6) % 7; // 0 = Mon + const firstDow = (new Date(viewYear, viewMonth, 1).getDay() - firstJsDay + 7) % 7; const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const cells: (number | null)[] = []; for (let i = 0; i < firstDow; i++) cells.push(null); @@ -108,8 +154,14 @@ } const hours = Array.from({ length: 24 }, (_, i) => i); + // 12-hour clock face, ordered 12, 1, 2 … 11. + const hours12 = [12, ...Array.from({ length: 11 }, (_, i) => i + 1)]; const minutes = Array.from({ length: 60 }, (_, i) => i); + // 12-hour view of the internal 0-23 `hour`. + const displayHour = $derived(hour % 12 === 0 ? 12 : hour % 12); + const isPm = $derived(hour >= 12); + // --- emit ---------------------------------------------------------------- function commit() { if (selYear === null || selMonth === null || selDay === null) return; @@ -127,6 +179,16 @@ hour = Number((e.currentTarget as HTMLSelectElement).value); commit(); } + function onHour12(e: Event) { + const h12 = Number((e.currentTarget as HTMLSelectElement).value); // 1-12 + hour = (h12 % 12) + (isPm ? 12 : 0); + commit(); + } + function onMeridiem(e: Event) { + const pm = (e.currentTarget as HTMLSelectElement).value === 'pm'; + hour = (hour % 12) + (pm ? 12 : 0); + commit(); + } function onMinute(e: Event) { minute = Number((e.currentTarget as HTMLSelectElement).value); commit(); @@ -253,17 +315,36 @@
{m['datetime.time']()} - + {#if hour12} + + {:else} + + {/if} : + {#if hour12} + + {/if}
@@ -407,6 +488,10 @@ .colon { color: var(--text-dim); } + .meridiem { + margin-left: 6px; + text-transform: uppercase; + } .actions { display: flex; align-items: center;