import { afterEach, describe, expect, it, vi } from 'vitest'; import { get } from 'svelte/store'; import { availableTimeZones, createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone, timestampISO } from '../../src/lib/time-zone.js'; import { startTimeZonePreference, timeFormat, timeZone, timeZonePersistenceError, TIME_ZONE_STORAGE_KEY } from '../../src/lib/ui/stores/time-zone.js'; let stop: (() => void) | undefined; afterEach(() => { stop?.(); stop = undefined; timeZone.set(DEFAULT_TIME_ZONE); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('timezone formatting', () => { const ny = createTimeFormatter(DEFAULT_TIME_ZONE); it('uses New York by default, including when a saved zone is invalid', () => { expect(get(timeZone)).toBe('America/New_York'); expect(createTimeFormatter('invalid').dateTime('2026-01-15T12:00:00Z')).toBe('Jan 15, 2026, 7:00:00 AM EST'); expect(ny.dateTime('2026-07-15T12:00:00Z')).toBe('Jul 15, 2026, 8:00:00 AM EDT'); }); it('jumps over the missing spring hour and distinguishes repeated fall hours', () => { expect(ny.time('2026-03-08T06:59:59Z')).toBe('1:59:59 AM EST'); expect(ny.time('2026-03-08T07:00:00Z')).toBe('3:00:00 AM EDT'); expect(ny.period('2026-11-01T05:30:00Z')).toBe('Nov 1, 2026, 1:30 AM EDT'); expect(ny.period('2026-11-01T06:30:00Z')).toBe('Nov 1, 2026, 1:30 AM EST'); }); it('uses the selected calendar day for date-only displays and history groups', () => { const at = '2026-01-01T02:00:00Z'; expect(ny.date(at)).toBe('Dec 31, 2025'); expect(createTimeFormatter('Asia/Tokyo').date(at)).toBe('Jan 1, 2026'); expect(ny.dateTime(at)).toContain('Dec 31, 2025'); }); it('handles half-hour offsets, epoch values, and Date objects', () => { const format = createTimeFormatter('Asia/Kolkata'); const at = Date.parse('2026-01-15T12:00:00Z'); expect(format.minuteTime(at)).toBe('5:30 PM GMT+5:30'); expect(format.dateTime(new Date(at))).toBe(format.dateTime(at)); expect(createTimeFormatter('UTC').time(0)).toBe('12:00:00 AM UTC'); }); it('interprets SQLite and unzoned ISO timestamps as UTC while preserving explicit offsets', () => { const expected = ny.dateTime('2026-01-15T12:00:00Z'); for (const at of ['2026-01-15 12:00:00', '2026-01-15T12:00:00', '2026-01-15 12:00:00.123', '2026-01-15T07:00:00-05:00']) { expect(ny.dateTime(at)).toBe(expected); } }); it('renders missing or invalid timestamps without crashing the page', () => { for (const value of [null, undefined, '', 'bad timestamp', NaN, new Date(NaN)]) { expect(ny.dateTime(value)).toBe('—'); expect(ny.relative(value)).toBe('—'); expect(timestampISO(value)).toBeUndefined(); } }); it('shares compact recent times, exact older dates, and UTC normalization', () => { const now = Date.parse('2026-10-07T18:00:00Z'); expect(ny.relative(now - 2_000, now)).toBe('Just now'); expect(ny.relative('2026-10-07 17:59:48', now)).toBe('12 s ago'); expect(ny.relative(now - 120_000, now)).toBe('2 min ago'); expect(ny.relative(now - 23 * 3_600_000, now)).toBe('23 h ago'); expect(ny.relative('2026-10-06T17:00:00Z', now)).toBe('Yesterday, 1:00 PM'); expect(ny.relative('2026-10-05T17:00:00Z', now)).toBe('Oct 5, 2026, 1:00 PM EDT'); expect(ny.relative('2026-10-08T18:00:00Z', now)).toBe('Oct 8, 2026, 2:00 PM EDT'); expect(timestampISO('2026-10-07 17:59:48')).toBe('2026-10-07T17:59:48.000Z'); }); it('uses timezone calendar dates for yesterday across DST and year boundaries', () => { expect(ny.relative('2026-11-01T04:30:00Z', Date.parse('2026-11-02T05:15:00Z'))).toBe('Yesterday, 12:30 AM'); expect(ny.relative('2025-12-31T05:00:00Z', Date.parse('2026-01-01T06:00:00Z'))).toBe('Yesterday, 12:00 AM'); expect(createTimeFormatter('invalid').relative('2026-10-05T17:00:00Z', Date.parse('2026-10-07T18:00:00Z'))).toBe('Oct 5, 2026, 1:00 PM EDT'); }); it('offers valid zones including New York and UTC, with an older-browser fallback', () => { expect(availableTimeZones()).toContain(DEFAULT_TIME_ZONE); expect(availableTimeZones()).toContain('UTC'); expect(validTimeZone('America/Los_Angeles')).toBe(true); for (const value of [null, '', 'invalid', 123]) expect(validTimeZone(value)).toBe(false); vi.spyOn(Intl, 'supportedValuesOf').mockImplementation(() => ['Europe/London']); expect(availableTimeZones()).toEqual(['America/New_York', 'Europe/London', 'UTC']); vi.stubGlobal('Intl', new Proxy(Intl, { get: (target, key) => key === 'supportedValuesOf' ? undefined : Reflect.get(target, key) })); expect(availableTimeZones()).toContain('Asia/Kolkata'); expect(availableTimeZones().every(validTimeZone)).toBe(true); }); }); function browser(saved?: string) { const values = new Map(saved ? [[TIME_ZONE_STORAGE_KEY, saved]] : []); const storage = { getItem: vi.fn((key: string) => values.get(key) ?? null), setItem: vi.fn((key: string, value: string) => { values.set(key, value); }) }; const window = new EventTarget(); vi.stubGlobal('localStorage', storage); vi.stubGlobal('window', window); function change(newValue: string | null, key: string | null = TIME_ZONE_STORAGE_KEY) { window.dispatchEvent(Object.assign(new Event('storage'), { storageArea: storage, key, newValue })); } return { values, storage, change }; } describe('timezone preference', () => { it('restores the saved zone and updates subscribed formatters and persistence immediately', () => { const { values } = browser('UTC'); stop = startTimeZonePreference(); let shown = ''; const unsubscribe = timeFormat.subscribe(format => { shown = format.time('2026-01-15T12:00:00Z'); }); expect(shown).toBe('12:00:00 PM UTC'); timeZone.set('America/New_York'); expect(shown).toBe('7:00:00 AM EST'); expect(values.get(TIME_ZONE_STORAGE_KEY)).toBe('America/New_York'); stop(); stop = startTimeZonePreference(); expect(get(timeZone)).toBe('America/New_York'); unsubscribe(); }); it.each([undefined, 'invalid'])('defaults safely with a missing or corrupt preference (%s)', saved => { browser(saved); stop = startTimeZonePreference(); expect(get(timeZone)).toBe(DEFAULT_TIME_ZONE); }); it('syncs changes from other tabs and removes listeners on cleanup', () => { const { change } = browser(); stop = startTimeZonePreference(); change('Asia/Tokyo'); expect(get(timeZone)).toBe('Asia/Tokyo'); change('UTC', 'unrelated-key'); expect(get(timeZone)).toBe('Asia/Tokyo'); change(null, null); expect(get(timeZone)).toBe(DEFAULT_TIME_ZONE); stop(); stop = undefined; change('UTC'); expect(get(timeZone)).toBe(DEFAULT_TIME_ZONE); }); it('keeps the preference usable when browser storage is blocked', () => { const { storage } = browser(); storage.getItem.mockImplementation(() => { throw new Error('Storage blocked'); }); storage.setItem.mockImplementation(() => { throw new Error('Storage blocked'); }); expect(() => { stop = startTimeZonePreference(); }).not.toThrow(); timeZone.set('UTC'); expect(get(timeFormat).time('2026-01-15T12:00:00Z')).toBe('12:00:00 PM UTC'); expect(get(timeZonePersistenceError)).toBe(true); }); });