From 4f1fc12860da34f24ffa7bc77dc4933dafc08eb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:16:14 +0000 Subject: [PATCH 1/4] feat(i18n,plugin-calendar,plugin-timeline): the week starts on the locale's first day (objectui#11675) `@object-ui/i18n` exports `firstDayOfWeek(locale)`, which reads the engine's `Intl.Locale` week info (`getWeekInfo()` or the `weekInfo` accessor) and falls back to CLDR's region table where the engine has neither. The calendar's month grid, weekday heads, week view, header range and date popover, and the timeline's "This week" / "Next week" buckets, all start the week there instead of on a hard-coded Sunday (calendar) or Monday (timeline). Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- packages/i18n/src/index.ts | 5 + packages/i18n/src/utils/first-day-of-week.ts | 125 ++++++++++++++++++ packages/i18n/src/utils/index.ts | 2 + packages/plugin-calendar/src/CalendarView.tsx | 78 ++++++++--- .../plugin-timeline/src/ObjectTimeline.tsx | 30 ++++- 5 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 packages/i18n/src/utils/first-day-of-week.ts diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 5f2600262d..6101cf79db 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -165,3 +165,8 @@ export { shouldGroupDisplayNumber, type DisplayNumberFormatOptions, } from './utils/index.js'; + +// objectui#11675 — the first day of the week for a locale tag. The calendar's +// grids and the timeline's week buckets read it from the tag they format with, +// so the week starts where the locale says and never on a hard-coded day. +export { firstDayOfWeek, type WeekdayIndex } from './utils/index.js'; diff --git a/packages/i18n/src/utils/first-day-of-week.ts b/packages/i18n/src/utils/first-day-of-week.ts new file mode 100644 index 0000000000..0c7138f632 --- /dev/null +++ b/packages/i18n/src/utils/first-day-of-week.ts @@ -0,0 +1,125 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The first day of the week for a BCP-47 locale tag (objectui#11675). + * + * The first day of the week follows the user's locale, as dates, times and + * numbers do (ruling B on objectui#11675: there is no separate week-start + * setting). So a week grid, a weekday header row and a "this week" bound all + * read it from the tag they already format with, and never from a hard-coded + * Sunday or Monday: `en-US` starts on Sunday, `en-GB` and `zh-CN` on Monday. + * + * ## The engine's own week info comes first + * + * `Intl.Locale` carries CLDR's week data for the tag's region, including a + * `-u-fw-` keyword on the tag. It has two spellings: the `getWeekInfo()` method + * of the current Intl Locale Info proposal, and the `weekInfo` accessor engines + * shipped first. Both are read, the method first. Measured while writing this: + * Chromium 141 has both, Node 22 (the test and CI runtime) has the accessor + * only. Both count ISO weekdays, 1 (Monday) to 7 (Sunday). + * + * ## Where the engine has none, CLDR's table answers + * + * An engine with neither spelling gets {@link NON_MONDAY_REGIONS}: CLDR's + * `weekData` `firstDay` rows, read by region. The region is the tag's own, or + * the one CLDR's likely subtags give it (`Intl.Locale.prototype.maximize`), so + * `en` reads `US` and `zh` reads `CN`. A region the table does not list, and a + * tag with no region even after `maximize()`, reads Monday, CLDR's world + * default (`001`). This path reads the region only: a `-u-fw-` or `-u-rg-` + * keyword is the engine path's alone. + * + * The table is a snapshot of one CLDR release, the one in Node's ICU at the + * time of writing (`process.versions.cldr` printed `47.0`). Engines carry their + * own release, and releases differ: Chromium 141 starts `AE` on Saturday where + * CLDR 47 starts it on Monday. That is why the engine's answer wins wherever + * there is one. What re-derives the table against the running engine is + * `__tests__/firstDayOfWeek-11675.test.ts`, region by region. + * + * ## A tag `Intl` refuses is refused here too + * + * A malformed tag throws `Intl.Locale`'s `RangeError`, as `Intl.DateTimeFormat` + * and `toLocaleDateString` do for the same tag. Every caller formats its dates + * with the tag it hands this function, so a tag that reached here and is + * malformed fails the caller's own formatting the same way. ⛔ No Sunday or + * Monday is guessed for it. + */ + +/** A weekday as `Date.prototype.getDay` numbers it: 0 is Sunday, 6 is Saturday. */ +export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +/** The part of the engine's week info this function reads. */ +interface WeekInfoReading { + readonly firstDay: number; +} + +/** + * The two spellings of `Intl.Locale`'s week info. Neither is in the `ES2020` + * lib this package compiles against, and an engine may have either, both or + * neither, so both are optional. + */ +interface LocaleWeekInfoFaces { + getWeekInfo?: () => WeekInfoReading; + readonly weekInfo?: WeekInfoReading; +} + +/** ISO weekday 1 (Monday) to 7 (Sunday), as a {@link WeekdayIndex}. */ +const BY_ISO_WEEKDAY: readonly WeekdayIndex[] = [1, 2, 3, 4, 5, 6, 0]; + +function fromIsoWeekday(day: number): WeekdayIndex | undefined { + return Number.isInteger(day) && day >= 1 && day <= 7 ? BY_ISO_WEEKDAY[day - 1] : undefined; +} + +/** + * CLDR 47's `weekData` `firstDay` rows other than Monday, by region. Regions + * are the canonical codes `Intl.Locale` reports (`BU` reads `MM`), so the + * deprecated aliases CLDR also lists are left out. + */ +const NON_MONDAY_REGIONS: ReadonlyArray = [ + [ + 0, + 'AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ ' + + 'NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW', + ], + [5, 'MV'], + [6, 'AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'], +]; + +/** CLDR's world default (`001`): the week starts on Monday. */ +const WORLD_FIRST_DAY: WeekdayIndex = 1; + +const FIRST_DAY_BY_REGION: ReadonlyMap = new Map( + NON_MONDAY_REGIONS.flatMap(([day, regions]) => regions.split(' ').map((region) => [region, day] as const)), +); + +function engineFirstDay(locale: Intl.Locale): WeekdayIndex | undefined { + const faces: Intl.Locale & LocaleWeekInfoFaces = locale; + if (typeof faces.getWeekInfo === 'function') return fromIsoWeekday(faces.getWeekInfo().firstDay); + const info = faces.weekInfo; + return info ? fromIsoWeekday(info.firstDay) : undefined; +} + +function tableFirstDay(locale: Intl.Locale): WeekdayIndex { + const region = locale.region ?? locale.maximize().region; + return (region !== undefined ? FIRST_DAY_BY_REGION.get(region) : undefined) ?? WORLD_FIRST_DAY; +} + +/** + * The first day of the week in `locale`, numbered as `Date.prototype.getDay` + * numbers weekdays (0 is Sunday), which is also what react-day-picker's + * `weekStartsOn` takes. Read the module header for where the answer comes from. + * + * @example + * firstDayOfWeek('en-US'); // 0, Sunday + * firstDayOfWeek('en-GB'); // 1, Monday + * firstDayOfWeek('zh-CN'); // 1, Monday + */ +export function firstDayOfWeek(locale: string): WeekdayIndex { + const parsed = new Intl.Locale(locale); + return engineFirstDay(parsed) ?? tableFirstDay(parsed); +} diff --git a/packages/i18n/src/utils/index.ts b/packages/i18n/src/utils/index.ts index 832c74b31f..653cac1842 100644 --- a/packages/i18n/src/utils/index.ts +++ b/packages/i18n/src/utils/index.ts @@ -31,3 +31,5 @@ export { shouldGroupDisplayNumber, type DisplayNumberFormatOptions, } from './number-display.js'; + +export { firstDayOfWeek, type WeekdayIndex } from './first-day-of-week.js'; diff --git a/packages/plugin-calendar/src/CalendarView.tsx b/packages/plugin-calendar/src/CalendarView.tsx index 7899b7d5a3..7384bbe436 100644 --- a/packages/plugin-calendar/src/CalendarView.tsx +++ b/packages/plugin-calendar/src/CalendarView.tsx @@ -23,7 +23,7 @@ import { PopoverContent, PopoverTrigger } from "@object-ui/components" -import { createSafeTranslation, useDisplayLocale } from "@object-ui/i18n" +import { createSafeTranslation, firstDayOfWeek, useDisplayLocale, type WeekdayIndex } from "@object-ui/i18n" const DEFAULT_EVENT_COLOR = "bg-blue-100 text-blue-900 border border-blue-200" const STABLE_DEFAULT_DATE = new Date() @@ -183,6 +183,12 @@ function CalendarView({ // never the UI language (objectui#10442). const displayLocale = useDisplayLocale() const effectiveLocale = locale !== "default" ? locale : displayLocale + // The first day of the week is the locale's, read from the tag every date + // here formats with (objectui#11675): `en-US` starts on Sunday, `en-GB` and + // `zh-CN` on Monday. The month grid and its weekday heads, the week view's + // columns, the header's week range and the date popover all take this one + // answer, so no two of them start the week on different days. + const weekStart = firstDayOfWeek(effectiveLocale) // Sync state if props change React.useEffect(() => { @@ -254,10 +260,10 @@ function CalendarView({ year: "numeric", }) } else if (selectedView === "week") { - const weekStart = getWeekStart(selectedDate) - const weekEnd = new Date(weekStart) + const firstDay = getWeekStart(selectedDate, weekStart) + const weekEnd = new Date(firstDay) weekEnd.setDate(weekEnd.getDate() + 6) - return `${weekStart.toLocaleDateString(effectiveLocale, { + return `${firstDay.toLocaleDateString(effectiveLocale, { month: "short", day: "numeric", })} - ${weekEnd.toLocaleDateString(effectiveLocale, { @@ -358,9 +364,17 @@ function CalendarView({ `Date` instance through untouched): that names no month, and react-day-picker throws on an invalid `defaultMonth`, so it opens on today instead, as it always did. + + Its weeks start on the grids' first day (objectui#11675). + react-day-picker would otherwise read the date-fns locale's + own `weekStartsOn`, which is not CLDR's for every tag (date-fns + has no `es-MX`, so that tag reads `es`, a Monday start, where + CLDR starts Mexico's week on Sunday), and which reads `enUS`, + a Sunday start, until the locale has loaded. */} = 0; i--) { + for (let i = leadingDays - 1; i >= 0; i--) { const prevDate = new Date(firstDay.getTime()) prevDate.setDate(prevDate.getDate() - (i + 1)) days.push(prevDate) @@ -533,23 +559,27 @@ interface MonthViewProps { date: Date events: CalendarViewEvent[] locale?: string + /** The first day of each row and of the weekday heads (objectui#11675). */ + weekStart: WeekdayIndex onEventClick?: (event: CalendarViewEvent) => void onDateClick?: (date: Date) => void onEventDrop?: (event: CalendarViewEvent, newStart: Date, newEnd?: Date) => void } -function MonthView({ date, events, locale = "default", onEventClick, onDateClick, onEventDrop }: MonthViewProps) { - const days = React.useMemo(() => getMonthDays(date), [date.getFullYear(), date.getMonth()]) +function MonthView({ date, events, locale = "default", weekStart, onEventClick, onDateClick, onEventDrop }: MonthViewProps) { + const days = React.useMemo(() => getMonthDays(date, weekStart), [date.getFullYear(), date.getMonth(), weekStart]) const today = React.useMemo(() => new Date(), []) const { t } = useCalendarTranslation() const weekDays = React.useMemo(() => { + // 7 January 2024 is a Sunday, so `weekStart` days after it is the week's + // first day, and the heads run from there in the grid's column order. const refSunday = new Date(2024, 0, 7) return Array.from({ length: 7 }, (_, i) => { const d = new Date(refSunday) - d.setDate(d.getDate() + i) + d.setDate(d.getDate() + weekStart + i) return d.toLocaleDateString(locale, { weekday: "short" }) }) - }, [locale]) + }, [locale, weekStart]) const [draggedEventId, setDraggedEventId] = React.useState(null) const [dropTargetIndex, setDropTargetIndex] = React.useState(null) @@ -573,11 +603,12 @@ function MonthView({ date, events, locale = "default", onEventClick, onDateClick const isSpanEnd = cursor.getTime() === eventEnd.getTime() // Show the title on: // - the event's start day - // - Sundays (first day of each new week the span enters) + // - the first day of each new week the span enters (the + // locale's first day, the one each grid row opens on) // - the event's end day (so the user sees where it ends, and so // the right-edge resize handle gets full hit area) // Single-day events naturally satisfy isSpanStart && isSpanEnd. - const isTitleDay = isSpanStart || isSpanEnd || cursor.getDay() === 0 + const isTitleDay = isSpanStart || isSpanEnd || cursor.getDay() === weekStart const arr = map.get(key) const entry = { event, isTitleDay, isSpanStart, isSpanEnd } if (arr) { @@ -589,7 +620,7 @@ function MonthView({ date, events, locale = "default", onEventClick, onDateClick } } return map - }, [events]) + }, [events, weekStart]) // Drag mode: "move" shifts the entire event so the day cell the user // grabbed lands on the drop-target day. "resize-end" only adjusts the @@ -855,6 +886,8 @@ interface TimeGridViewProps { date: Date events: CalendarViewEvent[] locale?: string + /** The week view's first column (objectui#11675); the day view ignores it. */ + weekStart: WeekdayIndex slotMinutes?: number onEventClick?: (event: CalendarViewEvent) => void onEventDrop?: (event: CalendarViewEvent, newStart: Date, newEnd?: Date) => void @@ -989,6 +1022,7 @@ function TimeGridView({ date, events, locale = "default", + weekStart, slotMinutes = 30, onEventClick, onEventDrop, @@ -1001,13 +1035,13 @@ function TimeGridView({ // Day columns const days = React.useMemo(() => { if (mode === "day") return [startOfDay(date)] - const weekStart = getWeekStart(date) + const firstDay = getWeekStart(date, weekStart) return Array.from({ length: 7 }, (_, i) => { - const d = new Date(weekStart) - d.setDate(weekStart.getDate() + i) + const d = new Date(firstDay) + d.setDate(firstDay.getDate() + i) return startOfDay(d) }) - }, [mode, date.getFullYear(), date.getMonth(), date.getDate()]) + }, [mode, date.getFullYear(), date.getMonth(), date.getDate(), weekStart]) const today = React.useMemo(() => new Date(), []) diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index efad3db8cd..b44e43f8f2 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -12,6 +12,7 @@ import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema import { NavigationOverlay } from '@object-ui/components'; import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt, toDisplayDate } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; +import { firstDayOfWeek, useDisplayLocale } from '@object-ui/i18n'; import { usePullToRefresh } from '@object-ui/mobile'; import { z } from 'zod'; import { TimelineRenderer } from './renderer'; @@ -544,6 +545,9 @@ export const ObjectTimeline: React.FC = ({ const rawData = (props as any).data || boundData || fetchedData; const { t } = useTimelineTranslation(); const { fieldOptionLabel } = useSafeFieldLabel(); + // The tag this feed's dates format with (the renderer reads the same hook), + // and so the tag its week buckets start the week from (objectui#11675). + const displayLocale = useDisplayLocale(); // Resolve TimelineConfig with backwards-compatible fallbacks (computed // outside the items-derivation block so we can also use them for @@ -738,13 +742,25 @@ export const ObjectTimeline: React.FC = ({ // - otherwise → date bucket (Earlier / Today / Tomorrow / This week // / Next week / Later / No date) so the timeline doesn't render // as one undifferentiated stripe. + // + // The week is the locale's (objectui#11675): "This week" runs from the + // first day `firstDayOfWeek` gives the display locale, so an `en-US` feed + // starts its week on Sunday and an `en-GB` or `zh-CN` one on Monday, the + // day the calendar's grids start on for the same tag. Every bound is a + // local midnight stepped on the local calendar, ⛔ never a multiple of 24 + // hours from today: across a DST change, N times 24 hours from a midnight + // is an hour off the midnight N days later, so the day after the change + // fell out of "Tomorrow" and the first day of next week fell into "This + // week" (the calendar-day rule of objectui#11005). const now = new Date(); const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - const today = startOfDay(now); - const day = 86400000; - const startOfWeek = today - ((now.getDay() + 6) % 7) * day; // Monday - const endOfWeek = startOfWeek + 7 * day; - const endOfNextWeek = endOfWeek + 7 * day; + const daysFromToday = (days: number) => + new Date(now.getFullYear(), now.getMonth(), now.getDate() + days).getTime(); + const today = daysFromToday(0); + const tomorrow = daysFromToday(1); + const daysIntoWeek = (now.getDay() - firstDayOfWeek(displayLocale) + 7) % 7; + const endOfWeek = daysFromToday(7 - daysIntoWeek); + const endOfNextWeek = daysFromToday(14 - daysIntoWeek); // A day before today is "Earlier", never "Overdue" (objectui#11676). // "Overdue" is a judgement about the RECORD, and it needs two facts this @@ -766,7 +782,7 @@ export const ObjectTimeline: React.FC = ({ if (Number.isNaN(ts)) return t('timeline.bucket.noDate'); if (ts < today) return t('timeline.bucket.earlier'); if (ts === today) return t('timeline.bucket.today'); - if (ts === today + day) return t('timeline.bucket.tomorrow'); + if (ts === tomorrow) return t('timeline.bucket.tomorrow'); if (ts < endOfWeek) return t('timeline.bucket.thisWeek'); if (ts < endOfNextWeek) return t('timeline.bucket.nextWeek'); return t('timeline.bucket.later'); @@ -794,7 +810,7 @@ export const ObjectTimeline: React.FC = ({ } return mapped.map((m) => ({ ...m, group: dateBucket(m.startDate) })); - }, [schema.items, rawData, objectDef, schema.objectName, titleField, startDateField, endDateField, descField, variantField, colorField, groupByField, t, fieldOptionLabel]); + }, [schema.items, rawData, objectDef, schema.objectName, titleField, startDateField, endDateField, descField, variantField, colorField, groupByField, t, fieldOptionLabel, displayLocale]); const handleRefresh = useCallback(async () => { setRefreshKey(k => k + 1); From 793b921b232677098808dee3940c3eff11212666 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:25:37 +0000 Subject: [PATCH 2/4] test(i18n,plugin-calendar,plugin-timeline): pin the locale's week start in the helper, the calendar and the timeline (objectui#11675) The helper's engine and table paths, with a region-by-region parity case against the runtime's week data; every calendar site the week start moved (month grid and heads, week view and header range, a wrapped span's title day, the date popover); the timeline's week buckets under en-US, en-GB, zh-CN and ar-EG; and, in the driven zone pin, the bucket bounds across a DST change. The two header helpers in the 10144 and 10442 pins took the first day as an explicit argument: they modelled the Sunday-first heads the grid no longer draws for `de` / `fr-FR`. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../__tests__/firstDayOfWeek-11675.test.ts | 192 ++++++++++++++++++ .../CalendarView.displayLocale-10442.test.tsx | 18 +- ...CalendarView.gridcellLocale-10144.test.tsx | 16 +- ...darView.weekStartFromLocale-11675.test.tsx | 184 +++++++++++++++++ ...ObjectTimeline.dateOnlyZone-10866.test.tsx | 56 +++++ ...eline.weekBucketsFromLocale-11675.test.tsx | 145 +++++++++++++ 6 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts create mode 100644 packages/plugin-calendar/src/__tests__/CalendarView.weekStartFromLocale-11675.test.tsx create mode 100644 packages/plugin-timeline/src/__tests__/ObjectTimeline.weekBucketsFromLocale-11675.test.tsx diff --git a/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts b/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts new file mode 100644 index 0000000000..bdeb9f1e84 --- /dev/null +++ b/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts @@ -0,0 +1,192 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11675 — `firstDayOfWeek(locale)` reads the locale's week info. + * + * Ruling B on the card: the first day of the week follows the user's locale, + * and the card's pins are that `en-US` starts on Sunday while `zh-CN` and + * `en-GB` start on Monday, from the locale's week info. The calendar and the + * timeline both read this one function, so its two paths are pinned here: + * + * - the ENGINE path, `Intl.Locale`'s own week info (`getWeekInfo()`, else the + * `weekInfo` accessor), which Node 22, this suite's runtime, provides as the + * accessor only; + * - the TABLE path, CLDR's region table, for an engine with neither spelling. + * It is reached here by removing both spellings from `Intl.Locale.prototype` + * for the length of one call, and putting them back exactly. + * + * The parity case is what re-derives the table: region by region, it compares + * the table's answer with this runtime's own week data, so a CLDR release that + * moves a region reds here, naming the region. + * + * ⚠️ This file runs in the `unit` project, which shares one global object + * between files (`isolate: false`). Every removal is undone in a `finally`, and + * the last case checks that both spellings are exactly what they were when the + * file started. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import { firstDayOfWeek } from '../index'; + +const SUNDAY = 0; +const MONDAY = 1; +const FRIDAY = 5; +const SATURDAY = 6; + +const proto: object = Intl.Locale.prototype; +const SPELLINGS = ['getWeekInfo', 'weekInfo'] as const; +const ORIGINAL = SPELLINGS.map((name) => Object.getOwnPropertyDescriptor(proto, name)); + +interface EngineWeekInfo { + getWeekInfo?: () => { firstDay: number }; + weekInfo?: { firstDay: number }; +} + +/** This runtime's own first day for `tag`, read off the engine, as `getDay` numbers it. */ +function engineReading(tag: string): number | undefined { + const locale: Intl.Locale & EngineWeekInfo = new Intl.Locale(tag); + const info = typeof locale.getWeekInfo === 'function' ? locale.getWeekInfo() : locale.weekInfo; + return info ? info.firstDay % 7 : undefined; +} + +function restore(): void { + SPELLINGS.forEach((name, i) => { + const descriptor = ORIGINAL[i]; + if (descriptor) Object.defineProperty(proto, name, descriptor); + else Reflect.deleteProperty(proto, name); + }); +} + +/** `body`'s answer with the engine's week info removed, both spellings put back after. */ +function withoutEngineWeekInfo(body: () => T): T { + for (const name of SPELLINGS) Reflect.deleteProperty(proto, name); + try { + const probe = new Intl.Locale('en-US'); + expect('getWeekInfo' in probe || 'weekInfo' in probe, 'the engine week info was not removed').toBe(false); + return body(); + } finally { + restore(); + } +} + +/** `body`'s answer with a `getWeekInfo()` that answers `isoFirstDay` for every tag. */ +function withGetWeekInfo(isoFirstDay: number, body: () => T): T { + Object.defineProperty(proto, 'getWeekInfo', { + configurable: true, + writable: true, + value: () => ({ firstDay: isoFirstDay, weekend: [6, 7], minimalDays: 1 }), + }); + try { + return body(); + } finally { + restore(); + } +} + +/** Every region code this runtime names, in the canonical spelling `Intl.Locale` reports. */ +function canonicalRegions(): string[] { + const names = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'code' }); + const regions = new Set(); + for (let a = 65; a <= 90; a++) { + for (let b = 65; b <= 90; b++) { + const code = String.fromCharCode(a, b); + if (names.of(code) === code) continue; + const region = new Intl.Locale(`und-${code}`).region; + if (region) regions.add(region); + } + } + return [...regions].sort(); +} + +afterAll(restore); + +describe('firstDayOfWeek — the engine path (objectui#11675)', () => { + it('rig: this runtime provides week info, so the cases below read the engine', () => { + expect(engineReading('en-US'), 'this runtime has no Intl.Locale week info').toBeDefined(); + }); + + it("the card's pins: en-US starts on Sunday, en-GB and zh-CN on Monday", () => { + expect(firstDayOfWeek('en-US')).toBe(SUNDAY); + expect(firstDayOfWeek('en-GB')).toBe(MONDAY); + expect(firstDayOfWeek('zh-CN')).toBe(MONDAY); + }); + + it('a UI language with no region reads the region CLDR gives it: `en` Sunday, `zh` Monday', () => { + expect(firstDayOfWeek('en')).toBe(SUNDAY); + expect(firstDayOfWeek('zh')).toBe(MONDAY); + }); + + it('it is not a Sunday/Monday switch: ar-EG starts on Saturday', () => { + expect(firstDayOfWeek('ar-EG')).toBe(SATURDAY); + }); + + it('the engine is what answers: a `-u-fw-` keyword, which only the engine reads, moves the day', () => { + expect(firstDayOfWeek('en-US-u-fw-mon')).toBe(MONDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('en-US-u-fw-mon'))).toBe(SUNDAY); + }); + + it('`getWeekInfo()` is read before the `weekInfo` accessor, and its ISO 7 is Sunday', () => { + expect(withGetWeekInfo(3, () => firstDayOfWeek('en-US'))).toBe(3); + expect(withGetWeekInfo(7, () => firstDayOfWeek('en-GB'))).toBe(SUNDAY); + }); + + it('a tag `Intl` refuses is refused, not guessed', () => { + expect(() => firstDayOfWeek('en_US')).toThrow(RangeError); + }); +}); + +describe('firstDayOfWeek — where the engine has no week info, CLDR\'s table answers (objectui#11675)', () => { + it("the card's pins hold on the table path too", () => { + expect(withoutEngineWeekInfo(() => firstDayOfWeek('en-US'))).toBe(SUNDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('en-GB'))).toBe(MONDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('zh-CN'))).toBe(MONDAY); + }); + + it('a tag with no region reads the region CLDR gives it', () => { + expect(withoutEngineWeekInfo(() => firstDayOfWeek('en'))).toBe(SUNDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('zh'))).toBe(MONDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('ar'))).toBe(SATURDAY); + }); + + it('a Saturday and a Friday region, and a region the table does not list, which reads Monday', () => { + expect(withoutEngineWeekInfo(() => firstDayOfWeek('ar-EG'))).toBe(SATURDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('dv-MV'))).toBe(FRIDAY); + expect(withoutEngineWeekInfo(() => firstDayOfWeek('de-CH'))).toBe(MONDAY); + }); + + it('PARITY: for every region this runtime names, the table answers what its week data answers', () => { + const regions = canonicalRegions(); + // A collapsed enumeration would make the comparison below vacuous. + expect(regions.length, `only ${regions.length} regions enumerated`).toBeGreaterThan(200); + const engine = new Map(regions.map((region) => [region, engineReading(`und-${region}`)])); + expect([...new Set(engine.values())].sort(), 'the regions do not reach every first day CLDR uses').toEqual([ + SUNDAY, + MONDAY, + FRIDAY, + SATURDAY, + ]); + const table = withoutEngineWeekInfo( + () => new Map(regions.map((region) => [region, firstDayOfWeek(`und-${region}`)])), + ); + const drift = regions + .filter((region) => table.get(region) !== engine.get(region)) + .map((region) => `${region}: engine ${engine.get(region)}, table ${table.get(region)}`); + expect( + drift, + `the CLDR table in utils/first-day-of-week.ts no longer matches this runtime's week data ` + + `(CLDR ${process.versions.cldr ?? 'unknown'}); regenerate it from the engine`, + ).toEqual([]); + }); + + it('both spellings are back exactly as the file found them', () => { + SPELLINGS.forEach((name, i) => { + expect(Object.getOwnPropertyDescriptor(proto, name), name).toEqual(ORIGINAL[i]); + }); + }); +}); diff --git a/packages/plugin-calendar/src/CalendarView.displayLocale-10442.test.tsx b/packages/plugin-calendar/src/CalendarView.displayLocale-10442.test.tsx index 4af7b5ce26..8c0a957e6b 100644 --- a/packages/plugin-calendar/src/CalendarView.displayLocale-10442.test.tsx +++ b/packages/plugin-calendar/src/CalendarView.displayLocale-10442.test.tsx @@ -68,14 +68,22 @@ function facesUnder(displayLocale: string, props: { locale?: string } = {}): Mon const dayLabelIn = (locale: string) => DAY.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) -const weekdaysIn = (locale: string) => - Array.from({ length: 7 }, (_, i) => new Date(2024, 0, 7 + i).toLocaleDateString(locale, { weekday: 'short' })) +/** + * The weekday heads in `locale`, from the week's first day. The first day is + * the locale's (objectui#11675) and is stated by each case, not read back from + * the component: `en-US` starts the week on Sunday, `de-CH` and `fr-FR` on + * Monday. 7 January 2024 is a Sunday. + */ +const SUNDAY = 0 +const MONDAY = 1 +const weekdaysIn = (locale: string, firstDay: number) => + Array.from({ length: 7 }, (_, i) => new Date(2024, 0, 7 + firstDay + i).toLocaleDateString(locale, { weekday: 'short' })) describe('CalendarView — the default locale is the display locale (objectui#10442)', () => { it('renders the month as de-CH under an English UI with a de-CH display locale', () => { const faces = facesUnder('de-CH') expect(faces.text, `got: ${faces.text.slice(0, 200)}`).toContain('März 2020') - expect(faces.weekdays).toEqual(weekdaysIn('de-CH')) + expect(faces.weekdays).toEqual(weekdaysIn('de-CH', MONDAY)) expect(dayLabelIn('de-CH')).toBe('Mittwoch, 4. März 2020') expect(faces.cells.some((l) => l.startsWith(dayLabelIn('de-CH'))), `saw: ${JSON.stringify(faces.cells.slice(0, 3))}`).toBe(true) }) @@ -83,7 +91,7 @@ describe('CalendarView — the default locale is the display locale (objectui#10 it('control: renders the month as en-US under an en-US display locale', () => { const faces = facesUnder('en-US') expect(faces.text, `got: ${faces.text.slice(0, 200)}`).toContain('March 2020') - expect(faces.weekdays).toEqual(weekdaysIn('en-US')) + expect(faces.weekdays).toEqual(weekdaysIn('en-US', SUNDAY)) expect(dayLabelIn('en-US')).toBe('Wednesday, March 4, 2020') expect(faces.cells.some((l) => l.startsWith(dayLabelIn('en-US'))), `saw: ${JSON.stringify(faces.cells.slice(0, 3))}`).toBe(true) }) @@ -100,7 +108,7 @@ describe('CalendarView — the default locale is the display locale (objectui#10 it('an explicit `locale` prop still wins over the display locale', () => { const faces = facesUnder('de-CH', { locale: 'fr-FR' }) expect(faces.text, `got: ${faces.text.slice(0, 200)}`).toContain('mars 2020') - expect(faces.weekdays).toEqual(weekdaysIn('fr-FR')) + expect(faces.weekdays).toEqual(weekdaysIn('fr-FR', MONDAY)) expect(faces.cells.some((l) => l.startsWith(dayLabelIn('fr-FR'))), `saw: ${JSON.stringify(faces.cells.slice(0, 3))}`).toBe(true) }) diff --git a/packages/plugin-calendar/src/CalendarView.gridcellLocale-10144.test.tsx b/packages/plugin-calendar/src/CalendarView.gridcellLocale-10144.test.tsx index 988f533322..d8fc0c2205 100644 --- a/packages/plugin-calendar/src/CalendarView.gridcellLocale-10144.test.tsx +++ b/packages/plugin-calendar/src/CalendarView.gridcellLocale-10144.test.tsx @@ -69,11 +69,15 @@ const asRuntimeDefault = (d: Date) => d.toLocaleDateString('default', DAY_LABEL_ const asLocale = (d: Date, locale: string) => d.toLocaleDateString(locale, DAY_LABEL_OPTS) /** The weekday column headers, formatted the way `MonthView` formats them — - * off a reference Sunday, `{ weekday: 'short' }`. This is the LIT CONTROL. */ -const weekdayHeaders = (locale: string) => + * `{ weekday: 'short' }`, from the week's first day. This is the LIT CONTROL. + * The first day is the locale's (objectui#11675) and is stated here, not read + * back from the component: `de` and `de-DE` start the week on Monday. */ +const MONDAY = 1 +const weekdayHeaders = (locale: string, firstDay: number) => Array.from({ length: 7 }, (_, i) => { + // 7 January 2024 is a Sunday. const d = new Date(2024, 0, 7) - d.setDate(d.getDate() + i) + d.setDate(d.getDate() + firstDay + i) return d.toLocaleDateString(locale, { weekday: 'short' }) }) @@ -140,8 +144,8 @@ describe('objectui#10144 — the month gridcell speaks the RESOLVED locale', () // so their German proves the locale reached this component. const headers = columnHeaderTexts() expect(headers, 'no column headers rendered — the control would be unlit').toHaveLength(7) - expect(headers).toEqual(weekdayHeaders('de')) - expect(headers).not.toEqual(weekdayHeaders('default')) + expect(headers).toEqual(weekdayHeaders('de', MONDAY)) + expect(headers).not.toEqual(weekdayHeaders('default', MONDAY)) // THE FINDING: the day the user actually hears. const labels = gridcellLabels() @@ -171,7 +175,7 @@ describe('objectui#10144 — the month gridcell speaks the RESOLVED locale', () renderMonth({ language: 'en', locale: 'de-DE' }) const headers = columnHeaderTexts() - expect(headers, 'no column headers rendered — the control would be unlit').toEqual(weekdayHeaders('de-DE')) + expect(headers, 'no column headers rendered — the control would be unlit').toEqual(weekdayHeaders('de-DE', MONDAY)) const labels = gridcellLabels() const eventCell = labels.filter((v) => v.includes(asLocale(EVENT_DAY, 'de-DE'))) diff --git a/packages/plugin-calendar/src/__tests__/CalendarView.weekStartFromLocale-11675.test.tsx b/packages/plugin-calendar/src/__tests__/CalendarView.weekStartFromLocale-11675.test.tsx new file mode 100644 index 0000000000..9f36545d52 --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/CalendarView.weekStartFromLocale-11675.test.tsx @@ -0,0 +1,184 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11675 — the calendar's week starts on the locale's first day. + * + * ── What was measured ─────────────────────────────────────────────────────── + * Every week the calendar drew started on Sunday, whatever the locale: the + * month grid opened each row on `getDay() === 0`, its weekday heads ran from a + * reference Sunday, and the week view's columns and the header's week range + * came from a Sunday `getWeekStart`. So a `zh-CN` or `en-GB` user, whose week + * starts on Monday, read every week shifted by a day, and the timeline beside + * it started its "This week" on Monday. + * + * ── What the repair reads ─────────────────────────────────────────────────── + * Ruling B: the first day of the week follows the user's locale. `CalendarView` + * reads it once, `firstDayOfWeek(effectiveLocale)` (`@object-ui/i18n`), from + * the tag every date it draws formats with, and hands that one answer to each + * site below. The card's pins: under `en-US` the week starts on Sunday, under + * `zh-CN` and `en-GB` on Monday. `ar-EG` (Saturday) shows the answer is the + * locale's and not a Sunday/Monday switch. + * + * Every expected first day is a literal date stated here, ⛔ not a call to the + * function under test. The suite runs in UTC (`vitest.config.mts`), and every + * date is a local calendar date, so the zone does not enter. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as React from 'react' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n' +import { CalendarView, type CalendarViewEvent } from '../CalendarView' + +/** Thursday 15 October 2026. October 2026 opens on a Thursday. */ +const OCT_15 = new Date(2026, 9, 15) +/** Wednesday 14 October 2026, the week view's date. */ +const OCT_14 = new Date(2026, 9, 14) + +const DAY_LABEL: Intl.DateTimeFormatOptions = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' } + +/** The first day of October 2026's first grid row, and of 14 October's week, per locale. */ +const CASES = [ + { locale: 'en-US', first: 'Sunday', gridStart: new Date(2026, 8, 27), weekStart: new Date(2026, 9, 11) }, + { locale: 'en-GB', first: 'Monday', gridStart: new Date(2026, 8, 28), weekStart: new Date(2026, 9, 12) }, + { locale: 'zh-CN', first: 'Monday', gridStart: new Date(2026, 8, 28), weekStart: new Date(2026, 9, 12) }, + { locale: 'ar-EG', first: 'Saturday', gridStart: new Date(2026, 8, 26), weekStart: new Date(2026, 9, 10) }, +] as const + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(OCT_15) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +/** An ENGLISH UI with `displayLocale` as the declared display locale. */ +function session(displayLocale: string, node: React.ReactNode): React.ReactElement { + return ( + + {node} + + ) +} + +const columnHeads = () => + [...document.querySelectorAll('[role="columnheader"]')].map((el) => (el.textContent ?? '').trim()) +const gridcellLabels = () => + [...document.querySelectorAll('[role="gridcell"]')].map((el) => el.getAttribute('aria-label') ?? '') +const headerLabel = () => document.body.querySelector('[aria-label^="Current date:"]')?.getAttribute('aria-label') ?? '' + +/** `start` plus `days` calendar days. */ +function plusDays(start: Date, days: number): Date { + const d = new Date(start) + d.setDate(d.getDate() + days) + return d +} + +describe('CalendarView month grid — each row and the weekday heads open on the locale\'s first day (objectui#11675)', () => { + for (const c of CASES) { + it(`${c.locale}: the grid and its heads start on ${c.first}`, () => { + render(session(c.locale, )) + + const cells = gridcellLabels() + expect(cells, 'the month grid is six rows of seven').toHaveLength(42) + expect(cells[0]).toBe(c.gridStart.toLocaleDateString(c.locale, DAY_LABEL)) + // Each later row opens on the same weekday, seven days on. + expect(cells[7]).toBe(plusDays(c.gridStart, 7).toLocaleDateString(c.locale, DAY_LABEL)) + + const heads = columnHeads() + expect(heads).toEqual( + Array.from({ length: 7 }, (_, i) => plusDays(c.gridStart, i).toLocaleDateString(c.locale, { weekday: 'short' })), + ) + }) + } + + it('an authored `locale` wins for the week start too: en-GB under an en-US display locale starts on Monday', () => { + render(session('en-US', )) + expect(gridcellLabels()[0]).toBe(new Date(2026, 8, 28).toLocaleDateString('en-GB', DAY_LABEL)) + }) +}) + +describe('CalendarView week view — the columns and the header range start on the locale\'s first day (objectui#11675)', () => { + for (const c of CASES.filter((x) => x.locale === 'en-US' || x.locale === 'en-GB')) { + it(`${c.locale}: the week of 14 October runs from ${c.first} ${c.weekStart.getDate()} October`, () => { + render(session(c.locale, )) + + const heads = columnHeads() + expect(heads).toHaveLength(7) + expect(heads).toEqual( + Array.from({ length: 7 }, (_, i) => { + const d = plusDays(c.weekStart, i) + return `${d.toLocaleDateString(c.locale, { weekday: 'short' })}${d.getDate()}` + }), + ) + const rangeStart = c.weekStart.toLocaleDateString(c.locale, { month: 'short', day: 'numeric' }) + const rangeEnd = plusDays(c.weekStart, 6).toLocaleDateString(c.locale, { month: 'short', day: 'numeric', year: 'numeric' }) + expect(headerLabel()).toBe(`Current date: ${rangeStart} - ${rangeEnd}`) + }) + } +}) + +describe('CalendarView month grid — a span that wraps into a new row is titled on the row\'s first day (objectui#11675)', () => { + const SPAN: CalendarViewEvent[] = [ + { id: 'span', title: 'Offsite', start: new Date(2026, 9, 7), end: new Date(2026, 9, 14), allDay: true }, + ] + + /** The days of the month whose cell shows the span's title, not a bare continuation bar. */ + function titledDays(locale: string): number[] { + render(session(locale, )) + const days = [...document.querySelectorAll('[role="gridcell"]')] + .filter((cell) => + [...cell.querySelectorAll('[role="button"][aria-label="Offsite"]')].some((chip) => chip.textContent === 'Offsite'), + ) + .map((cell) => Number(cell.firstElementChild?.textContent)) + cleanup() + return days + } + + it('en-US titles Sunday the 11th, en-GB Monday the 12th, both beside the start and end days', () => { + expect(titledDays('en-US')).toEqual([7, 11, 14]) + expect(titledDays('en-GB')).toEqual([7, 12, 14]) + }) +}) + +describe('CalendarView date popover — its weeks start where the grid\'s do (objectui#11675)', () => { + /** + * The popover's weekday heads, by their full names, once its date-fns locale + * has loaded, which is when its caption reads `caption`. The caption is + * date-fns's own format, not the header's `Intl` one, so it is matched as + * written for that locale. + */ + async function popoverWeekdays(locale: string, caption: string): Promise { + render(session(locale, )) + const trigger = document.body.querySelector('[aria-label^="Current date:"]') + if (!trigger) throw new Error('no header date trigger rendered') + fireEvent.click(trigger) + await waitFor(() => expect(document.body.querySelector('.rdp-caption_label')).not.toBeNull()) + await vi.dynamicImportSettled() + await waitFor(() => expect((document.body.querySelector('.rdp-caption_label')?.textContent ?? '').trim()).toBe(caption)) + return [...document.body.querySelectorAll('.rdp-weekday')].map((th) => th.getAttribute('aria-label') ?? '') + } + + it('en-GB: the popover starts on Monday, as the grid does', async () => { + const weekdays = await popoverWeekdays('en-GB', 'October 2026') + expect(weekdays[0]).toBe('Monday') + }) + + it('es-MX: the popover starts on Sunday with the grid, though date-fns reads the tag as `es`, a Monday start', async () => { + // The date-fns locale `es-MX` resolves to has its own week start, Monday; + // CLDR starts Mexico's week on Sunday. The grid follows CLDR, so the popover + // must take the grid's day rather than date-fns's. + const weekdays = await popoverWeekdays('es-MX', 'octubre 2026') + expect(weekdays[0]).toBe('domingo') + expect(gridcellLabels()[0]).toBe(new Date(2026, 8, 27).toLocaleDateString('es-MX', DAY_LABEL)) + }) +}) diff --git a/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx b/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx index 7c7ad44a5c..258e4dd4ae 100644 --- a/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx +++ b/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx @@ -200,3 +200,59 @@ describe.runIf(DRIVEN)('ObjectTimeline date-only days east of UTC, the control ( expect(feed(MIXED)).toEqual(['Today: Day (10/6/2026), Instant (10/6/2026)']); }); }); + +/** + * objectui#11675 — the bucket bounds are local midnights, across a DST change + * too. + * + * The bounds used to be today's midnight plus multiples of 24 hours. In the + * west, 8 March 2026 is 23 hours long (DST begins at 02:00), so from that day + * 24 hours on is 01:00 on the 9th, and 7 days on is 01:00 on the 15th: the + * 9th was not "Tomorrow", and the 15th, the first day of next week under + * `en-US`, fell into "This week". The bounds are now stepped on the local + * calendar. The clock is 13:00 on Sunday 8 March in the west, and the feed + * reads `en-US`, whose week starts on Sunday. + */ +describe.runIf(DRIVEN)('ObjectTimeline week buckets across a DST change, west of UTC (objectui#11675)', () => { + /** 13:00 on Sunday 8 March 2026 in the west, after the 02:00 change. */ + const DST_CLOCK = '2026-03-08T20:00:00.000Z'; + + function enterDstDay(): void { + process.env.TZ = WEST; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(DST_CLOCK)); + } + + it('rig: the zone really moved, and the clock reads Sunday the 8th there', () => { + enterDstDay(); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST); + expect(new Date().getDate()).toBe(8); + expect(new Date().getDay()).toBe(0); + }); + + it('fixture validity: 8 March is 23 hours long here, so 24-hour steps miss the next midnight', () => { + enterDstDay(); + // Without this the case below would be green for free. + expect(new Date(2026, 2, 9).getTime() - new Date(2026, 2, 8).getTime()).toBe(23 * 60 * 60 * 1000); + }); + + it('the 9th is "Tomorrow", the 15th opens "Next week" and the 22nd is "Later"', () => { + enterDstDay(); + expect( + feed([ + { id: '1', name: 'Sun 8', when: '2026-03-08' }, + { id: '2', name: 'Mon 9', when: '2026-03-09' }, + { id: '3', name: 'Sat 14', when: '2026-03-14' }, + { id: '4', name: 'Sun 15', when: '2026-03-15' }, + { id: '5', name: 'Sat 21', when: '2026-03-21' }, + { id: '6', name: 'Sun 22', when: '2026-03-22' }, + ]), + ).toEqual([ + 'Today: Sun 8 (3/8/2026)', + 'Tomorrow: Mon 9 (3/9/2026)', + 'This week: Sat 14 (3/14/2026)', + 'Next week: Sun 15 (3/15/2026), Sat 21 (3/21/2026)', + 'Later: Sun 22 (3/22/2026)', + ]); + }); +}); diff --git a/packages/plugin-timeline/src/__tests__/ObjectTimeline.weekBucketsFromLocale-11675.test.tsx b/packages/plugin-timeline/src/__tests__/ObjectTimeline.weekBucketsFromLocale-11675.test.tsx new file mode 100644 index 0000000000..83227b90ce --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/ObjectTimeline.weekBucketsFromLocale-11675.test.tsx @@ -0,0 +1,145 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11675 — the timeline's "This week" starts on the locale's first day. + * + * ── What was measured ─────────────────────────────────────────────────────── + * With no `groupByField`, `ObjectTimeline` buckets entries by date, and its + * "This week" / "Next week" bounds were computed from a hard-coded Monday + * (`(now.getDay() + 6) % 7`), whatever the locale. The calendar beside it + * started every week on Sunday, so under `en-US` a Sunday entry sat in "This + * week" on the timeline and opened next week's row on the calendar. + * + * ── What the repair reads ─────────────────────────────────────────────────── + * Ruling B: the first day of the week follows the user's locale. The bounds + * now start on `firstDayOfWeek(displayLocale)` (`@object-ui/i18n`), the tag + * this feed's dates format with, the same answer the calendar's grids read. + * The card's pins: `en-US` starts the week on Sunday, `zh-CN` and `en-GB` on + * Monday. `ar-EG` (Saturday) shows the answer is the locale's and not a + * Sunday/Monday switch. + * + * The clock is frozen on Tuesday 6 October 2026, and every value is a + * date-only string, which the shared `toDisplayDate` step reads as local + * midnight of the day it names (objectui#10866), so the buckets do not move + * with the runner's zone. The expected buckets are written out per locale, + * ⛔ not derived from the function under test. Every case mounts an + * `I18nProvider` with an English UI, so only the display locale differs. + */ + +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { ObjectTimeline } from '../ObjectTimeline'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + }; +}); + +/** Noon on Tuesday 6 October 2026, local time. */ +const CLOCK = new Date(2026, 9, 6, 12, 0, 0); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +type Row = { id: string; title: string; when: string }; + +/** Friday 9 October to Monday 19 October 2026, across the end of this week in every locale below. */ +const ROWS: Row[] = [ + { id: '1', title: 'Fri 9', when: '2026-10-09' }, + { id: '2', title: 'Sat 10', when: '2026-10-10' }, + { id: '3', title: 'Sun 11', when: '2026-10-11' }, + { id: '4', title: 'Mon 12', when: '2026-10-12' }, + { id: '5', title: 'Fri 16', when: '2026-10-16' }, + { id: '6', title: 'Sat 17', when: '2026-10-17' }, + { id: '7', title: 'Sun 18', when: '2026-10-18' }, + { id: '8', title: 'Mon 19', when: '2026-10-19' }, +]; + +/** Mount the timeline over `ROWS` under a `displayLocale` session, and read each bucket as `Bucket: title, title`. */ +function buckets(displayLocale: string): string[] { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(CLOCK); + // `data` is an undeclared passthrough prop on ObjectTimelineProps (the + // component reads it off the rest args, which is how ListView feeds it). + const props = { + schema: { + type: 'object-timeline', + objectName: 'showcase_task', + timeline: { startDateField: 'when', titleField: 'title' }, + }, + data: ROWS, + } as unknown as React.ComponentProps; + render( + + + + + , + ); + screen.getByText(ROWS[0].title); + const out = Array.from(document.querySelectorAll('section')).map((section) => { + const bucket = section.querySelector('header > span:first-child')?.textContent ?? ''; + const titles = Array.from(section.querySelectorAll('h3')).map((h3) => h3.textContent); + return `${bucket}: ${titles.join(', ')}`; + }); + cleanup(); + vi.useRealTimers(); + return out; +} + +describe("ObjectTimeline week buckets start on the locale's first day (objectui#11675)", () => { + it('en-US: this week is Sunday 4 to Saturday 10 October, so Sunday the 11th opens next week', () => { + expect(buckets('en-US')).toEqual([ + 'This week: Fri 9, Sat 10', + 'Next week: Sun 11, Mon 12, Fri 16, Sat 17', + 'Later: Sun 18, Mon 19', + ]); + }); + + it('en-GB: this week is Monday 5 to Sunday 11 October, so Sunday the 11th is still this week', () => { + expect(buckets('en-GB')).toEqual([ + 'This week: Fri 9, Sat 10, Sun 11', + 'Next week: Mon 12, Fri 16, Sat 17, Sun 18', + 'Later: Mon 19', + ]); + }); + + it('zh-CN: the week starts on Monday, as under en-GB', () => { + expect(buckets('zh-CN')).toEqual([ + 'This week: Fri 9, Sat 10, Sun 11', + 'Next week: Mon 12, Fri 16, Sat 17, Sun 18', + 'Later: Mon 19', + ]); + }); + + it('ar-EG: this week is Saturday 3 to Friday 9 October, so Saturday the 10th opens next week', () => { + expect(buckets('ar-EG')).toEqual([ + 'This week: Fri 9', + 'Next week: Sat 10, Sun 11, Mon 12, Fri 16', + 'Later: Sat 17, Sun 18, Mon 19', + ]); + }); +}); From e4f34300f85f1a698d0031964ea7c950ddaaea01 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:31:43 +0000 Subject: [PATCH 3/4] docs(i18n,plugin-calendar,plugin-timeline): document the locale's week start, and declare the changeset (objectui#11675) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11675-week-start-from-locale.md | 13 +++++++++++ content/docs/plugins/plugin-calendar.mdx | 2 ++ content/docs/plugins/plugin-timeline.mdx | 2 ++ packages/i18n/README.md | 26 ++++++++++++++++++++++ packages/plugin-calendar/README.md | 5 +++++ packages/plugin-timeline/README.md | 5 ++++- 6 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/11675-week-start-from-locale.md diff --git a/.changeset/11675-week-start-from-locale.md b/.changeset/11675-week-start-from-locale.md new file mode 100644 index 0000000000..879cb68bb3 --- /dev/null +++ b/.changeset/11675-week-start-from-locale.md @@ -0,0 +1,13 @@ +--- +'@object-ui/i18n': minor +'@object-ui/plugin-calendar': minor +'@object-ui/plugin-timeline': minor +--- + +The calendar and the timeline start the week on the first day of the week of the user's locale (objectui#11675), instead of a fixed Sunday (calendar) and a fixed Monday (timeline). Under `en-US` both start on Sunday; under `en-GB` or `zh-CN` both start on Monday; under `ar-EG` both start on Saturday. There is no separate week-start setting: the first day comes from the locale the dates are already formatted with. + +- **`@object-ui/i18n` exports `firstDayOfWeek(locale)` and its `WeekdayIndex` type.** It returns the first day of the week for a BCP-47 tag, numbered as `Date.prototype.getDay` numbers weekdays (0 is Sunday), which is also what react-day-picker's `weekStartsOn` takes. It reads the engine's `Intl.Locale` week info (`getWeekInfo()`, else the `weekInfo` accessor), including a `-u-fw-` keyword on the tag. Where the engine has neither, it reads CLDR's first-day table by the tag's region, or the region CLDR's likely subtags give it (`en` reads `US`, `zh` reads `CN`), and a region the table does not list reads Monday, CLDR's world default. A malformed tag throws the same `RangeError` that formatting a date with it would. +- **The calendar's weeks start on the locale's first day.** `CalendarView` (and `object-calendar` through it) reads the first day from its `locale` prop, else the display locale, and the month grid's rows, its weekday heads, the week view's columns, the header's week range, the day a span that wraps into a new row shows its title on, and the header's date popover all start there. The popover used to take date-fns's own week start for the tag, which is not CLDR's for every tag (date-fns reads `es-MX` as `es`, a Monday start, where CLDR starts Mexico's week on Sunday) and which read Sunday until the date-fns locale had loaded. +- **The object timeline's "This week" and "Next week" start on the display locale's first day.** The bucket bounds are also stepped on the local calendar now, so across a DST change the day after it is "Tomorrow" again, and the first day of next week no longer falls into "This week". + +A host that relied on the calendar always starting on Sunday, or on the timeline's week always starting on Monday, now sees the locale's first day. The gantt variant's `week` axis is unchanged: it still counts plan weeks from the axis's first day. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index dcbf6e455c..738feda334 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -24,6 +24,8 @@ The `@object-ui/plugin-calendar` plugin provides two calendar components: Both components support month, week, and day views with full event management capabilities. +Every week starts on the first day of the week of the calendar's locale (the `locale` prop, else the user's display locale): Sunday under `en-US`, Monday under `en-GB` or `zh-CN`. The month grid, its weekday heads, the week view's columns, the header's week range and the date popover all start on that day. There is no separate week-start setting. + ## Drag-and-Drop Rescheduling ### Month view diff --git a/content/docs/plugins/plugin-timeline.mdx b/content/docs/plugins/plugin-timeline.mdx index edfb87bdd1..e422567919 100644 --- a/content/docs/plugins/plugin-timeline.mdx +++ b/content/docs/plugins/plugin-timeline.mdx @@ -396,6 +396,8 @@ const iso: TimelineSchema['dateFormat'] = 'iso' // "2024-01-15" A date-only value (`2024-01-15`) names a calendar day, so every face prints that day in every viewer's time zone, and the object timeline buckets and sorts it as that day. A value with a time part is an instant: `short` and `long` print its day in the viewer's zone, and `iso` prints its UTC day. +Without a `groupByField`, the object timeline groups entries into date buckets (Earlier, Today, Tomorrow, This week, Next week, Later, No date). "This week" starts on the first day of the week of the user's display locale, the day the calendar's weeks start on: Sunday under `en-US`, Monday under `en-GB` or `zh-CN`. + ## Time Scales (Gantt) For Gantt-style timelines, choose the appropriate time scale: diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 4359d3b2cf..02a9349edd 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -198,6 +198,32 @@ its midnight, and `formatRelativeTime` counts to the start of it). A string with a time part is an instant, read in the viewer's zone. `formatDateSpec` applies its `timeZone` to an instant only. +#### The first day of the week + +`firstDayOfWeek(locale)` answers the first day of the week for a BCP-47 tag, +numbered as `Date.prototype.getDay` numbers weekdays (0 is Sunday), which is +also what react-day-picker's `weekStartsOn` takes. It reads the engine's +`Intl.Locale` week info, and CLDR's region table where the engine has none, so +a week grid or a "this week" bound starts where the user's locale says rather +than on a fixed day. There is no separate week-start setting: the tag is the +one the caller already formats its dates with, such as `useDisplayLocale()`. + +```tsx +import { firstDayOfWeek, useDisplayLocale } from '@object-ui/i18n'; + +firstDayOfWeek('en-US'); // 0, Sunday +firstDayOfWeek('en-GB'); // 1, Monday +firstDayOfWeek('zh-CN'); // 1, Monday + +function WeekHeader() { + const weekStart = firstDayOfWeek(useDisplayLocale()); + // ...lay the week out from `weekStart` +} +``` + +A tag `Intl` refuses (`en_US`) throws the same `RangeError` that formatting a +date with it would. + ### Built-in locales — one is resident, nine are fetched on demand The package entry re-exports **`en` only**. It is `fallbackLng`, it is the diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index 69b645eee4..ecedf2585f 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -19,6 +19,11 @@ Calendar view plugins for Object UI - includes both ObjectQL-integrated and stan option. - **ObjectQL Integration** - Connect to ObjectStack data sources - **Standalone Mode** - Use with static data or custom backends +- **Locale week start** - Every week starts on the first day of the week + of the calendar's locale (the `locale` prop, else the display locale): + Sunday under `en-US`, Monday under `en-GB` or `zh-CN`. The month grid, + its weekday heads, the week view's columns, the header's week range and + the date popover all start on that day. - **Responsive** - Mobile-friendly calendar layouts - **Customizable** - Tailwind CSS styling support diff --git a/packages/plugin-timeline/README.md b/packages/plugin-timeline/README.md index 9f448d7390..fc861d85da 100644 --- a/packages/plugin-timeline/README.md +++ b/packages/plugin-timeline/README.md @@ -105,7 +105,10 @@ is read where the block leaves a binding unset, and `descriptionField` names the description field (`mapping.description` outranks it). `limit` caps the query (`$top`, default 100), and `filter` / `sort` lower to `$filter` / `$orderby` on it; the rail still draws composed entries by start date. -`dateFormat` is `short`, `long` or `iso`. `rowLabel`, `minDate`, `maxDate` and +`dateFormat` is `short`, `long` or `iso`. Without a `groupByField`, entries +group into date buckets (Earlier, Today, Tomorrow, This week, Next week, Later, +No date), and "This week" starts on the first day of the week of the display +locale: Sunday under `en-US`, Monday under `en-GB` or `zh-CN`. `rowLabel`, `minDate`, `maxDate` and `timeline.scale` are read by the gantt branch only, which on this block draws authored `items`. From a393686c95ec4676c8d5f4fe467f5de74971168a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:51:44 +0000 Subject: [PATCH 4/4] fix(i18n): the week-start table transcribes CLDR 48, and its parity pin expects each runtime's recorded differences (objectui#11675) CI's floating `22.x` Node ships CLDR 48.0, where Iceland's week starts on Sunday; the table transcribed CLDR 47, so the parity pin went red there while it stayed green on a CLDR 47 runtime. The table now declares the release it transcribes (`WEEK_DATA_CLDR`, 48) and carries Iceland on Sunday. The pin reads the runtime's release from `process.versions.cldr` and requires the differences it finds to EQUAL the ones a measured ledger records for that release (none on the table's own release), so it is deterministic on CLDR 46, 47 and 48 and still fails on real drift in either direction. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../__tests__/firstDayOfWeek-11675.test.ts | 72 ++++++++++++++++--- packages/i18n/src/utils/first-day-of-week.ts | 30 +++++--- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts b/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts index bdeb9f1e84..76454facc9 100644 --- a/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts +++ b/packages/i18n/src/__tests__/firstDayOfWeek-11675.test.ts @@ -22,8 +22,16 @@ * for the length of one call, and putting them back exactly. * * The parity case is what re-derives the table: region by region, it compares - * the table's answer with this runtime's own week data, so a CLDR release that - * moves a region reds here, naming the region. + * the table's answer with this runtime's own week data. The table transcribes + * one CLDR release (`WEEK_DATA_CLDR`), and the runtimes this repository runs on + * ship different ones: `engines` admits Node 22.13, and CI's `22.x` floats to + * the newest. So the case reads the runtime's release from + * `process.versions.cldr`, and the differences it must find are exactly the + * ones {@link RELEASE_DELTAS} records for that release. On the table's own + * release that is none at all. Exact equality is what keeps the case able to + * fail on every runtime: a table edited away from its release, a release that + * moves a region nobody recorded, and a recorded difference that no longer + * holds all go red, each naming the region. * * ⚠️ This file runs in the `unit` project, which shares one global object * between files (`isolate: false`). Every removal is undone in a `finally`, and @@ -33,12 +41,35 @@ import { afterAll, describe, expect, it } from 'vitest'; import { firstDayOfWeek } from '../index'; +import { WEEK_DATA_CLDR } from '../utils/first-day-of-week'; const SUNDAY = 0; const MONDAY = 1; const FRIDAY = 5; const SATURDAY = 6; +/** + * Where a CLDR release other than the table's (`WEEK_DATA_CLDR`) starts a + * region's week on another day: by release, as the major part of + * `process.versions.cldr`, the region and the day that release answers. + * + * Measured when this ledger was written, by running this file's region probe + * on Node 22.13.0 (CLDR 46.0, the `engines` floor), Node 22.22.0 (CLDR 47.0) + * and Node 22.23.3 (CLDR 48.0, the table's release and CI's `22.x`): Iceland + * moved to a Sunday start in CLDR 48, and no other region differs across the + * three. ⛔ Not a list to grow by guessing: a row is added from a run on a + * runtime that ships the release, which is what the parity case prints. + */ +const RELEASE_DELTAS: Readonly>>> = { + '46': { IS: MONDAY }, + '47': { IS: MONDAY }, +}; + +/** The CLDR release this runtime ships, as the major part of `process.versions.cldr`. */ +function runtimeRelease(): string | undefined { + return process.versions.cldr?.split('.')[0]; +} + const proto: object = Intl.Locale.prototype; const SPELLINGS = ['getWeekInfo', 'weekInfo'] as const; const ORIGINAL = SPELLINGS.map((name) => Object.getOwnPropertyDescriptor(proto, name)); @@ -160,7 +191,24 @@ describe('firstDayOfWeek — where the engine has no week info, CLDR\'s table an expect(withoutEngineWeekInfo(() => firstDayOfWeek('de-CH'))).toBe(MONDAY); }); - it('PARITY: for every region this runtime names, the table answers what its week data answers', () => { + it('rig: this runtime reports its CLDR release, so the parity case knows which differences to expect', () => { + expect(runtimeRelease(), 'process.versions.cldr is unreadable here').toMatch(/^[0-9]+$/); + expect(WEEK_DATA_CLDR).toMatch(/^[0-9]+$/); + }); + + it("the ledger is honest about the table: no row for the table's own release, and every row names a day the table does not answer", () => { + expect(Object.keys(RELEASE_DELTAS)).not.toContain(WEEK_DATA_CLDR); + const rows = Object.entries(RELEASE_DELTAS).flatMap(([release, delta]) => + Object.entries(delta).map(([region, day]) => ({ release, region, day })), + ); + expect(rows.length, 'an empty ledger would make the case below the strict comparison by default').toBeGreaterThan(0); + for (const { release, region, day } of rows) { + expect(withoutEngineWeekInfo(() => firstDayOfWeek(`und-${region}`)), `CLDR ${release}: ${region}`).not.toBe(day); + } + }); + + it('PARITY: for every region this runtime names, the table answers what its week data answers, less the differences recorded for its CLDR release', () => { + const release = runtimeRelease() ?? 'unknown'; const regions = canonicalRegions(); // A collapsed enumeration would make the comparison below vacuous. expect(regions.length, `only ${regions.length} regions enumerated`).toBeGreaterThan(200); @@ -174,14 +222,18 @@ describe('firstDayOfWeek — where the engine has no week info, CLDR\'s table an const table = withoutEngineWeekInfo( () => new Map(regions.map((region) => [region, firstDayOfWeek(`und-${region}`)])), ); - const drift = regions - .filter((region) => table.get(region) !== engine.get(region)) - .map((region) => `${region}: engine ${engine.get(region)}, table ${table.get(region)}`); + const found = Object.fromEntries( + regions.filter((region) => table.get(region) !== engine.get(region)).map((region) => [region, engine.get(region)]), + ); + const expected = release === WEEK_DATA_CLDR ? {} : (RELEASE_DELTAS[release] ?? {}); expect( - drift, - `the CLDR table in utils/first-day-of-week.ts no longer matches this runtime's week data ` + - `(CLDR ${process.versions.cldr ?? 'unknown'}); regenerate it from the engine`, - ).toEqual([]); + found, + `this runtime ships CLDR ${process.versions.cldr ?? 'unknown'}; the table in utils/first-day-of-week.ts ` + + `transcribes CLDR ${WEEK_DATA_CLDR}. On the table's own release every difference is drift: regenerate the ` + + `table from that runtime. On another release, a difference RELEASE_DELTAS does not record is CLDR moving a ` + + `region: record it there from a run on a runtime that ships that release, or move the table and ` + + `WEEK_DATA_CLDR to the newer release; a recorded row that is not found no longer holds`, + ).toEqual(expected); }); it('both spellings are back exactly as the file found them', () => { diff --git a/packages/i18n/src/utils/first-day-of-week.ts b/packages/i18n/src/utils/first-day-of-week.ts index 0c7138f632..37d188d1b8 100644 --- a/packages/i18n/src/utils/first-day-of-week.ts +++ b/packages/i18n/src/utils/first-day-of-week.ts @@ -34,12 +34,14 @@ * default (`001`). This path reads the region only: a `-u-fw-` or `-u-rg-` * keyword is the engine path's alone. * - * The table is a snapshot of one CLDR release, the one in Node's ICU at the - * time of writing (`process.versions.cldr` printed `47.0`). Engines carry their - * own release, and releases differ: Chromium 141 starts `AE` on Saturday where - * CLDR 47 starts it on Monday. That is why the engine's answer wins wherever + * The table transcribes ONE CLDR release, the one {@link WEEK_DATA_CLDR} names: + * the newest release the repository's CI runtime ships. Engines carry their own + * week data, and it differs: CLDR moves a region now and then, and a browser + * may carry data of its own. That is why the engine's answer wins wherever * there is one. What re-derives the table against the running engine is - * `__tests__/firstDayOfWeek-11675.test.ts`, region by region. + * `__tests__/firstDayOfWeek-11675.test.ts`, region by region. On a runtime + * whose CLDR release is not the table's, it reads that release's recorded + * differences from the table instead of reporting them as drift. * * ## A tag `Intl` refuses is refused here too * @@ -76,15 +78,23 @@ function fromIsoWeekday(day: number): WeekdayIndex | undefined { } /** - * CLDR 47's `weekData` `firstDay` rows other than Monday, by region. Regions - * are the canonical codes `Intl.Locale` reports (`BU` reads `MM`), so the - * deprecated aliases CLDR also lists are left out. + * The CLDR release {@link NON_MONDAY_REGIONS} transcribes, as the major part of + * `process.versions.cldr`. Change it only together with the table, from a + * runtime that ships that release. + */ +export const WEEK_DATA_CLDR = '48'; + +/** + * CLDR's `weekData` `firstDay` rows other than Monday, by region, as of the + * release {@link WEEK_DATA_CLDR} names. Regions are the canonical codes + * `Intl.Locale` reports (`BU` reads `MM`), so the deprecated aliases CLDR also + * lists are left out. */ const NON_MONDAY_REGIONS: ReadonlyArray = [ [ 0, - 'AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ ' + - 'NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW', + 'AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN IS JM JP KE KH KR LA MH MM MO MT MX ' + + 'MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW', ], [5, 'MV'], [6, 'AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'],