Simplify metrics overview and paginate breakdowns
This commit is contained in:
@@ -65,7 +65,7 @@
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<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="heading"><div><h2 id="metrics-trend-title">Instance activity 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>
|
||||
@@ -91,6 +91,8 @@
|
||||
<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="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle instances'}</span></div>
|
||||
<details class="inspection"><summary>Inspect a period</summary>
|
||||
<div class="period-controls">
|
||||
<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>
|
||||
@@ -105,6 +107,7 @@
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
</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>
|
||||
</details>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
<details class="time-data"><summary>View exact time data</summary>
|
||||
@@ -118,6 +121,7 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selection-summary { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 16px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.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; }
|
||||
|
||||
@@ -1,64 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
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);
|
||||
let { report }: { report: MetricsData } = $props();
|
||||
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}`; }
|
||||
const highlighted = $derived(recommendations.filter(item => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')).slice(0, 3));
|
||||
const remaining = $derived(recommendations.filter(item => !highlighted.includes(item)));
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const query = $derived((params.get('insightQ') ?? '').slice(0, 100));
|
||||
const sort = $derived(params.get('insightSort') === 'map' ? 'map' : 'priority');
|
||||
const visibleRemaining = $derived(remaining.filter(item => `${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0));
|
||||
function updateContext(key: string, value: string) {
|
||||
const url = new URL(page.url); url.search = contextSearch;
|
||||
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
const limited = $derived(recommendations.filter(item => item.tentative).length);
|
||||
</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]))}
|
||||
{#snippet finding(suggestion: ReturnType<typeof metricRecommendations>[number])}
|
||||
<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>
|
||||
<div><strong>{suggestion.map}</strong><small>{suggestion.game.toUpperCase()}{suggestion.tentative ? ' · Limited evidence' : ''}</small></div>
|
||||
<div><strong>{suggestion.title}</strong><details><summary>Evidence and considerations</summary><p>{suggestion.evidence}</p><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}
|
||||
{/snippet}
|
||||
<section class="explore" aria-labelledby="metrics-explore-title">
|
||||
<h2 id="metrics-explore-title">Hosting insights</h2>
|
||||
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p>
|
||||
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
|
||||
{:else}<p class="empty">No hosting trials meet the evidence threshold in this period.</p>{/if}
|
||||
{#if limited}<p class="notice">{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting, player limits, and observation duration before changing hosting.</p>{/if}
|
||||
{#if remaining.length}<details class="remaining"><summary>Other findings ({remaining.length})</summary>
|
||||
<div class="finding-controls"><Field label="Find a map"><Input type="search" value={query} placeholder="Map or game…" oninput={event => updateContext('insightQ', (event.currentTarget as HTMLInputElement).value)} /></Field><Field label="Sort findings"><Select value={sort} options={[{ value: 'priority', label: 'Priority' }, { value: 'map', label: 'Map A–Z' }]} onchange={event => updateContext('insightSort', (event.currentTarget as HTMLSelectElement).value)} /></Field></div>
|
||||
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>No other findings match this search.</p>{/each}</details>{/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>
|
||||
@@ -68,53 +47,19 @@
|
||||
</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); }
|
||||
.finding-controls { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.explore { padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
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); } }
|
||||
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
.recommendation { display: grid; grid-template-columns: minmax(140px, .6fr) minmax(0, 2fr); gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||
small { display: block; color: var(--text-secondary); margin-top: 6px; font-size: var(--text-xs); }
|
||||
details { font-size: var(--text-xs); margin-top: 10px; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.remaining, .rules { margin-top: 20px; }
|
||||
.notice { border-left: 2px solid var(--state-warning); padding-left: 12px; }
|
||||
li { margin: 8px 0; color: var(--text-secondary); }
|
||||
@media (max-width: 540px) { .recommendation { grid-template-columns: 1fr; gap: 8px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
let { report }: { report: MetricsData } = $props();
|
||||
const prior = $derived(report.previous);
|
||||
const pressure = $derived(report.pooledPressure);
|
||||
const comparable = $derived(report.summary.observed > 0 && prior.summary.observed > 0);
|
||||
const occupancyDelta = $derived(comparable ? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
|
||||
const playerDelta = $derived(comparable && prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1) : comparable && report.summary.playerHours === 0 ? 0 : null);
|
||||
const previousLabel = $derived(`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`);
|
||||
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
|
||||
</script>
|
||||
|
||||
<div class="summary">
|
||||
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' pp')} vs ${previousLabel}`}</p>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed instance time</small>
|
||||
</Card>
|
||||
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
|
||||
<details><summary>What are player-hours?</summary><p>Players × observed time. One player for one hour equals one player-hour.</p></details>
|
||||
</Card>
|
||||
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
|
||||
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
|
||||
<small>Pooled map time · not simultaneous fleet saturation</small>
|
||||
</Card>
|
||||
</div>
|
||||
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
|
||||
{#if report.maps.some(map => map.capacityObserved <= 0)}<p class="notice">{report.maps.filter(map => map.capacityObserved <= 0).length} of {report.maps.length} maps lack capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.</p>{/if}
|
||||
{#if report.summary.unknown > 0}<p class="quality">{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time with players.” Collection gaps are separate.</p>{/if}
|
||||
|
||||
<style>
|
||||
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
p { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
small, details { display: block; margin-top: 8px; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.5; }
|
||||
summary { cursor: pointer; }
|
||||
.notice { margin: 0; padding-left: 12px; border-left: 2px solid var(--state-warning); }
|
||||
.quality { margin: 0; font-size: var(--text-xs); }
|
||||
@media (max-width: 760px) { .summary { grid-template-columns: 1fr; gap: 8px; } }
|
||||
</style>
|
||||
@@ -2,23 +2,23 @@
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
||||
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { metricPage, compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { maps, servers }: { maps: MapMetrics[]; servers: ServerMetrics[] } = $props();
|
||||
let { maps, servers, view = 'maps' }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'capacityObserved', label: 'Known capacity' }
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
{ key: 'server', label: 'Server' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'idle', label: 'Idle' }, { key: 'observed', label: 'Known server time' }, { key: 'unknown', label: 'Unknown' }
|
||||
{ key: 'server', label: 'Instance' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' }, { key: 'unknown', label: 'Unknown time' }, { key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
type MapSort = typeof mapColumns[number]['key'];
|
||||
type ServerSort = typeof serverColumns[number]['key'];
|
||||
@@ -29,8 +29,8 @@
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
||||
const mapSort = $derived(mapColumns.find(column => column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapSort = $derived(mapColumns.find(column => column.key !== 'details' && column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key !== 'details' && column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
|
||||
@@ -44,100 +44,99 @@
|
||||
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
}));
|
||||
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
|
||||
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
|
||||
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
|
||||
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 {
|
||||
return key === 'full' && !map.capacityObserved || key === 'playerHours' && !map.observed ? null : map[key];
|
||||
if (key === 'details') return null;
|
||||
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
|
||||
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
|
||||
}
|
||||
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
|
||||
if (key === 'utilization' || key === 'idle') return server.observed > 0 ? server[key === 'utilization' ? 'active' : 'idle'] / server.observed : null;
|
||||
if (key === 'details') return null;
|
||||
if (key === 'utilization') return server.observed > 0 ? server.active / server.observed : null;
|
||||
return server[key];
|
||||
}
|
||||
function updateContext(changes: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
|
||||
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
|
||||
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
|
||||
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="map-metrics-title">
|
||||
<div class="heading"><div><h2 id="map-metrics-title">Map play time & capacity</h2><p>Compare play time and how often every tracked lobby was full.</p></div>
|
||||
<div class="search"><Field label="Find a map" hint="Search this table only."><Input type="search" value={mapQuery} placeholder="Map or game…" oninput={event => updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
|
||||
<div class="heading">
|
||||
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
|
||||
<div class="search"><Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}><Input type="search" value={view === 'maps' ? mapQuery : serverQuery} placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'} oninput={event => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
</div>
|
||||
<p class="definition">“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.</p>
|
||||
{#if mapRows.length}
|
||||
{#if currentPage.total}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Map play time and capacity table" tabindex="0"><table>
|
||||
<thead><tr>{#each mapColumns as column}<th scope="col" aria-sort={mapSort === column.key ? mapDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortMap(column.key)}>{column.label}<span aria-hidden="true">{mapSort === column.key ? mapDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
|
||||
<tbody>{#each mapRows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)} · {map.game.toUpperCase()}</small></th>
|
||||
<td class="player-hours"><span>{map.observed ? metricNumber(map.playerHours) : '—'}</span><div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{metricDuration(map.active)}<small>server time</small></td>
|
||||
<td>{#if map.capacityObserved > 0}{metricDuration(map.full)}<small>{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known</small>{:else}<span class="unavailable">Unknown</span><small>No known capacity time</small>{/if}</td>
|
||||
<td>{metricDuration(map.capacityObserved)}<small>map time</small></td>
|
||||
</tr>{/each}</tbody>
|
||||
<div class="table-wrap" role="region" aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'} tabindex="0"><table>
|
||||
<thead><tr>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th scope="col" aria-sort={column.key === 'details' ? undefined : (view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? 'ascending' : 'descending' : undefined}>
|
||||
{#if column.key === 'details'}Details{:else}<button type="button" onclick={() => view === 'maps' ? sortMap(column.key as MapSort) : sortServer(column.key as ServerSort)}>{column.label}<span aria-hidden="true">{(view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? '↑' : '↓' : '↕'}</span></button>{/if}
|
||||
</th>
|
||||
{/each}</tr></thead>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}</small></th>
|
||||
<td class="player-hours">{map.observed ? metricNumber(map.playerHours) : '—'}<div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td><details><summary><span class="sr-only">{map.map} </span>Details</summary><dl>
|
||||
<dt>Observed instance time</dt><dd>{metricDuration(map.observed)}</dd><dt>Unknown status</dt><dd>{metricDuration(map.unknown)}</dd><dt>All lobbies full</dt><dd>{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}</dd>
|
||||
</dl></details></td>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row">{#if server.currentRegistered}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}`, `/metrics${contextSearch}`)} title="Open current instance with this name">{serverTitle(server.server)}</a>{:else}{serverTitle(server.server)}<small>Historical</small>{/if}</th>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
<td><details><summary><span class="sr-only">{serverTitle(server.server)} </span>Details</summary><dl><dt>Recorded name</dt><dd><code>{server.server}</code></dd><dt>Time with players</dt><dd>{metricDuration(server.active)}</dd><dt>Idle</dt><dd>{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}</dd></dl></details></td>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title={maps.length ? 'No maps match this search' : 'No map observations'} description={maps.length ? 'Try a recorded map name or game.' : 'Map rows appear when server observations include a map.'}>{#snippet actions()}{#if mapQuery}<Button size="sm" onclick={() => updateContext({ mapQ: '' })}>Clear map search</Button>{/if}{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{mapRows.length} of {maps.length} maps</span><span>Missing player limits leave capacity unknown, even when occupancy is available.</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="server-metrics-title">
|
||||
<div class="heading"><div><h2 id="server-metrics-title">Server utilization</h2><p>Time with players as a share of each server’s known time.</p></div>
|
||||
<div class="search"><Field label="Find a server" hint="Search this table only."><Input type="search" value={serverQuery} placeholder="Server name or map…" oninput={event => updateContext({ serverQ: (event.currentTarget as HTMLInputElement).value })} /></Field></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>{/if}
|
||||
<div class="footer"><span>{currentPage.start}–{currentPage.end} of {currentPage.total} {view === 'maps' ? 'maps' : 'instances'}</span>
|
||||
{#if currentPage.pages > 1}<div class="paging"><Button size="sm" disabled={currentPage.page === 1} onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}>Previous</Button><span>Page {currentPage.page} of {currentPage.pages}</span><Button size="sm" disabled={currentPage.page === currentPage.pages} onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}>Next</Button></div>{/if}
|
||||
</div>
|
||||
{#if serverRows.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<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>{#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>Names may include multiple registrations. Links open the current registration, which may differ from historical instances.</span></div>
|
||||
{#if view === 'instances'}<p class="identity-note">Grouped by recorded name; links open the current instance, which may differ from the historical registration.</p>{/if}
|
||||
<details class="table-help"><summary>About these metrics</summary><p>{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It 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>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
|
||||
.heading { padding: var(--space-5); display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.search { flex: 0 1 230px; min-width: 180px; }
|
||||
.definition { margin: 0; padding: 0 var(--space-5) var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; max-width: 110ch; }
|
||||
.search { flex: 0 1 240px; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: 14px var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
th:first-child { text-align: left; min-width: 185px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); }
|
||||
thead button { display: inline-flex; justify-content: flex-end; align-items: center; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; text-align: inherit; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 4px; }
|
||||
thead button span { color: var(--text-tertiary); }
|
||||
thead th[aria-sort] { color: var(--text-primary); }
|
||||
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
|
||||
th:first-child { text-align: left; min-width: 160px; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); white-space: nowrap; }
|
||||
thead button { display: inline-flex; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
tbody th { font-weight: var(--weight-medium); }
|
||||
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; }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; opacity: .7; }
|
||||
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-4) var(--space-5); font-size: 11px; color: var(--text-tertiary); line-height: 1.6; }
|
||||
@media (max-width: 640px) {
|
||||
.heading { padding: var(--space-4); }
|
||||
.search { flex: 1 1 100%; }
|
||||
.definition { padding-inline: var(--space-4); }
|
||||
th, td { padding: 12px; font-size: var(--text-xs); }
|
||||
th:first-child { position: sticky; left: 0; z-index: 1; min-width: 130px; max-width: 150px; background: var(--surface); box-shadow: 1px 0 var(--border-strong); }
|
||||
thead th:first-child { background: var(--surface-sunken); }
|
||||
.footer { padding: var(--space-3) var(--space-4); }
|
||||
}
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; }
|
||||
.footer, .paging { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.footer { padding: 16px 20px; }
|
||||
details { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; }
|
||||
dl { text-align: left; min-width: 170px; max-width: 300px; }
|
||||
dt { margin-top: 10px; } dd { margin: 4px 0; color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.identity-note { margin: 0; padding: 0 20px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.table-help { padding: 0 20px 16px; } .table-help p { max-width: 90ch; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 640px) { th, td { padding: 10px; } .search { flex: 1 1 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -30,3 +30,12 @@ export function compareMetricValues(left: number | null, right: number | null, d
|
||||
if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1;
|
||||
return (a - b) * (direction === 'asc' ? 1 : -1);
|
||||
}
|
||||
|
||||
/** Clamp saved pages after filtering or polling changes the result count. */
|
||||
export function metricPage<T>(rows: T[], requested: string | null, size = 15) {
|
||||
const parsed = Number(requested);
|
||||
const pages = Math.max(1, Math.ceil(rows.length / size));
|
||||
const page = Math.min(pages, Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1);
|
||||
const start = (page - 1) * size;
|
||||
return { page, pages, rows: rows.slice(start, start + size), start: rows.length ? start + 1 : 0, end: Math.min(rows.length, start + size), total: rows.length };
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { ONLINE_MAX_MS } from '$lib/server-freshness.js';
|
||||
import { page } from '$app/state';
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto, replaceState } from '$app/navigation';
|
||||
import { timeZone } from '$ui/stores/time-zone.js';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import { metricDuration } from '$ui/metrics.js';
|
||||
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
import MetricsSummary from '$ui/components/metrics/MetricsSummary.svelte';
|
||||
import MetricsHeatmap from '$ui/components/metrics/MetricsHeatmap.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
@@ -33,6 +35,11 @@
|
||||
const format = $derived(createTimeFormatter(report.scope.timeZone));
|
||||
const captured = $derived(report.summary.observed + report.summary.unknown);
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const view = $derived(new URLSearchParams(contextSearch).get('view') === 'instances' ? 'instances' : new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps');
|
||||
function changeView(value: string) {
|
||||
const url = new URL(page.url); url.search = contextSearch; url.searchParams.set('view', value);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
const gameOptions = $derived([{ value: '', label: 'All games' }, ...[...new Set([...report.scopeOptions.games, ...(report.scope.game ? [report.scope.game] : [])])].map(game => ({ value: game, label: gameName(game) }))]);
|
||||
const mapOptions = $derived([
|
||||
{ value: '', label: 'All maps' },
|
||||
@@ -76,6 +83,7 @@
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
if (!params.has('timeZone')) params.set('timeZone', report.scope.timeZone);
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) params.set(key, value); else params.delete(key); }
|
||||
params.delete('mapPage'); params.delete('serverPage');
|
||||
refresh.cancel();
|
||||
scopeError = '';
|
||||
scopeNavigating = true;
|
||||
@@ -91,6 +99,7 @@
|
||||
function rangeHref(days: number): string {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
params.set('days', String(days));
|
||||
params.delete('mapPage'); params.delete('serverPage');
|
||||
params.set('timeZone', report.scope.timeZone);
|
||||
return `/metrics?${params}`;
|
||||
}
|
||||
@@ -99,21 +108,17 @@
|
||||
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<SectionHeader title="Metrics" />
|
||||
<div class="toolbar">
|
||||
<nav aria-label="Metrics time range">
|
||||
{#each [1, 7, 30] as days}
|
||||
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<span class="range">{format.period(report.since)} – {format.period(report.now)}</span>
|
||||
</div>
|
||||
<div class="scope-controls" aria-label="Filter the entire metrics report">
|
||||
|
||||
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
|
||||
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
|
||||
<p class="scope-note" role="status">{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}</p>
|
||||
<div class="refresh-controls"><span role="status">{scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`}</span><Button size="sm" variant="ghost" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button></div>
|
||||
</div>
|
||||
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
|
||||
{#if refreshError}
|
||||
@@ -122,26 +127,8 @@
|
||||
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if captured > 0}
|
||||
<div class="stats">
|
||||
<Card>
|
||||
<Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p class="stat-context">{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time</p>
|
||||
{#if report.summary.observed === 0}<p class="stat-note">Occupancy is unknown for all captured time.</p>{/if}
|
||||
</Card>
|
||||
<Card>
|
||||
<Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p class="stat-context">Players × known time · one player for one hour = 1</p>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="context" aria-label="About this report">
|
||||
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
|
||||
<span>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
|
||||
<span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
|
||||
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
|
||||
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
|
||||
</div>
|
||||
<p class="range">{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}</p>
|
||||
{#if captured > 0}<MetricsSummary {report} />{/if}
|
||||
{#if captured === 0}
|
||||
<Card>
|
||||
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this scope'}
|
||||
@@ -165,10 +152,23 @@
|
||||
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
|
||||
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
|
||||
{/if}
|
||||
<MetricsExplore {report} changingScope={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
|
||||
{#if captured > 0}<MetricsTables maps={report.maps} servers={report.servers} />{/if}
|
||||
{#if captured > 0}
|
||||
<details class="weekly"><summary>Activity by weekday and hour</summary><MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} /></details>
|
||||
<Tabs tabs={[{ id: 'maps', label: 'Maps' }, { id: 'instances', label: 'Instances' }, { id: 'insights', label: 'Hosting insights' }]} value={view} onchange={changeView} ariaLabel="Metrics breakdown" idPrefix="metrics-views" panelId="metrics-breakdown" />
|
||||
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
|
||||
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables maps={report.maps} servers={report.servers} {view} />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<details class="methodology">
|
||||
<summary>How these estimates work</summary>
|
||||
<summary>About this report</summary>
|
||||
<div class="context">
|
||||
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
|
||||
<span>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
|
||||
<span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
|
||||
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
|
||||
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
|
||||
</div>
|
||||
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. pp means percentage points. Equal windows do not guarantee equal observation coverage.</p>
|
||||
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
|
||||
<p>Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
|
||||
<p>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</p>
|
||||
@@ -178,36 +178,22 @@
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: var(--space-5); min-width: 0; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.scope-controls { display: grid; grid-template-columns: minmax(140px, 220px) minmax(180px, 300px) minmax(140px, 1fr); gap: var(--space-3); align-items: end; }
|
||||
.scope-note { color: var(--text-secondary); font-size: var(--text-xs); margin: 0 0 8px; line-height: 1.5; }
|
||||
.toolbar { display: flex; align-items: end; gap: var(--space-3); flex-wrap: wrap; }
|
||||
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
|
||||
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
|
||||
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
nav a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.range { max-width: 50ch; }
|
||||
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.stat-context { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; }
|
||||
.stat-note { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.range { margin: 0; }
|
||||
.refresh-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.weekly { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.weekly summary { cursor: pointer; }
|
||||
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
|
||||
.context strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
|
||||
.notice { margin: 0; border-left: 2px solid var(--state-warning); padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
.methodology { padding: var(--space-3) 2px; }
|
||||
.methodology summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
|
||||
@media (max-width: 540px) {
|
||||
.scope-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.scope-note { grid-column: 1 / -1; margin: 0; }
|
||||
.page { gap: var(--space-4); }
|
||||
.stats { gap: var(--space-2); }
|
||||
.stats :global(.card) { padding: var(--space-3); }
|
||||
.stats :global(.value) { font-size: var(--text-xl); }
|
||||
.stats :global(.label) { font-size: 11px; }
|
||||
.stat-context { font-size: var(--text-xs); }
|
||||
.context { font-size: 11px; column-gap: var(--space-3); }
|
||||
.refresh-error { align-items: flex-start; flex-wrap: wrap; }
|
||||
}
|
||||
@media (max-width: 540px) { .toolbar { align-items: stretch; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import type { MetricsData, MapMetrics, ServerMetrics } from '../../src/lib/metrics.js';
|
||||
import MetricsPage from '../../src/routes/metrics/+page.svelte';
|
||||
import MetricsSummary from '../../src/lib/ui/components/metrics/MetricsSummary.svelte';
|
||||
import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte';
|
||||
import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte';
|
||||
import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte';
|
||||
import { metricPage } from '../../src/lib/ui/metrics.js';
|
||||
import { page } from '$app/state';
|
||||
|
||||
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/metrics'), state: {} } }));
|
||||
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn(), replaceState: vi.fn() }));
|
||||
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({ game: 't5', map: `Map ${String(i).padStart(2, '0')}`, observed: 10, active: 5, idle: 5, unknown: 0, playerHours: i, full: 1, capacityObserved: 4, ...changes });
|
||||
const server = (i: number): ServerMetrics => ({ server: `t5_zombie_theater_${i}`, currentRegistered: i !== 1, observed: 10, active: i / 10, idle: 10 - i / 10, unknown: 0, playerHours: i });
|
||||
function report(): MetricsData {
|
||||
const now = Date.parse('2026-10-06T12:00:00Z');
|
||||
const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 };
|
||||
return { days: 7, since: now - 7 * 86400000, now, firstRetainedSampleAt: now - 14 * 86400000, latestSampleAt: now,
|
||||
registeredServerCount: 24, scope: { game: null, map: null, timeZone: 'America/New_York' }, scopeOptions: { games: ['t5'], maps: [] },
|
||||
scopeFirstSampleAt: now - 14 * 86400000, scopeLatestSampleAt: now, summary,
|
||||
maps: Array.from({ length: 24 }, (_, i) => map(i + 1)), servers: Array.from({ length: 24 }, (_, i) => server(i + 1)),
|
||||
points: [{ ...summary, at: now - 86400000, startAt: now - 86400000, endAt: now, partial: true }],
|
||||
previous: { since: now - 14 * 86400000, until: now - 7 * 86400000, summary: { ...summary, active: 4, idle: 6, playerHours: 10 }, firstSampleAt: now - 14 * 86400000, lastSampleAt: now - 7 * 86400000, partialHistory: true },
|
||||
pooledPressure: { value: 25, fullMapHours: 1, knownMapHours: 4, coveredMaps: 24, totalMaps: 24 }, heatmap: { timeZone: 'America/New_York', cells: [] }
|
||||
};
|
||||
}
|
||||
function scope(search: string, stateSearch?: string) {
|
||||
Object.assign(page, { url: new URL(`http://localhost/metrics${search}`), state: stateSearch ? { metricsTableSearch: stateSearch } : {} });
|
||||
}
|
||||
beforeEach(() => scope(''));
|
||||
|
||||
it('shows a summary and one paginated breakdown, preserving scope on range links', () => {
|
||||
scope('?game=t5&days=7&view=maps&mapPage=2');
|
||||
const body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body;
|
||||
expect(body).toContain('aria-label="Metrics breakdown"');
|
||||
expect(body).toContain('aria-label="Map metrics table"');
|
||||
expect(body).not.toContain('aria-label="Instance metrics table"');
|
||||
expect(body).toContain('16–24 of 24 maps');
|
||||
expect(body).toContain('game=t5&days=30&view=maps');
|
||||
expect(body).toContain('Partial previous history');
|
||||
expect(body).toContain('About this report');
|
||||
expect(body).not.toContain('Explore this period');
|
||||
});
|
||||
|
||||
it('keeps percentage points, relative changes and unavailable capacity distinct', () => {
|
||||
const data = report();
|
||||
let body = render(MetricsSummary, { props: { report: data } }).body;
|
||||
expect(body).toContain('+10 pp'); expect(body).toContain('+100%');
|
||||
data.pooledPressure = { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 24 };
|
||||
data.maps = [map(1, { capacityObserved: 0 })];
|
||||
data.previous.summary.playerHours = 0;
|
||||
body = render(MetricsSummary, { props: { report: data } }).body;
|
||||
expect(body).toContain('Capacity data unavailable');
|
||||
expect(body).toContain('New · no previous player-hours');
|
||||
expect(body).toContain('1 of 1 maps lack capacity data');
|
||||
expect(body).not.toContain('Infinity');
|
||||
data.summary.observed = 0;
|
||||
body = render(MetricsSummary, { props: { report: data } }).body;
|
||||
expect(body).toContain('Comparison unavailable');
|
||||
});
|
||||
|
||||
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
|
||||
const data = report();
|
||||
scope('?mapPage=2');
|
||||
let body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body;
|
||||
expect(body).toContain('Map 09'); expect(body).not.toContain('Map 24');
|
||||
scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours');
|
||||
body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body;
|
||||
expect(body).toContain('Map 24'); expect(body).toContain('1–1 of 1 maps');
|
||||
expect(body).not.toContain('>Next<');
|
||||
expect(metricPage(data.maps, '-1').page).toBe(1);
|
||||
expect(metricPage(data.maps, 'Infinity').page).toBe(1);
|
||||
expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 });
|
||||
});
|
||||
|
||||
it('keeps missing capacity last when sorting and never presents it as zero full time', () => {
|
||||
scope('?mapSort=full&mapDirection=asc');
|
||||
const maps = [map(1, { capacityObserved: 0 }), map(2, { full: 0 }), map(3)];
|
||||
const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
|
||||
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
|
||||
expect(body).toContain('0.0%'); expect(body).toContain('Capacity data unavailable');
|
||||
});
|
||||
|
||||
it('preserves instance return context and distinguishes historic names without current links', () => {
|
||||
scope('?view=instances&serverQ=theater&days=30');
|
||||
const body = render(MetricsTables, { props: { maps: [], servers: [server(1), server(2)], view: 'instances' } }).body;
|
||||
expect(body).toContain('Historical');
|
||||
expect(body).not.toContain('href="/servers/t5_zombie_theater_1');
|
||||
expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo=');
|
||||
expect(body).toContain('serverQ%3Dtheater');
|
||||
expect(body).not.toContain('>Idle</button>');
|
||||
expect(body).not.toContain('Open current server');
|
||||
});
|
||||
|
||||
it('highlights at most three supported trials and collapses tentative findings', () => {
|
||||
const data = report(); data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, capacityObserved: 0 }));
|
||||
const body = render(MetricsExplore, { props: { report: data } }).body;
|
||||
const fold = body.indexOf('Other findings');
|
||||
expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3);
|
||||
expect(body.indexOf('Map 25')).toBeGreaterThan(fold);
|
||||
expect(body).toContain('Limited evidence');
|
||||
expect(body).not.toMatch(/<details[^>]*open/);
|
||||
data.maps = [map(1, { capacityObserved: 0 })];
|
||||
expect(render(MetricsExplore, { props: { report: data } }).body).toContain('No hosting trials meet the evidence threshold');
|
||||
});
|
||||
|
||||
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
|
||||
const data = report();
|
||||
data.points.push({ at: data.now, startAt: data.now, endAt: data.now + 1000, partial: false, observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 });
|
||||
const body = render(MetricsChart, { props: { points: data.points, days: data.days, timeZone: data.scope.timeZone } }).body;
|
||||
expect(body).toContain('aria-label="Selected period summary"');
|
||||
expect(body).toContain('No captured time · not evidence of idle instances');
|
||||
expect(body).toContain('tabindex="0"');
|
||||
expect(body).toContain('Inspect a period');
|
||||
expect(body).toContain('Partial period');
|
||||
expect(body).not.toMatch(/<details[^>]*open/);
|
||||
});
|
||||
|
||||
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
|
||||
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
|
||||
let body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body;
|
||||
expect(body).toContain('aria-label="Instance metrics table"');
|
||||
expect(body).not.toContain('aria-label="Map metrics table"');
|
||||
expect(body).toContain('16–24 of 24 instances');
|
||||
scope('?view=maps', '?view=insights');
|
||||
body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body;
|
||||
expect(body).toContain('id="metrics-explore-title"');
|
||||
expect(body).toContain('Time all lobbies were full');
|
||||
expect(body).not.toContain('aria-label="Map metrics table"');
|
||||
expect(body).not.toContain('aria-label="Instance metrics table"');
|
||||
});
|
||||
|
||||
it('filters other insights without hiding the highlighted supported trials', () => {
|
||||
scope('?view=insights&insightQ=Map+24&insightSort=map');
|
||||
const body = render(MetricsExplore, { props: { report: report() } }).body;
|
||||
expect(body).toContain('Map 01');
|
||||
expect(body).toContain('Map 24');
|
||||
expect(body).not.toContain('Map 23');
|
||||
});
|
||||
Reference in New Issue
Block a user