From 70185eb98cc15f24e159c6db31e36c37fc045218 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 19:46:40 -0400 Subject: [PATCH] Make UI language, timestamps and live feedback consistent --- docs/development/ui-patterns.md | 36 ++++++++ src/lib/game-files.ts | 4 +- src/lib/host-operation-labels.ts | 16 ++++ src/lib/script-library.ts | 3 +- src/lib/server/activity.ts | 15 +--- src/lib/server/game-files/index.ts | 4 +- src/lib/server/hosts/preparation.ts | 2 +- src/lib/time-zone.ts | 32 ++++++- src/lib/ui/activity-history.ts | 13 +-- src/lib/ui/campaigns.ts | 3 +- src/lib/ui/components/ActivityLog.svelte | 4 +- src/lib/ui/components/AddHostDrawer.svelte | 3 +- src/lib/ui/components/ArtifactDetail.svelte | 7 +- src/lib/ui/components/BetaTestProgress.svelte | 11 +-- src/lib/ui/components/CapacitySamples.svelte | 3 +- src/lib/ui/components/ConfigLibrary.svelte | 7 +- .../ui/components/DeployServerDialog.svelte | 2 +- src/lib/ui/components/Freshness.svelte | 13 +-- src/lib/ui/components/GameFileHosts.svelte | 2 +- src/lib/ui/components/HostList.svelte | 7 +- src/lib/ui/components/HostPreparation.svelte | 4 +- src/lib/ui/components/LaunchRecipe.svelte | 4 +- src/lib/ui/components/LogPanel.svelte | 33 ++++---- src/lib/ui/components/MatchHistory.svelte | 7 +- .../ui/components/NativeHostSelection.svelte | 13 +-- src/lib/ui/components/OperationHistory.svelte | 10 +-- src/lib/ui/components/PlayerMatchCards.svelte | 5 +- .../ui/components/PluginGithubImport.svelte | 2 +- .../ui/components/ProvisioningActivity.svelte | 8 +- .../ui/components/ProvisioningProgress.svelte | 16 ++-- src/lib/ui/components/RelativeTime.svelte | 12 +++ .../ui/components/ScriptUploadDrawer.svelte | 6 +- .../ui/components/TelemetryDiagnostics.svelte | 35 ++++---- .../components/capacity/CapacityHosts.svelte | 8 +- .../ui/components/metrics/MetricsChart.svelte | 2 +- .../components/metrics/MetricsSummary.svelte | 2 +- src/lib/ui/host-overview.ts | 9 ++ src/lib/ui/log-text.ts | 4 + src/lib/ui/match-history.ts | 17 +--- src/lib/ui/player-match.ts | 8 ++ src/lib/ui/plural.ts | 11 +++ src/lib/ui/server-labels.ts | 6 ++ src/routes/+page.svelte | 7 +- src/routes/api/system-status/+server.ts | 2 +- src/routes/autoscaling/+page.svelte | 13 ++- src/routes/betas/+page.svelte | 4 +- src/routes/betas/[id]/+page.svelte | 7 +- src/routes/betas/new/+page.svelte | 9 +- src/routes/campaigns/+page.svelte | 11 +-- src/routes/campaigns/[id]/+page.svelte | 18 ++-- src/routes/capacity/+page.svelte | 15 ++-- src/routes/collections/+page.svelte | 7 +- .../collections/launches/[id]/+page.svelte | 5 +- src/routes/game-files/+page.svelte | 21 ++--- src/routes/hosts/+page.svelte | 3 +- src/routes/hosts/[id]/+page.svelte | 20 +++-- src/routes/hosts/operations/+page.svelte | 30 ++++--- src/routes/leaderboard/+page.svelte | 4 +- .../leaderboard/players/[guid]/+page.svelte | 9 +- src/routes/maintenance/+page.svelte | 18 ++-- src/routes/maintenance/[id]/+page.svelte | 7 +- src/routes/matches/[id]/+page.svelte | 8 +- src/routes/provisioning/+page.svelte | 83 +++++++++++-------- src/routes/provisioning/[id]/+page.svelte | 39 +++++---- src/routes/scripts/+page.svelte | 2 +- src/routes/server-keys/+page.svelte | 6 +- src/routes/servers/[name]/+page.svelte | 6 +- src/routes/settings/users/+page.svelte | 5 +- src/routes/setup/+page.svelte | 3 +- tests/unit/game-file-browser-upload.test.ts | 2 +- tests/unit/game-file-library.test.ts | 4 +- tests/unit/host-preparation.test.ts | 6 +- tests/unit/time-zone.test.ts | 22 ++++- tests/unit/ui-activity.test.ts | 2 +- tests/unit/ui-capacity.test.ts | 8 +- tests/unit/ui-freshness.test.ts | 51 ++++++++++++ tests/unit/ui-host-operation-labels.test.ts | 34 ++++++++ tests/unit/ui-host-overview.test.ts | 6 +- tests/unit/ui-language.test.ts | 72 ++++++++++++++++ tests/unit/ui-match-history.test.ts | 8 +- tests/unit/ui-metrics-page.test.ts | 2 +- 81 files changed, 656 insertions(+), 332 deletions(-) create mode 100644 src/lib/host-operation-labels.ts create mode 100644 src/lib/ui/components/RelativeTime.svelte create mode 100644 src/lib/ui/log-text.ts create mode 100644 src/lib/ui/player-match.ts create mode 100644 src/lib/ui/plural.ts create mode 100644 tests/unit/ui-freshness.test.ts create mode 100644 tests/unit/ui-host-operation-labels.test.ts create mode 100644 tests/unit/ui-language.test.ts diff --git a/docs/development/ui-patterns.md b/docs/development/ui-patterns.md index b5d3302..c0d4dd3 100644 --- a/docs/development/ui-patterns.md +++ b/docs/development/ui-patterns.md @@ -71,3 +71,39 @@ Use the shared `--text-*` scale, with 12 px as the minimum. Mobile inputs use in `npm run check`, rejects raw pixel font sizes. Use Inter with tabular figures for counts, scores, durations and charts; reserve mono for code, IDs, paths and logs. Static values use text or state colors; the accent marks links and controls. + +### Product language + +| Concept | Visible label | +| --- | --- | +| A managed game instance | Server; use the friendly map and lobby name | +| Creating a server | Create server | +| Removing a server | Remove server | +| Favourite server | Pin | +| Game does not answer | Not responding | +| Games | Black Ops I / Black Ops II; BO1 / BO2 in compact labels | +| Releasing file changes | Campaigns | +| Player completion evidence | Present in final report / Not present in final report | + +Keep internal values such as `t5`, `t6`, `destroy`, instance IDs, and filenames in +API fields and technical details. User-facing actions use the labels above. +Player presence in the final report does not establish a win or continuous +survival. A missing timestamp is **Not recorded**, not **Never**. + +Use `RelativeTime` for observed history: seconds, minutes or hours for the last +24 hours, Yesterday for the preceding calendar day, then the shared absolute +format. It includes the full timestamp in a tooltip and follows the account's +timezone. Scheduled future actions use `timeFormat.dateTime` directly. Database +timestamps without an offset are UTC; never parse them as browser-local time. +Receipt freshness uses the browser clock and shows Paused or a retry reason +when updates are unavailable. These clocks are not live regions. + +Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent +language, including verbs: `plural(count, 'needs', 'need')`. The helper uses +`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators +inside string expressions when a Svelte conditional would trim their leading +space, for example `{offline ? ' · Offline' : ''}`. + +Recommend an agent upgrade only when its reported version is known to lack a +needed capability. Missing calibration samples can simply mean the host is +still collecting measurements. diff --git a/src/lib/game-files.ts b/src/lib/game-files.ts index bdc7974..c23d3a1 100644 --- a/src/lib/game-files.ts +++ b/src/lib/game-files.ts @@ -1,7 +1,7 @@ export type GameLibraryGame = 't5' | 't6'; export const GAME_FILE_GAMES = [ - { id: 't5', name: 'Black Ops I (T5)' }, - { id: 't6', name: 'Black Ops II (T6)' } + { id: 't5', name: 'Black Ops I' }, + { id: 't6', name: 'Black Ops II' } ] as const; export type GameLibraryState = 'importing' | 'ready' | 'failed'; export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; diff --git a/src/lib/host-operation-labels.ts b/src/lib/host-operation-labels.ts new file mode 100644 index 0000000..e4fe07d --- /dev/null +++ b/src/lib/host-operation-labels.ts @@ -0,0 +1,16 @@ +export const hostOperationTitles: Record = { + 'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container', + 'instance.start': 'Start server container', 'instance.restart': 'Restart server container', + 'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container', + 'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container', + 'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime', + 'library.download': 'Download game files', 'library.import': 'Import game files', + 'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files', + 'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files', + 'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files', + 'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs' +}; + +export function hostOperationTitle(kind: string): string { + return hostOperationTitles[kind] ?? 'Host operation'; +} diff --git a/src/lib/script-library.ts b/src/lib/script-library.ts index d5d7f79..7e75bf6 100644 --- a/src/lib/script-library.ts +++ b/src/lib/script-library.ts @@ -1,4 +1,5 @@ import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js'; +import { gameTitle } from './ui/server-labels.js'; export interface LibraryVersion { id: number; version: number; sha256: string; size: number; @@ -30,7 +31,7 @@ export function mapLabel(canonical: string): string { return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical; } export function scopeLabel(s: Pick): string { - return s.scope_type === 'game' ? (s.scope_value ?? '').toUpperCase() + return s.scope_type === 'game' ? gameTitle(s.scope_value) : s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped'; } export interface BetaSelection { scriptId: number; versionId: number; } diff --git a/src/lib/server/activity.ts b/src/lib/server/activity.ts index 790a22e..0da97cd 100644 --- a/src/lib/server/activity.ts +++ b/src/lib/server/activity.ts @@ -1,3 +1,4 @@ +import { hostOperationTitles } from '../host-operation-labels.js'; import type Database from 'better-sqlite3'; import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js'; import { serverTitle } from '../ui/server-labels.js'; @@ -7,18 +8,6 @@ import { importantOperationSql } from './hosts/observations.js'; const initialized = new WeakSet(); const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; -const hostTitles: Record = { - 'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container', - 'instance.start': 'Start server container', 'instance.restart': 'Restart server container', - 'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container', - 'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container', - 'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime', - 'library.download': 'Download game files', 'library.import': 'Import game files', - 'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files', - 'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files', - 'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files', - 'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs' -}; const labelCase = (column: string, labels: Record, fallback: string) => `CASE ${column} ${Object.entries(labels).map(([key, value]) => `WHEN ${quote(key)} THEN ${quote(value)}`).join(' ')} ELSE ${quote(fallback)} END`; @@ -115,7 +104,7 @@ items AS ( SELECT 'host:' || o.id,o.created_at,'host','|host|', CASE WHEN o.state='completed' AND o.routine=1 AND o.observation_success=0 THEN 'observation-failed' WHEN o.state='completed' AND COALESCE(o.error,'')<>'' THEN 'warning' ELSE o.state END, - ${labelCase('o.kind', hostTitles, 'Host operation')}, + ${labelCase('o.kind', hostOperationTitles, 'Host operation')}, CASE WHEN o.routine=1 AND o.state='completed' AND o.observation_success=0 THEN 'The command completed, but the check reported a problem.' WHEN o.state='unknown' THEN 'The host has not confirmed the result.' ELSE NULL END, COALESCE(o.server_name,n.server),s.destroyed_at,o.host_id,NULL,'host',o.id diff --git a/src/lib/server/game-files/index.ts b/src/lib/server/game-files/index.ts index 165e00c..58ee6b6 100644 --- a/src/lib/server/game-files/index.ts +++ b/src/lib/server/game-files/index.ts @@ -120,7 +120,7 @@ function libraryForGame(game: GameLibraryGame, now: string): string { /** Register a folder on the controller; no host or Docker volume is needed. */ export function createLibraryUpload(input: Record): GameLibraryVersion { const game = input.game; - if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); + if (game !== 't5' && game !== 't6') throw new Error('Choose Black Ops I or Black Ops II game files.'); const files = validateUploadPlan(input.files); const handle = database(); const id = randomUUID(); const now = new Date().toISOString(); @@ -220,7 +220,7 @@ export async function requestBrowserPublication(id: string) { export function createLibraryImport(input: Record): GameLibraryVersion { const game = input.game; - if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); + if (game !== 't5' && game !== 't6') throw new Error('Choose Black Ops I or Black Ops II game files.'); const hostId = textField(input.hostId, 'source host', 128); const host = getHost(hostId); if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.'); diff --git a/src/lib/server/hosts/preparation.ts b/src/lib/server/hosts/preparation.ts index 923cd2f..a9669c3 100644 --- a/src/lib/server/hosts/preparation.ts +++ b/src/lib/server/hosts/preparation.ts @@ -162,7 +162,7 @@ export function hostPreparationStatus(host: Host): HostPreparation { items.push({ kind: 'library', label: game.name, state: 'blocked', detail: `Upload and publish ${game.name} game files before this host can be enabled.` }); } } else if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting', - detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' }); + detail: 'Upload Black Ops I or Black Ops II game files; hosts download the latest available files automatically.' }); // Cache health is visible, but never makes a usable host ineligible or // delays re-enabling: a lobby's normal updater handles cache misses. const required = items.filter(item => item.kind !== 'plutonium'); diff --git a/src/lib/time-zone.ts b/src/lib/time-zone.ts index 9f88bac..b577832 100644 --- a/src/lib/time-zone.ts +++ b/src/lib/time-zone.ts @@ -17,7 +17,7 @@ export function availableTimeZones(): string[] { return [...new Set([DEFAULT_TIME_ZONE, 'UTC', ...zones])].sort(); } -type Timestamp = string | number | Date | null | undefined; +export type Timestamp = string | number | Date | null | undefined; /** Database timestamps without an offset are UTC, never browser-local time. */ function parseTimestamp(value: Exclude): Date { @@ -28,6 +28,12 @@ function parseTimestamp(value: Exclude): Date { return new Date(value); } +export function timestampISO(value: Timestamp): string | undefined { + if (value === null || value === undefined || value === '') return undefined; + const parsed = parseTimestamp(value); + return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined; +} + export function createTimeFormatter(zone: string) { const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE; const date: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; @@ -40,12 +46,34 @@ export function createTimeFormatter(zone: string) { return Number.isFinite(parsed.getTime()) ? format.format(parsed) : '—'; }; } + const absolute = formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); + const clock = formatter({ hour: 'numeric', minute: '2-digit' }); + const calendar = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }); + const calendarDay = (value: Date) => { + const parts = Object.fromEntries(calendar.formatToParts(value).map(part => [part.type, part.value])); + return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)); + }; + function relative(value: Timestamp, now = Date.now()): string { + if (value === null || value === undefined || value === '') return '—'; + const parsed = parseTimestamp(value); + if (!Number.isFinite(parsed.getTime()) || !Number.isFinite(now)) return '—'; + const age = now - parsed.getTime(); + if (age < 0) return absolute(parsed); + if (age < 5_000) return 'Just now'; + if (age < 60_000) return `${Math.floor(age / 1000)} s ago`; + if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`; + if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`; + // Calendar arithmetic keeps Yesterday correct across DST and year changes. + if (calendarDay(new Date(now)) - calendarDay(parsed) === 86_400_000) return `Yesterday, ${clock(parsed)}`; + return absolute(parsed); + } return { + relative, dateTime: formatter({ ...date, ...time }), date: formatter(date), time: formatter(time), clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }), minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), - period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }) + period: absolute }; } diff --git a/src/lib/ui/activity-history.ts b/src/lib/ui/activity-history.ts index ab6f40c..2b6bd35 100644 --- a/src/lib/ui/activity-history.ts +++ b/src/lib/ui/activity-history.ts @@ -1,5 +1,5 @@ import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js'; -import { matchStartedLabel } from './match-history.js'; +import { createTimeFormatter } from '../time-zone.js'; export interface ActivityScope { server?: string; source?: ActivitySource } const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const; @@ -35,13 +35,6 @@ export function activityHistoryHref(url: URL, changes: Partial server.restart_state === 'done').length; const live = campaign.servers.filter(server => !server.server_deleted_at); const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state)); - if (attention.length) return { status: 'attention', label: `${attention.length} need attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed }; + if (attention.length) return { status: 'attention', label: `${attention.length} ${plural(attention.length, 'needs', 'need')} attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed }; if (live.some(server => server.restart_state === 'pending')) return { status: 'progress', label: `${completed}/${campaign.servers.length} complete`, tone: 'info', completed }; if (campaign.rolled_back_by) return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed }; if (campaign.servers.length && completed === campaign.servers.length) return { status: 'complete', label: 'Complete', tone: 'online', completed }; diff --git a/src/lib/ui/components/ActivityLog.svelte b/src/lib/ui/components/ActivityLog.svelte index 9831d62..366a00b 100644 --- a/src/lib/ui/components/ActivityLog.svelte +++ b/src/lib/ui/components/ActivityLog.svelte @@ -4,7 +4,7 @@ import { goto } from '$app/navigation'; import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js'; import { contextHref } from '$lib/navigation-context.js'; - import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '$ui/activity-history.js'; + import { activityApiHref, activityHistoryHref, activityQuery } from '$ui/activity-history.js'; import { pollVisible } from '$ui/poll.js'; import { serverTitle } from '$ui/server-labels.js'; import { fleet } from '$ui/stores/fleet.js'; @@ -117,7 +117,7 @@ {#if row.hostName}{row.hostName}{/if} -
+
{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}
diff --git a/src/lib/ui/components/AddHostDrawer.svelte b/src/lib/ui/components/AddHostDrawer.svelte index 6231292..caa39a1 100644 --- a/src/lib/ui/components/AddHostDrawer.svelte +++ b/src/lib/ui/components/AddHostDrawer.svelte @@ -1,4 +1,5 @@
- {#if error}{error}{#if onRetry}{/if} - {:else}{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}{/if} + {#if error}{error}{#if onRetry}{/if} + {:else}{paused ? 'Paused' : known ? 'Live' : 'Waiting for update'}{#if known}{' · updated '}{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}{/if}
diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte index fa5ebda..9a9210f 100644 --- a/src/routes/scripts/+page.svelte +++ b/src/routes/scripts/+page.svelte @@ -101,7 +101,7 @@
{#if loading || scripts.length || error}
- + diff --git a/src/routes/server-keys/+page.svelte b/src/routes/server-keys/+page.svelte index 023648e..c2d2bfa 100644 --- a/src/routes/server-keys/+page.svelte +++ b/src/routes/server-keys/+page.svelte @@ -1,5 +1,5 @@