From 3922e28ca8374733c64b197edebcf581e4567478 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 27 Sep 2026 22:12:11 -0400 Subject: [PATCH] Improve metrics clarity, exploration, and refresh reliability --- docs/metrics-improvement-plan.md | 107 +++++++++ docs/metrics.md | 22 ++ src/lib/metrics.ts | 49 +++++ src/lib/server/fleet/metrics.ts | 143 ++++++++---- .../ui/components/metrics/MetricsChart.svelte | 144 ++++++++++++ .../components/metrics/MetricsTables.svelte | 140 ++++++++++++ src/lib/ui/metrics-refresh.ts | 64 ++++++ src/lib/ui/metrics.ts | 32 +++ src/lib/ui/styles/tokens.css | 6 + src/routes/api/metrics/+server.ts | 9 + src/routes/metrics/+page.server.ts | 4 +- src/routes/metrics/+page.svelte | 207 +++++++++++------- tests/unit/metrics-refresh.test.ts | 68 ++++++ tests/unit/metrics.test.ts | 103 ++++++++- tests/unit/ui-metrics.test.ts | 21 ++ 15 files changed, 984 insertions(+), 135 deletions(-) create mode 100644 docs/metrics-improvement-plan.md create mode 100644 src/lib/metrics.ts create mode 100644 src/lib/ui/components/metrics/MetricsChart.svelte create mode 100644 src/lib/ui/components/metrics/MetricsTables.svelte create mode 100644 src/lib/ui/metrics-refresh.ts create mode 100644 src/lib/ui/metrics.ts create mode 100644 src/routes/api/metrics/+server.ts create mode 100644 tests/unit/metrics-refresh.test.ts create mode 100644 tests/unit/ui-metrics.test.ts diff --git a/docs/metrics-improvement-plan.md b/docs/metrics-improvement-plan.md new file mode 100644 index 0000000..22fcbdd --- /dev/null +++ b/docs/metrics-improvement-plan.md @@ -0,0 +1,107 @@ +Improve Metrics so an operator can quickly answer how much hosting time had players, which maps receive play, where measured capacity stays full, and whether the recorded history supports a decision. + +This plan combines three UI/UX reviews (information architecture, visual design, and interaction/accessibility) and two software engineering reviews (frontend and metrics data). The reviewers exchanged proposals and challenged the tradeoffs. No product changes are included. + +The review covered the current Svelte page, collector, SQLite aggregation, shared design system, and tests. Desktop (1440px) and mobile (390px) screenshots of the existing build were inspected using an isolated synthetic database. That fixture contained six server names, seven days of samples, an outage, unknown status, and missing capacity. It establishes layout behavior, not production usage patterns. + +| Finding | Consequence | Planned response | +| --- | --- | --- | +| “Servers utilized” is occupied time divided by known server time. | It can be mistaken for a percentage of server count. | Name the measure “Time with players” and show its denominator. | +| Four equal cards include complementary occupied/idle percentages. At 390px, the chart heading starts around 970px down the page. | Repeated summary information delays useful analysis, especially on phones. | Two compact headline metrics and a nearby data-quality strip. | +| The chart has endpoint labels but no calibrated vertical ticks. Partial edge periods look like ordinary periods. | A short final bar can look like declining activity. | Add clear axes, explicit partial-period treatment, and inspectable intervals. | +| “Updated” is the report calculation time. | A fresh report can contain old samples. | Separate “Report refreshed” from “Last collected.” | +| Capacity can be absent even when telemetry supplies valid occupancy. | “Unknown” pressure can be mistaken for a failure or no pressure. | Keep known-capacity duration beside every pressure figure and explain unavailable capacity. | +| Tables have fixed ordering and limited comparison affordances. | Operators must scan raw values to identify useful patterns. | Sorting, restrained comparison bars, readable identities, and local search. | +| Metrics aggregate historical samples by server name without an instance identifier. | Linking a row to a current server can connect old history to a replacement. | Keep historical row identities unlinked in the first release. | +| A synthetic 118,806-row fixture took about 0.99–1.35 seconds to read for the seven-day view. | Synchronous aggregation can block other application requests. | Profile and reduce repeated aggregation work before expanding exploration. | + +The proposed page composition is: + +```text +Metrics Refresh +24 hours / 7 days / 30 days selected range · timezone + +Time with players Player-hours +58.6% 1,350.5 +555.7 of 948.8 known server-hours Players × observed time + +Known status … · Unknown status … · Last collected … +Report refreshed … · Samples retained from … + +Server time over time occupied / idle / unknown +[stacked server-hours chart with axes and marked partial periods] +[Period selector] [Previous] [Next] [selected interval and values] +View exact time data + +Map play time & capacity +[sortable ranking; pressure and known-capacity time stay together] + +Server utilization Find a server… +[sortable table; friendly names and secondary technical IDs] + +How these estimates work +``` + +The example values above illustrate the synthetic preview, not a target or recommendation. + +1. Establish a precise, efficient report contract. + + Keep the existing 24-hour, seven-day, and 30-day windows, 45-second observation cap, UTC bucket boundaries, and 31-day retention. Add typed report metadata for the latest recorded collection sample, retained sample bounds, and current registered-server count. Return the actual clipped start/end and partial flag for each bucket. Distinguish report calculation time from sample time. Collection stops when no servers are registered, so old history alone does not imply a failed collector. Prefer factual timestamps; do not apply a ticking 45-second stale threshold to a page refreshed only every minute. + + Keep the formulas explicit: occupied percentage = occupied server-hours / known server-hours; known server-hours = occupied + idle. Unknown samples are separate. Long collection gaps are excluded, so known / (known + unknown) must not be called collection coverage. Earliest retained sample does not establish first-ever collection or uninterrupted history. + + Pressure remains map wall time: “All lobbies full for X during Y with known capacity.” It requires known occupancy and capacity for every tracked lobby in that recorded game/map group. It is not a measure of failed joins or unmet demand. Missing capacity must remain unknown; do not assume four slots or carry old capacity forward without a separate validity policy. + + Profile realistic retained histories, then consolidate repeated sample/bucket aggregation while preserving interval clipping and pressure semantics. The current implementation queries each chart bucket separately in addition to summary and table queries. One profiled seven-day read spent roughly 349ms on summary, 158ms on servers, 441ms on maps, and 109ms across chart buckets: reducing bucket queries alone misses most of the cost. Use parity checks against existing calculations before replacing it; evaluate query plans before adding indexes. Avoid transferring raw samples to the browser. Do not introduce permanent rollups or a background analytics subsystem unless profiling justifies them. + + Primary files: `src/lib/server/fleet/metrics.ts`, `src/routes/metrics/+page.server.ts`, `tests/unit/metrics.test.ts`. Add a shared metrics report type and range parser where both page and API can use them. Schema or collector changes are conditional on measured need. + +2. Make refresh and report states dependable. + + Retain server rendering for the initial page. Add a narrow read-only `/api/metrics?days=…` endpoint using the same report builder and range validation for background refresh. Use the existing `pollVisible` helper to avoid overlapping refreshes and refresh on return to a visible tab. The installed SvelteKit invalidation path can navigate into an error page; catching `invalidateAll()` alone does not guarantee preservation of the last successful report. + + Store the last successful report, update it only after a successful response, and show refreshing, stale/error, and retry feedback inline. Give requests a timeout, abort on navigation/unmount, and use a request/scope identifier so an old response cannot overwrite a newly selected range. Keep range state in the URL. Preserve table search/sort and selected bucket through refresh; reconcile bucket selection when the range changes. Optional shareable table state should use shallow URL updates without rerunning aggregation. Avoid moving keyboard focus or announcing the entire report each minute. + + Distinguish no retained samples, no samples in this selected range, unknown-only observations, unavailable capacity, and a failed report refresh. An empty range should offer a wider range. Do not promise that collection will begin if no servers are registered, or claim “first observations” solely because retention left the table empty. + + Primary files: the metrics route, a new `src/routes/api/metrics/+server.ts`, and existing `src/lib/ui/poll.ts` usage. Keep shared-helper changes minimal. + +3. Rebuild the summary and timeline around readable evidence. + + Replace four equal cards with “Time with players” and “Player-hours.” Put the occupied numerator and known-time denominator beneath the percentage. Keep idle in the chart/table and known/unknown time in a compact strip. Show collection time separately from report refresh; display sparse-history caveats without inventing a confidence score. + + Reuse `Stat` with numeric tweening disabled, existing cards/inputs/empty states, and semantic design tokens. Keep blue for occupied time, a quieter neutral for idle, and patterned unknown. Define chart colors as tokens rather than scattered literals. Use short-duration formatting so a real observation never appears as `0.0 h` just because it is brief. Keep player-hours and server-hours explicitly named. + + Preserve absolute stacked server-hours as the default chart. Add three to five readable vertical ticks, intermediate time labels, marked partial edge buckets, and a persistent selected-period readout. The readout shows the exact interval in the selected timezone, occupied/idle/unknown server time, player-hours, and occupied percentage of known time. UTC-aligned buckets displayed in a local timezone must not be described as local calendar days. + + Use a labelled native period selector and Previous/Next buttons for keyboard and touch access, with pointer selection as a convenience. Keep interactive controls and the readout outside any chart region exposed as `role="img"`. Keep the exact-data table with semantic headers. A bucket with no captured time needs an explicit “No captured time” readout; do not equate it with an idle fleet. Preserve selection by bucket key across minute refreshes. + + On phones, keep the summary compact enough to reach the chart without scrolling through four tall cards. Allow controls to wrap and reduce tick density; do not remove units or denominators to make the view fit. Use a small metrics-specific component set rather than introducing a general dashboard framework or chart dependency. + +4. Make the map and server rankings useful. + + Keep both tables full width. Right-align numeric values, use tabular numerals, add keyboard-operable sort buttons with `aria-sort`, and define deterministic ties and missing-value placement. Keep the map default as player-hours descending. Give the server table an explicit default matching the displayed measure: occupied percentage descending, then known time, with unavailable percentages last. + + Add restrained proportional player-hour bars to map rows. Keep pressure duration, its percentage, and known-capacity duration adjacent. Avoid severity colors or “add servers” recommendations without a defined policy and sufficient evidence. Known-capacity duration is a wall-time quantity, not server-hours. + + Use friendly server names with raw identifiers available as secondary text. Preserve exact recorded game/map grouping and variants. Current collection stores friendly configured variants when resolvable; legacy engine-map strings can be ambiguous, so do not silently relabel them as one specific variant. + + Add clearly labelled table-only search, starting with “Find a server”; it must not imply that the headline metrics or chart were filtered. Keep historical rows unlinked in the first release. A later explicitly labelled “Open current server” action could be added separately, but current registration alone cannot prove historical identity. + + On narrow screens retain identity, the primary measure, and its denominator. Make full details available through a compact row disclosure or a labelled, keyboard-scrollable table region with sticky identity. Test which approach fits actual row lengths before choosing it. Empty searches offer a clear/reset action. + +5. Validate the completed behavior and update usage documentation. + + Extend calculation tests for new metadata and clipped intervals, then compare optimized results to the existing implementation across fixture scenarios. Test refresh errors, retry, visibility changes, overlapping refresh attempts, and responses arriving after a range switch. Prefer behavior checks over tests that duplicate markup. + + Review no samples, a first sample with zero elapsed time, sparse history, all-unknown status, telemetry-only occupancy, missing capacity, nonzero durations under a minute, collection outages, changing fleet membership, partial edge buckets, timezone/DST presentation, removed/reused server names, and T6 variants. Verify sorting with unavailable percentages and equal values. + + Inspect 390px, tablet, and desktop layouts; keyboard-only selection/sorting/table access; touch selection; readable focus states; reduced motion; and screen-reader descriptions. Ensure minute refresh preserves interaction state and background failures keep the last successful report visible. Benchmark 24-hour, seven-day, and 30-day queries against representative retained data, and check impact on concurrent application requests. + + Run the relevant unit tests, `npm run check`, and `npm run build`. Update `docs/metrics.md` to match the labels, states, and counting rules. The review baseline passed the existing four metrics tests and 23 status/observation tests; that does not replace validation of the eventual implementation. + +The team accepted two primary metrics, visible data-quality context, an absolute-hours chart, progressive detail, and small native interaction controls. The engineering review changed two early UX ideas: live historical row links are deferred because names can be reused, and “Last collected” replaces wording that could imply a fresh successful game observation. It also added background fetch isolation and measured query work to the implementation scope. + +Defer a default percentage-only chart, fleet-wide “all full” KPI, period-over-period deltas, heatmaps, automatic hosting recommendations, and global game/map filters. A percentage-only chart can hide tiny denominators; map pressure intervals overlap and cannot be summed into a meaningful fleet total. Comparisons and heatmaps need fair coverage treatment. Global filters should follow later as a complete query scope applied consistently to summary, timeline, and both tables, with URL state. + +Delivery can be split into three reviewable changes: (1) report metadata, measured query improvements, and calculation tests; (2) refresh handling plus summary/timeline and report states; (3) rankings, mobile/accessibility refinement, and documentation. The data contract comes first; frontend layout work can proceed against that agreed contract in parallel. The result should let an operator read utilization correctly, see its evidence limits, inspect a period, and find relevant maps or servers without treating missing data as zero. diff --git a/docs/metrics.md b/docs/metrics.md index a2297c0..89988d8 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -3,6 +3,28 @@ Metrics is a primary navigation tab at `/metrics` (keyboard shortcut: `g m`). Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute. +The overview shows **Time with players** (occupied server-hours divided by known +server-hours) and **Player-hours**. Known and unknown status time remain visible +alongside the totals. **Last collected** is the newest recorded batch, including +unknown checks; **Report refreshed** is when this report was calculated. Collection +stops when no servers are registered. Earliest retained observations do not prove +continuous history or the original installation date. + +Background refresh keeps the last successful report visible on failure and offers +Retry. Returning to a visible tab refreshes immediately. The selected range is +stored in the URL, and refreshes preserve table controls and chart selection. + +The timeline has labelled axes and marks partial edge periods. Select a period, +use Previous/Next, or select a bar to inspect its exact interval and values. The +time table supplies the same figures for comparison and assistive technology. +Zero captured time is distinct from an observed empty server. Short durations use +seconds or minutes so a real observation is not rounded to zero. + +Map and server tables support numeric sorting and local search. Their searches +apply only to the named table. Known-capacity duration stays next to all-full time. +Server identities retain the technical name; rows describe history grouped by +name, which may include different instances if a name was reused. + - Utilized means a successful status response reported at least one player. - Idle means a successful response reported zero players. - Unknown covers failed checks, provisioning, and unavailable containers; it is excluded from utilization percentages. diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts new file mode 100644 index 0000000..fc1732d --- /dev/null +++ b/src/lib/metrics.ts @@ -0,0 +1,49 @@ +export type MetricsDays = 1 | 7 | 30; + +export function parseMetricsDays(value: string | null | undefined): MetricsDays { + const days = Number(value ?? 7); + return days === 1 || days === 7 || days === 30 ? days : 7; +} + +/** All durations are hours. Observed is known server time, not collection coverage. */ +export interface Utilization { + observed: number; + active: number; + idle: number; + unknown: number; + playerHours: number; +} + +export interface ServerMetrics extends Utilization { + server: string; +} + +export interface MapMetrics extends Utilization { + game: string; + map: string; + /** Map wall time, not summed server time. */ + full: number; + capacityObserved: number; +} + +export interface MetricPoint extends Utilization { + /** UTC bucket boundary, including when the selected interval starts later. */ + at: number; + startAt: number; + endAt: number; + partial: boolean; +} + +export interface MetricsData { + days: MetricsDays; + since: number; + now: number; + /** Retained sample timestamps describe collection, not gameplay freshness. */ + firstRetainedSampleAt: number | null; + latestSampleAt: number | null; + registeredServerCount: number; + summary: Utilization; + servers: ServerMetrics[]; + maps: MapMetrics[]; + points: MetricPoint[]; +} diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index f703852..9890345 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -1,5 +1,8 @@ import { db } from '../db/index.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js'; +import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js'; + +export type { MapMetrics, Utilization } from '../../metrics.js'; export interface MetricSample { server: string; @@ -18,61 +21,105 @@ export function recordMetrics(samples: MetricSample[], at = Date.now()): void { })(); } -export interface Utilization { - observed: number; - active: number; - idle: number; - unknown: number; - playerHours: number; -} -export interface MapMetrics extends Utilization { - game: string; - map: string; - full: number; - capacityObserved: number; +function emptyUtilization(): Utilization { + return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; } -/** Treat each observation as valid until the next poll, capped at the freshness limit. - * Longer collection gaps are excluded, never interpreted as empty servers. */ -export function readMetrics(days: number, now = Date.now()) { +function addObservation(total: Utilization, players: number | null, hours: number): void { + if (players === null) total.unknown += hours; + else { + total.observed += hours; + if (players > 0) total.active += hours; + else if (players === 0) total.idle += hours; + total.playerHours += players * hours; + } +} + +/** Each collection is a complete fleet snapshot. Hold it until the next collection, + * capped at the freshness limit; longer gaps contribute no observed or unknown time. + * Read the indexed history once and share the intervals across every aggregation. */ +export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { const since = now - days * 86400_000; const bucketMs = days === 1 ? 3600_000 : 86400_000; const handle = db(); - const cte = `WITH times AS ( - SELECT at, LEAD(at, 1, @now) OVER (ORDER BY at) AS next_at - FROM (SELECT DISTINCT at FROM metric_samples WHERE at >= @since - @fresh AND at <= @now) - ), samples AS ( - SELECT s.*, MAX(0, MIN(t.next_at, s.at + @fresh, @now) - MAX(s.at, @since)) / 3600000.0 AS hours - FROM metric_samples s JOIN times t ON t.at = s.at - )`; - const params = { now, since, fresh: ONLINE_MAX_MS }; - const totals = `COALESCE(SUM(CASE WHEN players IS NOT NULL THEN hours ELSE 0 END), 0) AS observed, - COALESCE(SUM(CASE WHEN players > 0 THEN hours ELSE 0 END), 0) AS active, - COALESCE(SUM(CASE WHEN players = 0 THEN hours ELSE 0 END), 0) AS idle, - COALESCE(SUM(CASE WHEN players IS NULL THEN hours ELSE 0 END), 0) AS unknown, - COALESCE(SUM(players * hours), 0) AS playerHours`; - const summary = handle.prepare(`${cte} SELECT ${totals} FROM samples`).get(params) as Utilization; - const servers = handle.prepare(`${cte} SELECT server, ${totals} FROM samples GROUP BY server ORDER BY active DESC, server`).all(params) as (Utilization & { server: string })[]; - const maps = handle.prepare(`${cte}, pressure AS ( - SELECT game, map, at, MAX(hours) AS hours, - MIN(CASE WHEN players IS NOT NULL AND capacity > 0 THEN 1 ELSE 0 END) AS known, - MIN(CASE WHEN players >= capacity AND capacity > 0 THEN 1 ELSE 0 END) AS full - FROM samples GROUP BY game, map, at - ), map_pressure AS ( - SELECT game, map, SUM(CASE WHEN known = 1 THEN hours ELSE 0 END) AS capacityObserved, - SUM(CASE WHEN known = 1 AND full = 1 THEN hours ELSE 0 END) AS full - FROM pressure GROUP BY game, map - ) SELECT s.game, s.map, ${totals}, p.full, p.capacityObserved - FROM samples s JOIN map_pressure p ON p.game = s.game AND p.map = s.map - GROUP BY s.game, s.map ORDER BY playerHours DESC, s.map`).all(params) as MapMetrics[]; - // Split intervals at chart boundaries so a midnight poll cannot shift time into the wrong day. - const points: (Utilization & { at: number })[] = []; + const metadata = handle.prepare(`SELECT + (SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt, + (SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt, + (SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as + Pick; + const summary = emptyUtilization(); + const serverTotals = new Map(); + const mapTotals = new Map>(); + const points: MetricPoint[] = []; for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) { const end = Math.min(at + bucketMs, now); const start = Math.max(at, since); - const point = handle.prepare(`${cte} SELECT ${totals} FROM samples`) - .get({ ...params, since: start, now: end }) as Utilization; - points.push({ at, ...point }); + points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() }); } - return { days, since, now, summary, servers, maps, points }; + + type RecordedSample = MetricSample & { at: number }; + let batch: RecordedSample[] = []; + function aggregateBatch(nextAt: number): void { + if (!batch.length) return; + const start = Math.max(batch[0].at, since); + const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now); + const hours = Math.max(0, end - start) / 3600_000; + const pressure = new Map(); + // Split once for the entire batch; observations crossing UTC hour/day + // boundaries contribute to both buckets without shifting their duration. + const slices: { point: MetricPoint; hours: number }[] = []; + for (let cursor = start; cursor < end;) { + const index = Math.floor((cursor - points[0].at) / bucketMs); + const point = points[index]; + const stop = Math.min(end, point.endAt); + slices.push({ point, hours: (stop - cursor) / 3600_000 }); + cursor = stop; + } + for (const sample of batch) { + let server = serverTotals.get(sample.server); + if (!server) { + server = { server: sample.server, ...emptyUtilization() }; + serverTotals.set(sample.server, server); + } + let game = mapTotals.get(sample.game); + if (!game) { game = new Map(); mapTotals.set(sample.game, game); } + let map = game.get(sample.map); + if (!map) { + map = { game: sample.game, map: sample.map, full: 0, capacityObserved: 0, ...emptyUtilization() }; + game.set(sample.map, map); + } + addObservation(summary, sample.players, hours); + addObservation(server, sample.players, hours); + addObservation(map, sample.players, hours); + for (const slice of slices) addObservation(slice.point, sample.players, slice.hours); + const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0; + const full = known && sample.players! >= sample.capacity!; + const previous = pressure.get(map); + pressure.set(map, { known: known && (previous?.known ?? true), full: full && (previous?.full ?? true) }); + } + for (const [map, state] of pressure) { + if (state.known) { + map.capacityObserved += hours; + if (state.full) map.full += hours; + } + } + } + + type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null]; + const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples + WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(since - ONLINE_MAX_MS, now) as IterableIterator; + for (const [at, server, game, map, players, capacity] of samples) { + if (batch.length && at !== batch[0].at) { + aggregateBatch(at); + batch = []; + } + batch.push({ at, server, game, map, players, capacity }); + } + aggregateBatch(now); + + const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; + const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server)); + const maps = [...mapTotals.values()].flatMap(game => [...game.values()]) + .sort((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game)); + return { days, since, now, ...metadata, summary, servers, maps, points }; } diff --git a/src/lib/ui/components/metrics/MetricsChart.svelte b/src/lib/ui/components/metrics/MetricsChart.svelte new file mode 100644 index 0000000..06a664c --- /dev/null +++ b/src/lib/ui/components/metrics/MetricsChart.svelte @@ -0,0 +1,144 @@ + + +
+

