Standardize UI typography, charts and empty states

This commit is contained in:
2026-10-07 19:40:02 -04:00
parent 05bdeaf624
commit 0ed3cd6b47
57 changed files with 417 additions and 382 deletions
+10 -9
View File
@@ -20,6 +20,7 @@
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types';
@@ -31,6 +32,7 @@
let scopeNavigating = $state(false);
let mounted = $state(false);
let attemptedPreference = $state<string | null>(null);
let checkedAt = $state(untrack(() => Date.now()));
const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown);
@@ -55,7 +57,7 @@
if (data.scope.map) params.set('map', data.scope.map);
return params.toString();
},
onData: value => { snapshot = value; },
onData: value => { snapshot = value; checkedAt = Date.now(); },
onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; }
});
@@ -64,7 +66,7 @@
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; checkedAt = Date.now(); });
});
$effect(() => {
// Layout initializes the saved preference on mount; react when it arrives.
@@ -118,7 +120,7 @@
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
<div class="refresh-controls"><span role="status">{scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`}</span><Button size="sm" variant="ghost" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button></div>
<div class="refresh-controls">{#if scopeNavigating}<span role="status">Updating report…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every minute" />{/if}</div>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
@@ -153,7 +155,7 @@
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if}
{#if captured > 0}
<details class="weekly"><summary>Activity by weekday and hour</summary><MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} /></details>
<MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
<Tabs tabs={[{ id: 'maps', label: 'Maps' }, { id: 'instances', label: 'Instances' }, { id: 'insights', label: 'Hosting insights' }]} value={view} onchange={changeView} ariaLabel="Metrics breakdown" idPrefix="metrics-views" panelId="metrics-breakdown" />
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables maps={report.maps} servers={report.servers} {view} />{/if}
@@ -168,7 +170,7 @@
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
</div>
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. pp means percentage points. Equal windows do not guarantee equal observation coverage.</p>
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. Percentage points show the difference between two percentages. Equal windows do not guarantee equal observation coverage.</p>
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
<p>Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
<p>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</p>
@@ -177,8 +179,9 @@
</div>
<style>
.page { display: grid; gap: var(--space-5); min-width: 0; }
.page { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-5); min-width: 0; }
.toolbar { display: flex; align-items: end; gap: var(--space-3); flex-wrap: wrap; }
.toolbar :global(.field) { flex: 1 1 180px; max-width: 300px; }
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
@@ -186,8 +189,6 @@
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
.range { margin: 0; }
.refresh-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: var(--text-xs); color: var(--text-secondary); }
.weekly { font-size: var(--text-sm); color: var(--text-secondary); }
.weekly summary { cursor: pointer; }
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
@@ -195,5 +196,5 @@
.methodology { padding: var(--space-3) 2px; }
.methodology summary { cursor: pointer; color: var(--text-secondary); }
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
@media (max-width: 540px) { .toolbar { align-items: stretch; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
@media (max-width: 540px) { .toolbar { align-items: stretch; } .toolbar :global(.field) { flex-basis: 100%; max-width: none; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
</style>