feat(days): public getWeekInfo(locale) + locale-aware calendar weekend (C6)

Add getWeekInfo(locale) -> { firstDay, weekend } to $libs/days (Intl
convention, 1 = Monday ... 7 = Sunday): firstDay mirrors the internal
getWeekStart (Intl weekInfo + region-table fallback — one source of truth,
now exported from the vendored port), weekend comes from Intl.Locale
weekInfo (getWeekInfo() method or the Safari weekInfo accessor) with the
DOCUMENTED Saturday/Sunday fallback. Cached per locale.

Calendar + RangeCalendar derive their weekend flag from it: the shared
helpers.isWeekend(date) hardcoded Sat/Sun against a fixed 'en-US' — wrong
for e.g. ar-SA (Fri/Sat) — and now takes the provider's effectiveLocale.
Verified live: data-weekend moves to Fri+Sat under ar-SA, stays Sat+Sun
for en-US / es-ES; firstDay verified (es-ES Monday-first, en-US
Sunday-first grids; explicit weekStartsOn still overrides).

Directed firstDay verification also caught getSimpleWeekNumber mixing
frames: an ABSOLUTE weekStartsOn (0 = Sunday) was passed to
getLastFirstDayOfWeek with the display locale, which re-interprets the
index relative to that locale's own week start — es-ES 'simple' week
numbers anchored the year on a Tuesday and ran one week behind. Anchor in
the fixed en-US frame (same convention as buildMonth's explicit branch);
the dead locale param is removed from both call sites.

Tests: getWeekInfo (es-ES / en-US / ar-SA / cache identity) + new calendar
helpers suite (locale weekend, grid week-start per locale, week-number
regression). Suites 15/15, baseline 59.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 83e13be649
commit 3b02fd11a5

@ -285,7 +285,7 @@ function getRegion(locale: string): string | undefined {
return part === 'u' ? undefined : part;
}
function getWeekStart(locale: string): number {
export function getWeekStart(locale: string): number {
// TODO: use Intl.Locale for this once browsers support the weekInfo property
// https://github.com/tc39/proposal-intl-locale-info
let weekInfo = cachedWeekInfo.get(locale);

@ -50,6 +50,71 @@ export {
setLocalTimeZone,
resetLocalTimeZone
} from './_vendor/operations/queries';
import { getWeekStart } from './_vendor/operations/queries';
// ── Locale week info ─────────────────────────────────────────────────────────
/**
* Locale week metadata in the `Intl.Locale` weekInfo convention: days are
* numbered 1 (Monday) … 7 (Sunday).
*/
export interface WeekInfo {
/** First day of the week for the locale (1 = Monday … 7 = Sunday). */
firstDay: number;
/** Days forming the locale's weekend (e.g. `[6, 7]` = Sat/Sun, `[5, 6]` = Fri/Sat). */
weekend: readonly number[];
}
const cachedLocaleWeekInfo = new Map<string, WeekInfo>();
/** Normalize a day index to the Intl convention: 0 and 7 are Sunday (7); 1–6 kept. */
function normalizeWeekDay(day: number): number {
return day % 7 || 7;
}
/**
* Weekend days from `Intl.Locale` weekInfo (`getWeekInfo()` method — Chrome /
* Node — or the `weekInfo` accessor — Safari). Documented fallback when the
* engine has no weekInfo data: Saturday/Sunday (`[6, 7]`).
*/
function readIntlWeekend(locale: string): readonly number[] {
if (typeof Intl !== 'undefined' && Intl.Locale) {
try {
const inst = new Intl.Locale(locale) as Intl.Locale & {
getWeekInfo?: () => { weekend?: number[] } | undefined;
weekInfo?: { weekend?: number[] };
};
const weekend = inst.getWeekInfo?.()?.weekend ?? inst.weekInfo?.weekend;
if (weekend && weekend.length > 0) return weekend.map(normalizeWeekDay);
} catch {
// Malformed locale — fall through to the documented Sat/Sun fallback.
}
}
return [6, 7];
}
/**
* Returns the locale's week metadata (`firstDay` + `weekend`), normalized to
* the Intl convention (1 = Monday … 7 = Sunday).
*
* `firstDay` mirrors the internal week start the rest of this library uses
* (`getWeekStart` — Intl weekInfo with a region-table fallback), so a grid
* built with `startOfWeek` / `getDayOfWeek` and a consumer of this function
* can never disagree. `weekend` comes from `Intl.Locale` weekInfo (CLDR —
* e.g. Fri/Sat for `ar-SA`) with a **documented Saturday/Sunday fallback**
* for engines without weekInfo data. Cached per locale.
*/
export function getWeekInfo(locale: string): WeekInfo {
let info = cachedLocaleWeekInfo.get(locale);
if (!info) {
info = {
firstDay: normalizeWeekDay(getWeekStart(locale)),
weekend: readIntlWeekend(locale)
};
cachedLocaleWeekInfo.set(locale, info);
}
return info;
}
/** Type guard for `CalendarDate` values. */
export function isCalendarDate(value: unknown): value is CalendarDate {

@ -4,6 +4,7 @@ import {
CalendarDateTime,
Time,
ZonedDateTime,
getWeekInfo,
isCalendarDate,
isCalendarDateTime,
isTime,
@ -38,3 +39,28 @@ describe('dias query guards', () => {
expect(isZonedDateTime('2024-01-01T10:15+01:00[Europe/Madrid]' as never)).toBe(false);
});
});
describe('getWeekInfo', () => {
// Intl convention throughout: 1 = Monday … 7 = Sunday.
it('returns Monday-first weeks and a Sat/Sun weekend for es-ES', () => {
const info = getWeekInfo('es-ES');
expect(info.firstDay).toBe(1);
expect([...info.weekend]).toEqual([6, 7]);
});
it('returns Sunday-first weeks and a Sat/Sun weekend for en-US', () => {
const info = getWeekInfo('en-US');
expect(info.firstDay).toBe(7);
expect([...info.weekend]).toEqual([6, 7]);
});
it('returns the CLDR Fri/Sat weekend for ar-SA', () => {
// Requires Intl.Locale weekInfo (all supported engines); the documented
// Sat/Sun fallback only applies where that data is missing.
expect([...getWeekInfo('ar-SA').weekend]).toEqual([5, 6]);
});
it('caches per locale (same object identity)', () => {
expect(getWeekInfo('es-ES')).toBe(getWeekInfo('es-ES'));
});
});

@ -162,7 +162,7 @@ A visually hidden live-region inside `Provider` announces the current heading wh
| Cell | `data-disabled` | cell blocked (bounds / matcher / outside) |
| Cell | `data-unavailable` | matched by `isDateUnavailable` |
| Cell | `data-today` | cell is today |
| Cell | `data-weekend` | Saturday or Sunday (Gregorian) |
| Cell | `data-weekend` | locale weekend (`getWeekInfo`; Sat/Sun fallback) |
| Cell | `data-holiday` | matched by `isDateHoliday` |
| Cell | `data-outside-month` | padding cell around the visible month |
| Day | same flags as Cell | +`data-focused` for the roving tabindex=0 |

@ -547,7 +547,7 @@ export class CalendarProvider implements CalendarNav {
getWeekNumber(weekFirstDate: DateValue): number {
return this.opts.weekNumberFormat.current === 'iso'
? getIsoWeekNumber(weekFirstDate)
: getSimpleWeekNumber(weekFirstDate, this.weekStartsOnResolved, this.effectiveLocale);
: getSimpleWeekNumber(weekFirstDate, this.weekStartsOnResolved);
}
// ── Props ─────────────────────────────────────────────────────────────────
@ -1036,7 +1036,9 @@ export class CalendarCellProvider {
isOutsideMonth(this.opts.date.current, this.opts.month.current)
);
readonly todayFlag = $derived.by(() => isToday(this.opts.date.current));
readonly weekendFlag = $derived.by(() => isWeekend(this.opts.date.current));
readonly weekendFlag = $derived.by(() =>
isWeekend(this.opts.date.current, this.provider.effectiveLocale)
);
readonly isDisabled = $derived.by(() => {
const base = this.flags.disabled;

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { CalendarDate } from '$libs/days';
import { createMonths, getSimpleWeekNumber, isWeekend } from './helpers';
// 2026-07-10 = Friday, 2026-07-11 = Saturday, 2026-07-12 = Sunday.
const FRI = new CalendarDate(2026, 7, 10);
const SAT = new CalendarDate(2026, 7, 11);
const SUN = new CalendarDate(2026, 7, 12);
describe('isWeekend (locale-aware, C6)', () => {
it('es-ES: Sat/Sun weekend', () => {
expect(isWeekend(FRI, 'es-ES')).toBe(false);
expect(isWeekend(SAT, 'es-ES')).toBe(true);
expect(isWeekend(SUN, 'es-ES')).toBe(true);
});
it('ar-SA: Fri/Sat weekend (CLDR weekInfo), Sunday is a weekday', () => {
expect(isWeekend(FRI, 'ar-SA')).toBe(true);
expect(isWeekend(SAT, 'ar-SA')).toBe(true);
expect(isWeekend(SUN, 'ar-SA')).toBe(false);
});
});
describe('createMonths firstDay (locale week start)', () => {
const opts = { numberOfMonths: 1, weekStartsOn: undefined, fixedWeeks: false };
it('es-ES grids start on Monday', () => {
// July 2026 starts on a Wednesday → the leading cell is Mon 2026-06-29.
const [july] = createMonths(new CalendarDate(2026, 7, 15), { ...opts, locale: 'es-ES' });
expect(july.weeks[0][0].toString()).toBe('2026-06-29');
});
it('en-US grids start on Sunday', () => {
const [july] = createMonths(new CalendarDate(2026, 7, 15), { ...opts, locale: 'en-US' });
expect(july.weeks[0][0].toString()).toBe('2026-06-28');
});
});
describe('getSimpleWeekNumber (absolute weekStartsOn frame)', () => {
// 2026-01-01 = Thursday. Sunday-start anchor: Sun 2025-12-28; Monday-start
// anchor: Mon 2025-12-29. The row starting Mon 2026-01-05 is week 2 in both
// framings — the old locale-mixed anchor (es-ES) shifted it to week 1.
it('week containing January 1 is week 1', () => {
expect(getSimpleWeekNumber(new CalendarDate(2026, 1, 1), 0)).toBe(1);
expect(getSimpleWeekNumber(new CalendarDate(2026, 1, 1), 1)).toBe(1);
});
it('the next row is week 2 regardless of the display locale (regression)', () => {
expect(getSimpleWeekNumber(new CalendarDate(2026, 1, 4), 0)).toBe(2);
expect(getSimpleWeekNumber(new CalendarDate(2026, 1, 5), 1)).toBe(2);
});
});

@ -13,6 +13,7 @@ import {
getLastFirstDayOfWeek,
getNextLastDayOfWeek,
getLocalTimeZone,
getWeekInfo,
isSameDay,
isSameMonth,
startOfMonth,
@ -126,10 +127,17 @@ export function getIsoWeekNumber(date: DateValue): number {
/**
* Simple week number: 1-based count starting with the week containing
* January 1, counted from the user's `weekStartsOn` (0 = Sunday).
*
* `weekStartsOn` is ABSOLUTE (0 = Sunday … 6 = Saturday), so the year anchor
* is computed in the fixed en-US frame — the same convention as `buildMonth`'s
* explicit-`weekStartsOn` branch. Passing the display locale here would
* re-interpret the index relative to that locale's own week start and shift
* the anchor (es-ES + `simple` numbers anchored the year on a Tuesday and
* labelled January's first visible row "week 0").
*/
export function getSimpleWeekNumber(date: DateValue, weekStartsOn: number, locale: string): number {
export function getSimpleWeekNumber(date: DateValue, weekStartsOn: number): number {
const yearStart = new CalendarDate(date.year, 1, 1);
const firstCell = getLastFirstDayOfWeek(yearStart, weekStartsOn, locale);
const firstCell = getLastFirstDayOfWeek(yearStart, weekStartsOn, 'en-US');
const d = date.toDate(getLocalTimeZone());
const first = firstCell.toDate(getLocalTimeZone());
const days = Math.floor((d.getTime() - first.getTime()) / 86_400_000);
@ -330,9 +338,16 @@ export function isToday(date: DateValue): boolean {
return isSameDay(date, today(getLocalTimeZone()));
}
export function isWeekend(date: DateValue): boolean {
const dow = getDayOfWeek(date, 'en-US'); // 0 = Sunday
return dow === 0 || dow === 6;
/**
* Locale-aware weekend test: the weekend days come from the locale's
* `getWeekInfo` (CLDR — e.g. Fri/Sat in `ar-SA`, Sat/Sun in `es-ES`; documented
* Sat/Sun fallback), replacing the old hardcoded Saturday/Sunday. Indices are
* compared in the Intl convention (1 = Monday … 7 = Sunday) via an explicit
* Monday-start `getDayOfWeek`, so the index is absolute — not locale-relative.
*/
export function isWeekend(date: DateValue, locale: string): boolean {
const intlDay = getDayOfWeek(date, locale, 'mon') + 1;
return getWeekInfo(locale).weekend.includes(intlDay);
}
export function isOutsideMonth(date: DateValue, monthStart: DateValue): boolean {

@ -50,7 +50,7 @@ export type CalendarCellSnippetProps = {
selected: boolean;
/** True when the date is `today()`. */
today: boolean;
/** True when the date is Saturday or Sunday (weekend in Gregorian). */
/** True when the date falls on the locale's weekend (`getWeekInfo` — e.g. Fri/Sat in `ar-SA`; Sat/Sun fallback). */
weekend: boolean;
/** True when the date matches `isDateHoliday`. */
holiday: boolean;

@ -626,7 +626,7 @@ export class RangeCalendarProvider implements CalendarNav {
getWeekNumber(weekFirstDate: DateValue): number {
return this.opts.weekNumberFormat.current === 'iso'
? getIsoWeekNumber(weekFirstDate)
: getSimpleWeekNumber(weekFirstDate, this.weekStartsOnResolved, this.effectiveLocale);
: getSimpleWeekNumber(weekFirstDate, this.weekStartsOnResolved);
}
readonly props = $derived.by(() =>
@ -1060,7 +1060,9 @@ export class RangeCalendarCellProvider {
isOutsideMonth(this.opts.date.current, this.opts.month.current)
);
readonly todayFlag = $derived.by(() => isToday(this.opts.date.current));
readonly weekendFlag = $derived.by(() => isWeekend(this.opts.date.current));
readonly weekendFlag = $derived.by(() =>
isWeekend(this.opts.date.current, this.provider.effectiveLocale)
);
readonly rangeStart = $derived.by(() => this.provider.isRangeStart(this.opts.date.current));
readonly rangeEnd = $derived.by(() => this.provider.isRangeEnd(this.opts.date.current));

Loading…
Cancel
Save

Powered by TurnKey Linux.