Add active/inactive/game-time timers to fleet + server pages

Fleet cards and the per-server detail hero now show three live-ticking
timers: Active (since players > 0), Inactive (since server emptied), and
Game (since InitGame in games_sp.log, back-computed from the log's own
MM:SS prefix so it stays accurate across restarts). Anchors are
persisted on `servers` and fanned out via the SSE bus; a single 1s
ticker feeds every card. Idle map cycles are filtered out so the game
timer doesn't flicker.
This commit is contained in:
2026-09-13 21:45:54 -04:00
parent 79b2ba1464
commit 3d111880d3
11 changed files with 403 additions and 13 deletions
+66 -1
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import StatusDot from './StatusDot.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js';
import { elapsedSince } from '$ui/duration.js';
interface Held {
target: string;
@@ -19,6 +21,10 @@
held?: Held[];
lastRestartedAt?: string | null;
restarting?: boolean;
activeSince?: string | null;
inactiveSince?: string | null;
gameStartedAt?: string | null;
gameEndedAt?: string | null;
}
let {
name,
@@ -31,7 +37,11 @@
lastError = null,
held = [],
lastRestartedAt = null,
restarting = false
restarting = false,
activeSince = null,
inactiveSince = null,
gameStartedAt = null,
gameEndedAt = null
}: Props = $props();
function ago(iso: string | null): string {
@@ -52,6 +62,16 @@
let showRoster = $derived(health !== 'offline');
let mapLabel = $derived(map ?? '—');
let gtLabel = $derived(gametypeLabel(gametype));
// Live-ticking occupancy + match timers. Anchored to server-issued
// ISO timestamps and re-derived from a shared 1s ticker.
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt);
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
let showTiming = $derived(
health !== 'offline' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
);
</script>
<a class="card" href="/servers/{name}" class:restarting class:offline={health === 'offline'}>
@@ -77,6 +97,28 @@
{/if}
</div>
{#if showTiming}
<div class="timing">
{#if activeElapsed}
<span class="timing-item active">
<span class="label">Active</span>
<span class="value">{activeElapsed}</span>
</span>
{:else if inactiveElapsed}
<span class="timing-item inactive">
<span class="label">Inactive</span>
<span class="value">{inactiveElapsed}</span>
</span>
{/if}
{#if gameElapsed}
<span class="timing-item game">
<span class="label">Game</span>
<span class="value">{gameElapsed}</span>
</span>
{/if}
</div>
{/if}
{#if showRoster}
{#if topRoster.length > 0}
<ul class="roster">
@@ -244,6 +286,29 @@
text-align: right;
}
.timing {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.timing-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.timing-item .label {
color: var(--ink-500);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
font-size: var(--text-xs);
}
.timing-item .value { color: var(--cream); }
.timing-item.active .value { color: var(--spark-500); }
.timing-item.inactive .value { color: var(--ink-300); }
.timing-item.game .value { color: var(--accent, var(--spark-500)); }
.lobby-empty {
font-family: var(--font-mono);
font-size: var(--text-mono-sm);