Server time over time

Server-hours per {days === 1 ? 'hour' : '24-hour period'}. Partial periods contain less elapsed time.

+
With playersIdleUnknown* Partial period
+
Server-hours
+
+ +
+ + {#each points as point, index (point.at)} + + {/each} +
+ +
+ {#if selected} +
+
updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} />
+
+

“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} + +
+ {#each mapColumns as column}{/each} + {#each mapRows as map (JSON.stringify([map.game, map.map]))} + + + + + + {/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
+ {:else}{#snippet actions()}{#if mapQuery}{/if}{/snippet}{/if} + +
+ +
+

Server utilization

Time with players as a share of each server’s known time.

+ +
+ {#if serverRows.length} + +
+ {#each serverColumns as column}{/each} + {#each serverRows as server (server.server)} + + + + + {/each} +
{server.server}{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)}
+ {:else}{#snippet actions()}{/snippet}{/if} + +
+ + diff --git a/src/lib/ui/metrics-refresh.ts b/src/lib/ui/metrics-refresh.ts new file mode 100644 index 0000000..f88ebb7 --- /dev/null +++ b/src/lib/ui/metrics-refresh.ts @@ -0,0 +1,64 @@ +import type { MetricsData } from '$lib/metrics.js'; + +interface Options { + getQuery: () => string; + onData: (report: MetricsData) => void; + onError: (message: string | null) => void; + onLoading: (loading: boolean) => void; + fetcher?: typeof fetch; + timeoutMs?: number; +} + +/** Keeps background failures and obsolete responses out of the last successful report. */ +export function createMetricsRefresh(options: Options) { + const request = options.fetcher ?? fetch; + let generation = 0; + let pending: { query: string; controller: AbortController; promise: Promise; timer?: ReturnType } | null = null; + + function cancel() { + generation++; + if (pending) { + clearTimeout(pending.timer); + pending.controller.abort(); + pending = null; + } + options.onLoading(false); + } + + function refresh(): Promise { + const query = options.getQuery(); + if (pending?.query === query) return pending.promise; + cancel(); + const id = generation; + const controller = new AbortController(); + let timedOut = false; + const current = () => generation === id && options.getQuery() === query; + options.onLoading(true); + options.onError(null); + const entry = { query, controller, promise: Promise.resolve(), timer: undefined as ReturnType | undefined }; + pending = entry; + entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000); + entry.promise = (async () => { + 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) { + throw new Error('The metrics response was incomplete.'); + } + if (current()) options.onData(report); + } catch (error) { + if (current() && (!controller.signal.aborted || timedOut)) { + options.onError(timedOut ? 'Report refresh timed out. Try again.' : error instanceof Error ? error.message : 'Unable to refresh metrics. Try again.'); + } + } finally { + clearTimeout(entry.timer); + if (pending === entry) pending = null; + if (current()) options.onLoading(false); + } + })(); + return entry.promise; + } + + return { refresh, cancel }; +} diff --git a/src/lib/ui/metrics.ts b/src/lib/ui/metrics.ts new file mode 100644 index 0000000..7f7b25c --- /dev/null +++ b/src/lib/ui/metrics.ts @@ -0,0 +1,32 @@ +const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); + +export function metricNumber(value: number): string { + if (!Number.isFinite(value)) return '—'; + if (value > 0 && value < 0.1) return '<0.1'; + return numberFormat.format(value); +} + +export function metricPercent(value: number, total: number): string { + if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—'; + const percent = 100 * value / total; + return percent > 0 && percent < 0.1 ? '<0.1%' : `${percent.toFixed(1)}%`; +} + +/** Input is hours; retain short, real observations instead of rounding them to zero. */ +export function metricDuration(hours: number): string { + if (!Number.isFinite(hours) || hours < 0) return '—'; + if (hours === 0) return '0 h'; + const seconds = hours * 3600; + if (seconds < 1) return '<1 s'; + if (seconds < 60) return `${numberFormat.format(seconds)} s`; + if (hours < 0.1) return `${numberFormat.format(seconds / 60)} min`; + return `${numberFormat.format(hours)} h`; +} + +/** Missing evidence always sorts last, in either direction. */ +export function compareMetricValues(left: number | null, right: number | null, direction: 'asc' | 'desc'): number { + const a = left !== null && Number.isFinite(left) ? left : null; + const b = right !== null && Number.isFinite(right) ? right : null; + if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1; + return (a - b) * (direction === 'asc' ? 1 : -1); +} diff --git a/src/lib/ui/styles/tokens.css b/src/lib/ui/styles/tokens.css index 4e5be6d..2c7e850 100644 --- a/src/lib/ui/styles/tokens.css +++ b/src/lib/ui/styles/tokens.css @@ -78,6 +78,12 @@ --accent-primary-hover: var(--blue-300); --accent-primary-active: #398fcf; + /* Metrics: unknown uses a pattern as well as color. */ + --chart-active: var(--accent-primary); + --chart-idle: #7b9baf; + --chart-unknown-base: #605b64; + --chart-unknown-stripe: #a7a0ad; + /* Typography — Inter everywhere, mono for data. Display face lives inside BrandMark; no global --font-display token. */ --font-body: 'Inter', system-ui, -apple-system, sans-serif; diff --git a/src/routes/api/metrics/+server.ts b/src/routes/api/metrics/+server.ts new file mode 100644 index 0000000..908e092 --- /dev/null +++ b/src/routes/api/metrics/+server.ts @@ -0,0 +1,9 @@ +import { json } from '@sveltejs/kit'; +import { parseMetricsDays } from '$lib/metrics.js'; +import { readMetrics } from '$lib/server/fleet/metrics.js'; + +export function GET({ url }: { url: URL }) { + return json(readMetrics(parseMetricsDays(url.searchParams.get('days'))), { + headers: { 'cache-control': 'no-store' } + }); +} diff --git a/src/routes/metrics/+page.server.ts b/src/routes/metrics/+page.server.ts index a26f2f3..38d03fa 100644 --- a/src/routes/metrics/+page.server.ts +++ b/src/routes/metrics/+page.server.ts @@ -1,7 +1,7 @@ import { readMetrics } from '$lib/server/fleet/metrics.js'; +import { parseMetricsDays } from '$lib/metrics.js'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = ({ url }) => { - const requested = Number(url.searchParams.get('days') ?? 7); - return readMetrics([1, 7, 30].includes(requested) ? requested : 7); + return readMetrics(parseMetricsDays(url.searchParams.get('days'))); }; diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte index 57a8921..8c7d722 100644 --- a/src/routes/metrics/+page.svelte +++ b/src/routes/metrics/+page.svelte @@ -1,109 +1,148 @@ Metrics · Electric Cherry
- + + {#snippet actions()}{/snippet} +
- Updated {$timeFormat.time(data.now)} · refreshes every minute + {$timeFormat.period(report.since)} – {$timeFormat.period(report.now)}
- - {#if data.servers.length === 0} -

Collecting your first observations

Metrics start with the next server status checks. Leave the manager running to build a history of busy hours and map demand.

- {:else} -
-
Servers utilized{percent(data.summary.active, data.summary.observed)}{hours(data.summary.active)} with players
-
Servers idle{percent(data.summary.idle, data.summary.observed)}{hours(data.summary.idle)} empty
-
Player time{hours(data.summary.playerHours)}Players × observed time
-
Unknown status{hours(data.summary.unknown)}Excluded from utilization percentages
+ {#if refreshError} + - -
-

Utilization over time

-

Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.

-
UtilizedIdleUnknown
- -
{label(data.points[0].at)}0–{hours(peak)} per bar{label(data.points[data.points.length - 1].at)}
-
View time data
{#each data.points as point}{/each}
Period start ({$timeZone.replaceAll('_', ' ')})UtilizedIdleUnknownPlayer time
{label(point.at)}{hours(point.active)}{hours(point.idle)}{hours(point.unknown)}{hours(point.playerHours)}
-
- -
-

Map play time & capacity pressure

-

Ranked by player-hours. “All lobbies full” measures time when every tracked lobby for a map has known capacity and is full. It indicates capacity pressure; failed joins and unmet demand are not tracked.

-
- - {#each data.maps as map}{/each} -
MapPlayer timeUtilized server timeAll lobbies fullCapacity coverage
{map.map}{map.game.toUpperCase()}{hours(map.playerHours)}{hours(map.active)}{map.capacityObserved > 0 ? `${hours(map.full)} (${percent(map.full, map.capacityObserved)})` : 'Unknown'}{hours(map.capacityObserved)}
-

Capacity coverage is map time with a known player limit and status for every tracked lobby. Servers that do not report a player limit cannot establish whether all lobbies are full.

-
- -
-

Server utilization

-
- {#each data.servers as server}{/each} -
ServerUtilizedIdleObserved timeUnknown
{server.server}{percent(server.active, server.observed)}{percent(server.idle, server.observed)}{hours(server.observed)}{hours(server.unknown)}
-
{/if} -

Estimates use periodic server status observations. Each observation covers at most 45 seconds; longer collection gaps are excluded. History begins when collection is enabled and is retained for 31 days. Player time includes all players reported by the server.

+ {#if captured > 0} +
+ + +

{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} +
+ + 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} /> +

Players × known time · one player for one hour = 1

+
+
+ {/if} +
+ {#if captured > 0}{metricDuration(report.summary.observed)} known server time{metricDuration(report.summary.unknown)} unknown status{/if} + Last collected {report.latestSampleAt === null ? 'No retained samples' : $timeFormat.dateTime(report.latestSampleAt)} + Report refreshed {$timeFormat.time(report.now)} · refreshes every minute while visible + {#if report.firstRetainedSampleAt !== null}Earliest retained sample {$timeFormat.dateTime(report.firstRetainedSampleAt)}{/if} +
+ {#if captured === 0} + + 0 ? 'Waiting for observed time' : 'No captured time in this range'} + description={report.latestSampleAt === null + ? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.' + : report.servers.length > 0 ? 'The first sample has arrived. Time builds between status checks; this report has no elapsed observations yet.' + : 'There are no elapsed observations in the selected window. Collection gaps are excluded from these estimates.'}> + {#snippet actions()} + {#if report.latestSampleAt !== null && report.days < 30}{/if} + {#if report.registeredServerCount === 0}{/if} + {/snippet} + + + {:else} + {#if report.registeredServerCount === 0} +

No servers are currently registered. This report shows retained history; new samples resume when servers are registered.

+ {:else if report.latestSampleAt !== null && report.now - report.latestSampleAt > 5 * 60_000} +

No new collection samples in the last five minutes. This report still includes the retained observations shown below.

+ {/if} + {#if report.summary.observed === 0}

All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.

{/if} + + + {/if} +
+ How these estimates work +

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 45 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.

+

Chart buckets are aligned to UTC, with labels shown in {$timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial.

+

Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.

+
diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts new file mode 100644 index 0000000..cc50455 --- /dev/null +++ b/tests/unit/metrics-refresh.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js'; + +afterEach(() => vi.useRealTimers()); +const report = (days: number) => ({ days, summary: {}, points: [], maps: [], servers: [] }); +function setup(fetcher: typeof fetch) { + let query = 'days=7'; + const onData = vi.fn(), onError = vi.fn(), onLoading = vi.fn(); + return { ...createMetricsRefresh({ getQuery: () => query, onData, onError, onLoading, fetcher, timeoutMs: 100 }), + onData, onError, onLoading, setQuery: (value: string) => { query = value; } }; +} + +describe('metrics background refresh', () => { + it('keeps the previous report after failure and accepts a successful retry', async () => { + const fetcher = vi.fn().mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(Response.json(report(7))); + const refresh = setup(fetcher); + await refresh.refresh(); + expect(refresh.onData).not.toHaveBeenCalled(); + expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh failed (503).'); + await refresh.refresh(); + expect(refresh.onData).toHaveBeenLastCalledWith(report(7)); + expect(refresh.onError).toHaveBeenLastCalledWith(null); + expect(refresh.onLoading).toHaveBeenLastCalledWith(false); + }); + + it('coalesces duplicate requests and ignores old replies even when transport ignores abort', async () => { + const resolve: ((r: Response) => void)[] = []; + const fetcher = vi.fn(() => new Promise(done => resolve.push(done))); + const refresh = setup(fetcher); + const old = refresh.refresh(); + expect(refresh.refresh()).toBe(old); + expect(fetcher).toHaveBeenCalledTimes(1); + refresh.setQuery('days=1'); + const next = refresh.refresh(); + resolve[1](Response.json(report(1))); + await next; + resolve[0](Response.json(report(7))); + await old; + expect(refresh.onData).toHaveBeenCalledTimes(1); + expect(refresh.onData).toHaveBeenLastCalledWith(report(1)); + }); + + it('suppresses errors from a cancelled request', async () => { + let reject!: (error: Error) => void; + const refresh = setup(vi.fn(() => new Promise((_, fail) => { reject = fail; }))); + const pending = refresh.refresh(); + refresh.cancel(); + reject(new Error('Obsolete response')); + await pending; + expect(refresh.onData).not.toHaveBeenCalled(); + expect(refresh.onError.mock.calls).toEqual([[null]]); + expect(refresh.onLoading).toHaveBeenLastCalledWith(false); + }); + + it('times out with retry feedback and clears loading', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn((_input, init) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + })) as typeof fetch; + const refresh = setup(fetcher); + const pending = refresh.refresh(); + await vi.advanceTimersByTimeAsync(100); + await pending; + expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh timed out. Try again.'); + expect(refresh.onLoading).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts index 163af94..11e8d77 100644 --- a/tests/unit/metrics.test.ts +++ b/tests/unit/metrics.test.ts @@ -3,7 +3,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); -afterEach(async () => { await fixture.cleanup(); }); +afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); describe('historical metrics', () => { it('weights occupancy by time and excludes failed checks and collection gaps', async () => { @@ -56,4 +56,105 @@ describe('historical metrics', () => { recordMetrics([sample], at + 32 * 86400_000); expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 }); }); + + it('distinguishes retained history, an empty range, and current collection targets', async () => { + const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const at = Date.UTC(2026, 0, 2); + expect(readMetrics(1, at)).toMatchObject({ firstRetainedSampleAt: null, latestSampleAt: null, + registeredServerCount: 0, servers: [], maps: [] }); + db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('current', 'current', null); + db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('removed', 'removed', new Date(at).toISOString()); + const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null }; + recordMetrics([sample], at); + const emptyRange = readMetrics(1, at + 2 * 86400_000); + expect(emptyRange).toMatchObject({ firstRetainedSampleAt: at, latestSampleAt: at, + registeredServerCount: 1, servers: [], maps: [], summary: { observed: 0, unknown: 0 } }); + expect(emptyRange.points.every(point => point.observed === 0 && point.unknown === 0)).toBe(true); + }); + + it('keeps unknown status separate from no samples and preserves zero elapsed observations', async () => { + const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const at = Date.UTC(2026, 0, 2, 12); + recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }], at); + const initial = readMetrics(1, at); + expect(initial.latestSampleAt).toBe(at); + expect(initial.servers).toHaveLength(1); + expect(initial.summary).toEqual({ observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }); + const later = readMetrics(1, at + 120_000); + expect(later.summary.observed).toBe(0); + expect(later.summary.unknown * 3600).toBeCloseTo(45); + expect(later.maps[0].capacityObserved).toBe(0); + }); + + it('returns clipped interval boundaries and marks only partial UTC buckets', async () => { + const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const now = Date.UTC(2026, 0, 8, 12, 30); + for (const days of [1, 7, 30] as const) { + const result = readMetrics(days, now); + expect(result.points[0].startAt).toBe(result.since); + expect(result.points.at(-1)?.endAt).toBe(now); + expect(result.points[0].partial).toBe(true); + expect(result.points.at(-1)?.partial).toBe(true); + expect(result.points.slice(1, -1).every(point => !point.partial)).toBe(true); + expect(result.points.reduce((ms, point) => ms + point.endAt - point.startAt, 0)).toBe(days * 86400_000); + } + expect(readMetrics(1, Date.UTC(2026, 0, 8, 12)).points.every(point => !point.partial)).toBe(true); + }); + + it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => { + const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const at = Date.UTC(2026, 0, 2, 23, 59, 40); + const one = { server: 'one', game: 't6', map: 'Town', players: 4, capacity: 4 }; + const two = { ...one, server: 'two' }; + recordMetrics([one, two], at); + // Removing a lobby ends its previous observation at the next complete collection. + recordMetrics([{ ...one, map: 'Farm', players: 0 }], at + 30_000); + // A collection outage is excluded; an identically named map in another game remains separate. + recordMetrics([{ ...one, game: 't5', players: 2, capacity: null }], at + 120_000); + const result = readMetrics(1, at + 150_000); + expect(result.summary.active * 3600).toBeCloseTo(90); + expect(result.summary.idle * 3600).toBeCloseTo(45); + expect(result.summary.playerHours * 3600).toBeCloseTo(300); + expect(result.servers.find(server => server.server === 'two')!.observed * 3600).toBeCloseTo(30); + const town = result.maps.find(map => map.game === 't6' && map.map === 'Town')!; + expect(town.observed * 3600).toBeCloseTo(60); + expect(town.capacityObserved * 3600).toBeCloseTo(30); + expect(town.full * 3600).toBeCloseTo(30); + expect(result.maps.find(map => map.game === 't5')!.capacityObserved).toBe(0); + for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) { + expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(result.summary[field]); + expect(result.servers.reduce((sum, server) => sum + server[field], 0)).toBeCloseTo(result.summary[field]); + expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo(result.summary[field]); + } + }); + + it('does not include future samples in collection metadata or aggregates', async () => { + const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const at = Date.UTC(2026, 0, 2, 12); + const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 }; + recordMetrics([sample], at); + recordMetrics([{ ...sample, players: 4 }], at + 60_000); + const result = readMetrics(1, at + 30_000); + expect(result.latestSampleAt).toBe(at); + expect(result.summary.playerHours * 3600).toBeCloseTo(30); + }); + + it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => { + const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const { GET } = await import('../../src/routes/api/metrics/+server.js'); + const { load } = await import('../../src/routes/metrics/+page.server.js'); + const at = Date.UTC(2026, 0, 2, 12); + recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at); + vi.spyOn(Date, 'now').mockReturnValue(at + 30_000); + for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) { + const url = new URL(`http://localhost/metrics?days=${range}`); + const response = GET({ url }); + const payload = await response.json(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(payload).toEqual(await load({ url } as Parameters[0])); + expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7); + } + expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7); + }); }); diff --git a/tests/unit/ui-metrics.test.ts b/tests/unit/ui-metrics.test.ts new file mode 100644 index 0000000..4262b68 --- /dev/null +++ b/tests/unit/ui-metrics.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '../../src/lib/ui/metrics.js'; + +describe('metric presentation', () => { + it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => { + expect(metricPercent(0, 0)).toBe('—'); + expect(metricPercent(0, 1)).toBe('0.0%'); + expect(metricPercent(0.0001, 1)).toBe('<0.1%'); + expect(metricDuration(0)).toBe('0 h'); + expect(metricDuration(0.1 / 3600)).toBe('<1 s'); + expect(metricDuration(45 / 3600)).toBe('45 s'); + expect(metricDuration(90 / 3600)).toBe('1.5 min'); + expect(metricNumber(0.0125)).toBe('<0.1'); + }); + + it('sorts numerically while putting unavailable observations last in both directions', () => { + const values = [2, null, 100, 0, 10]; + expect([...values].sort((a, b) => compareMetricValues(a, b, 'desc'))).toEqual([100, 10, 2, 0, null]); + expect([...values].sort((a, b) => compareMetricValues(a, b, 'asc'))).toEqual([0, 2, 10, 100, null]); + }); +});