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
+76 -1
View File
@@ -2,8 +2,9 @@
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { statusByServer } from '$ui/stores/servers.js';
import { statusByServer, uiNow } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
import StatusDot from '$ui/components/StatusDot.svelte';
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
import LogPanel from '$ui/components/LogPanel.svelte';
@@ -78,6 +79,10 @@
endpoint: RconEndpoint | null;
replySnippet: string | null;
held: Array<{ target: string; betaId: number }>;
active_since: string | null;
inactive_since: string | null;
game_started_at: string | null;
game_ended_at: string | null;
}
interface InventoryEntry {
relPath: string;
@@ -159,6 +164,29 @@
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
);
// Occupancy + match timers — live SSE first, DB row as fallback so the
// display works on initial load before the first SSE event arrives.
let effectiveActiveSince = $derived<string | null>(
live?.activeSince ?? server?.active_since ?? null
);
let effectiveInactiveSince = $derived<string | null>(
live?.inactiveSince ?? server?.inactive_since ?? null
);
let effectiveGameStartedAt = $derived<string | null>(
live?.gameStartedAt ?? server?.game_started_at ?? null
);
let effectiveGameEndedAt = $derived<string | null>(
live?.gameEndedAt ?? server?.game_ended_at ?? null
);
let activeElapsed = $derived(effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
let inactiveElapsed = $derived(effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt);
let gameElapsed = $derived(gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
let showTiming = $derived(
effectiveHealth !== 'offline' &&
(activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
);
function ago(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
@@ -321,6 +349,27 @@
<span class="pair"><span class="k">last seen</span> <span class="v">{ago(effectiveSeen)}</span></span>
{/if}
</div>
{#if showTiming}
<div class="hero-timing">
{#if activeElapsed}
<div class="tblock active">
<div class="tlabel">Active</div>
<div class="tvalue">{activeElapsed}</div>
</div>
{:else if inactiveElapsed}
<div class="tblock inactive">
<div class="tlabel">Inactive</div>
<div class="tvalue">{inactiveElapsed}</div>
</div>
{/if}
{#if gameElapsed}
<div class="tblock game">
<div class="tlabel">Game</div>
<div class="tvalue">{gameElapsed}</div>
</div>
{/if}
</div>
{/if}
</section>
{#if effectiveHealth !== 'online' && integrations}
@@ -677,6 +726,32 @@
.v.health-stale { color: var(--amber-500); }
.v.health-offline { color: var(--danger-500); }
.hero-timing {
display: flex;
flex-wrap: wrap;
gap: 32px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--border);
}
.tblock { display: flex; flex-direction: column; gap: 4px; }
.tlabel {
font-family: var(--font-body);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-500);
}
.tvalue {
font-family: var(--font-mono);
font-size: var(--text-lg);
color: var(--cream);
letter-spacing: 0.5px;
}
.tblock.active .tvalue { color: var(--spark-500); }
.tblock.inactive .tvalue { color: var(--ink-300); }
.tblock.game .tvalue { color: var(--accent, var(--spark-500)); }
.panel {
background: var(--bg-nav);
border: 1px solid var(--border);