Add experimental metrics insights, comparisons, and scope filters
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user