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);
+27
View File
@@ -0,0 +1,27 @@
/**
* Format an elapsed millisecond count as a compact human string:
* < 60s -> "12s"
* < 1h -> "5m 23s"
* >= 1h -> "2h 05m"
* Negatives clamp to 0s (clock skew tolerance).
*/
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) ms = 0;
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${String(mins).padStart(2, '0')}m`;
}
/**
* Convenience: elapsed since an ISO timestamp, formatted. Returns null when
* the anchor is null so callers can `{#if}` on it.
*/
export function elapsedSince(iso: string | null, now: number): string | null {
if (!iso) return null;
return formatDuration(now - new Date(iso).getTime());
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { writable } from 'svelte/store';
import { writable, readonly } from 'svelte/store';
import { browser } from '$app/environment';
export interface RosterPlayer {
@@ -29,10 +29,21 @@ export interface ServerStatus {
endpoint: RconEndpoint | null;
reason: RconFailure | null;
replySnippet: string | null;
activeSince: string | null;
inactiveSince: string | null;
gameStartedAt: string | null;
gameEndedAt: string | null;
}
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
// Shared 1s ticker for elapsed-time displays. One interval feeds every card
// on the page instead of N per-card timers. Timer starts on the first
// startServerStream() call in the browser.
const _uiNow = writable(Date.now());
export const uiNow = readonly(_uiNow);
let tickInterval: ReturnType<typeof setInterval> | null = null;
let source: EventSource | null = null;
export function startServerStream(): void {
@@ -46,4 +57,7 @@ export function startServerStream(): void {
return m;
});
});
if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
}
}