Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion components/i18n/use-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ export function useFamilyClock(): FamilyClock {
/** A `YYYY-MM-DD` day key as the local-calendar Date the date-only helpers read. */
export function calendarDayOfKey(key: string): Date {
const [y, m, d] = key.split('-').map(Number);
return new Date(y, m - 1, d);
if (!Number.isInteger(y) || y < 1 || y > 9999) return new Date(y, m - 1, d);
const date = new Date(0);
date.setFullYear(y, m - 1, d);
date.setHours(0, 0, 0, 0);
return date;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions components/modules/calendar-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1071,8 +1071,8 @@ function NewEventModal({ existing, onClose, onSaved }: {
// device skips (its own DST gap) moves (#688 comment 5922125002).
// `eventSchema` already rejected a blank start, so the `?? ''` is for the
// type and cannot fire.
const startsAt = fromLocalInput(parsed.data.starts_at, timeZone) ?? '';
const endsAt = fromLocalInput(parsed.data.ends_at, timeZone) ?? null;
const startsAt = fromLocalInput(parsed.data.starts_at, timeZone, existing?.starts_at) ?? '';
const endsAt = fromLocalInput(parsed.data.ends_at, timeZone, existing?.ends_at) ?? null;
// End must be after start when both are provided.
if (endsAt && new Date(endsAt) <= new Date(startsAt)) {
setErrors({ ends_at: tr('calendarModule.endAfterStart') });
Expand Down
50 changes: 36 additions & 14 deletions finalaudit.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion lib/briefing/calendar-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ export function normalizeCalendarWindowInstant(value: string, timezone = 'UTC'):
if (!resolved) throw new RangeError('Invalid calendar local time');
const exact = BigInt(resolved.getTime()) * 1_000_000n + BigInt(second) * 1_000_000_000n + BigInt(fraction.padEnd(9, '0') || '0');
const normalized = formatExactInstant(exact);
parseExactInstant(normalized);
return normalized.slice(0, 20) + normalized.slice(20, -1).padEnd(Math.max(3, fraction.length), '0') + 'Z';
}
return normalizeExactInstant(value);
const normalized = normalizeExactInstant(value);
parseExactInstant(normalized);
return normalized;
}

/** The PostgREST OR filter for a window's two halves, for the rows that are NOT series. */
Expand Down
6 changes: 4 additions & 2 deletions lib/schedule/zoned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ export function zonedTimeMs(dayKey: string, hour: number, minute: number, tz: st
/** "6:00 PM" for an instant, on the family's wall clock. Empty when unparseable. */
export function clockInZone(ms: number, tz: string): string {
if (!Number.isFinite(ms)) return '';
const instant = new Date(ms);
if (!Number.isFinite(instant.getTime())) return '';
try {
return new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(new Date(ms));
return new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(instant);
} catch {
return new Date(ms).toISOString().slice(11, 16);
return `${String(instant.getUTCHours()).padStart(2, '0')}:${String(instant.getUTCMinutes()).padStart(2, '0')}`;
}
}

Expand Down
17 changes: 16 additions & 1 deletion lib/time/local-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
// Framework-free and safe in a client bundle: no `server-only`, no DOM. The
// zoned half uses `Intl` through `lib/time/zoned.ts`.
import { instantForLocalTime, isValidTimezone, localPartsAt } from '@/lib/time/zoned';
import { parseExactInstant } from '@/lib/calendar/exact-instant';

/**
* The naive wall clock that a `datetime-local` box shows for an instant, as the
Expand Down Expand Up @@ -95,12 +96,26 @@ const NAIVE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3})
* dropping the event out of the day. From that instant on the round trip is
* closed as usual, because the box then shows the hour that does exist.
*/
export function fromLocalInput(value: string | null | undefined, timeZone?: string): string | undefined {
export function fromLocalInput(
value: string | null | undefined, timeZone?: string, originalInstant?: string | null,
): string | undefined {
if (value == null) return undefined;
const v = value.trim();
if (!v) return undefined;
const m = NAIVE.exec(v);
if (!m || Number(m[1]) < 1) return v;
// A minute-only box cannot distinguish both occurrences of a repeated hour
// or carry the stored seconds. An unchanged edit retains its own original
// instant, only when it is valid and displays this exact box in this zone.
// Changed values and callers without a hint keep the existing resolution.
if (originalInstant) {
try {
parseExactInstant(originalInstant);
if (toLocalInput(originalInstant, timeZone) === v) return originalInstant;
} catch {
// A malformed or non-absolute hint cannot change the legacy resolution.
}
}
if (timeZone && isValidTimezone(timeZone)) {
// The family's clock, the same frame `toLocalInput(iso, timeZone)` read the
// prefill in. Seconds and milliseconds are carried on top of the minute the
Expand Down
31 changes: 21 additions & 10 deletions lib/time/zoned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,22 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
if (!dtf) {
dtf = new Intl.DateTimeFormat('en-US', {
timeZone: timezone, hour12: false,
era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
});
partsCache.set(timezone, dtf);
}
return dtf;
}

/** The wall-clock reading an observer in `timezone` sees at `instant`. */
export function localPartsAt(instant: Date, timezone: string): LocalParts {
function formattedPartsAt(instant: Date, timezone: string): Record<string, string> {
const parts: Record<string, string> = {};
for (const part of formatterFor(timezone).formatToParts(instant)) parts[part.type] = part.value;
return parts;
}

/** The wall-clock reading an observer in `timezone` sees at `instant`. */
export function localPartsAt(instant: Date, timezone: string): LocalParts {
const parts = formattedPartsAt(instant, timezone);
return {
// Intl numbers BC years from one; Date uses astronomical year zero.
year: parts.era === 'BC' ? 1 - Number(parts.year) : Number(parts.year),
Expand Down Expand Up @@ -90,10 +95,19 @@ export function dayKeyIn(instant: Date, timezone: string): string {
}
}

/** Internal exact civil clock; public LocalParts intentionally stays minute-only. */
function wallMillisecondsAt(instant: Date, timezone: string): number {
const parts = formattedPartsAt(instant, timezone);
const year = parts.era === 'BC' ? 1 - Number(parts.year) : Number(parts.year);
const wall = new Date(0);
wall.setUTCFullYear(year, Number(parts.month) - 1, Number(parts.day));
wall.setUTCHours(Number(parts.hour) % 24, Number(parts.minute), Number(parts.second), instant.getUTCMilliseconds());
return wall.getTime();
}

/** Zone offset in ms at a given instant (positive east of UTC). */
function offsetMsAt(instant: Date, timezone: string): number {
const p = localPartsAt(instant, timezone);
return utcMilliseconds(p.year, p.month - 1, p.day, p.hour, p.minute) - instant.getTime();
return wallMillisecondsAt(instant, timezone) - instant.getTime();
}

/**
Expand All @@ -118,7 +132,7 @@ export function zonedLocalToInstant(
const check = localPartsAt(new Date(ts), timezone);
const matches = check.year === year && check.month === month && check.day === day
&& check.hour === hour && check.minute === minute;
return matches ? new Date(ts) : null;
return matches && wallMillisecondsAt(new Date(ts), timezone) === naive ? new Date(ts) : null;
}

/**
Expand Down Expand Up @@ -172,10 +186,7 @@ export function instantForIcsLocalTime(
): Date | null {
const wall = utcMilliseconds(year, month - 1, day, Math.floor(minutes / 60), minutes % 60);
if (!Number.isFinite(wall)) return null;
const reads = (instant: number) => {
const p = localPartsAt(new Date(instant), timezone);
return utcMilliseconds(p.year, p.month - 1, p.day, p.hour, p.minute);
};
const reads = (instant: number) => wallMillisecondsAt(new Date(instant), timezone);
const offsets = new Set([-2, -1, 0, 1, 2].map((days) => offsetMsAt(new Date(wall + days * DAY_MS), timezone)));
const candidates = [...offsets].map((offset) => wall - offset).sort((a, b) => a - b);
const exact = candidates.find((instant) => reads(instant) === wall);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ describe('the calendar form compares the times it will save (#688 comment 592212
it('NewEventModal resolves both boxes on the family\'s clock before comparing them, and saves those', () => {
const src = readFileSync(join(__dirname, '..', 'components/modules/calendar-module.tsx'), 'utf8');
const submit = between(src, 'const parsed = eventSchema.safeParse(input);', 'const result = existing');
expect(submit).toContain('const startsAt = fromLocalInput(parsed.data.starts_at, timeZone)');
expect(submit).toContain('const startsAt = fromLocalInput(parsed.data.starts_at, timeZone, existing?.starts_at)');
expect(submit).toContain('if (endsAt && new Date(endsAt) <= new Date(startsAt))');
expect(submit).not.toMatch(/new Date\(parsed\.data\./);
expect(at(submit, 'const startsAt')).toBeLessThan(at(submit, 'new Date(endsAt)'));
Expand Down
91 changes: 91 additions & 0 deletions tests/calendar-date-handoff-keeps-supported-years.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createElement, type ComponentProps } from 'react';
import { renderToString } from 'react-dom/server';
import { FamilyTimeZoneProvider, LocaleProvider } from '@/components/i18n/locale-provider';
import { calendarDayOfKey, useFamilyClock, type FamilyClock } from '@/components/i18n/use-format';
import { localeOrDefault } from '@/lib/i18n/locales';

// A DATE-only handoff is a device-local calendar Date, not a wall reading or
// an instant. Use its local fields; the helper's calendar overflow remains
// unchanged. These native Date and real React-provider checks do not claim
// browser hydration, provider, authorization, API or production acceptance.
const supportedDays = ['0001-01-01', '0004-02-29', '0099-12-31', '0100-03-01', '0400-02-29', '2026-10-09', '9999-12-31'];
const parts = (date: Date) => [date.getFullYear(), date.getMonth() + 1, date.getDate()];
const expectedParts = (day: string) => day.split('-').map(Number);

function clockFor(timeZone: string): FamilyClock {
let clock: FamilyClock | undefined;
const Probe = () => { clock = useFamilyClock(); return null; };
renderToString(createElement(LocaleProvider, {
locale: localeOrDefault('en-US'), source: 'default', messages: {},
} as ComponentProps<typeof LocaleProvider>,
createElement(FamilyTimeZoneProvider, { timeZone } as ComponentProps<typeof FamilyTimeZoneProvider>, createElement(Probe))));
if (!clock) throw new Error('The actual provider did not produce a family clock');
return clock;
}

afterEach(() => vi.restoreAllMocks());

describe('the DATE-only constructor keeps supported Gregorian years', () => {
it.each(supportedDays)('%s retains its local calendar fields', day => {
expect(parts(calendarDayOfKey(day))).toEqual(expectedParts(day));
});

it.each([
['0001-12-32', [2, 1, 1]],
['0004-02-30', [4, 3, 1]],
['0099-13-01', [100, 1, 1]],
['0100-02-29', [100, 3, 1]],
['0400-02-30', [400, 3, 1]],
['2026-02-30', [2026, 3, 2]],
['2026-00-00', [2025, 11, 30]],
['9999-12-00', [9999, 11, 30]],
['9999-13-01', [10000, 1, 1]],
] as const)('%s keeps the existing constructor calendar overflow', (key, expected) => {
// Public DATE admission is validated separately. An internal constructor
// rollover must not silently return a different century or adopt a new
// strict-date policy here.
expect(parts(calendarDayOfKey(key))).toEqual(expected);
});

it.each(['bad', '', '2026-01', '2026/01/01', '2026-01-notday', 'Infinity-01-01'])('%s remains unparseable', key => {
expect(Number.isNaN(calendarDayOfKey(key).getTime())).toBe(true);
});

it.each(['0000-01-01', '10000-01-01', '-000001-01-01', '1.5-01-01', '0-00-00'])('%s retains its unsupported or legacy constructor result', key => {
// Preserve the prior result without admitting this input as a supported
// public day or changing the existing invalid-input policy.
const [year, month, day] = key.split('-').map(Number);
expect(calendarDayOfKey(key).getTime()).toBe(new Date(year, month - 1, day).getTime());
});
});

describe('the actual family clock hands off the family date unchanged', () => {
it.each(supportedDays)('%s stays the same through the React-provider DATE handoff', day => {
const now = new Date(day + 'T12:30:00Z');
const clock = clockFor('UTC');
expect(clock.todayKey(now)).toBe(day);
expect(clock.wallKey(clock.wallNow(now))).toBe(day);
expect(parts(clock.calendarToday(now))).toEqual(expectedParts(day));
});

it.each([
['America/Los_Angeles', '2026-10-09T01:30:00Z', '2026-10-08'],
['Asia/Tokyo', '2026-10-09T23:30:00Z', '2026-10-10'],
['America/Santiago', '2026-09-06T15:00:00Z', '2026-09-06'],
])('%s hands off its own day rather than the UTC day', (zone, instant, day) => {
const clock = clockFor(zone);
const now = new Date(instant);
expect(clock.todayKey(now)).toBe(day);
expect(clock.wallKey(clock.wallNow(now))).toBe(day);
expect(parts(clock.calendarToday(now))).toEqual(expectedParts(day));
});

it('uses the supplied AD1 clock without reading Date.now', () => {
const clock = clockFor('UTC');
const now = new Date('0001-01-01T12:30:00Z');
const read = vi.spyOn(Date, 'now').mockImplementation(() => { throw new Error('Unexpected ambient clock read'); });
expect(parts(clock.calendarToday(now))).toEqual([1, 1, 1]);
expect(read).not.toHaveBeenCalled();
});
});
Loading
Loading