Remove fleet lobby follow notifications

This commit is contained in:
2026-09-21 19:33:57 -04:00
parent e448457d58
commit f875260b7e
5 changed files with 10 additions and 123 deletions
-18
View File
@@ -87,21 +87,3 @@ export function matchesFleet(server: FleetServer, query: string, filter: FleetFi
...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase();
return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term));
}
export interface LobbyWatch {
id: string;
server: string;
kind: 'empty' | 'online' | 'operation';
operationKey?: string;
href: string;
}
export function watchSatisfied(watch: LobbyWatch, server: FleetServer | undefined,
operations: readonly FleetOperation[], now: number): boolean {
if (watch.kind === 'operation') {
// Disappearance, failure and replacement by another operation are not completion.
return operations.some(op => op.key === watch.operationKey && op.state === 'done');
}
if (!server || !freshServer(server, now)) return false;
return watch.kind === 'online' || server.last_players === 0;
}
+4 -47
View File
@@ -1,9 +1,7 @@
import { derived, get, writable } from 'svelte/store';
import { derived, writable } from 'svelte/store';
import { browser } from '$app/environment';
import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime, matchEventsByServer } from './servers.js';
import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js';
import { serverTitle } from '../server-labels.js';
import { toast } from '../toast.js';
import { statusByServer, statusReceivedAt, syncServerTime, matchEventsByServer } from './servers.js';
import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js';
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
servers: [], operations: [], receivedAt: 0
@@ -11,7 +9,6 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
export const fleetError = writable('');
export const fleetRefreshing = writable(false);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const lobbyWatches = writable<LobbyWatch[]>([]);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) =>
snapshot.servers.map(server => {
@@ -62,58 +59,18 @@ export function refreshFleet(): Promise<void> {
return inFlight;
}
export function followLobby(server: string, kind: LobbyWatch['kind'], operation?: FleetOperation): void {
const id = `${server}:${kind}:${operation?.key ?? ''}`;
lobbyWatches.update(watches => watches.some(watch => watch.id === id) ? watches : [...watches, {
id, server, kind, operationKey: operation?.key,
href: operation?.href ?? `/servers/${encodeURIComponent(server)}`
}]);
toast('Watching this lobby while the app is open.');
}
export function cancelWatch(id: string): void {
lobbyWatches.update(watches => watches.filter(watch => watch.id !== id));
}
let stop: (() => void) | null = null;
export function startFleetMonitor(): () => void {
if (!browser || stop) return () => {};
try {
const saved: unknown = JSON.parse(sessionStorage.getItem('electric-cherry:lobby-watches') ?? '[]');
if (Array.isArray(saved)) lobbyWatches.set(saved.filter((watch): watch is LobbyWatch =>
watch && typeof watch.id === 'string' && typeof watch.server === 'string'
&& ['empty', 'online', 'operation'].includes(watch.kind)
&& typeof watch.href === 'string' && /^\/(servers|campaigns|deployments)\//.test(watch.href)
&& (watch.kind !== 'operation' || typeof watch.operationKey === 'string')));
} catch { /* Watches still work with session storage disabled. */ }
const stopSaving = lobbyWatches.subscribe(watches => {
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
});
void refreshFleet();
const interval = setInterval(() => {
if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet();
if (document.visibilityState !== 'hidden') void refreshFleet();
}, 10_000);
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
document.addEventListener('visibilitychange', onFocus);
const check = () => {
// Never complete a watch using an operation snapshot from a failed refresh.
if (get(fleetError)) return;
const snapshot = get(fleet);
if (!snapshot.receivedAt || Date.now() - snapshot.receivedAt > 30_000) return;
const servers = get(fleetServers);
for (const watch of get(lobbyWatches)) {
if (!watchSatisfied(watch, servers.find(server => server.name === watch.server), snapshot.operations, serverTimeNow())) continue;
const message = `${serverTitle(watch.server)} ${watch.kind === 'empty' ? 'is empty' : watch.kind === 'online' ? 'is back online' : 'has finished its operation'}.`;
cancelWatch(watch.id);
toast(message, { variant: 'success', ttl: 0, href: watch.href });
}
};
const unsubscribe = fleetServers.subscribe(check);
stop = () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onFocus);
unsubscribe();
stopSaving();
stop = null;
};
return stop;
+4 -34
View File
@@ -9,7 +9,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
@@ -119,16 +119,6 @@
function launch(selection: { game: ZombieGame; slug: string } | null = null) {
initialGame = selection?.game ?? 't5'; initialMapSlug = selection?.slug ?? ''; deployOpen = true;
}
function watch(server: FleetServer, event: Event) {
const input = event.currentTarget as HTMLSelectElement;
const value = input.value;
input.value = '';
if (value === 'empty' || value === 'online') followLobby(server.name, value);
else {
const operation = activeOperations.find(op => op.key === value);
if (operation) followLobby(server.name, 'operation', operation);
}
}
function resetFilters() { query = ''; filter = 'all'; game = ''; }
</script>
@@ -183,18 +173,9 @@
</a>
{/if}
{#if activeOperations.length || $lobbyWatches.length}
{#if activeOperations.length}
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
<summary>Activity · {activeOperations.length} operations{ $lobbyWatches.length ? ` · ${$lobbyWatches.length} lobby watches` : ''}{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
{#if $lobbyWatches.length}
<div class="watches" aria-label="Lobby watches">
<div><strong>Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}</strong><span class="hint">Notifications stay active as you navigate. Keep this app open.</span></div>
{#each $lobbyWatches as item (item.id)}
<button title="Cancel this watch" onclick={() => cancelWatch(item.id)} aria-label="Cancel {item.kind} watch for {serverTitle(item.server)}">{serverTitle(item.server)} · {item.kind === 'empty' ? 'when empty' : item.kind === 'online' ? 'when online' : 'when complete'} ×</button>
{/each}
</div>
{/if}
<summary>Activity · {activeOperations.length} operations{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
<div class="operation-list">{#each activeOperations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
{/each}</div>
@@ -269,14 +250,6 @@
{#snippet serverActions(server: FleetServer)}
<button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button>
<select class="watch-select" aria-label="Follow {serverTitle(server.name)}" onchange={event => watch(server, event)}>
<option value="">Follow lobby…</option>
<option value="empty" disabled={freshServer(server, $uiNow) && server.last_players === 0}>Tell me when it’s empty</option>
<option value="online" disabled={freshServer(server, $uiNow)}>Tell me when it’s online</option>
{#each activeOperations.filter(op => op.server === server.name && (op.kind === 'campaign' || op.kind === 'deployment') && op.state === 'active') as operation (operation.key)}
<option value={operation.key}>When complete: {operation.label}</option>
{/each}
</select>
{/snippet}
<style>
@@ -319,7 +292,7 @@
input::placeholder { color: var(--text-secondary); }
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
@@ -334,7 +307,6 @@
.server-tile :global(.card) { border-radius: 0; }
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
.pin { white-space: nowrap; }
.watch-select { font-size: 12px; min-height: 36px; min-width: 0; width: 145px; padding: 6px; }
.server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
@@ -344,8 +316,6 @@
.operation-list { display: grid; gap: 10px; margin-top: 16px; }
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); }
.operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
.watches { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
.watches > div { margin-right: auto; }
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
.error button { margin-left: 8px; }
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }