121 lines
12 KiB
Svelte
121 lines
12 KiB
Svelte
<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>
|