diff --git a/src/lib/time-zone.ts b/src/lib/time-zone.ts new file mode 100644 index 0000000..d72b525 --- /dev/null +++ b/src/lib/time-zone.ts @@ -0,0 +1,50 @@ +export const DEFAULT_TIME_ZONE = 'America/New_York'; + +export function validTimeZone(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) return false; + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }); + return true; + } catch { return false; } +} + +export function availableTimeZones(): string[] { + // Older browsers can still select common zones when enumeration is unavailable. + const fallback = [DEFAULT_TIME_ZONE, 'America/Chicago', 'America/Denver', 'America/Los_Angeles', + 'America/Anchorage', 'Pacific/Honolulu', 'Europe/London', 'Europe/Paris', + 'Asia/Kolkata', 'Asia/Tokyo', 'Australia/Sydney']; + const zones = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : fallback; + return [...new Set([DEFAULT_TIME_ZONE, 'UTC', ...zones])].sort(); +} + +type Timestamp = string | number | Date | null | undefined; + +/** Database timestamps without an offset are UTC, never browser-local time. */ +function parseTimestamp(value: Exclude): Date { + if (value instanceof Date) return value; + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value)) { + return new Date(value.replace(' ', 'T') + 'Z'); + } + return new Date(value); +} + +export function createTimeFormatter(zone: string) { + const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE; + const date: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; + const time: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'short' }; + function formatter(options: Intl.DateTimeFormatOptions) { + const format = new Intl.DateTimeFormat('en-US', { ...options, timeZone }); + return (value: Timestamp): string => { + if (value === null || value === undefined || value === '') return '—'; + const parsed = parseTimestamp(value); + return Number.isFinite(parsed.getTime()) ? format.format(parsed) : '—'; + }; + } + return { + dateTime: formatter({ ...date, ...time }), + date: formatter(date), + time: formatter(time), + minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), + period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }) + }; +} diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index 5c79f0c..729248e 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,4 +1,5 @@ @@ -13,7 +14,7 @@
{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'} -

{historical ? 'Recorded' : 'Previewed'} {new Date(recipe.capturedAt).toLocaleString()}. {historical ? 'This records the files selected for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}

+

{historical ? 'Recorded' : 'Previewed'} {$timeFormat.dateTime(recipe.capturedAt)}. {historical ? 'This records the files selected for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}

Recipe {recipe.fingerprint.slice(0, 16)}
diff --git a/src/lib/ui/components/MatchHistory.svelte b/src/lib/ui/components/MatchHistory.svelte index 3245ece..cf2dd81 100644 --- a/src/lib/ui/components/MatchHistory.svelte +++ b/src/lib/ui/components/MatchHistory.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index f1c08d7..761ae3b 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index 7928aff..fb34408 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/deployments/[id]/+page.svelte b/src/routes/deployments/[id]/+page.svelte index b4a1d18..60ccaec 100644 --- a/src/routes/deployments/[id]/+page.svelte +++ b/src/routes/deployments/[id]/+page.svelte @@ -1,4 +1,5 @@ {deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index df66414..5961a35 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -1,4 +1,5 @@ -System status · Electric Cherry +Settings · Electric Cherry
- - {#snippet actions()}{/snippet} - + +
+

Display preferences

+ +

Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.

+ {#if data}

Example: {$timeFormat.dateTime(data.checkedAt)}

{/if} + {#if $timeZonePersistenceError}

Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.

{/if} +
+
+

System status

Check your connections and the prerequisites for a playable server.

+ +
{#if error}{/if} {#if data} -

Checked {new Date(data.checkedAt).toLocaleString()} · {data.readyServers} / {data.totalServers} servers ready

+

Checked {$timeFormat.dateTime(data.checkedAt)} · {data.readyServers} / {data.totalServers} servers ready

    {#each data.checks as check}
  1. {check.title}

    {labels[check.state]}

    {check.detail}

    {#if check.href}{check.action} →{/if}
  2. {/each}
{:else if loading}

Checking system setup…

{/if}

Connection setup

Set these variables on the Electric Cherry container in Komodo, then restart the app to load the changes. Credentials are kept on the server and are never returned by this page.

Komodo
KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET, and KOMODO_TARGET_SERVER. The target must match the host name in Komodo.
Game servers
RCON_PASSWORD must match the game-server password. Set RCON_HOST if the default host.docker.internal does not reach the game host. Forward the allocated UDP game ports.
@@ -48,5 +64,7 @@ dd { margin: 5px 0 0; } code { font-size: var(--text-xs); } .guide { scroll-margin-top: 90px; } + .timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; } + .timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); } @media (max-width: 700px) { .checks { grid-template-columns: 1fr; } } diff --git a/tests/unit/time-zone.test.ts b/tests/unit/time-zone.test.ts new file mode 100644 index 0000000..218ea6a --- /dev/null +++ b/tests/unit/time-zone.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { get } from 'svelte/store'; +import { availableTimeZones, createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone } 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('—'); + } + }); + + 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); + }); +});