import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js'; export type MetricsDays = 1 | 7 | 30; export function parseMetricsDays(value: string | null | undefined): MetricsDays { const days = Number(value ?? 7); 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; active: number; idle: number; unknown: number; playerHours: number; } 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 { game: string; map: string; /** Map wall time, not summed server time. */ full: number; capacityObserved: number; } export interface MetricPoint extends Utilization { /** UTC bucket boundary, including when the selected interval starts later. */ at: number; startAt: number; endAt: number; 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; now: number; /** Retained sample timestamps describe collection, not gameplay freshness. */ 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; }