This commit is contained in:
@@ -83,6 +83,40 @@ statistics do not establish lobby-hours, availability, or an autoscaling shortag
|
|||||||
Match statistics cover the selected period only and have no previous-period
|
Match statistics cover the selected period only and have no previous-period
|
||||||
comparison.
|
comparison.
|
||||||
|
|
||||||
|
## Players over time
|
||||||
|
|
||||||
|
The player chart uses the lobby chart's periods: hourly for 24 hours, and UTC-aligned
|
||||||
|
24-hour periods for 7 or 30 days. Labels use the report timezone. **Unique players**
|
||||||
|
counts distinct player GUIDs in every period overlapped by their recorded match
|
||||||
|
participation, from first to last sighting. Multiple matches in one period count
|
||||||
|
once, and the same GUID across Black Ops I and II is one player. Matches that began
|
||||||
|
before the range still contribute participation inside it. These are recorded
|
||||||
|
participants, not concurrent player counts or the lobby chart's player-hours.
|
||||||
|
|
||||||
|
**New players** counts a GUID at its earliest recorded sighting across both games.
|
||||||
|
Game and Map filters limit unique players to matching retained matches; new players
|
||||||
|
count only when their original sighting was in a matching match. A regular's first
|
||||||
|
visit to another map or game does not make them new again. Period boundaries belong
|
||||||
|
to the following period; a sighting exactly at the range's end counts in the final
|
||||||
|
period. Range totals count distinct GUIDs across the entire window independently:
|
||||||
|
daily unique counts cannot be added to get the range total.
|
||||||
|
|
||||||
|
Periods before the earliest retained match are gaps, not zero players. This history
|
||||||
|
boundary is fleet-wide, so filtering to a quiet map does not hide recorded zeroes.
|
||||||
|
The chart notes limited history and marks periods clipped by the selected range or
|
||||||
|
the retained-history boundary with hollow points. Totals cover available history
|
||||||
|
only. Select a point, use Left/Right or Home/End, or open **Inspect a period** for
|
||||||
|
the exact interval and counts. **View exact time data** provides the same values
|
||||||
|
in a keyboard-accessible table, with missing history shown as a dash.
|
||||||
|
|
||||||
|
History begins when leaderboard recording started; earlier play cannot be recovered.
|
||||||
|
Players already playing then count as new when first recorded. Participation spans
|
||||||
|
do not prove continuous presence between reports, and unrecorded matches are absent.
|
||||||
|
Match retention removes participation, which can shorten or leave holes in this
|
||||||
|
history. The durable first-seen player identities survive retention, so removing an
|
||||||
|
old match cannot make a returning player new again. The original map cannot be
|
||||||
|
recovered from an expired match. The chart remains available without occupancy samples.
|
||||||
|
|
||||||
## Timeline and tables
|
## Timeline and tables
|
||||||
|
|
||||||
The timeline defaults to **Average lobbies running**, split into **With players**,
|
The timeline defaults to **Average lobbies running**, split into **With players**,
|
||||||
@@ -217,6 +251,14 @@ retains the recorded number of starts. Match scope uses the exact saved `game` a
|
|||||||
labels, and its time window uses the saved `started_at` timestamp. Occupancy
|
labels, and its time window uses the saved `started_at` timestamp. Occupancy
|
||||||
collection and evidence timestamps remain independent of match history.
|
collection and evidence timestamps remain independent of match history.
|
||||||
|
|
||||||
|
`players` contains the separately deduplicated range totals `uniquePlayers` and
|
||||||
|
`newPlayers`, plus `historyStartAt` (the earliest retained match, in epoch
|
||||||
|
milliseconds). Every `points` entry also has `uniquePlayers`, `newPlayers`, and
|
||||||
|
`playerHistoryPartial`. Counts before retained history are `null`; known periods
|
||||||
|
with no scoped participation are zero. Player charts treat either `partial` or
|
||||||
|
`playerHistoryPartial` as partial. Participation timestamp changes and durable
|
||||||
|
first-seen identity changes invalidate the report cache.
|
||||||
|
|
||||||
Map rows expose `noEmpty`, `availabilityObserved`, and
|
Map rows expose `noEmpty`, `availabilityObserved`, and
|
||||||
`availabilityUnknown` durations, plus current `policy` context and
|
`availabilityUnknown` durations, plus current `policy` context and
|
||||||
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
|
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
|
||||||
|
|||||||
+15
-1
@@ -92,7 +92,18 @@ export interface RunningMetrics {
|
|||||||
capturedHours: number;
|
capturedHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetricPoint extends Utilization {
|
export interface PlayerCounts {
|
||||||
|
/** Distinct GUIDs, across games; null means this interval predates retained history. */
|
||||||
|
uniquePlayers: number | null;
|
||||||
|
newPlayers: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerMetrics extends PlayerCounts {
|
||||||
|
/** Global earliest retained match, independent of the selected game/map. */
|
||||||
|
historyStartAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricPoint extends Utilization, PlayerCounts {
|
||||||
/** UTC bucket boundary, including when the selected interval starts later. */
|
/** UTC bucket boundary, including when the selected interval starts later. */
|
||||||
at: number;
|
at: number;
|
||||||
startAt: number;
|
startAt: number;
|
||||||
@@ -100,6 +111,8 @@ export interface MetricPoint extends Utilization {
|
|||||||
partial: boolean;
|
partial: boolean;
|
||||||
/** Captured scope wall time, excluding collection gaps. */
|
/** Captured scope wall time, excluding collection gaps. */
|
||||||
capturedHours: number;
|
capturedHours: number;
|
||||||
|
/** Retained player history begins inside this period. */
|
||||||
|
playerHistoryPartial: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreviousMetrics {
|
export interface PreviousMetrics {
|
||||||
@@ -164,6 +177,7 @@ export interface MetricsData {
|
|||||||
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
|
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
|
||||||
sampledServerCount: number;
|
sampledServerCount: number;
|
||||||
matches: MatchMetrics;
|
matches: MatchMetrics;
|
||||||
|
players: PlayerMetrics;
|
||||||
autoscaling: AutoscalingMetrics;
|
autoscaling: AutoscalingMetrics;
|
||||||
maps: MapMetrics[];
|
maps: MapMetrics[];
|
||||||
points: MetricPoint[];
|
points: MetricPoint[];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { migrateFileBlobs } from './file-blobs.js';
|
|||||||
import { installPerformanceIndexes } from './performance-indexes.js';
|
import { installPerformanceIndexes } from './performance-indexes.js';
|
||||||
import {
|
import {
|
||||||
installMetricsMatchRevisions,
|
installMetricsMatchRevisions,
|
||||||
|
installMetricsPlayerRevisions,
|
||||||
installMetricsPolicyRevisions,
|
installMetricsPolicyRevisions,
|
||||||
installMetricsProvisioningRevisions,
|
installMetricsProvisioningRevisions,
|
||||||
installReportRevisions
|
installReportRevisions
|
||||||
@@ -85,6 +86,11 @@ export const migrations: readonly Migration[] = [
|
|||||||
version: 15,
|
version: 15,
|
||||||
name: 'metrics provisioning report revisions',
|
name: 'metrics provisioning report revisions',
|
||||||
run: installMetricsProvisioningRevisions
|
run: installMetricsProvisioningRevisions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 16,
|
||||||
|
name: 'metrics player history and report revisions',
|
||||||
|
run: installMetricsPlayerRevisions
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import type { MetricPoint, MetricsScope, PlayerMetrics } from '../../metrics.js';
|
||||||
|
|
||||||
|
/** Populate the shared periods from retained participation, independently of occupancy samples. */
|
||||||
|
export function readPlayerMetrics(
|
||||||
|
scope: MetricsScope,
|
||||||
|
since: number,
|
||||||
|
now: number,
|
||||||
|
points: MetricPoint[]
|
||||||
|
): PlayerMetrics {
|
||||||
|
const handle = db();
|
||||||
|
const nowIso = new Date(now).toISOString();
|
||||||
|
const sinceIso = new Date(since).toISOString();
|
||||||
|
const { first } = handle
|
||||||
|
.prepare('SELECT MIN(started_at) AS first FROM leaderboard_matches WHERE started_at <= ?')
|
||||||
|
.get(nowIso) as { first: string | null };
|
||||||
|
const historyStartAt = first === null ? null : Date.parse(first);
|
||||||
|
const periods = points.map((point) => {
|
||||||
|
const available =
|
||||||
|
historyStartAt !== null &&
|
||||||
|
(historyStartAt < point.endAt || (historyStartAt === now && point.endAt === now));
|
||||||
|
point.uniquePlayers = available ? 0 : null;
|
||||||
|
point.newPlayers = available ? 0 : null;
|
||||||
|
point.playerHistoryPartial = available && historyStartAt! > point.startAt;
|
||||||
|
return { unique: new Set<string>(), fresh: new Set<string>() };
|
||||||
|
});
|
||||||
|
if (historyStartAt === null) return { historyStartAt, uniquePlayers: null, newPlayers: null };
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
'm.last_received_at >= ?',
|
||||||
|
'm.started_at <= ?',
|
||||||
|
'p.last_seen_at >= ?',
|
||||||
|
'p.first_seen_at <= ?'
|
||||||
|
];
|
||||||
|
const params = [sinceIso, nowIso, sinceIso, nowIso];
|
||||||
|
if (scope.game !== null) {
|
||||||
|
conditions.push('m.game = ?');
|
||||||
|
params.push(scope.game);
|
||||||
|
}
|
||||||
|
if (scope.map !== null) {
|
||||||
|
conditions.push('m.map_name = ?');
|
||||||
|
params.push(scope.map);
|
||||||
|
}
|
||||||
|
// The match clock and participation PK bound this scan to recent matches.
|
||||||
|
// The GUID/first-seen index looks up the durable cross-game identity without
|
||||||
|
// reclassifying a returning player after their older matches expire.
|
||||||
|
const rows = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.guid, p.first_seen_at AS start, p.last_seen_at AS end,
|
||||||
|
(SELECT MIN(first_seen_at) FROM leaderboard_players WHERE guid=p.guid) AS firstSeen
|
||||||
|
FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id
|
||||||
|
WHERE ${conditions.join(' AND ')}`
|
||||||
|
)
|
||||||
|
.iterate(...params) as Iterable<{
|
||||||
|
guid: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
firstSeen: string | null;
|
||||||
|
}>;
|
||||||
|
const unique = new Set<string>();
|
||||||
|
const fresh = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const start = Date.parse(row.start);
|
||||||
|
const end = Date.parse(row.end);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
|
||||||
|
unique.add(row.guid);
|
||||||
|
// Scope the original sighting, never the first later visit to this game/map.
|
||||||
|
const isNew =
|
||||||
|
row.firstSeen !== null && Date.parse(row.firstSeen) === start && start >= since;
|
||||||
|
if (isNew) fresh.add(row.guid);
|
||||||
|
for (let index = 0; index < points.length; index++) {
|
||||||
|
const point = points[index];
|
||||||
|
// Internal boundaries belong to the next period. Include a sighting
|
||||||
|
// exactly at the report's inclusive right edge in its final period.
|
||||||
|
const startsBeforeEnd = start < point.endAt || (start === now && point.endAt === now);
|
||||||
|
if (point.uniquePlayers === null || end < point.startAt || !startsBeforeEnd) continue;
|
||||||
|
periods[index].unique.add(row.guid);
|
||||||
|
if (isNew && start >= point.startAt) periods[index].fresh.add(row.guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let index = 0; index < points.length; index++) {
|
||||||
|
if (points[index].uniquePlayers === null) continue;
|
||||||
|
points[index].uniquePlayers = periods[index].unique.size;
|
||||||
|
points[index].newPlayers = periods[index].fresh.size;
|
||||||
|
}
|
||||||
|
return { historyStartAt, uniquePlayers: unique.size, newPlayers: fresh.size };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fleetSettings } from '../settings.js';
|
import { fleetSettings } from '../settings.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
|
import { readPlayerMetrics } from './metrics-players.js';
|
||||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||||
import { zombieMaps } from '../../map-aliases.js';
|
import { zombieMaps } from '../../map-aliases.js';
|
||||||
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
|
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
|
||||||
@@ -314,9 +315,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
|||||||
endAt: end,
|
endAt: end,
|
||||||
partial: start !== at || end !== at + bucketMs,
|
partial: start !== at || end !== at + bucketMs,
|
||||||
capturedHours: 0,
|
capturedHours: 0,
|
||||||
|
uniquePlayers: null,
|
||||||
|
newPlayers: null,
|
||||||
|
playerHistoryPartial: false,
|
||||||
...emptyUtilization()
|
...emptyUtilization()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const players = readPlayerMetrics(scope, since, now, points);
|
||||||
|
|
||||||
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
|
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
|
||||||
weekday: Math.floor(index / 24),
|
weekday: Math.floor(index / 24),
|
||||||
@@ -517,6 +522,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
|||||||
summary,
|
summary,
|
||||||
running,
|
running,
|
||||||
matches: matchMetrics(totalMatches),
|
matches: matchMetrics(totalMatches),
|
||||||
|
players,
|
||||||
autoscaling: autoscaling.summary,
|
autoscaling: autoscaling.summary,
|
||||||
sampledServerCount: sampledServers.size,
|
sampledServerCount: sampledServers.size,
|
||||||
maps,
|
maps,
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/** Player trends also change when a participation interval extends into another period. */
|
||||||
|
export function installMetricsPlayerRevisions(handle: Database.Database) {
|
||||||
|
handle.exec(`CREATE INDEX IF NOT EXISTS idx_leaderboard_players_guid_first_seen
|
||||||
|
ON leaderboard_players(guid,first_seen_at);
|
||||||
|
DROP TRIGGER IF EXISTS report_metrics_leaderboard_match_players_UPDATE;
|
||||||
|
CREATE TRIGGER report_metrics_leaderboard_match_players_UPDATE
|
||||||
|
AFTER UPDATE ON leaderboard_match_players
|
||||||
|
WHEN OLD.match_id IS NOT NEW.match_id OR OLD.guid IS NOT NEW.guid
|
||||||
|
OR OLD.first_seen_at IS NOT NEW.first_seen_at OR OLD.last_seen_at IS NOT NEW.last_seen_at
|
||||||
|
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
|
||||||
|
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
|
||||||
|
const condition =
|
||||||
|
action === 'UPDATE'
|
||||||
|
? 'WHEN OLD.guid IS NOT NEW.guid OR OLD.first_seen_at IS NOT NEW.first_seen_at'
|
||||||
|
: '';
|
||||||
|
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_leaderboard_players_${action}
|
||||||
|
AFTER ${action} ON leaderboard_players ${condition}
|
||||||
|
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Provisioning outcomes remain report inputs even after the policy is removed. */
|
/** Provisioning outcomes remain report inputs even after the policy is removed. */
|
||||||
export function installMetricsProvisioningRevisions(handle: Database.Database) {
|
export function installMetricsProvisioningRevisions(handle: Database.Database) {
|
||||||
for (const table of ['server_deployments', 'provisioning_sources'])
|
for (const table of ['server_deployments', 'provisioning_sources'])
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { MetricPoint, MetricsDays, PlayerMetrics } from '$lib/metrics.js';
|
||||||
|
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||||
|
import {
|
||||||
|
metricLobbyAxis,
|
||||||
|
metricNumber,
|
||||||
|
metricPeriodLabels,
|
||||||
|
metricPlayerPaths
|
||||||
|
} 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,
|
||||||
|
players,
|
||||||
|
days,
|
||||||
|
since,
|
||||||
|
timeZone
|
||||||
|
}: {
|
||||||
|
points: MetricPoint[];
|
||||||
|
players: PlayerMetrics;
|
||||||
|
days: MetricsDays;
|
||||||
|
since: number;
|
||||||
|
timeZone: string;
|
||||||
|
} = $props();
|
||||||
|
let selectedAt = $state<number | null>(null);
|
||||||
|
let announcement = $state('');
|
||||||
|
const format = $derived(createTimeFormatter(timeZone));
|
||||||
|
const selectedIndex = $derived.by(() => {
|
||||||
|
const existing = points.findIndex((point) => point.at === selectedAt);
|
||||||
|
if (existing >= 0) return existing;
|
||||||
|
for (let index = points.length - 1; index >= 0; index--)
|
||||||
|
if (!partial(points[index]) && points[index].uniquePlayers !== null) return index;
|
||||||
|
return Math.max(0, points.length - 1);
|
||||||
|
});
|
||||||
|
const selected = $derived(points[selectedIndex]);
|
||||||
|
const axis = $derived(
|
||||||
|
metricLobbyAxis(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
...points.flatMap((point) => [point.uniquePlayers ?? 0, point.newPlayers ?? 0])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const uniquePath = $derived(
|
||||||
|
metricPlayerPaths(
|
||||||
|
points.map((point) => point.uniquePlayers),
|
||||||
|
axis.ceiling
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const newPath = $derived(
|
||||||
|
metricPlayerPaths(
|
||||||
|
points.map((point) => point.newPlayers),
|
||||||
|
axis.ceiling
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
|
||||||
|
const periodOptions = $derived(
|
||||||
|
points.map((point) => ({
|
||||||
|
value: String(point.at),
|
||||||
|
label: `${format.period(point.startAt)}${partial(point) ? ' · partial' : ''}${point.uniquePlayers === null ? ' · no retained history' : ''}`
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const limitedHistory = $derived(
|
||||||
|
players.historyStartAt !== null && players.historyStartAt > since
|
||||||
|
);
|
||||||
|
|
||||||
|
function partial(point: MetricPoint) {
|
||||||
|
return point.partial || point.playerHistoryPartial;
|
||||||
|
}
|
||||||
|
function count(value: number | null) {
|
||||||
|
return value === null ? '—' : metricNumber(value);
|
||||||
|
}
|
||||||
|
function summary(point: MetricPoint) {
|
||||||
|
return point.uniquePlayers === null
|
||||||
|
? 'No retained player history'
|
||||||
|
: `${count(point.uniquePlayers)} unique players · ${count(point.newPlayers)} new`;
|
||||||
|
}
|
||||||
|
function selectPeriod(at: number) {
|
||||||
|
selectedAt = at;
|
||||||
|
const point = points.find((item) => item.at === at);
|
||||||
|
if (point)
|
||||||
|
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${partial(point) ? ', partial period' : ''}. ${summary(point)}`;
|
||||||
|
}
|
||||||
|
function move(direction: number) {
|
||||||
|
const point = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||||
|
if (point) selectPeriod(point.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))
|
||||||
|
);
|
||||||
|
selectPeriod(points[next].at);
|
||||||
|
(event.currentTarget as HTMLElement).parentElement
|
||||||
|
?.querySelectorAll<HTMLButtonElement>('button')
|
||||||
|
[next]?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="metrics-players-title">
|
||||||
|
<div class="heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="metrics-players-title">Players over time</h2>
|
||||||
|
<p>
|
||||||
|
Distinct players seen in each {days === 1 ? 'hour' : '24-hour period'}, and players
|
||||||
|
seen for the first time. A returning player counts once per period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="totals">
|
||||||
|
<dl aria-label="Players across the selected range">
|
||||||
|
<div>
|
||||||
|
<dt>Unique players</dt>
|
||||||
|
<dd>{count(players.uniquePlayers)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>New players</dt>
|
||||||
|
<dd>{count(players.newPlayers)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p>
|
||||||
|
Last {days === 1 ? '24 hours' : `${days} days`}{limitedHistory
|
||||||
|
? ' · Partial history'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="unique-key"></i>Unique players</span>
|
||||||
|
<span><i class="new-key"></i>New players</span>
|
||||||
|
<span><i class="partial-key"></i>Partial period</span>
|
||||||
|
</div>
|
||||||
|
{#if players.historyStartAt === null}
|
||||||
|
<p class="history-note">
|
||||||
|
No retained player history yet. Counts will appear after match participation is
|
||||||
|
recorded.
|
||||||
|
</p>
|
||||||
|
{:else if limitedHistory}
|
||||||
|
<p class="history-note">
|
||||||
|
Retained player history starts {format.period(players.historyStartAt)}. Earlier periods
|
||||||
|
are gaps; range totals cover available history only.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="axis-title">Players</div>
|
||||||
|
<div class="chart-grid">
|
||||||
|
<div class="y-axis" aria-hidden="true">
|
||||||
|
{#each axis.ticks as value}<span style:top={`${(1 - value / axis.ceiling) * 100}%`}
|
||||||
|
>{metricNumber(value)}</span
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="plot"
|
||||||
|
role="group"
|
||||||
|
aria-label="Players by period. Select a point or use the period selector below."
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
{#each axis.ticks as value}<line
|
||||||
|
x1="0"
|
||||||
|
x2="100"
|
||||||
|
y1={100 - (value / axis.ceiling) * 100}
|
||||||
|
y2={100 - (value / axis.ceiling) * 100}
|
||||||
|
class="grid-line"
|
||||||
|
/>{/each}
|
||||||
|
<path d={uniquePath.area} class="area" />
|
||||||
|
<path d={uniquePath.line} class="unique-line" />
|
||||||
|
<path d={newPath.line} class="new-line" />
|
||||||
|
</svg>
|
||||||
|
{#each points as point, index (point.at)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="period"
|
||||||
|
class:selected={point.at === selected?.at}
|
||||||
|
class:partial={partial(point)}
|
||||||
|
tabindex={index === selectedIndex ? 0 : -1}
|
||||||
|
aria-pressed={point.at === selected?.at}
|
||||||
|
aria-label={`${format.period(point.startAt)}${partial(point) ? ', partial period' : ''}: ${summary(point)}`}
|
||||||
|
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}: ${summary(point)}`}
|
||||||
|
onclick={() => selectPeriod(point.at)}
|
||||||
|
onkeydown={(event) => chartKey(event, index)}
|
||||||
|
>
|
||||||
|
{#if point.uniquePlayers !== null}<span
|
||||||
|
class="dot unique-dot"
|
||||||
|
style:top={`${100 - (point.uniquePlayers / axis.ceiling) * 100}%`}
|
||||||
|
></span>{/if}
|
||||||
|
{#if point.newPlayers !== null}<span
|
||||||
|
class="dot new-dot"
|
||||||
|
style:top={`${100 - (point.newPlayers / axis.ceiling) * 100}%`}
|
||||||
|
></span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
|
||||||
|
{#each periodLabels as item}<span
|
||||||
|
style:left={`${item.position}%`}
|
||||||
|
title={format.period(points[item.index].startAt)}
|
||||||
|
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
|
||||||
|
>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="period-note">
|
||||||
|
{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll(
|
||||||
|
'_',
|
||||||
|
' '
|
||||||
|
)}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'} Partial periods contain less history.
|
||||||
|
</p>
|
||||||
|
{#if selected}
|
||||||
|
<div class="selection-summary" aria-label="Selected player period summary">
|
||||||
|
<time
|
||||||
|
>{format.period(selected.startAt)}{partial(selected)
|
||||||
|
? ' · Partial period'
|
||||||
|
: ''}</time
|
||||||
|
><span>{summary(selected)}</span>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>Inspect a period</summary>
|
||||||
|
<div class="period-controls">
|
||||||
|
<div class="period-select">
|
||||||
|
<Field label={`Inspect a player period · ${timeZone.replaceAll('_', ' ')}`}
|
||||||
|
><Select
|
||||||
|
value={String(selected.at)}
|
||||||
|
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>
|
||||||
|
<p class="readout" aria-label="Selected player period details">
|
||||||
|
{format.period(selected.startAt)} – {format.period(selected.endAt)}{partial(
|
||||||
|
selected
|
||||||
|
)
|
||||||
|
? ' · Partial period'
|
||||||
|
: ''}<br />{summary(selected)}{selected.playerHistoryPartial
|
||||||
|
? ' · Retained history begins during this period.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||||
|
<details>
|
||||||
|
<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="Player time data" tabindex="0">
|
||||||
|
<table>
|
||||||
|
<caption
|
||||||
|
>Players by period in {timeZone.replaceAll('_', ' ')}. A dash means no retained
|
||||||
|
history.</caption
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
><tr
|
||||||
|
><th scope="col">Period</th><th scope="col">Unique players</th><th
|
||||||
|
scope="col">New players</th
|
||||||
|
></tr
|
||||||
|
></thead
|
||||||
|
>
|
||||||
|
<tbody
|
||||||
|
>{#each points as point (point.at)}<tr
|
||||||
|
><th scope="row"
|
||||||
|
>{format.period(point.startAt)}<small
|
||||||
|
>to {format.period(point.endAt)}{partial(point)
|
||||||
|
? ' · Partial'
|
||||||
|
: ''}{point.uniquePlayers === null
|
||||||
|
? ' · No retained history'
|
||||||
|
: point.playerHistoryPartial
|
||||||
|
? ' · History begins'
|
||||||
|
: ''}</small
|
||||||
|
></th
|
||||||
|
><td>{count(point.uniquePlayers)}</td><td>{count(point.newPlayers)}</td
|
||||||
|
></tr
|
||||||
|
>{/each}</tbody
|
||||||
|
>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<p class="method-note">
|
||||||
|
Range totals count each player once, so period counts do not add up to the unique total.
|
||||||
|
“New” means first recorded across both games; players already playing when recording began
|
||||||
|
also count as new. Missing or expired matches cannot be reconstructed.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.heading > div:first-child {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading p {
|
||||||
|
margin: var(--space-2) 0 var(--space-4);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.totals dl {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.totals p {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.legend span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.legend i {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
.unique-key {
|
||||||
|
border-top: 2px solid var(--chart-active);
|
||||||
|
}
|
||||||
|
.new-key {
|
||||||
|
border-top: 2px dashed var(--text-secondary);
|
||||||
|
}
|
||||||
|
.legend .partial-key {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border: 1px solid var(--text-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.axis-title {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.chart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
column-gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.y-axis {
|
||||||
|
position: relative;
|
||||||
|
height: 210px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.y-axis span {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.plot {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 210px;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
svg path,
|
||||||
|
svg line {
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.grid-line {
|
||||||
|
stroke: var(--border-subtle);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.area {
|
||||||
|
fill: var(--chart-active);
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
.unique-line,
|
||||||
|
.new-line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.unique-line {
|
||||||
|
stroke: var(--chart-active);
|
||||||
|
}
|
||||||
|
.new-line {
|
||||||
|
stroke: var(--text-secondary);
|
||||||
|
stroke-dasharray: 5 4;
|
||||||
|
}
|
||||||
|
.period {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.period.selected {
|
||||||
|
background: color-mix(in srgb, var(--text-secondary) 6%, transparent);
|
||||||
|
}
|
||||||
|
.period:hover {
|
||||||
|
background: color-mix(in srgb, var(--chart-active) 10%, transparent);
|
||||||
|
}
|
||||||
|
.period:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-primary-hover);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.unique-dot {
|
||||||
|
background: var(--chart-active);
|
||||||
|
border-color: var(--chart-active);
|
||||||
|
}
|
||||||
|
.new-dot {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.partial .dot {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.x-axis {
|
||||||
|
grid-column: 2;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
height: 1.6em;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.x-axis.with-dates {
|
||||||
|
height: 3em;
|
||||||
|
}
|
||||||
|
.x-axis > span {
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.period-note,
|
||||||
|
.method-note,
|
||||||
|
.history-note {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
}
|
||||||
|
.history-note {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.selection-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
details {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.period-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
.period-select {
|
||||||
|
flex: 1 1 250px;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
.step-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.readout {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.table-wrap:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
caption {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
th:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
tbody th {
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
}
|
||||||
|
.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) {
|
||||||
|
.panel {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.chart-grid {
|
||||||
|
grid-template-columns: 32px minmax(0, 1fr);
|
||||||
|
column-gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,6 +32,11 @@ const autoscaling = (value: unknown) =>
|
|||||||
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
|
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
|
||||||
(key) => value[key] === null || nonnegative(value[key])
|
(key) => value[key] === null || nonnegative(value[key])
|
||||||
);
|
);
|
||||||
|
const players = (value: unknown) =>
|
||||||
|
record(value) &&
|
||||||
|
['uniquePlayers', 'newPlayers'].every(
|
||||||
|
(key) => value[key] === null || (nonnegative(value[key]) && Number.isInteger(value[key]))
|
||||||
|
);
|
||||||
const policy = (value: unknown) =>
|
const policy = (value: unknown) =>
|
||||||
value === null ||
|
value === null ||
|
||||||
(record(value) &&
|
(record(value) &&
|
||||||
@@ -51,6 +56,9 @@ function validReport(value: unknown): value is MetricsData {
|
|||||||
!totals(value.summary) ||
|
!totals(value.summary) ||
|
||||||
!running(value.running) ||
|
!running(value.running) ||
|
||||||
!matches(value.matches) ||
|
!matches(value.matches) ||
|
||||||
|
!record(value.players) ||
|
||||||
|
!players(value.players) ||
|
||||||
|
!timestamp(value.players.historyStartAt) ||
|
||||||
!autoscaling(value.autoscaling)
|
!autoscaling(value.autoscaling)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
@@ -95,6 +103,8 @@ function validReport(value: unknown): value is MetricsData {
|
|||||||
numeric(item.startAt) &&
|
numeric(item.startAt) &&
|
||||||
numeric(item.endAt) &&
|
numeric(item.endAt) &&
|
||||||
nonnegative(item.capturedHours) &&
|
nonnegative(item.capturedHours) &&
|
||||||
|
players(item) &&
|
||||||
|
typeof item.playerHistoryPartial === 'boolean' &&
|
||||||
typeof item.partial === 'boolean'
|
typeof item.partial === 'boolean'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
/** Separate SVG paths at unavailable periods, including isolated recorded points. */
|
||||||
|
export function metricPlayerPaths(values: readonly (number | null)[], ceiling: number) {
|
||||||
|
const segments: { x: number; y: number }[][] = [];
|
||||||
|
let segment: { x: number; y: number }[] = [];
|
||||||
|
for (let index = 0; index < values.length; index++) {
|
||||||
|
const value = values[index];
|
||||||
|
if (value === null) {
|
||||||
|
segment = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!segment.length) segments.push(segment);
|
||||||
|
segment.push({
|
||||||
|
x: ((index + 0.5) / values.length) * 100,
|
||||||
|
y: 100 - (value / ceiling) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const line = (points: typeof segment) =>
|
||||||
|
points.map(({ x, y }, index) => `${index ? 'L' : 'M'}${x},${y}`).join(' ');
|
||||||
|
return {
|
||||||
|
line: segments.map(line).join(' '),
|
||||||
|
area: segments
|
||||||
|
.map((points) => `${line(points)} L${points.at(-1)!.x},100 L${points[0].x},100 Z`)
|
||||||
|
.join(' ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Label the actual bucket start in the chosen timezone; never imply local calendar-day buckets. */
|
/** Label the actual bucket start in the chosen timezone; never imply local calendar-day buckets. */
|
||||||
export function metricPeriodLabels(
|
export function metricPeriodLabels(
|
||||||
points: readonly { startAt: number }[],
|
points: readonly { startAt: number }[],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
|
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
|
||||||
|
import MetricsPlayers from '$ui/components/metrics/MetricsPlayers.svelte';
|
||||||
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
|
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
|
||||||
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
|
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
|
||||||
import Freshness from '$ui/components/Freshness.svelte';
|
import Freshness from '$ui/components/Freshness.svelte';
|
||||||
@@ -304,6 +305,13 @@
|
|||||||
</p>{/if}
|
</p>{/if}
|
||||||
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
|
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
|
||||||
{/if}
|
{/if}
|
||||||
|
<MetricsPlayers
|
||||||
|
points={report.points}
|
||||||
|
players={report.players}
|
||||||
|
days={report.days}
|
||||||
|
since={report.since}
|
||||||
|
timeZone={report.scope.timeZone}
|
||||||
|
/>
|
||||||
{#if captured > 0}
|
{#if captured > 0}
|
||||||
<MetricsHeatmap
|
<MetricsHeatmap
|
||||||
heatmap={report.heatmap}
|
heatmap={report.heatmap}
|
||||||
|
|||||||
@@ -206,6 +206,54 @@ it('adds match report invalidation on upgrade without invalidating for score hea
|
|||||||
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
|
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('upgrades player report invalidation from version 15 for participation clocks and durable identities', async () => {
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { installMetricsMatchRevisions } =
|
||||||
|
await import('../../src/lib/server/reports/revisions.js');
|
||||||
|
const handle = db();
|
||||||
|
handle.exec(
|
||||||
|
'DROP TRIGGER report_metrics_leaderboard_match_players_UPDATE; DROP INDEX idx_leaderboard_players_guid_first_seen'
|
||||||
|
);
|
||||||
|
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
|
||||||
|
handle.exec(`DROP TRIGGER report_metrics_leaderboard_players_${action}`);
|
||||||
|
installMetricsMatchRevisions(handle);
|
||||||
|
handle.pragma('user_version = 15');
|
||||||
|
closeDb();
|
||||||
|
const upgraded = db();
|
||||||
|
const revision = () =>
|
||||||
|
(
|
||||||
|
upgraded
|
||||||
|
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
|
||||||
|
.get() as { revision: number }
|
||||||
|
).revision;
|
||||||
|
upgraded.exec(`INSERT INTO leaderboard_matches
|
||||||
|
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||||
|
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','test');
|
||||||
|
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
|
||||||
|
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
|
||||||
|
let before = revision();
|
||||||
|
upgraded.exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T13:00:00.000Z'");
|
||||||
|
expect(revision()).toBeGreaterThan(before);
|
||||||
|
before = revision();
|
||||||
|
upgraded.exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||||
|
VALUES('t5','player','Player','2026-10-10T12:00:00.000Z','2026-10-10T13:00:00.000Z')`);
|
||||||
|
expect(revision()).toBeGreaterThan(before);
|
||||||
|
before = revision();
|
||||||
|
upgraded.exec(
|
||||||
|
"UPDATE leaderboard_players SET name='Renamed',last_seen_at='2026-10-10T14:00:00.000Z'"
|
||||||
|
);
|
||||||
|
expect(revision()).toBe(before);
|
||||||
|
upgraded.exec("UPDATE leaderboard_players SET first_seen_at='2026-10-09T12:00:00.000Z'");
|
||||||
|
expect(revision()).toBeGreaterThan(before);
|
||||||
|
expect(
|
||||||
|
upgraded
|
||||||
|
.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE name='idx_leaderboard_players_guid_first_seen'"
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('adds provisioning report invalidation at version 14 without invalidating for progress updates', async () => {
|
it('adds provisioning report invalidation at version 14 without invalidating for progress updates', async () => {
|
||||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
const handle = db();
|
const handle = db();
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import type { MetricsDays } from '../../src/lib/metrics.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const hour = 3600_000;
|
||||||
|
const day = 24 * hour;
|
||||||
|
const now = Date.UTC(2026, 9, 10, 12, 30);
|
||||||
|
const iso = (at: number) => new Date(at).toISOString();
|
||||||
|
|
||||||
|
async function match(start: number, end: number, guids: string[], game = 't5', map = 'Moon') {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
const { lastInsertRowid: id } = handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_matches
|
||||||
|
(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
|
||||||
|
VALUES ('removed-server',?,?,?, ?,?,?,'completed','game_end','test')`
|
||||||
|
)
|
||||||
|
.run(game, map, `${game}:${map}`, map, iso(start), iso(end));
|
||||||
|
for (const guid of guids) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_match_players
|
||||||
|
(match_id,guid,first_seen_at,last_seen_at) VALUES(?,?,?,?)`
|
||||||
|
)
|
||||||
|
.run(id, guid, iso(start), iso(end));
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||||
|
VALUES(?,?,?,?,?) ON CONFLICT(game,guid) DO UPDATE SET last_seen_at=excluded.last_seen_at`
|
||||||
|
)
|
||||||
|
.run(game, guid, guid, iso(start), iso(end));
|
||||||
|
}
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('metrics player history', () => {
|
||||||
|
it.each<MetricsDays>([1, 7, 30])(
|
||||||
|
'counts spans and repeated matches once per period and range for %i days',
|
||||||
|
async (days) => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
const bucket = days === 1 ? hour : day;
|
||||||
|
const boundary = Math.floor((now - bucket) / bucket) * bucket;
|
||||||
|
await match(now - 40 * day, now - 40 * day, ['regular']);
|
||||||
|
await match(boundary - 10_000, boundary + 10_000, ['regular', 'newcomer']);
|
||||||
|
await match(boundary + 20_000, boundary + 30_000, ['regular', 'newcomer']);
|
||||||
|
const result = readMetrics(days, now);
|
||||||
|
const before = result.points.find((point) => point.at === boundary - bucket)!;
|
||||||
|
const after = result.points.find((point) => point.at === boundary)!;
|
||||||
|
expect(before).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
|
||||||
|
expect(after).toMatchObject({ uniquePlayers: 2, newPlayers: 0 });
|
||||||
|
expect(result.players).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
|
||||||
|
expect(result.points.reduce((sum, point) => sum + (point.uniquePlayers ?? 0), 0)).toBe(
|
||||||
|
4
|
||||||
|
);
|
||||||
|
expect(result.summary.observed).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('uses participation rather than match start and includes both range endpoints', async () => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
const since = now - day;
|
||||||
|
await match(since - 2 * hour, since - 1, ['ended-before']);
|
||||||
|
await match(since - hour, since + hour, ['spanning-start']);
|
||||||
|
await match(since, since, ['at-start']);
|
||||||
|
await match(now - hour, now + hour, ['spanning-end']);
|
||||||
|
await match(now, now, ['at-end']);
|
||||||
|
await match(now + 1, now + 1, ['future']);
|
||||||
|
const result = readMetrics(1, now);
|
||||||
|
expect(result.players).toMatchObject({ uniquePlayers: 4, newPlayers: 3 });
|
||||||
|
expect(result.points[0]).toMatchObject({ uniquePlayers: 2, newPlayers: 1, partial: true });
|
||||||
|
expect(result.points.at(-1)).toMatchObject({
|
||||||
|
uniquePlayers: 2,
|
||||||
|
newPlayers: 1,
|
||||||
|
partial: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a single sighting on an internal boundary only to the following period', async () => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
await match(now - 2 * day, now - 2 * day, []);
|
||||||
|
const boundary = Date.UTC(2026, 9, 10, 11);
|
||||||
|
await match(boundary, boundary, ['boundary']);
|
||||||
|
const result = readMetrics(1, now);
|
||||||
|
expect(result.points.find((point) => point.at === boundary - hour)).toMatchObject({
|
||||||
|
uniquePlayers: 0,
|
||||||
|
newPlayers: 0
|
||||||
|
});
|
||||||
|
expect(result.points.find((point) => point.at === boundary)).toMatchObject({
|
||||||
|
uniquePlayers: 1,
|
||||||
|
newPlayers: 1
|
||||||
|
});
|
||||||
|
expect(result.points.find((point) => point.at === boundary + hour)).toMatchObject({
|
||||||
|
uniquePlayers: 0,
|
||||||
|
newPlayers: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates identity across games and scopes new players to their original match', async () => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
await match(now - 2 * day, now - 2 * day, ['regular']);
|
||||||
|
await match(
|
||||||
|
now - 4 * hour,
|
||||||
|
now - 4 * hour,
|
||||||
|
['both-games', 'regular'],
|
||||||
|
't6',
|
||||||
|
'Town Survival'
|
||||||
|
);
|
||||||
|
await match(now - 3 * hour, now - 3 * hour, ['both-games', 'regular', 'moon-new']);
|
||||||
|
await match(
|
||||||
|
now - 2 * hour,
|
||||||
|
now - 2 * hour,
|
||||||
|
['moon-new', 'kino-new'],
|
||||||
|
't5',
|
||||||
|
'Kino Der Toten'
|
||||||
|
);
|
||||||
|
await match(now - hour, now - hour, ['town-new'], 't6', 'Town Survival');
|
||||||
|
await match(now - hour, now - hour, ['grief-new'], 't6', 'Town Grief');
|
||||||
|
const read = (game: string | null, map: string | null = null) =>
|
||||||
|
readMetrics({ days: 1, game, map, timeZone: 'UTC' }, now);
|
||||||
|
expect(read(null).players).toMatchObject({ uniquePlayers: 6, newPlayers: 5 });
|
||||||
|
expect(read('t5').players).toMatchObject({ uniquePlayers: 4, newPlayers: 2 });
|
||||||
|
expect(read('t5', 'Moon').players).toMatchObject({ uniquePlayers: 3, newPlayers: 1 });
|
||||||
|
expect(read('t5', 'Kino Der Toten').players).toMatchObject({
|
||||||
|
uniquePlayers: 2,
|
||||||
|
newPlayers: 1
|
||||||
|
});
|
||||||
|
expect(read('t6', 'Town Survival').players).toMatchObject({
|
||||||
|
uniquePlayers: 3,
|
||||||
|
newPlayers: 2
|
||||||
|
});
|
||||||
|
expect(read('t6', 'Town Grief').players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
|
||||||
|
expect(read('t5', 'Missing').players).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||||
|
expect(read('t5', 'Missing').points.every((point) => point.uniquePlayers === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves periods before recording as gaps and marks the first retained period partial', async () => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
const start = now - 2 * hour;
|
||||||
|
await match(start, start + 10_000, ['first']);
|
||||||
|
const result = readMetrics(1, now);
|
||||||
|
expect(result.players).toEqual({ historyStartAt: start, uniquePlayers: 1, newPlayers: 1 });
|
||||||
|
expect(
|
||||||
|
result.points
|
||||||
|
.filter((point) => point.endAt <= start)
|
||||||
|
.every((point) => point.uniquePlayers === null && point.newPlayers === null)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
result.points.find((point) => point.at === Math.floor(start / hour) * hour)
|
||||||
|
).toMatchObject({
|
||||||
|
uniquePlayers: 1,
|
||||||
|
newPlayers: 1,
|
||||||
|
partial: false,
|
||||||
|
playerHistoryPartial: true
|
||||||
|
});
|
||||||
|
expect(result.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the history bound after retention without making returning players new', async () => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const firstMatch = await match(now - 40 * day, now - 40 * day, ['regular']);
|
||||||
|
await match(now - day, now - day + hour, ['regular'], 't6', 'Town Survival');
|
||||||
|
db().prepare('DELETE FROM leaderboard_matches WHERE id=?').run(firstMatch);
|
||||||
|
const result = readMetrics(
|
||||||
|
{ days: 30, game: 't6', map: 'Town Survival', timeZone: 'UTC' },
|
||||||
|
now
|
||||||
|
);
|
||||||
|
expect(result.players).toEqual({
|
||||||
|
historyStartAt: now - day,
|
||||||
|
uniquePlayers: 1,
|
||||||
|
newPlayers: 0
|
||||||
|
});
|
||||||
|
expect(result.points[0].uniquePlayers).toBeNull();
|
||||||
|
expect(db().prepare('SELECT COUNT(*) AS n FROM leaderboard_players').get()).toEqual({
|
||||||
|
n: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps no history unavailable, including when only durable player identities remain', async () => {
|
||||||
|
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const assertMissing = () => {
|
||||||
|
const result = readMetrics(1, now);
|
||||||
|
expect(result.players).toEqual({
|
||||||
|
historyStartAt: null,
|
||||||
|
uniquePlayers: null,
|
||||||
|
newPlayers: null
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result.points.every(
|
||||||
|
(point) => point.uniquePlayers === null && point.newPlayers === null
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
};
|
||||||
|
assertMissing();
|
||||||
|
await match(now - day, now - day, ['old']);
|
||||||
|
db().prepare('DELETE FROM leaderboard_matches').run();
|
||||||
|
await match(now + hour, now + hour, ['future']);
|
||||||
|
assertMissing();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ const report = (days: number) => ({
|
|||||||
summary: totals,
|
summary: totals,
|
||||||
running: { average: null, peak: 0, capturedHours: 0 },
|
running: { average: null, peak: 0, capturedHours: 0 },
|
||||||
matches: emptyMatches,
|
matches: emptyMatches,
|
||||||
|
players: { uniquePlayers: null, newPlayers: null, historyStartAt: null },
|
||||||
autoscaling: {
|
autoscaling: {
|
||||||
added: 0,
|
added: 0,
|
||||||
removed: 0,
|
removed: 0,
|
||||||
@@ -286,11 +287,22 @@ describe('metrics background refresh', () => {
|
|||||||
startAt: 0,
|
startAt: 0,
|
||||||
endAt: 1000,
|
endAt: 1000,
|
||||||
partial: true,
|
partial: true,
|
||||||
|
uniquePlayers: 0,
|
||||||
|
newPlayers: 0,
|
||||||
|
playerHistoryPartial: false,
|
||||||
capturedHours: 1 / 3600
|
capturedHours: 1 / 3600
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const invalid = [
|
const invalid = [
|
||||||
|
{ ...value, players: undefined },
|
||||||
|
{ ...value, players: { uniquePlayers: 1, newPlayers: 0 } },
|
||||||
|
...[undefined, -1, 1.5, '2'].map((newPlayers) => ({
|
||||||
|
...value,
|
||||||
|
players: { ...value.players, newPlayers }
|
||||||
|
})),
|
||||||
|
{ ...value, points: [{ ...value.points[0], uniquePlayers: undefined }] },
|
||||||
|
{ ...value, points: [{ ...value.points[0], playerHistoryPartial: undefined }] },
|
||||||
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
|
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
|
||||||
{
|
{
|
||||||
...value,
|
...value,
|
||||||
|
|||||||
@@ -99,6 +99,32 @@ it('refreshes cached match metrics after active matches and recorded participant
|
|||||||
expect((await report()).scopeOptions).toEqual({ games: [], maps: [] });
|
expect((await report()).scopeOptions).toEqual({ games: [], maps: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes player trends when participation crosses a period and first-seen identity changes', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
|
||||||
|
const now = Date.UTC(2026, 9, 10, 12, 30);
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
const query = parseMetricsQuery(new URLSearchParams('days=1'));
|
||||||
|
db().exec(`INSERT INTO leaderboard_matches
|
||||||
|
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||||
|
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T11:30:00.000Z','2026-10-10T12:30:00.000Z','heartbeat','test');
|
||||||
|
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
|
||||||
|
VALUES(1,'player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');
|
||||||
|
INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||||
|
VALUES('t5','player','Player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');`);
|
||||||
|
const first = await metricsReport(query);
|
||||||
|
expect(first.players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
|
||||||
|
expect(first.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
|
||||||
|
db().exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T12:15:00.000Z'");
|
||||||
|
expect((await metricsReport(query)).points.at(-1)).toMatchObject({
|
||||||
|
uniquePlayers: 1,
|
||||||
|
newPlayers: 0
|
||||||
|
});
|
||||||
|
db().exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
|
||||||
|
VALUES('t6','player','Player','2026-10-01T11:30:00.000Z','2026-10-01T11:30:00.000Z')`);
|
||||||
|
expect((await metricsReport(query)).players).toMatchObject({ uniquePlayers: 1, newPlayers: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
|
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import MetricsAutoscaling from '../../src/lib/ui/components/metrics/MetricsAutos
|
|||||||
import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte';
|
import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte';
|
||||||
import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte';
|
import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte';
|
||||||
import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte';
|
import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte';
|
||||||
|
import MetricsPlayers from '../../src/lib/ui/components/metrics/MetricsPlayers.svelte';
|
||||||
import { metricPage } from '../../src/lib/ui/metrics.js';
|
import { metricPage } from '../../src/lib/ui/metrics.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ function report(): MetricsData {
|
|||||||
scopeFirstSampleAt: now - 14 * 86400000,
|
scopeFirstSampleAt: now - 14 * 86400000,
|
||||||
scopeLatestSampleAt: now,
|
scopeLatestSampleAt: now,
|
||||||
matches: matches(),
|
matches: matches(),
|
||||||
|
players: { uniquePlayers: 104, newPlayers: 70, historyStartAt: now - 14 * 86400000 },
|
||||||
autoscaling: {
|
autoscaling: {
|
||||||
added: 73,
|
added: 73,
|
||||||
removed: 48,
|
removed: 48,
|
||||||
@@ -86,6 +88,9 @@ function report(): MetricsData {
|
|||||||
startAt: now - 86400000,
|
startAt: now - 86400000,
|
||||||
endAt: now,
|
endAt: now,
|
||||||
partial: true,
|
partial: true,
|
||||||
|
uniquePlayers: 28,
|
||||||
|
newPlayers: 5,
|
||||||
|
playerHistoryPartial: false,
|
||||||
capturedHours: 1
|
capturedHours: 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -500,6 +505,68 @@ it('shows a global pause once while keeping Kino’s shortage ahead of quiet map
|
|||||||
expect(body).toContain('Consider raising the policy maximum');
|
expect(body).toContain('Consider raising the policy maximum');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders player totals separately from periods with accessible selection, gaps and partial history', () => {
|
||||||
|
const data = report();
|
||||||
|
const original = data.points[0];
|
||||||
|
data.points = [
|
||||||
|
{
|
||||||
|
...original,
|
||||||
|
at: original.at - 86400000,
|
||||||
|
startAt: original.startAt - 86400000,
|
||||||
|
endAt: original.startAt,
|
||||||
|
partial: false,
|
||||||
|
uniquePlayers: null,
|
||||||
|
newPlayers: null
|
||||||
|
},
|
||||||
|
{ ...original, partial: false, playerHistoryPartial: true }
|
||||||
|
];
|
||||||
|
data.players.historyStartAt = original.startAt + 3600000;
|
||||||
|
const body = render(MetricsPlayers, {
|
||||||
|
props: {
|
||||||
|
points: data.points,
|
||||||
|
players: data.players,
|
||||||
|
days: data.days,
|
||||||
|
since: data.since,
|
||||||
|
timeZone: data.scope.timeZone
|
||||||
|
}
|
||||||
|
}).body;
|
||||||
|
expect(body).toContain('Players over time');
|
||||||
|
expect(body).toContain('>104</dd>');
|
||||||
|
expect(body).toContain('>70</dd>');
|
||||||
|
expect(body).toContain('28 unique players · 5 new');
|
||||||
|
expect(body).toContain('Retained player history starts');
|
||||||
|
expect(body.replace(/\s+/g, ' ')).toContain('Earlier periods are gaps');
|
||||||
|
expect(body).toContain('Range totals count each player once');
|
||||||
|
expect(body).toContain('aria-label="Selected player period summary"');
|
||||||
|
expect(body).toContain('aria-label="Player time data" tabindex="0"');
|
||||||
|
expect(body).toContain('role="status"');
|
||||||
|
expect(body).toContain('View exact time data');
|
||||||
|
expect(body).toContain('No retained history');
|
||||||
|
const periods = body.match(/<button[^>]*class="period\b[\s\S]*?<\/button>/g) ?? [];
|
||||||
|
expect(periods).toHaveLength(2);
|
||||||
|
expect(periods[0]).not.toContain('class="dot');
|
||||||
|
expect(periods[1]).toContain('partial');
|
||||||
|
expect(periods[1]).toContain('tabindex="0"');
|
||||||
|
expect(body).not.toMatch(/<details[^>]*open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows player trends without occupancy and shows unavailable counts when no match history remains', () => {
|
||||||
|
const data = report();
|
||||||
|
data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
|
||||||
|
let body = render(MetricsPage, {
|
||||||
|
props: { data: { ...data, user: null, setupRequired: false } }
|
||||||
|
}).body;
|
||||||
|
expect(body).toContain('Players over time');
|
||||||
|
expect(body).toContain('>104</dd>');
|
||||||
|
data.players = { historyStartAt: null, uniquePlayers: null, newPlayers: null };
|
||||||
|
data.points = data.points.map((point) => ({ ...point, uniquePlayers: null, newPlayers: null }));
|
||||||
|
body = render(MetricsPage, {
|
||||||
|
props: { data: { ...data, user: null, setupRequired: false } }
|
||||||
|
}).body;
|
||||||
|
expect(body).toContain('No retained player history yet');
|
||||||
|
expect(body).toContain('>—</dd>');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
|
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
|
||||||
const data = report();
|
const data = report();
|
||||||
data.points.push({
|
data.points.push({
|
||||||
@@ -512,7 +579,10 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
|
|||||||
idle: 0,
|
idle: 0,
|
||||||
unknown: 0,
|
unknown: 0,
|
||||||
playerHours: 0,
|
playerHours: 0,
|
||||||
capturedHours: 0
|
capturedHours: 0,
|
||||||
|
uniquePlayers: null,
|
||||||
|
newPlayers: null,
|
||||||
|
playerHistoryPartial: false
|
||||||
});
|
});
|
||||||
const body = render(MetricsChart, {
|
const body = render(MetricsChart, {
|
||||||
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
|
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ import {
|
|||||||
metricLobbyAxis,
|
metricLobbyAxis,
|
||||||
metricNumber,
|
metricNumber,
|
||||||
metricPercent,
|
metricPercent,
|
||||||
metricPeriodLabels
|
metricPeriodLabels,
|
||||||
|
metricPlayerPaths
|
||||||
} from '../../src/lib/ui/metrics.js';
|
} from '../../src/lib/ui/metrics.js';
|
||||||
|
|
||||||
describe('metric presentation', () => {
|
describe('metric presentation', () => {
|
||||||
|
it('breaks player lines at missing history while retaining real zeroes and isolated points', () => {
|
||||||
|
const paths = metricPlayerPaths([null, 2, 0, null, 1], 2);
|
||||||
|
expect(paths.line).toBe('M30,0 L50,100 M90,50');
|
||||||
|
expect(paths.area).toBe('M30,0 L50,100 L50,100 L30,100 Z M90,50 L90,100 L90,100 Z');
|
||||||
|
expect(metricPlayerPaths([null, null], 1)).toEqual({ line: '', area: '' });
|
||||||
|
});
|
||||||
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
|
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
|
||||||
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
|
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
|
||||||
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);
|
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);
|
||||||
|
|||||||
Reference in New Issue
Block a user