diff --git a/src/lib/ui/components/metrics/MetricsChart.svelte b/src/lib/ui/components/metrics/MetricsChart.svelte
index 1f584cf..5b953ae 100644
--- a/src/lib/ui/components/metrics/MetricsChart.svelte
+++ b/src/lib/ui/components/metrics/MetricsChart.svelte
@@ -65,7 +65,7 @@
{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. {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. {metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.Server time over time
Instance activity over time
Inspect a period
View exact time data
@@ -118,6 +121,7 @@
{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' pp')} vs ${previousLabel}`}
+ {metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed instance time +{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}
+Players × observed time. One player for one hour equals one player-hour.
{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}
+ Pooled map time · not simultaneous fleet saturation +Partial previous history. Comparisons use available observations without extrapolation.
{/if} +{#if report.maps.some(map => map.capacityObserved <= 0)}{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.
{/if} +{#if report.summary.unknown > 0}{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time with players.” Collection gaps are separate.
{/if} + + diff --git a/src/lib/ui/components/metrics/MetricsTables.svelte b/src/lib/ui/components/metrics/MetricsTables.svelte index 94f0518..4bc849b 100644 --- a/src/lib/ui/components/metrics/MetricsTables.svelte +++ b/src/lib/ui/components/metrics/MetricsTables.svelte @@ -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 = $derivedCompare play time and how often every tracked lobby was full.
“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.
- {#if mapRows.length} + {#if currentPage.total} -| {/each} | ||||
|---|---|---|---|---|
| {map.map}{gameName(map.game)} · {map.game.toUpperCase()} | -{map.observed ? metricNumber(map.playerHours) : '—'} | -{metricDuration(map.active)}server time | -{#if map.capacityObserved > 0}{metricDuration(map.full)}{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known{:else}UnknownNo known capacity time{/if} | -{metricDuration(map.capacityObserved)}map time | -
| + {#if column.key === 'details'}Details{:else}{/if} + | + {/each}||||
|---|---|---|---|---|
| {map.map}{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''} | +{map.observed ? metricNumber(map.playerHours) : '—'} | +{map.observed ? metricDuration(map.active) : '—'} | +{metricPercent(map.full, map.capacityObserved)} | +{map.map} Details
|
+
| {#if server.currentRegistered}{serverTitle(server.server)}{:else}{serverTitle(server.server)}Historical{/if} | +{metricPercent(server.active, server.observed)} | {metricDuration(server.observed)} | {metricDuration(server.unknown)} | +{serverTitle(server.server)} Details
|
+
Time with players as a share of each server’s known time.
| {/each} | ||||
|---|---|---|---|---|
| {metricPercent(server.active, server.observed)}{metricDuration(server.active)} of {metricDuration(server.observed)} known | -{metricPercent(server.idle, server.observed)}{metricDuration(server.idle)} idle | -{metricDuration(server.observed)} | {metricDuration(server.unknown)} | -
Grouped by recorded name; links open the current instance, which may differ from the historical registration.
{/if} +{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.'}
{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}
+{scopeError}
{/if} {#if refreshError} @@ -122,26 +127,8 @@{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time
- {#if report.summary.observed === 0}Occupancy is unknown for all captured time.
{/if} -Players × known time · one player for one hour = 1
-{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}
+ {#if captured > 0}All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.
{/if}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.
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.
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.
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.
@@ -178,36 +178,22 @@ diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts new file mode 100644 index 0000000..b36b2ee --- /dev/null +++ b/tests/unit/ui-metrics-page.test.ts @@ -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