Files
electric-cherry/src/lib/ui/components/metrics/MetricsExplore.svelte
T

121 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>