Add experimental metrics insights, comparisons, and scope filters
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { MetricPoint, MetricsDays } from '$lib/metrics.js';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
|
||||
let { points, days }: { points: MetricPoint[]; days: MetricsDays } = $props();
|
||||
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
|
||||
let selectedAt = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const format = $derived(createTimeFormatter(timeZone));
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
|
||||
const selectedIndex = $derived.by(() => {
|
||||
const existing = points.findIndex(point => point.at === selectedAt);
|
||||
if (existing >= 0) return existing;
|
||||
@@ -17,34 +23,53 @@
|
||||
const selected = $derived(points[selectedIndex]);
|
||||
const selectedValue = $derived(selected ? String(selected.at) : '');
|
||||
const ceiling = $derived.by(() => {
|
||||
if (mode === 'percent') return 100;
|
||||
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
|
||||
if (!max) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(max));
|
||||
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
|
||||
});
|
||||
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
|
||||
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: tick.format(ceiling * fraction) })));
|
||||
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${$timeFormat.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
|
||||
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
|
||||
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
|
||||
const middle = $derived(points[Math.floor(points.length / 2)]);
|
||||
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
|
||||
|
||||
function setMode(value: 'hours' | 'percent') {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function selectPeriod(at: number) {
|
||||
selectedAt = at;
|
||||
const point = points.find(item => item.at === at);
|
||||
if (!point) return;
|
||||
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
|
||||
}
|
||||
function segmentHeight(point: MetricPoint, value: number) {
|
||||
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
|
||||
return denominator > 0 ? value / denominator * 100 : 0;
|
||||
}
|
||||
function move(direction: number) {
|
||||
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
|
||||
if (next) selectedAt = next.at;
|
||||
if (next) selectPeriod(next.at);
|
||||
}
|
||||
function chartKey(event: KeyboardEvent, index: number) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
selectedAt = points[next].at;
|
||||
selectPeriod(points[next].at);
|
||||
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<div class="heading"><h2 id="metrics-trend-title">Server time over time</h2><p>Server-hours per {days === 1 ? 'hour' : '24-hour period'}. Partial periods contain less elapsed time.</p></div>
|
||||
<div class="heading"><div><h2 id="metrics-trend-title">Server time over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
|
||||
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
|
||||
</div>
|
||||
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
|
||||
<div class="axis-title">Server-hours</div>
|
||||
<div class="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
|
||||
<div class="chart-grid">
|
||||
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
|
||||
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
|
||||
@@ -52,40 +77,42 @@
|
||||
{#each points as point, index (point.at)}
|
||||
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
|
||||
aria-pressed={point.at === selected?.at}
|
||||
aria-label={`${$timeFormat.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown`}
|
||||
title={`${$timeFormat.period(point.startAt)} – ${$timeFormat.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
|
||||
onclick={() => { selectedAt = point.at; }} onkeydown={event => chartKey(event, index)}>
|
||||
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
|
||||
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
|
||||
onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
|
||||
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
|
||||
<span class="segment unknown" style:height={`${point.unknown / ceiling * 100}%`}></span>
|
||||
<span class="segment idle" style:height={`${point.idle / ceiling * 100}%`}></span>
|
||||
<span class="segment occupied" style:height={`${point.active / ceiling * 100}%`}></span>
|
||||
<span class="segment unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
|
||||
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
|
||||
<span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
|
||||
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="x-axis" aria-hidden="true"><span>{points[0] ? $timeFormat.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{$timeFormat.period(middle.startAt)}</span>{/if}<span>{points.length ? $timeFormat.period(points[points.length - 1].endAt) : ''}</span></div>
|
||||
<div class="x-axis" aria-hidden="true"><span>{points[0] ? format.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{format.period(middle.startAt)}</span>{/if}<span>{points.length ? format.period(points[points.length - 1].endAt) : ''}</span></div>
|
||||
</div>
|
||||
{#if selected}
|
||||
<div class="period-controls">
|
||||
<div class="period-select"><Field label={`Inspect a period · ${$timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => { selectedAt = Number((event.currentTarget as HTMLSelectElement).value); }} /></Field></div>
|
||||
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
|
||||
</div>
|
||||
<div class="readout" aria-label="Selected period details">
|
||||
<p class="interval">{$timeFormat.period(selected.startAt)} – {$timeFormat.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
|
||||
<p class="interval">{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
|
||||
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
|
||||
{:else}<dl>
|
||||
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, selected.observed)} of known time</small></dd></div>
|
||||
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}</dd></div>
|
||||
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}</dd></div>
|
||||
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
|
||||
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
|
||||
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
</dl>{/if}
|
||||
</dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
<details class="time-data"><summary>View exact time data</summary>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
|
||||
<caption>Periods in {$timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
|
||||
<caption>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
|
||||
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.startAt)}<small>to {$timeFormat.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
|
||||
<tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
|
||||
</table></div>
|
||||
</details>
|
||||
</section>
|
||||
@@ -93,6 +120,11 @@
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
|
||||
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
|
||||
.legend span { display: flex; align-items: center; gap: 6px; }
|
||||
@@ -113,6 +145,7 @@
|
||||
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
|
||||
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
|
||||
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
|
||||
.no-time { color: var(--text-tertiary); font-size: 20px; line-height: 1; }
|
||||
.bar.partial { border-inline: 1px dashed var(--border-strong); }
|
||||
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: 15px; color: var(--text-secondary); }
|
||||
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
|
||||
@@ -130,6 +163,8 @@
|
||||
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
|
||||
small { display: block; color: var(--text-tertiary); font-size: 11px; font-weight: normal; line-height: 1.5; }
|
||||
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.denominator { color: var(--text-tertiary); font-size: 11px; line-height: 1.5; margin: var(--space-3) 0 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
details { margin-top: var(--space-4); font-size: var(--text-sm); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
|
||||
.table-wrap { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import { createTimeFormatter } from '$lib/time-zone.js';
|
||||
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import MetricsHeatmap from './MetricsHeatmap.svelte';
|
||||
|
||||
let { report, changingScope = false, onTimeZoneChange }: { report: MetricsData; changingScope?: boolean; onTimeZoneChange: (value: string) => void } = $props();
|
||||
const format = $derived(createTimeFormatter(report.scope.timeZone));
|
||||
const prior = $derived(report.previous);
|
||||
const pressure = $derived(report.pooledPressure);
|
||||
const recommendations = $derived(metricRecommendations(report.maps));
|
||||
const utilizationDelta = $derived(report.summary.observed > 0 && prior.summary.observed > 0
|
||||
? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
|
||||
const playerDelta = $derived(report.summary.observed <= 0 || prior.summary.observed <= 0 ? null
|
||||
: prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
|
||||
: report.summary.playerHours === 0 ? 0 : null);
|
||||
const playerNew = $derived(prior.summary.observed > 0 && report.summary.observed > 0 && prior.summary.playerHours === 0 && report.summary.playerHours > 0);
|
||||
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
|
||||
</script>
|
||||
|
||||
<section class="explore" aria-labelledby="metrics-explore-title">
|
||||
<div class="heading"><h2 id="metrics-explore-title">Explore this period</h2><p>Planning signals for the selected scope. Review the evidence before changing hosting.</p></div>
|
||||
<div class="signals">
|
||||
<article class="signal" aria-labelledby="pressure-title">
|
||||
<h3 id="pressure-title">Map capacity pressure</h3>
|
||||
<strong class="value">{metricPercent(pressure.fullMapHours, pressure.knownMapHours)}</strong>
|
||||
<p class="evidence">{metricDuration(pressure.fullMapHours)} full / {metricDuration(pressure.knownMapHours)} known map time</p>
|
||||
<p>Share of pooled known map-hours with all lobbies full. {pressure.coveredMaps} of {pressure.totalMaps} map groups have known capacity.</p>
|
||||
{#if pressure.knownMapHours === 0}<p class="notice">Capacity pressure is unavailable. Occupancy can still be recorded without a reported player limit.</p>{/if}
|
||||
<p class="fine">Map groups can overlap in clock time; this is not simultaneous fleet saturation.</p>
|
||||
</article>
|
||||
<article class="signal" aria-labelledby="comparison-title">
|
||||
<div class="comparison-heading"><h3 id="comparison-title">Compared with the previous {report.days === 1 ? '24 hours' : `${report.days} days`}</h3>{#if prior.partialHistory}<span class="badge">Partial previous history</span>{/if}</div>
|
||||
<div class="deltas">
|
||||
<div><span>Time with players</span><strong>{utilizationDelta === null ? '—' : signed(utilizationDelta, ' pp')}</strong><small>Percentage points · {metricPercent(prior.summary.active, prior.summary.observed)} → {metricPercent(report.summary.active, report.summary.observed)}</small></div>
|
||||
<div><span>Player-hours</span><strong>{playerNew ? 'New' : playerDelta === null ? '—' : signed(playerDelta, '%')}</strong><small>{prior.summary.observed > 0 ? metricNumber(prior.summary.playerHours) : '—'} → {report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}</small></div>
|
||||
</div>
|
||||
<p class="evidence">Known server time: {metricDuration(prior.summary.observed)} previous · {metricDuration(report.summary.observed)} current</p>
|
||||
{#if prior.summary.observed <= 0 || report.summary.observed <= 0}<p class="notice">A comparison needs known occupancy in both windows. The available durations are still shown.</p>
|
||||
{:else if prior.summary.playerHours === 0 && report.summary.playerHours > 0}<p class="notice">No previous player-hours: a percentage change has no baseline.</p>{/if}
|
||||
{#if prior.partialHistory}<p class="fine">Retained scope history does not span the full previous window. Deltas use the available observations without extrapolation.</p>{/if}
|
||||
<details class="comparison-detail"><summary>Compare observation windows</summary>
|
||||
<dl><div><dt>Previous</dt><dd>{format.period(prior.since)} – {format.period(prior.until)}<small>{metricDuration(prior.summary.active)} with players / {metricDuration(prior.summary.observed)} known · {metricDuration(prior.summary.unknown)} unknown</small></dd></div>
|
||||
<div><dt>Current</dt><dd>{format.period(report.since)} – {format.period(report.now)}<small>{metricDuration(report.summary.active)} with players / {metricDuration(report.summary.observed)} known · {metricDuration(report.summary.unknown)} unknown</small></dd></div></dl>
|
||||
<p class="fine">pp means percentage points. Collection gaps, changing fleet size, and retained history can affect both comparisons. Equal windows do not guarantee equal observation coverage.</p>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="suggestions">
|
||||
<div class="suggestion-heading"><h3>Hosting suggestions</h3><span class="advisory">Advice only · Rules v1</span></div>
|
||||
<p class="fine">Suggestions use historical observations. Check the current lineup before trying a change. These experiments do not estimate rejected joins or change servers automatically.</p>
|
||||
{#if recommendations.length}
|
||||
<div class="recommendations">{#each recommendations as suggestion (JSON.stringify([suggestion.game, suggestion.map]))}
|
||||
<article class="recommendation">
|
||||
<div class="map"><h4>{suggestion.map}</h4><small>{suggestion.game.toUpperCase()}</small>{#if suggestion.tentative}<span class="badge">Tentative</span>{/if}</div>
|
||||
<div class="advice"><strong>{suggestion.title}</strong><p class="evidence">{suggestion.evidence}</p><details><summary>Why & what to watch</summary><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
|
||||
</article>
|
||||
{/each}</div>
|
||||
{:else}<p class="empty">No map observations in this scope yet. Select another scope or leave collection running to generate suggestions.</p>{/if}
|
||||
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
|
||||
<ul>
|
||||
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
|
||||
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
|
||||
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
|
||||
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
|
||||
</ul>
|
||||
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<details class="heatmap-disclosure"><summary>View activity by weekday and hour</summary>
|
||||
<MetricsHeatmap heatmap={report.heatmap} disabled={changingScope} {onTimeZoneChange} />
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.explore { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||
.heading { padding: var(--space-5) var(--space-5) var(--space-4); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
h3 { margin: 0; font-size: var(--text-md); }
|
||||
p { margin: var(--space-2) 0 0; line-height: 1.6; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.heading p { font-size: var(--text-sm); }
|
||||
.signals { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-4); padding: 0 var(--space-5) var(--space-5); }
|
||||
.signal { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: var(--space-4); background: var(--surface-sunken); }
|
||||
.value { display: block; margin-top: var(--space-3); font-size: var(--text-2xl); font-variant-numeric: tabular-nums; }
|
||||
.evidence { color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.fine { color: var(--text-tertiary); font-size: 11px; line-height: 1.6; }
|
||||
.notice { padding-left: var(--space-2); border-left: 2px solid var(--state-warning); }
|
||||
.comparison-heading { display: flex; gap: var(--space-2); align-items: flex-start; flex-wrap: wrap; }
|
||||
.badge { display: inline-block; width: fit-content; color: var(--state-warning); font-size: 10px; border: 1px solid color-mix(in oklab, var(--state-warning) 40%, transparent); padding: 2px 6px; border-radius: 4px; white-space: nowrap; }
|
||||
.deltas { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin-top: var(--space-3); }
|
||||
.deltas span { color: var(--text-secondary); font-size: 11px; }
|
||||
.deltas strong { display: block; margin-top: 4px; font-size: var(--text-xl); font-variant-numeric: tabular-nums; }
|
||||
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); font-weight: normal; line-height: 1.6; }
|
||||
details { font-size: var(--text-xs); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); line-height: 1.6; }
|
||||
.comparison-detail { margin-top: var(--space-3); }
|
||||
dl { margin: var(--space-3) 0 0; display: grid; gap: var(--space-3); font-size: 11px; }
|
||||
dt { color: var(--text-tertiary); }
|
||||
dd { margin: 2px 0 0; color: var(--text-secondary); }
|
||||
.suggestions { padding: var(--space-5); border-top: 1px solid var(--border-subtle); }
|
||||
.suggestion-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.advisory { color: var(--text-tertiary); font-size: 11px; }
|
||||
.recommendations { margin-top: var(--space-3); }
|
||||
.recommendation { display: grid; grid-template-columns: minmax(130px, .6fr) minmax(0, 2fr); gap: var(--space-4); padding: var(--space-4) 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.map h4 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-medium); overflow-wrap: anywhere; }
|
||||
.map .badge { margin-top: 5px; }
|
||||
.advice strong { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
.advice p { margin-top: 5px; }
|
||||
.advice details { margin-top: 6px; }
|
||||
.rules { margin-top: var(--space-4); color: var(--text-secondary); }
|
||||
.rules ul { padding-left: var(--space-5); line-height: 1.8; }
|
||||
.empty { padding: var(--space-4) 0; }
|
||||
.heatmap-disclosure { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); }
|
||||
.heatmap-disclosure > summary { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
@media (max-width: 760px) { .signals { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 540px) { .heading, .suggestions { padding: var(--space-4); } .signals { padding: 0 var(--space-4) var(--space-4); gap: var(--space-3); } .recommendation { grid-template-columns: 1fr; gap: var(--space-2); } .map { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } .map small, .map .badge { margin: 0; } .heatmap-disclosure { padding: var(--space-4); } }
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import { availableTimeZones } from '$lib/time-zone.js';
|
||||
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
|
||||
type Heatmap = MetricsData['heatmap'];
|
||||
type Cell = Heatmap['cells'][number];
|
||||
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
|
||||
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const hours = Array.from({ length: 24 }, (_, hour) => hour);
|
||||
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
|
||||
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
|
||||
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
|
||||
let selectedKey = $state<number | null>(null);
|
||||
let announcement = $state('');
|
||||
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
|
||||
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
|
||||
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
|
||||
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
|
||||
function description(cell: Cell) {
|
||||
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
|
||||
}
|
||||
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
|
||||
function move(event: KeyboardEvent, cell: Cell) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
|
||||
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
|
||||
const key = day * 24 + hour;
|
||||
select(day, hour);
|
||||
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="heatmap">
|
||||
<div class="heading"><div><h3>Activity by weekday and hour</h3><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
|
||||
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
|
||||
</div>
|
||||
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
|
||||
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
|
||||
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
|
||||
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
|
||||
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
|
||||
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
|
||||
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
|
||||
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
|
||||
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
|
||||
{/each}</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
|
||||
{#if selected}
|
||||
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
|
||||
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
|
||||
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
|
||||
{:else}<dl>
|
||||
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
|
||||
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
|
||||
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
|
||||
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
|
||||
</dl>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.heatmap { min-width: 0; margin-top: var(--space-4); }
|
||||
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
h3 { margin: 0; font-size: var(--text-md); }
|
||||
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
|
||||
.zone { flex: 0 1 250px; min-width: 190px; }
|
||||
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: 11px; color: var(--text-secondary); }
|
||||
.legend span { display: flex; gap: 5px; align-items: center; }
|
||||
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
|
||||
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
|
||||
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
|
||||
.missing-example { border: 1px dashed var(--border-control); }
|
||||
.table-wrap { overflow-x: auto; max-width: 100%; padding: 3px; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
|
||||
th { color: var(--text-tertiary); font-weight: normal; font-size: 10px; text-align: center; padding: 0 0 3px; }
|
||||
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
|
||||
td { padding: 0; }
|
||||
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
|
||||
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
|
||||
.cell.missing { border-style: dashed; background: transparent; }
|
||||
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
|
||||
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
|
||||
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
|
||||
.note { color: var(--text-tertiary); font-size: 11px; }
|
||||
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
|
||||
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
|
||||
dt { font-size: 11px; color: var(--text-tertiary); }
|
||||
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
|
||||
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) { .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -92,14 +92,14 @@
|
||||
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table>
|
||||
<thead><tr>{#each serverColumns as column}<th scope="col" aria-sort={serverSort === column.key ? serverDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortServer(column.key)}>{column.label}<span aria-hidden="true">{serverSort === column.key ? serverDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
|
||||
<tbody>{#each serverRows as server (server.server)}<tr>
|
||||
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small></th>
|
||||
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small>{#if server.currentRegistered}<a class="current-server" href={`/servers/${encodeURIComponent(server.server)}`}>Open current server <span aria-hidden="true">↗</span></a>{/if}</th>
|
||||
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td>
|
||||
<td>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
|
||||
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
</tr>{/each}</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title="No servers match this search" description="Try a friendly map name or technical server ID.">{#snippet actions()}<Button size="sm" onclick={() => updateContext({ serverQ: '' })}>Clear server search</Button>{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>A dash means no known time. Names may include more than one registration.</span></div>
|
||||
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>Names may include multiple registrations. Links open the current registration, which may differ from historical instances.</span></div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -123,6 +123,9 @@
|
||||
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); }
|
||||
small { display: block; margin-top: 4px; font-size: 11px; font-weight: normal; line-height: 1.5; color: var(--text-tertiary); }
|
||||
.server-id { font-family: var(--font-mono); font-size: 10px; }
|
||||
.current-server { display: inline-block; margin-top: 6px; font-size: 11px; color: var(--accent-primary-hover); text-decoration: none; }
|
||||
.current-server:hover { text-decoration: underline; }
|
||||
.current-server:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.unavailable { color: var(--text-secondary); }
|
||||
.player-hours { min-width: 120px; }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
import type { MetricsData } from '$lib/metrics.js';
|
||||
import { validTimeZone } from '$lib/time-zone.js';
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const numeric = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const timestamp = (value: unknown) => value === null || numeric(value);
|
||||
const totals = (value: unknown) => record(value) && ['observed', 'active', 'idle', 'unknown', 'playerHours'].every(key => numeric(value[key]) && value[key] >= 0);
|
||||
|
||||
function validReport(value: unknown): value is MetricsData {
|
||||
if (!record(value) || ![1, 7, 30].includes(value.days as number) || !numeric(value.now) || !numeric(value.since) || !totals(value.summary)) return false;
|
||||
if (!record(value.scope) || !validTimeZone(value.scope.timeZone) || ![value.scope.game, value.scope.map].every(item => item === null || typeof item === 'string')) return false;
|
||||
if (!record(value.scopeOptions) || !Array.isArray(value.scopeOptions.games) || !value.scopeOptions.games.every(item => typeof item === 'string') ||
|
||||
!Array.isArray(value.scopeOptions.maps) || !value.scopeOptions.maps.every(item => record(item) && typeof item.game === 'string' && typeof item.map === 'string')) return false;
|
||||
if (!numeric(value.registeredServerCount) || !['firstRetainedSampleAt', 'latestSampleAt', 'scopeFirstSampleAt', 'scopeLatestSampleAt'].every(key => timestamp(value[key]))) return false;
|
||||
if (!Array.isArray(value.points) || !value.points.every(item => record(item) && totals(item) && numeric(item.at) && numeric(item.startAt) && numeric(item.endAt) && typeof item.partial === 'boolean')) return false;
|
||||
if (!Array.isArray(value.maps) || !value.maps.every(item => record(item) && totals(item) && typeof item.game === 'string' && typeof item.map === 'string' && numeric(item.full) && numeric(item.capacityObserved))) return false;
|
||||
if (!Array.isArray(value.servers) || !value.servers.every(item => record(item) && totals(item) && typeof item.server === 'string' && typeof item.currentRegistered === 'boolean')) return false;
|
||||
if (!record(value.previous) || !totals(value.previous.summary) || !numeric(value.previous.since) || !numeric(value.previous.until) ||
|
||||
!timestamp(value.previous.firstSampleAt) || !timestamp(value.previous.lastSampleAt) || typeof value.previous.partialHistory !== 'boolean') return false;
|
||||
if (!record(value.heatmap) || !validTimeZone(value.heatmap.timeZone) || !Array.isArray(value.heatmap.cells) ||
|
||||
!value.heatmap.cells.every(item => record(item) && totals(item) && numeric(item.weekday) && numeric(item.hour) && numeric(item.distinctDates))) return false;
|
||||
return record(value.pooledPressure) && (value.pooledPressure.value === null || numeric(value.pooledPressure.value)) &&
|
||||
['fullMapHours', 'knownMapHours', 'coveredMaps', 'totalMaps'].every(key => numeric((value.pooledPressure as Record<string, unknown>)[key]));
|
||||
}
|
||||
|
||||
interface Options {
|
||||
getQuery: () => string;
|
||||
@@ -42,8 +65,8 @@ export function createMetricsRefresh(options: Options) {
|
||||
try {
|
||||
const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`);
|
||||
const report = await response.json() as MetricsData;
|
||||
if (!report || !Array.isArray(report.points) || !Array.isArray(report.maps) || !Array.isArray(report.servers) || !report.summary) {
|
||||
const report: unknown = await response.json();
|
||||
if (!validReport(report)) {
|
||||
throw new Error('The metrics response was incomplete.');
|
||||
}
|
||||
if (current()) options.onData(report);
|
||||
|
||||
Reference in New Issue
Block a user