Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+602 -110
View File
@@ -3,19 +3,29 @@
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { createTimeFormatter } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '$ui/metrics.js';
import {
metricDuration,
metricNumber,
metricPercent,
metricPeriodLabels
} from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } =
$props();
let selectedAt = $state<number | null>(null);
let announcement = $state('');
const format = $derived(createTimeFormatter(timeZone));
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const mode = $derived(
new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent'
);
const selectedIndex = $derived.by(() => {
const existing = points.findIndex(point => point.at === selectedAt);
const existing = points.findIndex((point) => point.at === selectedAt);
if (existing >= 0) return existing;
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
return Math.max(0, points.length - 1);
@@ -24,32 +34,43 @@
const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => {
if (mode === 'percent') return 100;
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
const max = Math.max(0, ...points.map((point) => point.observed + point.unknown));
if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
});
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
const ticks = $derived(
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({
fraction,
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}`
}))
);
const periodOptions = $derived(
points.map((point) => ({
value: String(point.at),
label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}`
}))
);
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
function setMode(value: 'hours' | 'percent') {
const url = new URL(page.url);
url.search = contextSearch;
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
if (value === 'percent') url.searchParams.delete('chartMode');
else url.searchParams.set('chartMode', value);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function selectPeriod(at: number) {
selectedAt = at;
const point = points.find(item => item.at === at);
const point = points.find((item) => item.at === at);
if (!point) return;
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
}
function segmentHeight(point: MetricPoint, value: number) {
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
return denominator > 0 ? value / denominator * 100 : 0;
return denominator > 0 ? (value / denominator) * 100 : 0;
}
function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
@@ -58,129 +79,600 @@
function chartKey(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(
0,
Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1))
);
selectPeriod(points[next].at);
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
(event.currentTarget as HTMLElement).parentElement
?.querySelectorAll<HTMLButtonElement>('button')
[next]?.focus();
}
</script>
<section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"><div><h2 id="metrics-trend-title">Server activity over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
<div class="heading">
<div>
<h2 id="metrics-trend-title">Server activity over time</h2>
<p>
{mode === 'percent'
? 'Share of captured server time, including unknown status.'
: `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain
less elapsed time.
</p>
</div>
<div class="mode" role="group" aria-label="Timeline measure">
<button
type="button"
aria-pressed={mode === 'percent'}
onclick={() => setMode('percent')}>Percent</button
><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}
>Hours</button
>
</div>
</div>
<div class="legend">
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span
><i class="unknown"></i>Unknown</span
><span class="partial-key">* Partial period</span>
</div>
<div class="axis-title">
{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}
</div>
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
<div class="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
<div class="y-axis" aria-hidden="true">
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
>{item.value}</span
>{/each}
</div>
<div
class="plot"
role="group"
aria-label="Server time by period. Select a bar or use the period selector below."
>
<div class="grid-lines" aria-hidden="true">
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
></span>{/each}
</div>
{#each points as point, index (point.at)}
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
<button
class="bar"
class:selected={point.at === selected?.at}
class:partial={point.partial}
type="button"
tabindex={index === selectedIndex ? 0 : -1}
aria-pressed={point.at === selected?.at}
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
onclick={() => selectPeriod(point.at)}
onkeydown={(event) => chartKey(event, index)}
>
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
<span class="segment unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
<span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
<span
class="segment unknown"
style:height={`${segmentHeight(point, point.unknown)}%`}
></span>
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}
></span>
<span
class="segment occupied"
style:height={`${segmentHeight(point, point.active)}%`}
></span>
{#if point.observed + point.unknown === 0}<span
class="no-time"
aria-hidden="true">·</span
>{/if}
</button>
{/each}
</div>
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">{#each periodLabels as item}<span style:left={`${item.position}%`} title={format.period(points[item.index].startAt)}>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span>{/each}</div>
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
{#each periodLabels as item}<span
style:left={`${item.position}%`}
title={format.period(points[item.index].startAt)}
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
>{/each}
</div>
</div>
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
<p class="period-note">
{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll(
'_',
' '
)}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}
</p>
{#if selected}
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
<details class="inspection"><summary>Inspect a period</summary>
<div class="period-controls">
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
</div>
<div class="readout" aria-label="Selected period details">
<p class="interval">{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
{:else}<dl>
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
</dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
<div class="selection-summary" aria-label="Selected period summary">
<time
>{format.period(selected.startAt)}{selected.partial
? ' · Partial period'
: ''}</time
><span
>{captured
? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
: 'No captured time · not evidence of idle servers'}</span
>
</div>
<details class="inspection">
<summary>Inspect a period</summary>
<div class="period-controls">
<div class="period-select">
<Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}
><Select
value={selectedValue}
options={periodOptions}
onchange={(event) =>
selectPeriod(
Number((event.currentTarget as HTMLSelectElement).value)
)}
/></Field
>
</div>
<div class="step-buttons">
<Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}
>← Previous</Button
><Button
size="sm"
disabled={selectedIndex === points.length - 1}
onclick={() => move(1)}>Next →</Button
>
</div>
</div>
<div class="readout" aria-label="Selected period details">
<p class="interval">
{format.period(selected.startAt)} – {format.period(selected.endAt)}
{#if selected.partial}<span class="partial-label">Partial period</span>{/if}
</p>
{#if captured === 0}<p class="empty">
No captured time in this period. Missing observations do not mean the fleet
was idle.
</p>
{:else}<dl>
<div>
<dt>With players</dt>
<dd>
{metricDuration(selected.active)}<small
>{metricPercent(selected.active, captured)} captured · {metricPercent(
selected.active,
selected.observed
)} known</small
>
</dd>
</div>
<div>
<dt>Idle</dt>
<dd>
{metricDuration(selected.idle)}<small
>{metricPercent(selected.idle, captured)} of captured time</small
>
</dd>
</div>
<div>
<dt>Unknown</dt>
<dd>
{metricDuration(selected.unknown)}<small
>{metricPercent(selected.unknown, captured)} of captured time</small
>
</dd>
</div>
<div>
<dt>Player-hours</dt>
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
</div>
</dl>
<p class="denominator">
{metricDuration(captured)} captured server time · {metricDuration(
selected.observed
)} known. Percentage bars include unknown time; the occupancy KPI excludes it.
</p>{/if}
</div>
</details>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
<details class="time-data"><summary>View exact time data</summary>
<details class="time-data">
<summary>View exact time data</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
<caption>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
<tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
</table></div>
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0">
<table>
<caption
>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from
captured observations.</caption
>
<thead
><tr
><th scope="col">Period</th><th scope="col">With players</th><th scope="col"
>Idle</th
><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr
></thead
>
<tbody
>{#each points as point (point.at)}<tr
><th scope="row"
>{format.period(point.startAt)}<small
>to {format.period(point.endAt)}{point.partial
? ' · Partial'
: ''}</small
></th
><td>{metricDuration(point.active)}</td><td
>{metricDuration(point.idle)}</td
><td>{metricDuration(point.unknown)}</td><td
>{point.observed ? metricNumber(point.playerHours) : '—'}</td
></tr
>{/each}</tbody
>
</table>
</div>
</details>
</section>
<style>
.selection-summary { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 16px; font-size: var(--text-xs); color: var(--text-secondary); }
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.occupied { background: var(--chart-active); }
.idle { background: repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px), var(--chart-idle); }
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.partial-key { color: var(--text-secondary); }
.axis-title { margin-top: var(--space-5); font-size: var(--text-xs); color: var(--text-tertiary); }
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
.y-axis { position: relative; text-align: right; font-size: var(--text-xs); color: var(--text-tertiary); height: 210px; }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
.no-time { color: var(--text-tertiary); font-size: var(--text-lg); line-height: 1; }
.bar.partial { border-inline: 1px dashed var(--border-strong); }
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: var(--text-base); color: var(--text-secondary); }
.x-axis { grid-column: 2; position: relative; min-width: 0; height: 1.6em; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 10px; }
.x-axis.with-dates { height: 3em; }
.x-axis > span { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
.x-axis small { margin: 0; }
.period-note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); margin: var(--space-2) 0 0; }
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
.period-select { flex: 1 1 250px; max-width: 450px; }
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
small { display: block; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; }
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.denominator { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
details { margin-top: var(--space-4); font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
.table-wrap { position: relative; max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
thead th { color: var(--text-secondary); }
th:first-child { text-align: left; }
tbody th { font-weight: var(--weight-regular); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .step-buttons { flex-wrap: wrap; } }
.selection-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: 16px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.heading > div:first-child {
flex: 1 1 260px;
min-width: 0;
}
.mode {
display: flex;
border: 1px solid var(--border-subtle);
padding: 3px;
border-radius: var(--radius-md);
}
.mode button {
border: 0;
border-radius: var(--radius-sm);
padding: 7px 10px;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--text-xs);
cursor: pointer;
}
.mode button[aria-pressed='true'] {
background: var(--surface-raised);
color: var(--text-primary);
}
.mode button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.heading p {
margin: var(--space-2) 0 var(--space-4);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.legend {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
align-items: center;
font-size: var(--text-xs);
}
.legend span {
display: flex;
align-items: center;
gap: 6px;
}
.legend i {
width: 10px;
height: 10px;
border-radius: 2px;
}
.occupied {
background: var(--chart-active);
}
.idle {
background:
repeating-linear-gradient(
0deg,
transparent 0 5px,
color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px
),
var(--chart-idle);
}
.unknown {
background: repeating-linear-gradient(
135deg,
var(--chart-unknown-base) 0 3px,
var(--chart-unknown-stripe) 3px 5px
);
}
.partial-key {
color: var(--text-secondary);
}
.axis-title {
margin-top: var(--space-5);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.chart-grid {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
column-gap: 8px;
margin-top: 12px;
}
.y-axis {
position: relative;
text-align: right;
font-size: var(--text-xs);
color: var(--text-tertiary);
height: 210px;
}
.y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.plot {
display: flex;
position: relative;
height: 210px;
gap: 4px;
isolation: isolate;
border-bottom: 1px solid var(--border-strong);
}
.grid-lines {
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
}
.grid-lines span {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(--border-subtle);
}
.bar {
appearance: none;
position: relative;
flex: 1;
min-width: 0;
border: 0;
margin: 0;
padding: 0;
background: transparent;
display: flex;
flex-direction: column;
justify-content: flex-end;
cursor: pointer;
border-radius: 2px 2px 0 0;
}
.bar.selected {
outline: 1px solid var(--text-secondary);
outline-offset: 2px;
}
.bar:focus-visible {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 2px;
}
.bar:hover {
background: color-mix(in oklab, var(--accent-primary) 8%, transparent);
}
.segment {
display: block;
width: 100%;
flex-shrink: 0;
pointer-events: none;
}
.no-time {
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
}
.bar.partial {
border-inline: 1px dashed var(--border-strong);
}
.partial-marker {
position: absolute;
top: -17px;
left: 50%;
transform: translateX(-50%);
font-size: var(--text-base);
color: var(--text-secondary);
}
.x-axis {
grid-column: 2;
position: relative;
min-width: 0;
height: 1.6em;
color: var(--text-secondary);
font-size: var(--text-xs);
margin-top: 10px;
}
.x-axis.with-dates {
height: 3em;
}
.x-axis > span {
position: absolute;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
}
.x-axis small {
margin: 0;
}
.period-note {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
margin: var(--space-2) 0 0;
}
.period-controls {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
margin-top: var(--space-5);
}
.period-select {
flex: 1 1 250px;
max-width: 450px;
}
.step-buttons {
display: flex;
gap: var(--space-2);
padding-bottom: 3px;
}
.readout {
margin-top: var(--space-4);
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
}
.interval {
color: var(--text-secondary);
font-size: var(--text-xs);
margin: 0;
line-height: 1.6;
}
.partial-label {
display: inline-block;
margin-left: 4px;
padding: 1px 6px;
border: 1px dashed var(--border-strong);
border-radius: 4px;
}
dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
margin: var(--space-3) 0 0;
}
dt {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
dd {
margin: var(--space-1) 0 0;
font-variant-numeric: tabular-nums;
font-size: var(--text-md);
}
small {
display: block;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: normal;
line-height: 1.5;
}
.empty {
margin: var(--space-3) 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.denominator {
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.5;
margin: var(--space-3) 0 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
details {
margin-top: var(--space-4);
font-size: var(--text-sm);
}
summary {
cursor: pointer;
color: var(--text-secondary);
padding: 4px 0;
}
.table-wrap {
position: relative;
max-width: 100%;
overflow-x: auto;
margin-top: var(--space-3);
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
caption {
text-align: left;
color: var(--text-tertiary);
margin-bottom: 8px;
}
th,
td {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
thead th {
color: var(--text-secondary);
}
th:first-child {
text-align: left;
}
tbody th {
font-weight: var(--weight-regular);
}
@media (max-width: 640px) {
.panel {
padding: var(--space-4);
}
.legend {
gap: 10px;
}
.chart-grid {
grid-template-columns: 32px minmax(0, 1fr);
gap: 6px;
}
.plot {
gap: 3px;
}
dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.readout {
padding: var(--space-3);
}
.step-buttons {
flex-wrap: wrap;
}
}
</style>
@@ -8,25 +8,57 @@
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
let { report }: { report: MetricsData } = $props();
const recommendations = $derived(metricRecommendations(report.maps));
const highlighted = $derived(recommendations.filter(item => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')).slice(0, 3));
const remaining = $derived(recommendations.filter(item => !highlighted.includes(item)));
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const highlighted = $derived(
recommendations
.filter(
(item) => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')
)
.slice(0, 3)
);
const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item)));
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const params = $derived(new URLSearchParams(contextSearch));
const query = $derived((params.get('insightQ') ?? '').slice(0, 100));
const sort = $derived(params.get('insightSort') === 'map' ? 'map' : 'priority');
const visibleRemaining = $derived(remaining.filter(item => `${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0));
const visibleRemaining = $derived(
remaining
.filter((item) =>
`${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())
)
.sort((a, b) =>
sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0
)
);
function updateContext(key: string, value: string) {
const url = new URL(page.url); url.search = contextSearch;
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
const url = new URL(page.url);
url.search = contextSearch;
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
const limited = $derived(recommendations.filter(item => item.tentative).length);
const limited = $derived(recommendations.filter((item) => item.tentative).length);
</script>
{#snippet finding(suggestion: ReturnType<typeof metricRecommendations>[number])}
<article class="recommendation">
<div><strong>{suggestion.map}</strong><small>{suggestion.game.toUpperCase()}{suggestion.tentative ? ' · Limited evidence' : ''}</small></div>
<div><strong>{suggestion.title}</strong><details><summary>Evidence and considerations</summary><p>{suggestion.evidence}</p><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
<div>
<strong>{suggestion.map}</strong><small
>{suggestion.game.toUpperCase()}{suggestion.tentative
? ' · Limited evidence'
: ''}</small
>
</div>
<div>
<strong>{suggestion.title}</strong>
<details>
<summary>Evidence and considerations</summary>
<p>{suggestion.evidence}</p>
<p>{suggestion.reason}</p>
<p>{suggestion.outcome}</p>
</details>
</div>
</article>
{/snippet}
<section class="explore" aria-labelledby="metrics-explore-title">
@@ -34,32 +66,133 @@
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p>
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
{:else}<p class="empty">No hosting trials meet the evidence threshold in this period.</p>{/if}
{#if limited}<p class="notice">{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting, player limits, and observation duration before changing hosting.</p>{/if}
{#if remaining.length}<details class="remaining"><summary>Other findings ({remaining.length})</summary>
<div class="finding-controls"><Field label="Find a map"><Input type="search" value={query} placeholder="Map or game…" oninput={event => updateContext('insightQ', (event.currentTarget as HTMLInputElement).value)} /></Field><Field label="Sort findings"><Select value={sort} options={[{ value: 'priority', label: 'Priority' }, { value: 'map', label: 'Map A–Z' }]} onchange={event => updateContext('insightSort', (event.currentTarget as HTMLSelectElement).value)} /></Field></div>
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>No other findings match this search.</p>{/each}</details>{/if}
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
<ul>
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
</ul>
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
</details>
{#if limited}<p class="notice">
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting,
player limits, and observation duration before changing hosting.
</p>{/if}
{#if remaining.length}<details class="remaining">
<summary>Other findings ({remaining.length})</summary>
<div class="finding-controls">
<Field label="Find a map"
><Input
type="search"
value={query}
placeholder="Map or game…"
oninput={(event) =>
updateContext(
'insightQ',
(event.currentTarget as HTMLInputElement).value
)}
/></Field
><Field label="Sort findings"
><Select
value={sort}
options={[
{ value: 'priority', label: 'Priority' },
{ value: 'map', label: 'Map A–Z' }
]}
onchange={(event) =>
updateContext(
'insightSort',
(event.currentTarget as HTMLSelectElement).value
)}
/></Field
>
</div>
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>
No other findings match this search.
</p>{/each}
</details>{/if}
<details class="rules">
<summary>How Rules v1 chooses a suggestion</summary>
<ul>
<li>
Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion *
100}% of known-capacity time.
</li>
<li>
When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}%
occupied known time can suggest a tentative additional-lobby trial. Occupancy does
not establish saturation.
</li>
<li>
Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization *
100}%, and either measured pressure is below {HOSTING_RULES.quietPressure *
100}% or capacity is unavailable.
</li>
<li>
Missing occupancy prompts a reporting check. Other cases suggest watching hosting or
checking capacity reporting.
</li>
</ul>
<p>
Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity,
or substantial unknown status marks advice as tentative. These are trial policies, not validated
demand forecasts. Collection gaps are omitted from all rules.
</p>
</details>
</section>
<style>
.finding-controls { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.explore { padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
h2 { margin: 0; font-size: var(--text-lg); }
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
.recommendation { display: grid; grid-template-columns: minmax(140px, .6fr) minmax(0, 2fr); gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
small { display: block; color: var(--text-secondary); margin-top: 6px; font-size: var(--text-xs); }
details { font-size: var(--text-xs); margin-top: 10px; }
summary { cursor: pointer; color: var(--text-secondary); }
.remaining, .rules { margin-top: 20px; }
.notice { border-left: 2px solid var(--state-warning); padding-left: 12px; }
li { margin: 8px 0; color: var(--text-secondary); }
@media (max-width: 540px) { .recommendation { grid-template-columns: 1fr; gap: 8px; } }
.finding-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.explore {
padding: 20px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.recommendation {
display: grid;
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 2fr);
gap: 20px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
small {
display: block;
color: var(--text-secondary);
margin-top: 6px;
font-size: var(--text-xs);
}
details {
font-size: var(--text-xs);
margin-top: 10px;
}
summary {
cursor: pointer;
color: var(--text-secondary);
}
.remaining,
.rules {
margin-top: 20px;
}
.notice {
border-left: 2px solid var(--state-warning);
padding-left: 12px;
}
li {
margin: 8px 0;
color: var(--text-secondary);
}
@media (max-width: 540px) {
.recommendation {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
@@ -7,100 +7,469 @@
type Heatmap = MetricsData['heatmap'];
type Cell = Heatmap['cells'][number];
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
let {
heatmap,
disabled = false,
onTimeZoneChange
}: {
heatmap: Heatmap;
disabled?: boolean;
onTimeZoneChange: (value: string) => void;
} = $props();
const id = $props.id();
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const hours = Array.from({ length: 24 }, (_, hour) => hour);
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
const zones = $derived(
[...new Set([...availableTimeZones(), heatmap.timeZone])]
.sort()
.map((zone) => ({ value: zone, label: zone.replaceAll('_', ' ') }))
);
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
const hourOptions = hours.map((hour) => ({ value: String(hour), label: hourLabel(hour) }));
let selectedKey = $state<number | null>(null);
let announcement = $state('');
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
const cells = $derived(
new Map(heatmap.cells.map((cell) => [cell.weekday * 24 + cell.hour, cell]))
);
const selected = $derived(
cells.get(selectedKey ?? -1) ??
heatmap.cells.find((cell) => cell.observed + cell.unknown > 0) ??
heatmap.cells[0]
);
function hourLabel(hour: number) {
return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`;
}
function cellAt(day: number, hour: number): Cell {
return (
cells.get(day * 24 + hour) ?? {
weekday: day,
hour,
active: 0,
observed: 0,
idle: 0,
unknown: 0,
playerHours: 0,
distinctDates: 0
}
);
}
function description(cell: Cell) {
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
}
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
function select(day: number, hour: number) {
selectedKey = day * 24 + hour;
announcement = description(cellAt(day, hour));
}
function move(event: KeyboardEvent, cell: Cell) {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
return;
event.preventDefault();
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
const day = Math.max(
0,
Math.min(
6,
cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)
)
);
const hour =
event.key === 'Home'
? 0
: event.key === 'End'
? 23
: Math.max(
0,
Math.min(
23,
cell.hour +
(event.key === 'ArrowLeft'
? -1
: event.key === 'ArrowRight'
? 1
: 0)
)
);
const key = day * 24 + hour;
select(day, hour);
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
(event.currentTarget as HTMLElement)
.closest('table')
?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)
?.focus();
}
</script>
<section class="heatmap" aria-labelledby={`${id}-heatmap-title`}>
<div class="heading"><div><h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
<div class="heading">
<div>
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
<p>
Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll(
'_',
' '
)}.
</p>
</div>
<div class="zone">
<Field label="Report timezone"
><Select
value={heatmap.timeZone}
options={zones}
{disabled}
onchange={(event) =>
onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)}
/></Field
>
</div>
</div>
<div class="legend">
<span>0% <i class="scale"></i> 100% with players</span><span
><i class="unknown-example"></i>Unknown only</span
><span><i class="missing-example">·</i>No captured time</span>
</div>
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
{/each}</tr>{/each}</tbody>
</table></div>
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
<div
class="table-wrap"
role="region"
aria-label="Activity heatmap; use arrow keys between cells or the selectors below"
tabindex="0"
>
<table>
<caption class="sr-only"
>Weekday and local hour activity. Cell details include known time and distinct
recorded dates.</caption
>
<thead
><tr
><th scope="col">Day</th>{#each hours as hour}<th scope="col"
>{String(hour).padStart(2, '0')}</th
>{/each}</tr
></thead
>
<tbody
>{#each weekdays as day, weekday}<tr
><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell =
cellAt(weekday, hour)}
<td
><button
type="button"
class="cell"
class:known={cell.observed > 0}
class:unknown={cell.observed === 0 && cell.unknown > 0}
class:mixed={cell.observed > 0 && cell.unknown > 0}
class:missing={cell.observed + cell.unknown === 0}
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, (100 * cell.active) / cell.observed)) : 0}%`}
data-key={weekday * 24 + hour}
tabindex={selected?.weekday === weekday &&
selected?.hour === hour
? 0
: -1}
aria-pressed={selected?.weekday === weekday &&
selected?.hour === hour}
aria-label={description(cell)}
title={description(cell)}
onclick={() => select(weekday, hour)}
onkeydown={(event) => move(event, cell)}
><span aria-hidden="true"
>{cell.observed > 0
? ''
: cell.unknown > 0
? '?'
: '·'}</span
></button
></td
>
{/each}</tr
>{/each}</tbody
>
</table>
</div>
<p class="note">
A striped corner marks additional unknown time. Color uses known time only. Repeated
daylight-saving clock hours share a cell; collection gaps stay uncaptured.
</p>
{#if selected}
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
<div class="selectors">
<Field label="Inspect weekday"
><Select
value={String(selected.weekday)}
options={dayOptions}
onchange={(event) =>
select(
Number((event.currentTarget as HTMLSelectElement).value),
selected.hour
)}
/></Field
><Field label="Inspect hour"
><Select
value={String(selected.hour)}
options={hourOptions}
onchange={(event) =>
select(
selected.weekday,
Number((event.currentTarget as HTMLSelectElement).value)
)}
/></Field
>
</div>
<div class="readout">
<strong
>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll(
'_',
' '
)}</strong
>
{#if selected.observed + selected.unknown === 0}<p>
No captured time for this weekday and hour. This does not mean the fleet was
idle.
</p>
{:else}<dl>
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
</dl>{/if}
<div>
<dt>Time with players</dt>
<dd>
{metricPercent(selected.active, selected.observed)}<small
>{metricDuration(selected.active)} of {metricDuration(
selected.observed
)} known</small
>
</dd>
</div>
<div>
<dt>Unknown server time</dt>
<dd>{metricDuration(selected.unknown)}</dd>
</div>
<div>
<dt>Player-hours</dt>
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
</div>
<div>
<dt>Distinct recorded dates</dt>
<dd>{selected.distinctDates}</dd>
</div>
</dl>{/if}
</div>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
</section>
<style>
.heatmap { min-width: 0; padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
.zone { flex: 0 1 250px; min-width: 0; max-width: 100%; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.legend span { display: flex; gap: 5px; align-items: center; }
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.missing-example { border: 1px dashed var(--border-control); }
.table-wrap { position: relative; overflow-x: auto; max-width: 100%; padding: 3px; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
th { color: var(--text-tertiary); font-weight: normal; font-size: var(--text-xs); text-align: center; padding: 0 0 3px; }
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
td { padding: 0; }
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
.cell.missing { border-style: dashed; background: transparent; }
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
.note { color: var(--text-tertiary); font-size: var(--text-xs); }
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
dt { font-size: var(--text-xs); color: var(--text-tertiary); }
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) { .heatmap { padding: var(--space-4); } .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.heatmap {
min-width: 0;
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
.heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
flex-wrap: wrap;
}
.heading > div:first-child {
flex: 1 1 260px;
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
font-size: var(--text-xs);
color: var(--text-secondary);
margin: var(--space-2) 0 0;
line-height: 1.6;
}
.zone {
flex: 0 1 250px;
min-width: 0;
max-width: 100%;
}
.legend {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin: var(--space-4) 0 var(--space-3);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.legend span {
display: flex;
gap: 5px;
align-items: center;
}
.scale {
width: 64px;
height: 9px;
background: linear-gradient(to right, var(--surface-raised), var(--chart-active));
border-radius: 3px;
}
.unknown-example,
.missing-example {
width: 14px;
height: 14px;
display: inline-flex;
justify-content: center;
align-items: center;
font-style: normal;
}
.unknown-example,
.cell.unknown {
background: repeating-linear-gradient(
135deg,
var(--chart-unknown-base) 0 3px,
var(--chart-unknown-stripe) 3px 5px
);
}
.missing-example {
border: 1px dashed var(--border-control);
}
.table-wrap {
position: relative;
overflow-x: auto;
max-width: 100%;
padding: 3px;
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
table {
width: 100%;
min-width: 750px;
border-collapse: separate;
border-spacing: 3px;
table-layout: fixed;
}
th {
color: var(--text-tertiary);
font-weight: normal;
font-size: var(--text-xs);
text-align: center;
padding: 0 0 3px;
}
th:first-child {
width: 35px;
position: sticky;
left: 0;
background: var(--surface);
z-index: 2;
text-align: left;
}
td {
padding: 0;
}
.cell {
position: relative;
appearance: none;
display: block;
width: 100%;
height: 29px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--surface-raised);
color: var(--text-secondary);
font: inherit;
cursor: pointer;
overflow: hidden;
}
.cell.known {
background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised));
}
.cell.missing {
border-style: dashed;
background: transparent;
}
.cell.mixed::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 7px;
height: 7px;
background: repeating-linear-gradient(
135deg,
var(--chart-unknown-base) 0 2px,
var(--chart-unknown-stripe) 2px 4px
);
}
.cell[aria-pressed='true'] {
outline: 1px solid var(--text-primary);
outline-offset: 1px;
}
.cell:focus-visible {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 1px;
}
.note {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.selectors {
display: grid;
grid-template-columns: repeat(2, minmax(0, 190px));
gap: var(--space-3);
margin-top: var(--space-4);
}
.readout {
margin-top: var(--space-3);
padding: var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.readout > strong {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-secondary);
}
dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
margin: var(--space-3) 0 0;
}
dt {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
dd {
margin: 5px 0 0;
font-size: var(--text-md);
font-variant-numeric: tabular-nums;
}
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.heatmap {
padding: var(--space-4);
}
.zone {
flex-basis: 100%;
}
dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.readout {
padding: var(--space-3);
}
.selectors {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -7,36 +7,127 @@
const prior = $derived(report.previous);
const pressure = $derived(report.pooledPressure);
const comparable = $derived(report.summary.observed > 0 && prior.summary.observed > 0);
const occupancyDelta = $derived(comparable ? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
const playerDelta = $derived(comparable && prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1) : comparable && report.summary.playerHours === 0 ? 0 : null);
const previousLabel = $derived(`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`);
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
const occupancyDelta = $derived(
comparable
? 100 *
(report.summary.active / report.summary.observed -
prior.summary.active / prior.summary.observed)
: null
);
const playerDelta = $derived(
comparable && prior.summary.playerHours > 0
? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
: comparable && report.summary.playerHours === 0
? 0
: null
);
const previousLabel = $derived(
`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`
);
function signed(value: number, unit: string) {
return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`;
}
</script>
<div class="summary">
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
<Card
><Stat
label="Time with players"
value={metricPercent(report.summary.active, report.summary.observed)}
size="lg"
tween={false}
/>
<p>
{occupancyDelta === null
? 'Comparison unavailable'
: `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}
</p>
<small
>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed
server time</small
>
</Card>
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
<details><summary>What are player-hours?</summary><p>Players × observed time. One player for one hour equals one player-hour.</p></details>
<Card
><Stat
label="Player-hours"
value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}
size="lg"
tween={false}
/>
<p>
{playerDelta === null
? comparable && report.summary.playerHours > 0
? 'New · no previous player-hours'
: 'Comparison unavailable'
: `${signed(playerDelta, '%')} vs ${previousLabel}`}
</p>
<details>
<summary>What are player-hours?</summary>
<p>Players × observed time. One player for one hour equals one player-hour.</p>
</details>
</Card>
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
<Card
><Stat
label="Time all lobbies were full"
value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)}
size="lg"
tween={false}
/>
<p>
{pressure.knownMapHours > 0
? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data`
: 'Capacity data unavailable'}
</p>
<small>Combined map history · each map measured separately</small>
</Card>
</div>
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
{#if report.maps.some(map => map.capacityObserved <= 0)}<p class="notice">{report.maps.filter(map => map.capacityObserved <= 0).length} of {report.maps.length} maps lack capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.</p>{/if}
{#if report.summary.unknown > 0}<p class="quality">{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time with players.” Collection gaps are separate.</p>{/if}
{#if prior.partialHistory}<p class="notice">
Partial previous history. Comparisons use available observations without extrapolation.
</p>{/if}
{#if report.maps.some((map) => map.capacityObserved <= 0)}<p class="notice">
{report.maps.filter((map) => map.capacityObserved <= 0).length} of {report.maps.length} maps lack
capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.
</p>{/if}
{#if report.summary.unknown > 0}<p class="quality">
{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time
with players.” Collection gaps are separate.
</p>{/if}
<style>
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
p { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); }
small, details { display: block; margin-top: 8px; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.5; }
summary { cursor: pointer; }
.notice { margin: 0; padding-left: 12px; border-left: 2px solid var(--state-warning); }
.quality { margin: 0; font-size: var(--text-xs); }
@media (max-width: 760px) { .summary { grid-template-columns: 1fr; gap: 8px; } }
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
p {
margin: 10px 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
small,
details {
display: block;
margin-top: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
}
summary {
cursor: pointer;
}
.notice {
margin: 0;
padding-left: 12px;
border-left: 2px solid var(--state-warning);
}
.quality {
margin: 0;
font-size: var(--text-xs);
}
@media (max-width: 760px) {
.summary {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
@@ -2,7 +2,13 @@
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
import { metricPage, compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import {
metricPage,
compareMetricValues,
metricDuration,
metricNumber,
metricPercent
} from '$ui/metrics.js';
import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
import Input from '$ui/components/primitives/Input.svelte';
@@ -10,45 +16,97 @@
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { maps, servers, view = 'maps' }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
let {
maps,
servers,
view = 'maps'
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
{ key: 'map', label: 'Map' },
{ key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' },
{ key: 'full', label: 'All lobbies full' },
{ key: 'details', label: 'Details' }
] as const;
const serverColumns = [
{ key: 'server', label: 'Instance' }, { key: 'utilization', label: 'Time with players' },
{ key: 'observed', label: 'Observed time' }, { key: 'unknown', label: 'Unknown time' }, { key: 'details', label: 'Details' }
{ key: 'server', label: 'Instance' },
{ key: 'utilization', label: 'Time with players' },
{ key: 'observed', label: 'Observed time' },
{ key: 'unknown', label: 'Unknown time' },
{ key: 'details', label: 'Details' }
] as const;
type MapSort = typeof mapColumns[number]['key'];
type ServerSort = typeof serverColumns[number]['key'];
type MapSort = (typeof mapColumns)[number]['key'];
type ServerSort = (typeof serverColumns)[number]['key'];
type Direction = 'asc' | 'desc';
// Shallow navigation changes page.state, not page.url. Store the working
// search string there so filters react immediately and survive Back/Forward.
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const params = $derived(new URLSearchParams(contextSearch));
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
const mapSort = $derived(mapColumns.find(column => column.key !== 'details' && column.key === params.get('mapSort'))?.key ?? 'playerHours');
const serverSort = $derived(serverColumns.find(column => column.key !== 'details' && column.key === params.get('serverSort'))?.key ?? 'utilization');
const mapSort = $derived(
mapColumns.find(
(column) => column.key !== 'details' && column.key === params.get('mapSort')
)?.key ?? 'playerHours'
);
const serverSort = $derived(
serverColumns.find(
(column) => column.key !== 'details' && column.key === params.get('serverSort')
)?.key ?? 'utilization'
);
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
}));
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
}));
const serverDirection = $derived<Direction>(
params.get('serverDirection') === 'asc' ? 'asc' : 'desc'
);
const maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
const mapRows = $derived(
maps
.filter((map) =>
`${map.game} ${gameName(map.game)} ${map.map}`
.toLowerCase()
.includes(mapQuery.trim().toLowerCase())
)
.sort((a, b) => {
const result =
mapSort === 'map'
? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
: compareMetricValues(
mapValue(a, mapSort),
mapValue(b, mapSort),
mapDirection
);
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
})
);
const serverRows = $derived(
servers
.filter((server) =>
`${server.server} ${serverTitle(server.server)}`
.toLowerCase()
.includes(serverQuery.trim().toLowerCase())
)
.sort((a, b) => {
const result =
serverSort === 'server'
? serverTitle(a.server).localeCompare(serverTitle(b.server)) *
(serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(
serverValue(a, serverSort),
serverValue(b, serverSort),
serverDirection
);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
})
);
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
function gameName(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
}
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
if (key === 'details') return null;
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
@@ -56,87 +114,388 @@
}
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
if (key === 'details') return null;
if (key === 'utilization') return server.observed > 0 ? server.active / server.observed : null;
if (key === 'utilization')
return server.observed > 0 ? server.active / server.observed : null;
return server[key];
}
function updateContext(changes: Record<string, string>) {
const url = new URL(page.url);
url.search = contextSearch;
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
for (const [key, value] of Object.entries(changes)) {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
}
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
function sortMap(key: MapSort) {
updateContext({
mapSort: key,
mapDirection:
key === mapSort
? mapDirection === 'asc'
? 'desc'
: 'asc'
: key === 'map'
? 'asc'
: 'desc'
});
}
function sortServer(key: ServerSort) {
updateContext({
serverSort: key,
serverDirection:
key === serverSort
? serverDirection === 'asc'
? 'desc'
: 'asc'
: key === 'server'
? 'asc'
: 'desc'
});
}
</script>
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
<div class="heading">
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
<div class="search"><Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}><Input type="search" value={view === 'maps' ? mapQuery : serverQuery} placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'} oninput={event => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
<div class="search">
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
><Input
type="search"
value={view === 'maps' ? mapQuery : serverQuery}
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
oninput={(event) =>
updateContext({
[view === 'maps' ? 'mapQ' : 'serverQ']: (
event.currentTarget as HTMLInputElement
).value
})}
/></Field
>
</div>
</div>
{#if currentPage.total}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'} tabindex="0"><table>
<thead><tr>{#each view === 'maps' ? mapColumns : serverColumns as column}
<th scope="col" aria-sort={column.key === 'details' ? undefined : (view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? 'ascending' : 'descending' : undefined}>
{#if column.key === 'details'}Details{:else}<button type="button" onclick={() => view === 'maps' ? sortMap(column.key as MapSort) : sortServer(column.key as ServerSort)}>{column.label}<span aria-hidden="true">{(view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? '↑' : '↓' : '↕'}</span></button>{/if}
</th>
{/each}</tr></thead>
<tbody>
{#if view === 'maps'}
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row">{map.map}<small>{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}</small></th>
<td class="player-hours">{map.observed ? metricNumber(map.playerHours) : '—'}<div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.full, map.capacityObserved)}</td>
<td><details><summary><span class="sr-only">{map.map} </span>Details</summary><dl>
<dt>Observed instance time</dt><dd>{metricDuration(map.observed)}</dd><dt>Unknown status</dt><dd>{metricDuration(map.unknown)}</dd><dt>All lobbies full</dt><dd>{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}</dd>
</dl></details></td>
</tr>{/each}
{:else}
{#each serverPage.rows as server (server.server)}<tr>
<th scope="row">{#if server.currentRegistered}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}`, `/metrics${contextSearch}`)} title="Open current instance with this name">{serverTitle(server.server)}</a>{:else}{serverTitle(server.server)}<small>Historical</small>{/if}</th>
<td>{metricPercent(server.active, server.observed)}</td><td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
<td><details><summary><span class="sr-only">{serverTitle(server.server)} </span>Details</summary><dl><dt>Recorded name</dt><dd><code>{server.server}</code></dd><dt>Time with players</dt><dd>{metricDuration(server.active)}</dd><dt>Idle</dt><dd>{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}</dd></dl></details></td>
</tr>{/each}
{/if}
</tbody>
</table></div>
{:else}<EmptyState title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}>{#snippet actions()}<Button size="sm" onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}>Clear search</Button>{/snippet}</EmptyState>{/if}
<div class="footer"><span>{currentPage.start}–{currentPage.end} of {currentPage.total} {view === 'maps' ? 'maps' : 'instances'}</span>
{#if currentPage.pages > 1}<div class="paging"><Button size="sm" disabled={currentPage.page === 1} onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}>Previous</Button><span>Page {currentPage.page} of {currentPage.pages}</span><Button size="sm" disabled={currentPage.page === currentPage.pages} onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}>Next</Button></div>{/if}
<div
class="table-wrap"
role="region"
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
tabindex="0"
>
<table>
<thead
><tr
>{#each view === 'maps' ? mapColumns : serverColumns as column}
<th
scope="col"
aria-sort={column.key === 'details'
? undefined
: (view === 'maps' ? mapSort : serverSort) === column.key
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
? 'ascending'
: 'descending'
: undefined}
>
{#if column.key === 'details'}Details{:else}<button
type="button"
onclick={() =>
view === 'maps'
? sortMap(column.key as MapSort)
: sortServer(column.key as ServerSort)}
>{column.label}<span aria-hidden="true"
>{(view === 'maps' ? mapSort : serverSort) ===
column.key
? (view === 'maps'
? mapDirection
: serverDirection) === 'asc'
? '↑'
: '↓'
: '↕'}</span
></button
>{/if}
</th>
{/each}</tr
></thead
>
<tbody>
{#if view === 'maps'}
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row"
>{map.map}<small
>{gameName(map.game)}{map.observed < 1 ||
map.capacityObserved < 1 ||
map.unknown > map.observed / 4
? ' · Limited evidence'
: ''}</small
></th
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
<i
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
></i>
</div></td
>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.full, map.capacityObserved)}</td>
<td
><details>
<summary
><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Observed instance time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
<dd>{metricDuration(map.unknown)}</dd>
<dt>All lobbies full</dt>
<dd>
{map.capacityObserved
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
: 'Capacity data unavailable'}
</dd>
</dl>
</details></td
>
</tr>{/each}
{:else}
{#each serverPage.rows as server (server.server)}<tr>
<th scope="row"
>{#if server.currentRegistered}<a
href={contextHref(
`/servers/${encodeURIComponent(server.server)}`,
`/metrics${contextSearch}`
)}
title="Open current instance with this name"
>{serverTitle(server.server)}</a
>{:else}{serverTitle(server.server)}<small>Historical</small
>{/if}</th
>
<td>{metricPercent(server.active, server.observed)}</td><td
>{metricDuration(server.observed)}</td
><td>{metricDuration(server.unknown)}</td>
<td
><details>
<summary
><span class="sr-only"
>{serverTitle(server.server)}
</span>Details</summary
>
<dl>
<dt>Recorded name</dt>
<dd><code>{server.server}</code></dd>
<dt>Time with players</dt>
<dd>{metricDuration(server.active)}</dd>
<dt>Idle</dt>
<dd>
{metricPercent(server.idle, server.observed)} · {metricDuration(
server.idle
)}
</dd>
</dl>
</details></td
>
</tr>{/each}
{/if}
</tbody>
</table>
</div>
{:else}<EmptyState
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
>{#snippet actions()}<Button
size="sm"
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/snippet}</EmptyState
>{/if}
<div class="footer">
<span
>{currentPage.start}–{currentPage.end} of {currentPage.total}
{view === 'maps' ? 'maps' : 'instances'}</span
>
{#if currentPage.pages > 1}<div class="paging">
<Button
size="sm"
disabled={currentPage.page === 1}
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
>Previous</Button
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
size="sm"
disabled={currentPage.page === currentPage.pages}
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
>Next</Button
>
</div>{/if}
</div>
{#if view === 'instances'}<p class="identity-note">Grouped by recorded name; links open the current instance, which may differ from the historical registration.</p>{/if}
<details class="table-help"><summary>About these metrics</summary><p>{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.' : 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}</p></details>
{#if view === 'instances'}<p class="identity-note">
Grouped by recorded name; links open the current instance, which may differ from the
historical registration.
</p>{/if}
<details class="table-help">
<summary>About these metrics</summary>
<p>
{view === 'maps'
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
</p>
</details>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.search { flex: 0 1 240px; }
.table-wrap { position: relative; width: 100%; overflow-x: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
th:first-child { text-align: left; min-width: 160px; }
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); white-space: nowrap; }
thead button { display: inline-flex; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; white-space: nowrap; }
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
tbody th { font-weight: var(--weight-medium); }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; }
.footer, .paging { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
.footer { padding: 16px 20px; }
details { font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; }
dl { text-align: left; min-width: 170px; max-width: 300px; }
dt { margin-top: 10px; } dd { margin: 4px 0; color: var(--text-primary); overflow-wrap: anywhere; }
.identity-note { margin: 0; padding: 0 20px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
.table-help { padding: 0 20px 16px; } .table-help p { max-width: 90ch; line-height: 1.6; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 640px) { th, td { padding: 10px; } .search { flex: 1 1 100%; } }
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-width: 0;
overflow: hidden;
}
.heading {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.search {
flex: 0 1 240px;
}
.table-wrap {
position: relative;
width: 100%;
overflow-x: auto;
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: -3px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
th,
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
font-variant-numeric: tabular-nums;
vertical-align: top;
}
th:first-child {
text-align: left;
min-width: 160px;
}
thead th {
color: var(--text-secondary);
background: var(--surface-sunken);
font-size: var(--text-xs);
white-space: nowrap;
}
thead button {
display: inline-flex;
gap: 6px;
color: inherit;
background: transparent;
font: inherit;
padding: 5px 0;
border: 0;
cursor: pointer;
white-space: nowrap;
}
thead button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
tbody th {
font-weight: var(--weight-medium);
}
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.comparison {
height: 3px;
width: 90px;
margin: 8px 0 0 auto;
background: var(--surface-raised);
border-radius: 3px;
}
.comparison i {
display: block;
height: 100%;
background: var(--chart-active);
border-radius: inherit;
}
.footer,
.paging {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.footer {
padding: 16px 20px;
}
details {
font-size: var(--text-xs);
color: var(--text-secondary);
}
summary {
cursor: pointer;
}
dl {
text-align: left;
min-width: 170px;
max-width: 300px;
}
dt {
margin-top: 10px;
}
dd {
margin: 4px 0;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.identity-note {
margin: 0;
padding: 0 20px 12px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.table-help {
padding: 0 20px 16px;
}
.table-help p {
max-width: 90ch;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (max-width: 640px) {
th,
td {
padding: 10px;
}
.search {
flex: 1 1 100%;
}
}
</style>