Show match demand in metrics (Refs #138)
CI / checks (push) Successful in 4m44s

This commit is contained in:
2026-10-10 16:26:32 -04:00
parent ea932ec563
commit 616b1937ed
15 changed files with 775 additions and 42 deletions
@@ -63,6 +63,31 @@
<p>Players × observed time. One player for one hour equals one player-hour.</p>
</details>
</Card>
<Card
><Stat
label="Matches started"
value={metricNumber(report.matches.started)}
size="lg"
tween={false}
/>
<p>
{report.matches.averageGroupSize === null
? 'Average group unavailable'
: `Average group ${metricNumber(report.matches.averageGroupSize)} ${plural(report.matches.averageGroupSize, 'player')}`}
</p>
<small
>{report.matches.soloShare === null
? 'Solo share unavailable · no recorded players'
: `${metricNumber(report.matches.solo)} solo · ${metricPercent(report.matches.soloShare, 100)} of matches with recorded players`}</small
>
{#if report.matches.withRecordedPlayers < report.matches.started}<small
>Players recorded for {metricNumber(report.matches.withRecordedPlayers)} of {countLabel(
report.matches.started,
'match',
'matches'
)}</small
>{/if}
</Card>
<Card
><Stat
label="Time with no empty lobby"
@@ -89,7 +114,7 @@
<style>
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
p {
@@ -117,6 +142,11 @@
margin: 0;
font-size: var(--text-xs);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.summary {
grid-template-columns: 1fr;
@@ -23,6 +23,7 @@
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' },
{ key: 'matches', label: 'Matches' },
{ key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' },
{ key: 'noEmpty', label: 'No empty lobby' },
@@ -103,12 +104,14 @@
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
const currentQuery = $derived(view === 'maps' ? mapQuery : serverQuery);
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
function gameName(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
}
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
if (key === 'details') return null;
if (key === 'matches') return map.matches.started;
if (key === 'noEmpty')
return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null;
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
@@ -233,6 +236,13 @@
: ''}</small
></th
>
<td
>{metricNumber(map.matches.started)}<small
>{map.matches.averageGroupSize === null
? 'Average group unavailable'
: `avg group ${metricNumber(map.matches.averageGroupSize)}`}</small
></td
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
@@ -249,6 +259,18 @@
><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Matches with recorded players</dt>
<dd>
{metricNumber(map.matches.withRecordedPlayers)} of {metricNumber(
map.matches.started
)}
</dd>
<dt>Solo matches</dt>
<dd>
{map.matches.soloShare === null
? 'Unavailable · no recorded players'
: `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`}
</dd>
<dt>Observed lobby time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
@@ -308,12 +330,19 @@
</table>
</div>
{:else}<EmptyState
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
>{#snippet actions()}<Button
size="sm"
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/snippet}</EmptyState
title={currentQuery.trim()
? view === 'maps'
? 'No maps match this search'
: 'No instances match this search'
: view === 'maps'
? 'No map history in this window'
: 'No instance observations in this window'}
>{#snippet actions()}{#if currentQuery.trim()}<Button
size="sm"
onclick={() =>
updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/if}{/snippet}</EmptyState
>{/if}
<div class="footer">
<span
@@ -342,7 +371,7 @@
<summary>About these metrics</summary>
<p>
{view === 'maps'
? 'No empty lobby measures map time when at least one tracked lobby has known occupancy and none report zero players. If every lobby is unknown, map availability is unknown. Player limits are not required. This does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
? 'Matches counts retained matches started in this window, including ongoing and interrupted matches. Average group counts distinct recorded players across each match’s lifetime and excludes matches with no recorded players. No empty lobby measures map time when at least one tracked lobby has known occupancy and none report zero players. If every lobby is unknown, map availability is unknown. Player limits are not required. This does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
</p>
</details>
+10 -1
View File
@@ -17,6 +17,13 @@ const running = (value: unknown) =>
(value.average === null || nonnegative(value.average)) &&
nonnegative(value.peak) &&
nonnegative(value.capturedHours);
const matches = (value: unknown) =>
record(value) &&
['started', 'withRecordedPlayers', 'solo'].every(
(key) => nonnegative(value[key]) && Number.isInteger(value[key])
) &&
(value.averageGroupSize === null || nonnegative(value.averageGroupSize)) &&
(value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100));
const policy = (value: unknown) =>
value === null ||
(record(value) &&
@@ -32,7 +39,8 @@ function validReport(value: unknown): value is MetricsData {
!numeric(value.now) ||
!numeric(value.since) ||
!totals(value.summary) ||
!running(value.running)
!running(value.running) ||
!matches(value.matches)
)
return false;
if (
@@ -84,6 +92,7 @@ function validReport(value: unknown): value is MetricsData {
(item) =>
record(item) &&
totals(item) &&
matches(item.matches) &&
typeof item.game === 'string' &&
typeof item.map === 'string' &&
[