Add timezone preference across time displays

This commit is contained in:
2026-09-20 22:12:27 -04:00
parent 1e6ff3abbf
commit 5c5e75526d
28 changed files with 297 additions and 52 deletions
+50
View File
@@ -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<Timestamp, null | undefined>): 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' })
};
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
@@ -350,7 +351,7 @@
</div></details>
</Card>
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {new Date(installed.recordedAt).toLocaleString()}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {$timeFormat.dateTime(installed.recordedAt)}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
{#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
@@ -372,7 +373,7 @@
<span class="v-notes">{v.notes ?? ''}
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span>
<time class="v-when" datetime={v.uploaded_at}>{new Date(v.uploaded_at).toLocaleString()}</time>
<time class="v-when" datetime={v.uploaded_at}>{$timeFormat.dateTime(v.uploaded_at)}</time>
{#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import ServerIdentity from './ServerIdentity.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
@@ -22,7 +23,7 @@
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
async function action(action:string, extra:Record<string,string>={}) {
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? new Date(deadline).toLocaleString() : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
busy = true; error = ''; message = '';
try {
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
@@ -57,7 +58,7 @@
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary><ServerIdentity name={server.server}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={new Date(server.observation.lastGameplayAt).toLocaleString()}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
@@ -80,7 +81,7 @@
{#if evidenceOpen}
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{new Date(event.received_at).toLocaleString()}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{$timeFormat.dateTime(event.received_at)}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation';
@@ -100,7 +101,7 @@
</li>
{/each}
</ol>
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
</form>
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation';
@@ -136,7 +137,7 @@
<p class="muted">{preview.hasSavedKey ? 'A key is stored for this exact server ID; its validity is checked when the game starts. Enter a replacement here to update it at launch.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
</form>
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
</script>
@@ -13,7 +14,7 @@
<details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>{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.'}</p>
<p>{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.'}</p>
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ServerMatch } from '$lib/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
@@ -57,8 +58,8 @@
<tbody>
{#each matches as match (match.id)}
<tr>
<td><time datetime={match.startedAt}>{new Date(match.startedAt).toLocaleString()}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{new Date(match.endedAt).toLocaleString()}</time>{:else}—{/if}</td>
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
{#if server === undefined}<td><a href={`/servers/${encodeURIComponent(match.server)}?tab=matches`}>{serverTitle(match.server)}</a></td>{/if}
<td>{match.map}</td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import { notificationHistory, markNotificationsRead, clearNotifications } from '$ui/toast.js';
@@ -8,7 +9,7 @@
<Dialog bind:open title="Recent notifications" description="Recent action results and completed lobby watches in this browser tab. Watches run while the app is open.">
{#if $notificationHistory.length}
<ol>{#each $notificationHistory as item (item.id)}<li class:attention={item.variant === 'danger' || item.variant === 'warning'}><p>{item.message}</p><time datetime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>{#if item.href}<a href={item.href} onclick={() => open = false}>View details →</a>{/if}</li>{/each}</ol>
<ol>{#each $notificationHistory as item (item.id)}<li class:attention={item.variant === 'danger' || item.variant === 'warning'}><p>{item.message}</p><time datetime={item.createdAt}>{$timeFormat.dateTime(item.createdAt)}</time>{#if item.href}<a href={item.href} onclick={() => open = false}>View details →</a>{/if}</li>{/each}</ol>
{:else}<p class="empty">No recent notifications. Completed lobby watches and action results will appear here.</p>{/if}
{#snippet actions()}<Button disabled={!$notificationHistory.length} onclick={clearNotifications}>Clear history</Button><Button onclick={() => open = false}>Close</Button>{/snippet}
</Dialog>
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte';
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
@@ -22,14 +23,14 @@
</ol>
</section>
<section>
{#if latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{new Date(latest.created_at).toLocaleString()}</time></p>{/if}
{#if latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{$timeFormat.dateTime(latest.created_at)}</time></p>{/if}
<details>
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
{#if events.length}
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
<ol class="timeline">
{#each visible as event (event.id)}
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>
{/each}
</ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { GameTelemetry } from '$lib/telemetry.js';
import { uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
@@ -51,7 +52,7 @@
</div>
<div class="report-meta">
<span>{players.length} {players.length === 1 ? 'player' : 'players'}</span>
<time datetime={report.receivedAt} title={new Date(report.receivedAt).toLocaleString()}>
<time datetime={report.receivedAt} title={$timeFormat.dateTime(report.receivedAt)}>
Updated {elapsedSince(report.receivedAt, $uiNow)} ago
</time>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
@@ -118,7 +119,7 @@
{#if release}
<div class="preview">
<div class="heading"><h3>{release.name || release.tag}</h3><Badge variant={release.prerelease ? 'warning' : 'neutral'}>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge></div>
<p><a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> · Published {new Date(release.publishedAt).toLocaleDateString()}</p>
<p><a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> · Published {$timeFormat.date(release.publishedAt)}</p>
<details open><summary>Release notes</summary><pre>{release.notes || 'No release notes provided.'}</pre></details>
{#if release.assets.length === 0}
<p>No importable DLL assets found. Choose another release or upload a DLL manually. Direct DLL assets up to 64 MB are supported.</p>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
let { name, since = null }: { name: string; since?: string | null } = $props();
@@ -11,7 +12,7 @@
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
<div class="round" aria-live="polite">
<strong>Round {report.round}</strong>
<span title={report.receivedAt}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players.length} players</span>
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago · {report.players.length} players</span>
</div>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
import { pollVisible } from '$ui/poll.js';
@@ -61,10 +62,7 @@
});
function timestamp(value: string): string {
return new Date(value).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short'
});
return $timeFormat.dateTime(value);
}
function outcome(status: number): string {
+26
View File
@@ -0,0 +1,26 @@
import { derived, writable } from 'svelte/store';
import { createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone } from '$lib/time-zone.js';
export const TIME_ZONE_STORAGE_KEY = 'electric-cherry:time-zone';
export const timeZone = writable(DEFAULT_TIME_ZONE);
export const timeFormat = derived(timeZone, createTimeFormatter);
export const timeZonePersistenceError = writable(false);
/** Called only on mount so server rendering never shares a user's preference. */
export function startTimeZonePreference(): () => void {
let saved: string | null = null;
try { saved = localStorage.getItem(TIME_ZONE_STORAGE_KEY); } catch { /* Use the default. */ }
timeZone.set(validTimeZone(saved) ? saved : DEFAULT_TIME_ZONE);
const unsubscribe = timeZone.subscribe(value => {
try {
localStorage.setItem(TIME_ZONE_STORAGE_KEY, value);
timeZonePersistenceError.set(false);
} catch { timeZonePersistenceError.set(true); }
});
function onStorage(event: StorageEvent) {
if (event.storageArea !== localStorage || (event.key !== TIME_ZONE_STORAGE_KEY && event.key !== null)) return;
timeZone.set(validTimeZone(event.newValue) ? event.newValue : DEFAULT_TIME_ZONE);
}
window.addEventListener('storage', onStorage);
return () => { unsubscribe(); window.removeEventListener('storage', onStorage); };
}