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"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
@@ -350,7 +351,7 @@
</div></details> </div></details>
</Card> </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'} {#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id} <ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
@@ -372,7 +373,7 @@
<span class="v-notes">{v.notes ?? ''} <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} {#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span> </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} {#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge> <Badge variant="online" size="sm">Promoted</Badge>
{:else} {:else}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte'; import { tick } from 'svelte';
import ServerIdentity from './ServerIdentity.svelte'; import ServerIdentity from './ServerIdentity.svelte';
import StatusBadge from './primitives/StatusBadge.svelte'; import StatusBadge from './primitives/StatusBadge.svelte';
@@ -22,7 +23,7 @@
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; }); $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`; } 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>={}) { 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 = ''; busy = true; error = ''; message = '';
try { 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(); 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)} {#each ranked as server (server.server)}
<details class="server" open={!server.passed}> <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> <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)} {#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)} {@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}> {#if requirement}<div class="requirement" class:failed={progress.failed}>
@@ -80,7 +81,7 @@
{#if evidenceOpen} {#if evidenceOpen}
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p> <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> <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"/> <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 !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
{/if} {/if}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js'; import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -100,7 +101,7 @@
</li> </li>
{/each} {/each}
</ol> </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> </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} {: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> <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"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js'; import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation'; 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> <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> <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} {#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> </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> <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} {#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"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js'; import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props(); let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
</script> </script>
@@ -13,7 +14,7 @@
<details> <details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary> <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> <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> <small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details> </details>
</div> </div>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ServerMatch } from '$lib/match-history.js'; import type { ServerMatch } from '$lib/match-history.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
@@ -57,8 +58,8 @@
<tbody> <tbody>
{#each matches as match (match.id)} {#each matches as match (match.id)}
<tr> <tr>
<td><time datetime={match.startedAt}>{new Date(match.startedAt).toLocaleString()}</time></td> <td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{new Date(match.endedAt).toLocaleString()}</time>{:else}—{/if}</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} {#if server === undefined}<td><a href={`/servers/${encodeURIComponent(match.server)}?tab=matches`}>{serverTitle(match.server)}</a></td>{/if}
<td>{match.map}</td> <td>{match.map}</td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td> <td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte'; import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import { notificationHistory, markNotificationsRead, clearNotifications } from '$ui/toast.js'; 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."> <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} {#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} {: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} {#snippet actions()}<Button disabled={!$notificationHistory.length} onclick={clearNotifications}>Clear history</Button><Button onclick={() => open = false}>Close</Button>{/snippet}
</Dialog> </Dialog>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js'; import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props(); let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
@@ -22,14 +23,14 @@
</ol> </ol>
</section> </section>
<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> <details>
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary> <summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
{#if events.length} {#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> <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"> <ol class="timeline">
{#each visible as event (event.id)} {#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} {/each}
</ol> </ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if} {#if !matching.length}<p class="muted">No matching activity.</p>{/if}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { GameTelemetry } from '$lib/telemetry.js'; import type { GameTelemetry } from '$lib/telemetry.js';
import { uiNow } from '$ui/stores/servers.js'; import { uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js'; import { elapsedSince } from '$ui/duration.js';
@@ -51,7 +52,7 @@
</div> </div>
<div class="report-meta"> <div class="report-meta">
<span>{players.length} {players.length === 1 ? 'player' : 'players'}</span> <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 Updated {elapsedSince(report.receivedAt, $uiNow)} ago
</time> </time>
</div> </div>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js'; import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
@@ -118,7 +119,7 @@
{#if release} {#if release}
<div class="preview"> <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> <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> <details open><summary>Release notes</summary><pre>{release.notes || 'No release notes provided.'}</pre></details>
{#if release.assets.length === 0} {#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> <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"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js'; import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js'; import { elapsedSince } from '$ui/duration.js';
let { name, since = null }: { name: string; since?: string | null } = $props(); let { name, since = null }: { name: string; since?: string | null } = $props();
@@ -11,7 +12,7 @@
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh} {#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
<div class="round" aria-live="polite"> <div class="round" aria-live="polite">
<strong>Round {report.round}</strong> <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> </div>
{/if} {/if}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js'; import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -61,10 +62,7 @@
}); });
function timestamp(value: string): string { function timestamp(value: string): string {
return new Date(value).toLocaleString(undefined, { return $timeFormat.dateTime(value);
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short'
});
} }
function outcome(status: number): string { 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); };
}
+4 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { startTimeZonePreference } from '$ui/stores/time-zone.js';
import { afterNavigate, onNavigate, goto } from '$app/navigation'; import { afterNavigate, onNavigate, goto } from '$app/navigation';
import { startPipelineStream } from '$ui/stores/pipeline.js'; import { startPipelineStream } from '$ui/stores/pipeline.js';
import { startServerStream } from '$ui/stores/servers.js'; import { startServerStream } from '$ui/stores/servers.js';
@@ -42,6 +43,7 @@
} }
onMount(() => { onMount(() => {
const stopTimeZone = startTimeZonePreference();
const stopNotifications = startNotificationHistory(); const stopNotifications = startNotificationHistory();
const stopPipeline = startPipelineStream(); const stopPipeline = startPipelineStream();
const stopServers = startServerStream(); const stopServers = startServerStream();
@@ -80,6 +82,7 @@
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
stopTimeZone();
stopNotifications(); stopNotifications();
stopFleetMonitor(); stopFleetMonitor();
stopPipeline?.(); stopPipeline?.();
@@ -107,7 +110,7 @@
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }] ? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
: tabs[2].match($page.url.pathname) : tabs[2].match($page.url.pathname)
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Campaigns' }] ? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Campaigns' }]
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { href: '/server-keys', label: 'Server keys' }] : []); : tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences & status' }, { href: '/server-keys', label: 'Server keys' }] : []);
</script> </script>
<svelte:window onkeydown={closeMenu} /> <svelte:window onkeydown={closeMenu} />
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -74,7 +75,7 @@
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} /> {:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)} {:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
<a href={'/betas/' + beta.id} class="row"> <a href={'/betas/' + beta.id} class="row">
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={new Date(beta.opened_at).toLocaleString()}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {new Date(beta.deadlineAt).toLocaleString()}{:else if beta.closed_at} · Finished {new Date(beta.closed_at).toLocaleString()}{/if}</span></div> <div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div>
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div> <div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
</a> </a>
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if} {/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
+5 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { artifactVersionLabel } from '$lib/artifact-version.js'; import { artifactVersionLabel } from '$lib/artifact-version.js';
@@ -129,9 +130,9 @@
<div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div> <div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div>
<p>{nextAction}</p> <p>{nextAction}</p>
{#if test?.reason}<p class="reason">{test.reason}</p>{/if} {#if test?.reason}<p class="reason">{test.reason}</p>{/if}
<div class="timing"><span>Started <time datetime={beta.opened_at}>{new Date(beta.opened_at).toLocaleString()}</time></span> <div class="timing"><span>Started <time datetime={beta.opened_at}>{$timeFormat.dateTime(beta.opened_at)}</time></span>
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{deadline.toLocaleString()}</time> · {deadlineText}</strong>{/if} {#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{$timeFormat.dateTime(deadline)}</time> · {deadlineText}</strong>{/if}
{#if beta.closed_at}<span>Finished {new Date(beta.closed_at).toLocaleString()}</span>{/if} {#if beta.closed_at}<span>Finished {$timeFormat.dateTime(beta.closed_at)}</span>{/if}
</div> </div>
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if} {#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
<div class="actions"> <div class="actions">
@@ -163,7 +164,7 @@
{#if preview} {#if preview}
<p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p> <p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p>
<p>Files are installed when you confirm. Tested servers have already restarted. Additional servers restart when empty, then their health is checked. Eligibility is determined by each file's current scope.</p> <p>Files are installed when you confirm. Tested servers have already restarted. Additional servers restart when empty, then their health is checked. Eligibility is determined by each file's current scope.</p>
<p class="muted">Reviewed {new Date(preview.createdAt).toLocaleString()}. If targets, versions, or composed settings change, you must review again.</p> <p class="muted">Reviewed {$timeFormat.dateTime(preview.createdAt)}. If targets, versions, or composed settings change, you must review again.</p>
{#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each} {#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each}
<div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div> <div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div>
{#if preview.configWrites.length}<details><summary>Composed configuration files ({preview.configWrites.length})</summary>{#each preview.configWrites as write}<p>{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}</p>{/each}</details>{/if} {#if preview.configWrites.length}<details><summary>Composed configuration files ({preview.configWrites.length})</summary>{#each preview.configWrites as write}<p>{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}</p>{/each}</details>{/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js'; import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
@@ -178,7 +179,7 @@
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if} {#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if} {#if notice}<p role="status" class="notice">{notice}</p>{/if}
{#if loading}<EmptyState title="Loading beta setup…"/> {#if loading}<EmptyState title="Loading beta setup…"/>
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {new Date(availableDraft.savedAt).toLocaleString()} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section> {:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button> {:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
{:else if scripts.length} {:else if scripts.length}
<ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol> <ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol>
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -210,7 +211,7 @@
let historyByDay = $derived.by(() => { let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>(); const groups = new Map<string, Deployment[]>();
for (const d of history.slice(0,limit)) { for (const d of history.slice(0,limit)) {
const key = new Date(d.started_at).toDateString(); const key = $timeFormat.date(d.started_at);
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d); groups.get(key)!.push(d);
} }
@@ -283,7 +284,7 @@
{#if driftOpen} {#if driftOpen}
<div class="drift-body" id="drift-body"> <div class="drift-body" id="drift-body">
{#if drift}<p class="scan-context">Scanned {new Date(drift.scannedAt).toLocaleString()}. Selection controls the action scope; search only changes the visible list.</p>{/if} {#if drift}<p class="scan-context">Scanned {$timeFormat.dateTime(drift.scannedAt)}. Selection controls the action scope; search only changes the visible list.</p>{/if}
{#if drift && drift.totalDrift > 0} {#if drift && drift.totalDrift > 0}
<input <input
class="drift-filter" aria-label="Filter drift by server" class="drift-filter" aria-label="Filter drift by server"
@@ -385,7 +386,7 @@
{:else if plan} {:else if plan}
<p><strong>{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.</strong></p> <p><strong>{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.</strong></p>
<p>Files change immediately. Each affected server restarts after a fresh check confirms it is empty, then waits for health verification. Untracked files will be removed.</p> <p>Files change immediately. Each affected server restarts after a fresh check confirms it is empty, then waits for health verification. Untracked files will be removed.</p>
<p class="scan-context">Reviewed {new Date(plan.scannedAt).toLocaleString()}. Changes to this plan require another review.</p> <p class="scan-context">Reviewed {$timeFormat.dateTime(plan.scannedAt)}. Changes to this plan require another review.</p>
<div class="plan-files">{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}<section><h3><ServerIdentity name={server} /></h3>{#each files as file}<div class="plan-file"><strong>{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} <code>{file.relPath}</code></strong><span>{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}</span><details><summary>File fingerprints</summary><p>Before: {file.beforeSha256 ?? 'Absent'}<br />After: {file.afterSha256 ?? 'Removed'}</p></details></div>{/each}</section>{/if}{/each}</div> <div class="plan-files">{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}<section><h3><ServerIdentity name={server} /></h3>{#each files as file}<div class="plan-file"><strong>{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} <code>{file.relPath}</code></strong><span>{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}</span><details><summary>File fingerprints</summary><p>Before: {file.beforeSha256 ?? 'Absent'}<br />After: {file.afterSha256 ?? 'Removed'}</p></details></div>{/each}</section>{/if}{/each}</div>
{/if} {/if}
{#snippet actions()}<Button variant="ghost" disabled={running || reviewing} onclick={() => reviewOpen = false}>Cancel</Button><Button variant="danger" disabled={running || reviewing || !plan?.files.length} loading={running} onclick={runDrift}>Apply {plan?.files.length ?? 0} reviewed changes</Button>{/snippet} {#snippet actions()}<Button variant="ghost" disabled={running || reviewing} onclick={() => reviewOpen = false}>Cancel</Button><Button variant="danger" disabled={running || reviewing || !plan?.files.length} loading={running} onclick={runDrift}>Apply {plan?.files.length ?? 0} reviewed changes</Button>{/snippet}
+2 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import Dialog from '$ui/components/primitives/Dialog.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
@@ -101,11 +102,7 @@
'neutral'; 'neutral';
} }
function fmt(iso: string | null): string { function fmt(iso: string | null): string {
if (!iso) return '—'; return $timeFormat.dateTime(iso);
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
} }
</script> </script>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -121,7 +122,7 @@
} catch (err) { blueprintError = (err as Error).message; } } catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; } finally { removing = ''; }
} }
function fmt(value: string) { return new Date(value).toLocaleString(); } function fmt(value: string) { return $timeFormat.dateTime(value); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } } function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
</script> </script>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
@@ -106,7 +107,7 @@
} }
$effect(() => { $page.params.id; detail = null; void load(); }); $effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false)); onMount(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false));
function fmt(iso: string) { return new Date(iso).toLocaleString(); } function fmt(iso: string) { return $timeFormat.dateTime(iso); }
</script> </script>
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head> <svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -186,7 +187,7 @@
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}> <section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
<div class="rankings-header"> <div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div> <div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<span>Refreshes every 15 seconds</span></div> <div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
</div> </div>
{#if data.entries.length} {#if data.entries.length}
<div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div> <div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div>
@@ -239,7 +240,7 @@
<p>Records include active games and completed games. Filters apply to totals and rankings; player search only filters the rankings. Players are identified by their GUID within each game, so name changes keep their records together.</p> <p>Records include active games and completed games. Filters apply to totals and rankings; player search only filters the rankings. Players are identified by their GUID within each game, so name changes keep their records together.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p> <p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p> <p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{new Date(data.summary.firstTrackedAt).toLocaleString()}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{new Date(data.summary.updatedAt).toLocaleString()}</time>.{/if}</p> <p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
</div> </div>
</details> </details>
</section> </section>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js'; import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -9,7 +10,7 @@
const profile = $derived(data.profile); const profile = $derived(data.profile);
const stats = $derived(profile.stats); const stats = $derived(profile.stats);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const date = (value: string) => new Date(value).toLocaleString('en-US', { timeZone: 'UTC' }) + ' UTC'; const date = (value: string) => $timeFormat.dateTime(value);
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—'; const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[]; const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
let refreshing = $state(false); let refreshing = $state(false);
+7 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -8,7 +9,7 @@
const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—'; const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—';
const hours = (value: number) => `${value.toFixed(1)} h`; const hours = (value: number) => `${value.toFixed(1)} h`;
const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown))); const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown)));
const label = (at: number) => new Date(at).toISOString().slice(0, data.days === 1 ? 16 : 10).replace('T', ' '); const label = (at: number) => $timeFormat.period(at);
onMount(() => { onMount(() => {
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000); const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
return () => clearInterval(timer); return () => clearInterval(timer);
@@ -25,7 +26,7 @@
<a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a> <a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a>
{/each} {/each}
</nav> </nav>
<span>Updated {new Date(data.now).toISOString().slice(11, 19)} UTC · refreshes every minute</span> <span>Updated {$timeFormat.time(data.now)} · refreshes every minute</span>
</div> </div>
{#if data.servers.length === 0} {#if data.servers.length === 0}
@@ -40,19 +41,19 @@
<section> <section>
<h2>Utilization over time</h2> <h2>Utilization over time</h2>
<p>Server-hours per {data.days === 1 ? 'hour' : 'day'} (UTC). Taller bars mean more time under observation.</p> <p>Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.</p>
<div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div> <div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div>
<div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below."> <div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below.">
{#each data.points as point} {#each data.points as point}
<div class="bar" title={`${label(point.at)} UTC: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}> <div class="bar" title={`${label(point.at)}: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
<div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div> <div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div>
<div class="idle" style:height={`${point.idle / peak * 100}%`}></div> <div class="idle" style:height={`${point.idle / peak * 100}%`}></div>
<div class="active" style:height={`${point.active / peak * 100}%`}></div> <div class="active" style:height={`${point.active / peak * 100}%`}></div>
</div> </div>
{/each} {/each}
</div> </div>
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)} UTC</span></div> <div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)}</span></div>
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period (UTC)</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details> <details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period start ({$timeZone.replaceAll('_', ' ')})</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
</section> </section>
<section> <section>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -53,7 +54,7 @@
if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); } if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); }
open = true; open = true;
} }
function savedAt(value: string) { return new Date(value.includes('T') ? value : value.replace(' ', 'T') + 'Z').toLocaleString(); } function savedAt(value: string) { return $timeFormat.dateTime(value); }
async function save(e: SubmitEvent) { async function save(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!validId || !plutoKey.trim() || busy) return; if (!validId || !plutoKey.trim() || busy) return;
+23 -5
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { SystemStatus } from '$lib/system-status.js'; import type { SystemStatus } from '$lib/system-status.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -7,6 +9,7 @@
let data = $state<SystemStatus | null>(null); let data = $state<SystemStatus | null>(null);
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
async function refresh(probe = false) { async function refresh(probe = false) {
if (loading) return; if (loading) return;
loading = true; error = ''; loading = true; error = '';
@@ -21,14 +24,27 @@
const labels = { ready: 'Ready', attention: 'Needs attention', unconfigured: 'Not configured', unverified: 'Not verified' }; const labels = { ready: 'Ready', attention: 'Needs attention', unconfigured: 'Not configured', unverified: 'Not verified' };
</script> </script>
<svelte:head><title>System status · Electric Cherry</title></svelte:head> <svelte:head><title>Settings · Electric Cherry</title></svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="System status" subtitle="Check your connections and the prerequisites for a playable server."> <SectionHeader title="Settings" subtitle="Choose how dates and times appear across Electric Cherry." />
{#snippet actions()}<Button loading={loading} onclick={() => refresh(true)}>Check connections</Button>{/snippet} <section class="guide" aria-labelledby="display-heading">
</SectionHeader> <h2 id="display-heading">Display preferences</h2>
<label class="timezone">Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE ? ' (default)' : ''}</option>{/each}
</select>
</label>
<p id="timezone-help">Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.</p>
{#if data}<p>Example: {$timeFormat.dateTime(data.checkedAt)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.</p>{/if}
</section>
<div class="heading">
<div><h2>System status</h2><p>Check your connections and the prerequisites for a playable server.</p></div>
<Button loading={loading} onclick={() => refresh(true)}>Check connections</Button>
</div>
{#if error}<p class="ui-error" role="alert">{error}<Button size="sm" onclick={() => refresh()}>Retry</Button></p>{/if} {#if error}<p class="ui-error" role="alert">{error}<Button size="sm" onclick={() => refresh()}>Retry</Button></p>{/if}
{#if data} {#if data}
<p class="checked">Checked {new Date(data.checkedAt).toLocaleString()} · {data.readyServers} / {data.totalServers} servers ready</p> <p class="checked">Checked {$timeFormat.dateTime(data.checkedAt)} · {data.readyServers} / {data.totalServers} servers ready</p>
<ol class="checks">{#each data.checks as check}<li><div class="heading"><h2>{check.title}</h2><Badge variant={check.state === 'ready' ? 'online' : check.state === 'unverified' ? 'neutral' : 'warning'}>{labels[check.state]}</Badge></div><p>{check.detail}</p>{#if check.href}<a href={check.href}>{check.action} →</a>{/if}</li>{/each}</ol> <ol class="checks">{#each data.checks as check}<li><div class="heading"><h2>{check.title}</h2><Badge variant={check.state === 'ready' ? 'online' : check.state === 'unverified' ? 'neutral' : 'warning'}>{labels[check.state]}</Badge></div><p>{check.detail}</p>{#if check.href}<a href={check.href}>{check.action} →</a>{/if}</li>{/each}</ol>
{:else if loading}<p role="status">Checking system setup…</p>{/if} {:else if loading}<p role="status">Checking system setup…</p>{/if}
<section id="setup-guide" class="guide"><h2>Connection setup</h2><p>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.</p><dl><dt>Komodo</dt><dd><code>KOMODO_ADDRESS</code>, <code>KOMODO_API_KEY</code>, <code>KOMODO_API_SECRET</code>, and <code>KOMODO_TARGET_SERVER</code>. The target must match the host name in Komodo.</dd><dt>Game servers</dt><dd><code>RCON_PASSWORD</code> must match the game-server password. Set <code>RCON_HOST</code> if the default <code>host.docker.internal</code> does not reach the game host. Forward the allocated UDP game ports.</dd></dl></section> <section id="setup-guide" class="guide"><h2>Connection setup</h2><p>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.</p><dl><dt>Komodo</dt><dd><code>KOMODO_ADDRESS</code>, <code>KOMODO_API_KEY</code>, <code>KOMODO_API_SECRET</code>, and <code>KOMODO_TARGET_SERVER</code>. The target must match the host name in Komodo.</dd><dt>Game servers</dt><dd><code>RCON_PASSWORD</code> must match the game-server password. Set <code>RCON_HOST</code> if the default <code>host.docker.internal</code> does not reach the game host. Forward the allocated UDP game ports.</dd></dl></section>
@@ -48,5 +64,7 @@
dd { margin: 5px 0 0; } dd { margin: 5px 0 0; }
code { font-size: var(--text-xs); } code { font-size: var(--text-xs); }
.guide { scroll-margin-top: 90px; } .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; } } @media (max-width: 700px) { .checks { grid-template-columns: 1fr; } }
</style> </style>
+132
View File
@@ -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);
});
});