Add experimental metrics insights, comparisons, and scope filters

This commit is contained in:
2026-09-27 22:34:56 -04:00
parent 3922e28ca8
commit eb2a852fdc
18 changed files with 992 additions and 61 deletions
+73
View File
@@ -0,0 +1,73 @@
import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js';
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
export const HOSTING_RULES = {
pressureExpansion: 0.2,
occupancyExpansion: 0.8,
idleUtilization: 0.1,
quietPressure: 0.01,
minimumEvidenceHours: 1
} as const;
export interface MetricRecommendation {
game: string;
map: string;
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
title: string;
reason: string;
evidence: string;
outcome: string;
tentative: boolean;
}
/** Apply the same visible rules to all selected maps, including sparse observations. */
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps.map(map => {
const utilization = map.observed > 0 ? map.active / map.observed : null;
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
const knownShare = map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
const base = {
game: map.game,
map: map.map,
evidence: `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
(pressure === null ? 'Capacity unavailable.' : `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
tentative: map.observed < HOSTING_RULES.minimumEvidenceHours || pressure === null || map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownShare < 0.8
};
if (utilization === null) return {
...base, kind: 'capacity' as const, title: map.unknown > 0 ? 'Check occupancy reporting' : 'Let observations accumulate',
reason: map.unknown > 0
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known server time in the next report.', tentative: true
};
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) return {
...base, kind: 'expand' as const, title: 'Trial one additional lobby',
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
outcome: 'Compare all-full time and use of the additional lobby over the next comparable period.'
};
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion) return {
...base, kind: 'expand' as const, title: 'Trial an extra lobby during busy hours',
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
outcome: 'Check whether players use the extra lobby, and measure capacity if the server reports it.', tentative: true
};
if (utilization <= HOSTING_RULES.idleUtilization && (pressure === null || pressure < HOSTING_RULES.quietPressure)) return {
...base, kind: 'consolidate' as const, title: 'Trial less idle hosting',
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
outcome: 'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
};
if (pressure === null) return {
...base, kind: 'capacity' as const, title: 'Check capacity during busy hours',
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
outcome: 'Look for known-capacity time and all-full periods before the next hosting trial.'
};
return {
...base, kind: 'watch' as const, title: 'Keep the lineup and watch peaks',
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
outcome: 'Watch whether occupied time and measured capacity pressure change.'
};
}).sort((a, b) => {
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
return priority[a.kind] - priority[b.kind] || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
});
}
+60
View File
@@ -1,3 +1,5 @@
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
export type MetricsDays = 1 | 7 | 30;
export function parseMetricsDays(value: string | null | undefined): MetricsDays {
@@ -5,6 +7,28 @@ export function parseMetricsDays(value: string | null | undefined): MetricsDays
return days === 1 || days === 7 || days === 30 ? days : 7;
}
export interface MetricsScope {
game: string | null;
map: string | null;
timeZone: string;
}
export interface MetricsQuery extends MetricsScope {
days: MetricsDays;
}
/** A map is an exact recorded label within a game, never a cross-game match. */
export function parseMetricsQuery(params: URLSearchParams): MetricsQuery {
const requestedGame = params.get('game')?.trim() ?? '';
const game = /^[a-z0-9_-]{1,32}$/.test(requestedGame) ? requestedGame : null;
const requestedMap = params.get('map');
const map = game && requestedMap && requestedMap.trim() && requestedMap.length <= 200 && !/[\u0000-\u001f\u007f]/.test(requestedMap)
? requestedMap : null;
const requestedZone = params.get('timeZone');
return { days: parseMetricsDays(params.get('days')), game, map,
timeZone: validTimeZone(requestedZone) ? requestedZone : DEFAULT_TIME_ZONE };
}
/** All durations are hours. Observed is known server time, not collection coverage. */
export interface Utilization {
observed: number;
@@ -16,6 +40,8 @@ export interface Utilization {
export interface ServerMetrics extends Utilization {
server: string;
/** A current registration with this name exists; historical identity may differ. */
currentRegistered: boolean;
}
export interface MapMetrics extends Utilization {
@@ -34,6 +60,33 @@ export interface MetricPoint extends Utilization {
partial: boolean;
}
export interface PreviousMetrics {
since: number;
until: number;
summary: Utilization;
firstSampleAt: number | null;
lastSampleAt: number | null;
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */
partialHistory: boolean;
}
export interface HeatmapCell extends Utilization {
/** Monday = 0, Sunday = 6. */
weekday: number;
hour: number;
/** Distinct local dates with any captured time, including unknown status. */
distinctDates: number;
}
export interface PooledPressure {
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */
value: number | null;
fullMapHours: number;
knownMapHours: number;
coveredMaps: number;
totalMaps: number;
}
export interface MetricsData {
days: MetricsDays;
since: number;
@@ -42,8 +95,15 @@ export interface MetricsData {
firstRetainedSampleAt: number | null;
latestSampleAt: number | null;
registeredServerCount: number;
scope: MetricsScope;
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
scopeFirstSampleAt: number | null;
scopeLatestSampleAt: number | null;
summary: Utilization;
servers: ServerMetrics[];
maps: MapMetrics[];
points: MetricPoint[];
previous: PreviousMetrics;
heatmap: { timeZone: string; cells: HeatmapCell[] };
pooledPressure: PooledPressure;
}
+94 -13
View File
@@ -1,6 +1,7 @@
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js';
import { parseMetricsQuery, type HeatmapCell, type MapMetrics, type MetricPoint, type MetricsData,
type MetricsDays, type MetricsQuery, type PreviousMetrics, type ServerMetrics, type Utilization } from '../../metrics.js';
export type { MapMetrics, Utilization } from '../../metrics.js';
@@ -38,16 +39,34 @@ function addObservation(total: Utilization, players: number | null, hours: numbe
/** Each collection is a complete fleet snapshot. Hold it until the next collection,
* capped at the freshness limit; longer gaps contribute no observed or unknown time.
* Read the indexed history once and share the intervals across every aggregation. */
export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()): MetricsData {
const query = parseMetricsQuery(new URLSearchParams(typeof input === 'number' ? { days: String(input) }
: { days: String(input.days), game: input.game ?? '', map: input.map ?? '', timeZone: input.timeZone }));
const { days, ...scope } = query;
const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db();
const metadata = handle.prepare(`SELECT
(SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt,
(SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt,
(SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as
Pick<MetricsData, 'firstRetainedSampleAt' | 'latestSampleAt' | 'registeredServerCount'>;
// These small grouped results keep options and retained evidence independent
// of the chosen scope/window without transferring a second raw history scan.
const groups = handle.prepare(`SELECT game, map, MIN(at) AS first, MAX(at) AS latest
FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`).all(now) as
{ game: string; map: string; first: number; latest: number }[];
const matchesScope = (sample: { game: string; map: string }) =>
(scope.game === null || sample.game === scope.game) && (scope.map === null || sample.map === scope.map);
const evidenceBounds = (values: typeof groups) => values.reduce<{ first: number | null; latest: number | null }>((bounds, group) => ({
first: bounds.first === null ? group.first : Math.min(bounds.first, group.first),
latest: bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest)
}), { first: null, latest: null });
const globalBounds = evidenceBounds(groups);
const scopedBounds = evidenceBounds(groups.filter(matchesScope));
const registered = new Set((handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as { name: string }[])
.map(server => server.name));
const metadata = { firstRetainedSampleAt: globalBounds.first, latestSampleAt: globalBounds.latest,
registeredServerCount: registered.size, scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest };
const scopeOptions = { games: [...new Set(groups.map(group => group.game))], maps: groups.map(({ game, map }) => ({ game, map })) };
const summary = emptyUtilization();
const previous: PreviousMetrics = { since: since - days * 86400_000, until: since, summary: emptyUtilization(),
firstSampleAt: null, lastSampleAt: null, partialHistory: false };
const serverTotals = new Map<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>();
const points: MetricPoint[] = [];
@@ -57,14 +76,61 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
}
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24), hour: index % 24, distinctDates: 0, ...emptyUtilization()
}));
const cellDates = cells.map(() => new Set<string>());
const calendar = new Intl.DateTimeFormat('en-US', { timeZone: scope.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: 'numeric', hourCycle: 'h23' });
const weekdays: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
let cachedMinute = NaN;
let localCell = { index: 0, date: '' };
interface BatchCounts { known: number; active: number; idle: number; unknown: number; players: number }
function addHeatmap(counts: BatchCounts, start: number, end: number): void {
// Samples are at most 45 seconds. Splitting at UTC minute boundaries
// handles local hour/day boundaries, DST, and fractional-hour zones.
// Cache each minute once across the complete fleet collection batches.
for (let cursor = start; cursor < end;) {
const minute = Math.floor(cursor / 60_000);
if (minute !== cachedMinute) {
const parts = Object.fromEntries(calendar.formatToParts(cursor).map(part => [part.type, part.value]));
localCell = { index: weekdays[parts.weekday] * 24 + Number(parts.hour), date: `${parts.year}-${parts.month}-${parts.day}` };
cachedMinute = minute;
}
const stop = Math.min(end, (minute + 1) * 60_000);
const hours = (stop - cursor) / 3600_000;
const cell = cells[localCell.index];
cell.observed += counts.known * hours;
cell.active += counts.active * hours;
cell.idle += counts.idle * hours;
cell.unknown += counts.unknown * hours;
cell.playerHours += counts.players * hours;
cellDates[localCell.index].add(localCell.date);
cursor = stop;
}
}
type RecordedSample = MetricSample & { at: number };
let batch: RecordedSample[] = [];
function aggregateBatch(nextAt: number): void {
if (!batch.length) return;
const start = Math.max(batch[0].at, since);
const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now);
const at = batch[0].at;
const samples = scope.game === null ? batch : batch.filter(matchesScope);
if (!samples.length) return;
const end = Math.min(nextAt, at + ONLINE_MAX_MS, now);
const priorHours = Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000;
if (priorHours > 0) {
previous.firstSampleAt ??= at;
previous.lastSampleAt = at;
for (const sample of samples) addObservation(previous.summary, sample.players, priorHours);
}
// Preserve current-window zero-duration rows without bringing rows from
// the separate comparison window into the visible server/map tables.
if (at < since - ONLINE_MAX_MS) return;
const start = Math.max(at, since);
const hours = Math.max(0, end - start) / 3600_000;
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
// Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration.
const slices: { point: MetricPoint; hours: number }[] = [];
@@ -75,10 +141,10 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
slices.push({ point, hours: (stop - cursor) / 3600_000 });
cursor = stop;
}
for (const sample of batch) {
for (const sample of samples) {
let server = serverTotals.get(sample.server);
if (!server) {
server = { server: sample.server, ...emptyUtilization() };
server = { server: sample.server, currentRegistered: registered.has(sample.server), ...emptyUtilization() };
serverTotals.set(sample.server, server);
}
let game = mapTotals.get(sample.game);
@@ -92,6 +158,13 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
addObservation(server, sample.players, hours);
addObservation(map, sample.players, hours);
for (const slice of slices) addObservation(slice.point, sample.players, slice.hours);
if (sample.players === null) counts.unknown++;
else {
counts.known++;
counts.players += sample.players;
if (sample.players > 0) counts.active++;
else if (sample.players === 0) counts.idle++;
}
const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0;
const full = known && sample.players! >= sample.capacity!;
const previous = pressure.get(map);
@@ -103,11 +176,12 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
if (state.full) map.full += hours;
}
}
addHeatmap(counts, start, end);
}
type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null];
const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples
WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
for (const [at, server, game, map, players, capacity] of samples) {
if (batch.length && at !== batch[0].at) {
aggregateBatch(at);
@@ -121,5 +195,12 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData {
const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server));
const maps = [...mapTotals.values()].flatMap(game => [...game.values()])
.sort((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game));
return { days, since, now, ...metadata, summary, servers, maps, points };
previous.partialHistory = scopedBounds.first === null || scopedBounds.first > previous.since || previous.firstSampleAt === null;
for (let index = 0; index < cells.length; index++) cells[index].distinctDates = cellDates[index].size;
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
const pooledPressure = { value: knownMapHours > 0 ? 100 * fullMapHours / knownMapHours : null,
fullMapHours, knownMapHours, coveredMaps: maps.filter(map => map.capacityObserved > 0).length, totalMaps: maps.length };
return { days, since, now, ...metadata, scope, scopeOptions, summary, servers, maps, points, previous,
heatmap: { timeZone: scope.timeZone, cells }, pooledPressure };
}
@@ -1,13 +1,19 @@
<script lang="ts">
import type { MetricPoint, MetricsDays } from '$lib/metrics.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { createTimeFormatter } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } 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 }: { points: MetricPoint[]; days: MetricsDays } = $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 selectedIndex = $derived.by(() => {
const existing = points.findIndex(point => point.at === selectedAt);
if (existing >= 0) return existing;
@@ -17,34 +23,53 @@
const selected = $derived(points[selectedIndex]);
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));
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) })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${$timeFormat.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
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 middle = $derived(points[Math.floor(points.length / 2)]);
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);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function selectPeriod(at: number) {
selectedAt = 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;
}
function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
if (next) selectedAt = next.at;
if (next) selectPeriod(next.at);
}
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)));
selectedAt = points[next].at;
selectPeriod(points[next].at);
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}
</script>
<section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"><h2 id="metrics-trend-title">Server time over time</h2><p>Server-hours per {days === 1 ? 'hour' : '24-hour period'}. Partial periods contain less elapsed time.</p></div>
<div class="heading"><div><h2 id="metrics-trend-title">Server time 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">Server-hours</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.">
@@ -52,40 +77,42 @@
{#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}
aria-pressed={point.at === selected?.at}
aria-label={`${$timeFormat.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown`}
title={`${$timeFormat.period(point.startAt)} – ${$timeFormat.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => { selectedAt = point.at; }} onkeydown={event => chartKey(event, index)}>
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)}>
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
<span class="segment unknown" style:height={`${point.unknown / ceiling * 100}%`}></span>
<span class="segment idle" style:height={`${point.idle / ceiling * 100}%`}></span>
<span class="segment occupied" style:height={`${point.active / ceiling * 100}%`}></span>
<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" aria-hidden="true"><span>{points[0] ? $timeFormat.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{$timeFormat.period(middle.startAt)}</span>{/if}<span>{points.length ? $timeFormat.period(points[points.length - 1].endAt) : ''}</span></div>
<div class="x-axis" aria-hidden="true"><span>{points[0] ? format.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{format.period(middle.startAt)}</span>{/if}<span>{points.length ? format.period(points[points.length - 1].endAt) : ''}</span></div>
</div>
{#if selected}
<div class="period-controls">
<div class="period-select"><Field label={`Inspect a period · ${$timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => { selectedAt = Number((event.currentTarget as HTMLSelectElement).value); }} /></Field></div>
<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">{$timeFormat.period(selected.startAt)} – {$timeFormat.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
<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, selected.observed)} of known time</small></dd></div>
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}</dd></div>
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}</dd></div>
<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>{/if}
</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>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
<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>
<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">{$timeFormat.period(point.startAt)}<small>to {$timeFormat.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>
<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>
@@ -93,6 +120,11 @@
<style>
.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; }
.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; }
@@ -113,6 +145,7 @@
.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: 20px; 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: 15px; color: var(--text-secondary); }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
@@ -130,6 +163,8 @@
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: 11px; 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: 11px; 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 { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
@@ -0,0 +1,120 @@
<script lang="ts">
import type { MetricsData } from '$lib/metrics.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import MetricsHeatmap from './MetricsHeatmap.svelte';
let { report, changingScope = false, onTimeZoneChange }: { report: MetricsData; changingScope?: boolean; onTimeZoneChange: (value: string) => void } = $props();
const format = $derived(createTimeFormatter(report.scope.timeZone));
const prior = $derived(report.previous);
const pressure = $derived(report.pooledPressure);
const recommendations = $derived(metricRecommendations(report.maps));
const utilizationDelta = $derived(report.summary.observed > 0 && prior.summary.observed > 0
? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
const playerDelta = $derived(report.summary.observed <= 0 || prior.summary.observed <= 0 ? null
: prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
: report.summary.playerHours === 0 ? 0 : null);
const playerNew = $derived(prior.summary.observed > 0 && report.summary.observed > 0 && prior.summary.playerHours === 0 && report.summary.playerHours > 0);
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
</script>
<section class="explore" aria-labelledby="metrics-explore-title">
<div class="heading"><h2 id="metrics-explore-title">Explore this period</h2><p>Planning signals for the selected scope. Review the evidence before changing hosting.</p></div>
<div class="signals">
<article class="signal" aria-labelledby="pressure-title">
<h3 id="pressure-title">Map capacity pressure</h3>
<strong class="value">{metricPercent(pressure.fullMapHours, pressure.knownMapHours)}</strong>
<p class="evidence">{metricDuration(pressure.fullMapHours)} full / {metricDuration(pressure.knownMapHours)} known map time</p>
<p>Share of pooled known map-hours with all lobbies full. {pressure.coveredMaps} of {pressure.totalMaps} map groups have known capacity.</p>
{#if pressure.knownMapHours === 0}<p class="notice">Capacity pressure is unavailable. Occupancy can still be recorded without a reported player limit.</p>{/if}
<p class="fine">Map groups can overlap in clock time; this is not simultaneous fleet saturation.</p>
</article>
<article class="signal" aria-labelledby="comparison-title">
<div class="comparison-heading"><h3 id="comparison-title">Compared with the previous {report.days === 1 ? '24 hours' : `${report.days} days`}</h3>{#if prior.partialHistory}<span class="badge">Partial previous history</span>{/if}</div>
<div class="deltas">
<div><span>Time with players</span><strong>{utilizationDelta === null ? '—' : signed(utilizationDelta, ' pp')}</strong><small>Percentage points · {metricPercent(prior.summary.active, prior.summary.observed)} → {metricPercent(report.summary.active, report.summary.observed)}</small></div>
<div><span>Player-hours</span><strong>{playerNew ? 'New' : playerDelta === null ? '—' : signed(playerDelta, '%')}</strong><small>{prior.summary.observed > 0 ? metricNumber(prior.summary.playerHours) : '—'} → {report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}</small></div>
</div>
<p class="evidence">Known server time: {metricDuration(prior.summary.observed)} previous · {metricDuration(report.summary.observed)} current</p>
{#if prior.summary.observed <= 0 || report.summary.observed <= 0}<p class="notice">A comparison needs known occupancy in both windows. The available durations are still shown.</p>
{:else if prior.summary.playerHours === 0 && report.summary.playerHours > 0}<p class="notice">No previous player-hours: a percentage change has no baseline.</p>{/if}
{#if prior.partialHistory}<p class="fine">Retained scope history does not span the full previous window. Deltas use the available observations without extrapolation.</p>{/if}
<details class="comparison-detail"><summary>Compare observation windows</summary>
<dl><div><dt>Previous</dt><dd>{format.period(prior.since)} – {format.period(prior.until)}<small>{metricDuration(prior.summary.active)} with players / {metricDuration(prior.summary.observed)} known · {metricDuration(prior.summary.unknown)} unknown</small></dd></div>
<div><dt>Current</dt><dd>{format.period(report.since)} – {format.period(report.now)}<small>{metricDuration(report.summary.active)} with players / {metricDuration(report.summary.observed)} known · {metricDuration(report.summary.unknown)} unknown</small></dd></div></dl>
<p class="fine">pp means percentage points. Collection gaps, changing fleet size, and retained history can affect both comparisons. Equal windows do not guarantee equal observation coverage.</p>
</details>
</article>
</div>
<div class="suggestions">
<div class="suggestion-heading"><h3>Hosting suggestions</h3><span class="advisory">Advice only · Rules v1</span></div>
<p class="fine">Suggestions use historical observations. Check the current lineup before trying a change. These experiments do not estimate rejected joins or change servers automatically.</p>
{#if recommendations.length}
<div class="recommendations">{#each recommendations as suggestion (JSON.stringify([suggestion.game, suggestion.map]))}
<article class="recommendation">
<div class="map"><h4>{suggestion.map}</h4><small>{suggestion.game.toUpperCase()}</small>{#if suggestion.tentative}<span class="badge">Tentative</span>{/if}</div>
<div class="advice"><strong>{suggestion.title}</strong><p class="evidence">{suggestion.evidence}</p><details><summary>Why & what to watch</summary><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
</article>
{/each}</div>
{:else}<p class="empty">No map observations in this scope yet. Select another scope or leave collection running to generate suggestions.</p>{/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>
</div>
<details class="heatmap-disclosure"><summary>View activity by weekday and hour</summary>
<MetricsHeatmap heatmap={report.heatmap} disabled={changingScope} {onTimeZoneChange} />
</details>
</section>
<style>
.explore { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.heading { padding: var(--space-5) var(--space-5) var(--space-4); }
h2 { margin: 0; font-size: var(--text-lg); }
h3 { margin: 0; font-size: var(--text-md); }
p { margin: var(--space-2) 0 0; line-height: 1.6; font-size: var(--text-xs); color: var(--text-secondary); }
.heading p { font-size: var(--text-sm); }
.signals { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-4); padding: 0 var(--space-5) var(--space-5); }
.signal { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: var(--space-4); background: var(--surface-sunken); }
.value { display: block; margin-top: var(--space-3); font-size: var(--text-2xl); font-variant-numeric: tabular-nums; }
.evidence { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.fine { color: var(--text-tertiary); font-size: 11px; line-height: 1.6; }
.notice { padding-left: var(--space-2); border-left: 2px solid var(--state-warning); }
.comparison-heading { display: flex; gap: var(--space-2); align-items: flex-start; flex-wrap: wrap; }
.badge { display: inline-block; width: fit-content; color: var(--state-warning); font-size: 10px; border: 1px solid color-mix(in oklab, var(--state-warning) 40%, transparent); padding: 2px 6px; border-radius: 4px; white-space: nowrap; }
.deltas { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin-top: var(--space-3); }
.deltas span { color: var(--text-secondary); font-size: 11px; }
.deltas strong { display: block; margin-top: 4px; font-size: var(--text-xl); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); font-weight: normal; line-height: 1.6; }
details { font-size: var(--text-xs); }
summary { cursor: pointer; color: var(--text-secondary); line-height: 1.6; }
.comparison-detail { margin-top: var(--space-3); }
dl { margin: var(--space-3) 0 0; display: grid; gap: var(--space-3); font-size: 11px; }
dt { color: var(--text-tertiary); }
dd { margin: 2px 0 0; color: var(--text-secondary); }
.suggestions { padding: var(--space-5); border-top: 1px solid var(--border-subtle); }
.suggestion-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.advisory { color: var(--text-tertiary); font-size: 11px; }
.recommendations { margin-top: var(--space-3); }
.recommendation { display: grid; grid-template-columns: minmax(130px, .6fr) minmax(0, 2fr); gap: var(--space-4); padding: var(--space-4) 0; border-bottom: 1px solid var(--border-subtle); }
.map h4 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-medium); overflow-wrap: anywhere; }
.map .badge { margin-top: 5px; }
.advice strong { font-size: var(--text-sm); font-weight: var(--weight-medium); }
.advice p { margin-top: 5px; }
.advice details { margin-top: 6px; }
.rules { margin-top: var(--space-4); color: var(--text-secondary); }
.rules ul { padding-left: var(--space-5); line-height: 1.8; }
.empty { padding: var(--space-4) 0; }
.heatmap-disclosure { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); }
.heatmap-disclosure > summary { font-size: var(--text-sm); font-weight: var(--weight-medium); }
@media (max-width: 760px) { .signals { grid-template-columns: 1fr; } }
@media (max-width: 540px) { .heading, .suggestions { padding: var(--space-4); } .signals { padding: 0 var(--space-4) var(--space-4); gap: var(--space-3); } .recommendation { grid-template-columns: 1fr; gap: var(--space-2); } .map { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } .map small, .map .badge { margin: 0; } .heatmap-disclosure { padding: var(--space-4); } }
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import type { MetricsData } from '$lib/metrics.js';
import { availableTimeZones } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
type Heatmap = MetricsData['heatmap'];
type Cell = Heatmap['cells'][number];
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
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 dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
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 }; }
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 move(event: KeyboardEvent, cell: Cell) {
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 key = day * 24 + hour;
select(day, hour);
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
}
</script>
<div class="heatmap">
<div class="heading"><div><h3>Activity by weekday and hour</h3><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>
<!-- 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>
{#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>
{: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>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
</div>
<style>
.heatmap { min-width: 0; margin-top: var(--space-4); }
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
h3 { margin: 0; font-size: var(--text-md); }
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: 190px; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: 11px; 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 { 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: 10px; 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: 11px; }
.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: 11px; 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: 11px; 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) { .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>
@@ -92,14 +92,14 @@
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table>
<thead><tr>{#each serverColumns as column}<th scope="col" aria-sort={serverSort === column.key ? serverDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortServer(column.key)}>{column.label}<span aria-hidden="true">{serverSort === column.key ? serverDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
<tbody>{#each serverRows as server (server.server)}<tr>
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small></th>
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small>{#if server.currentRegistered}<a class="current-server" href={`/servers/${encodeURIComponent(server.server)}`}>Open current server <span aria-hidden="true">↗</span></a>{/if}</th>
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td>
<td>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
</tr>{/each}</tbody>
</table></div>
{:else}<EmptyState title="No servers match this search" description="Try a friendly map name or technical server ID.">{#snippet actions()}<Button size="sm" onclick={() => updateContext({ serverQ: '' })}>Clear server search</Button>{/snippet}</EmptyState>{/if}
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>A dash means no known time. Names may include more than one registration.</span></div>
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>Names may include multiple registrations. Links open the current registration, which may differ from historical instances.</span></div>
</section>
<style>
@@ -123,6 +123,9 @@
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); }
small { display: block; margin-top: 4px; font-size: 11px; font-weight: normal; line-height: 1.5; color: var(--text-tertiary); }
.server-id { font-family: var(--font-mono); font-size: 10px; }
.current-server { display: inline-block; margin-top: 6px; font-size: 11px; color: var(--accent-primary-hover); text-decoration: none; }
.current-server:hover { text-decoration: underline; }
.current-server:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.unavailable { color: var(--text-secondary); }
.player-hours { min-width: 120px; }
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
+25 -2
View File
@@ -1,4 +1,27 @@
import type { MetricsData } from '$lib/metrics.js';
import { validTimeZone } from '$lib/time-zone.js';
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
const numeric = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const timestamp = (value: unknown) => value === null || numeric(value);
const totals = (value: unknown) => record(value) && ['observed', 'active', 'idle', 'unknown', 'playerHours'].every(key => numeric(value[key]) && value[key] >= 0);
function validReport(value: unknown): value is MetricsData {
if (!record(value) || ![1, 7, 30].includes(value.days as number) || !numeric(value.now) || !numeric(value.since) || !totals(value.summary)) return false;
if (!record(value.scope) || !validTimeZone(value.scope.timeZone) || ![value.scope.game, value.scope.map].every(item => item === null || typeof item === 'string')) return false;
if (!record(value.scopeOptions) || !Array.isArray(value.scopeOptions.games) || !value.scopeOptions.games.every(item => typeof item === 'string') ||
!Array.isArray(value.scopeOptions.maps) || !value.scopeOptions.maps.every(item => record(item) && typeof item.game === 'string' && typeof item.map === 'string')) return false;
if (!numeric(value.registeredServerCount) || !['firstRetainedSampleAt', 'latestSampleAt', 'scopeFirstSampleAt', 'scopeLatestSampleAt'].every(key => timestamp(value[key]))) return false;
if (!Array.isArray(value.points) || !value.points.every(item => record(item) && totals(item) && numeric(item.at) && numeric(item.startAt) && numeric(item.endAt) && typeof item.partial === 'boolean')) return false;
if (!Array.isArray(value.maps) || !value.maps.every(item => record(item) && totals(item) && typeof item.game === 'string' && typeof item.map === 'string' && numeric(item.full) && numeric(item.capacityObserved))) return false;
if (!Array.isArray(value.servers) || !value.servers.every(item => record(item) && totals(item) && typeof item.server === 'string' && typeof item.currentRegistered === 'boolean')) return false;
if (!record(value.previous) || !totals(value.previous.summary) || !numeric(value.previous.since) || !numeric(value.previous.until) ||
!timestamp(value.previous.firstSampleAt) || !timestamp(value.previous.lastSampleAt) || typeof value.previous.partialHistory !== 'boolean') return false;
if (!record(value.heatmap) || !validTimeZone(value.heatmap.timeZone) || !Array.isArray(value.heatmap.cells) ||
!value.heatmap.cells.every(item => record(item) && totals(item) && numeric(item.weekday) && numeric(item.hour) && numeric(item.distinctDates))) return false;
return record(value.pooledPressure) && (value.pooledPressure.value === null || numeric(value.pooledPressure.value)) &&
['fullMapHours', 'knownMapHours', 'coveredMaps', 'totalMaps'].every(key => numeric((value.pooledPressure as Record<string, unknown>)[key]));
}
interface Options {
getQuery: () => string;
@@ -42,8 +65,8 @@ export function createMetricsRefresh(options: Options) {
try {
const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`);
const report = await response.json() as MetricsData;
if (!report || !Array.isArray(report.points) || !Array.isArray(report.maps) || !Array.isArray(report.servers) || !report.summary) {
const report: unknown = await response.json();
if (!validReport(report)) {
throw new Error('The metrics response was incomplete.');
}
if (current()) options.onData(report);
+2 -2
View File
@@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit';
import { parseMetricsDays } from '$lib/metrics.js';
import { parseMetricsQuery } from '$lib/metrics.js';
import { readMetrics } from '$lib/server/fleet/metrics.js';
export function GET({ url }: { url: URL }) {
return json(readMetrics(parseMetricsDays(url.searchParams.get('days'))), {
return json(readMetrics(parseMetricsQuery(url.searchParams)), {
headers: { 'cache-control': 'no-store' }
});
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { readMetrics } from '$lib/server/fleet/metrics.js';
import { parseMetricsDays } from '$lib/metrics.js';
import { parseMetricsQuery } from '$lib/metrics.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
return readMetrics(parseMetricsDays(url.searchParams.get('days')));
return readMetrics(parseMetricsQuery(url.searchParams));
};
+79 -15
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { page } from '$app/state';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { timeZone } from '$ui/stores/time-zone.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
@@ -10,7 +12,10 @@
import Stat from '$ui/components/primitives/Stat.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
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 type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types';
@@ -19,27 +24,73 @@
let snapshot = $state<MetricsData | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
let scopeError = $state('');
let scopeNavigating = $state(false);
let mounted = $state(false);
let attemptedPreference = $state<string | null>(null);
const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown);
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const gameOptions = $derived([{ value: '', label: 'All games' }, ...[...new Set([...report.scopeOptions.games, ...(report.scope.game ? [report.scope.game] : [])])].map(game => ({ value: game, label: gameName(game) }))]);
const mapOptions = $derived([
{ value: '', label: 'All maps' },
...report.scopeOptions.maps.filter(map => !report.scope.game || map.game === report.scope.game).map(map => ({ value: JSON.stringify([map.game, map.map]), label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}` })),
...(report.scope.map && !report.scopeOptions.maps.some(map => map.game === report.scope.game && map.map === report.scope.map) ? [{ value: JSON.stringify([report.scope.game, report.scope.map]), label: report.scope.map }] : [])
]);
const mapValue = $derived(report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : '');
const scopeLabel = $derived(report.scope.map ? `${report.scope.map} · ${gameName(report.scope.game!)}` : report.scope.game ? gameName(report.scope.game) : 'All games and maps');
const refresh = createMetricsRefresh({
getQuery: () => new URLSearchParams({ days: String(data.days) }).toString(),
getQuery: () => {
const params = new URLSearchParams({ days: String(data.days), timeZone: data.scope.timeZone });
if (data.scope.game) params.set('game', data.scope.game);
if (data.scope.map) params.set('map', data.scope.map);
return params.toString();
},
onData: value => { snapshot = value; },
onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; }
});
beforeNavigate(() => { refresh.cancel(); scopeNavigating = true; });
afterNavigate(() => { scopeNavigating = false; attemptedPreference = null; });
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
});
$effect(() => {
// Layout initializes the saved preference on mount; react when it arrives.
const preferredZone = $timeZone;
if (mounted && !scopeNavigating && !new URLSearchParams(contextSearch).has('timeZone') && preferredZone !== data.scope.timeZone && preferredZone !== attemptedPreference) {
untrack(() => { attemptedPreference = preferredZone; void changeScope({ timeZone: preferredZone }, true); });
}
});
onMount(() => {
const stop = pollVisible(() => refresh.refresh(), 60_000, false);
mounted = true;
const stop = pollVisible(() => scopeNavigating ? undefined : refresh.refresh(), 60_000, false);
return () => { stop(); refresh.cancel(); };
});
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
async function changeScope(changes: Record<string, string>, replace = false) {
const params = new URLSearchParams(contextSearch);
if (!params.has('timeZone')) params.set('timeZone', report.scope.timeZone);
for (const [key, value] of Object.entries(changes)) { if (value) params.set(key, value); else params.delete(key); }
refresh.cancel();
scopeError = '';
scopeNavigating = true;
try { await goto(`/metrics?${params}`, { keepFocus: true, noScroll: true, replaceState: replace }); }
catch { scopeError = 'Could not change the report scope. The previous report is still shown.'; }
finally { scopeNavigating = false; }
}
function changeMap(value: string) {
if (!value) return changeScope({ map: '' });
const [game, map] = JSON.parse(value) as [string, string];
return changeScope({ game, map });
}
function rangeHref(days: number): string {
const params = new URLSearchParams(contextSearch);
params.set('days', String(days));
params.set('timeZone', report.scope.timeZone);
return `/metrics?${params}`;
}
</script>
@@ -48,7 +99,7 @@
<div class="page">
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
</SectionHeader>
<div class="toolbar">
<nav aria-label="Metrics time range">
@@ -56,11 +107,17 @@
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
{/each}
</nav>
<span class="range">{$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}</span>
<span class="range">{format.period(report.since)} – {format.period(report.now)}</span>
</div>
<div class="scope-controls" aria-label="Filter the entire metrics report">
<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>
<p class="scope-note" role="status">{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}</p>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
<div class="refresh-error" role="alert">
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {$timeFormat.dateTime(report.now)}.</small></div>
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small></div>
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
</div>
{/if}
@@ -79,19 +136,21 @@
{/if}
<div class="context" aria-label="About this report">
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
<span>Last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : $timeFormat.dateTime(report.latestSampleAt)}</strong></span>
<span>Report refreshed {$timeFormat.time(report.now)} · refreshes every minute while visible</span>
{#if report.firstRetainedSampleAt !== null}<span>Earliest retained sample {$timeFormat.dateTime(report.firstRetainedSampleAt)}</span>{/if}
<span>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
<span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
<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>
{#if captured === 0}
<Card>
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this range'}
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this scope'}
description={report.latestSampleAt === null
? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.'
: report.servers.length > 0 ? 'The first sample has arrived. Time builds between status checks; this report has no elapsed observations yet.'
: 'There are no elapsed observations in the selected window. Collection gaps are excluded from these estimates.'}>
: report.servers.length > 0 ? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}>
{#snippet actions()}
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
{#if report.scope.game || report.scope.map}<Button size="sm" onclick={() => changeScope({ game: '', map: '' })}>View all games</Button>{/if}
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
{/snippet}
</EmptyState>
@@ -103,14 +162,15 @@
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
{/if}
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
<MetricsChart points={report.points} days={report.days} />
<MetricsTables maps={report.maps} servers={report.servers} />
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if}
<MetricsExplore {report} changingScope={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
{#if captured > 0}<MetricsTables maps={report.maps} servers={report.servers} />{/if}
<details class="methodology">
<summary>How these estimates work</summary>
<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 45 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>Chart buckets are aligned to UTC, with labels shown in {$timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial.</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>
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
</details>
</div>
@@ -118,6 +178,8 @@
<style>
.page { display: grid; gap: var(--space-5); min-width: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.scope-controls { display: grid; grid-template-columns: minmax(140px, 220px) minmax(180px, 300px) minmax(140px, 1fr); gap: var(--space-3); align-items: end; }
.scope-note { color: var(--text-secondary); font-size: var(--text-xs); margin: 0 0 8px; line-height: 1.5; }
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); }
@@ -136,6 +198,8 @@
.methodology summary { cursor: pointer; color: var(--text-secondary); }
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
@media (max-width: 540px) {
.scope-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.scope-note { grid-column: 1 / -1; margin: 0; }
.page { gap: var(--space-4); }
.stats { gap: var(--space-2); }
.stats :global(.card) { padding: var(--space-3); }