Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ dist/
.DS_Store
sounds/*
!sounds/.keep

AGENTS.md
5 changes: 3 additions & 2 deletions backend/app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .events import EventBus
from .timer import TimerService
from .seating import randomize_seating, rebalance, deseat_seating, normalize_seats
from .utils import now_ms
from .utils import now_ms, format_state

router = APIRouter()

Expand All @@ -24,7 +24,8 @@ async def health():
async def read_state(request: Request):
conn: aiosqlite.Connection = request.app.state.db
settings = await get_settings(conn)
state = await get_state(conn)
raw_state = await get_state(conn)
state = format_state(raw_state)
return {"settings": settings, "state": state}

@router.put("/settings")
Expand Down
33 changes: 13 additions & 20 deletions backend/app/timer.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import asyncio, time
import asyncio
from typing import Optional
from .events import EventBus, Event
from .db import get_settings, get_state, set_state, add_announcement
from .utils import now_ms, format_state

def now_ms() -> int:
return int(time.time() * 1000)

class TimerService:
"""
Server-truth timer model:
- When running: we store finish_at_server_ms (absolute server time)
- When paused: we store remaining_ms (and broadcast remaining_s)
- When paused: we store remaining_ms
- All client-facing state goes through format_state() which always sends
both remaining_ms and finish_at_server_ms (0 when paused).
- Clients render:
running => finish_at_server_ms - (serverNowMs)
paused => remaining_s
running => finish_at_server_ms - serverNowMs()
paused => remaining_ms
"""

def __init__(self, *, conn, bus: EventBus) -> None:
Expand Down Expand Up @@ -84,20 +85,12 @@ def _current_remaining_ms(self) -> int:
async def _emit_full_state(self) -> None:
settings = await get_settings(self.conn)

if self.running:
payload_state = {
"current_level_index": self.current_level_index,
"running": True,
"server_time_ms": now_ms(),
"finish_at_server_ms": int(self.finish_at_server_ms),
}
else:
payload_state = {
"current_level_index": self.current_level_index,
"running": False,
"server_time_ms": now_ms(),
"remaining_s": int(max(0, self.remaining_ms) // 1000),
}
payload_state = format_state({
"current_level_index": self.current_level_index,
"running": self.running,
"remaining_ms": self.remaining_ms,
"finish_at_server_ms": self.finish_at_server_ms,
})

await self.bus.publish(Event("state", {
"state": payload_state,
Expand Down
48 changes: 47 additions & 1 deletion backend/app/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,50 @@
import time
from typing import Any


def now_ms() -> int:
return int(time.time() * 1000)
return int(time.time() * 1000)


def format_state(raw: dict[str, Any]) -> dict[str, Any]:
"""
Convert raw DB state (or in-memory timer fields) into the canonical
client-facing shape.

All code paths that send state to the client (REST, WS initial snapshot,
timer broadcast) MUST use this function so every consumer sees the same
field names, types, and semantics.

Canonical shape:
{
"current_level_index": int,
"running": bool,
"server_time_ms": int,
"remaining_ms": int,
"finish_at_server_ms": int, # >0 only when running
}
"""
now = now_ms()
running = bool(raw.get("running"))
remaining_ms = int(raw.get("remaining_ms") or 0)
finish_at = int(raw.get("finish_at_server_ms") or 0)

if running:
# If finish_at not stored (legacy), derive from remaining + updated_at
if finish_at <= 0:
updated_at = int(raw.get("updated_at_ms") or now)
elapsed = max(0, now - updated_at)
remaining_ms = max(0, remaining_ms - elapsed)
finish_at = now + remaining_ms
else:
remaining_ms = max(0, finish_at - now)
else:
finish_at = 0

return {
"current_level_index": int(raw.get("current_level_index") or 0),
"running": running,
"server_time_ms": now,
"remaining_ms": remaining_ms,
"finish_at_server_ms": finish_at,
}
5 changes: 3 additions & 2 deletions backend/app/ws_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Set, Dict, Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from .db import get_settings, get_state
from .utils import now_ms
from .utils import now_ms, format_state

router = APIRouter()

Expand Down Expand Up @@ -47,7 +47,8 @@ async def websocket_endpoint(ws: WebSocket):

async def send_initial_state():
settings = await get_settings(conn)
state = await get_state(conn) # should include server_time_ms + finish_at_server_ms OR remaining_ms
raw_state = await get_state(conn)
state = format_state(raw_state)
await ws.send_json({"type": "state", "payload": {"settings": settings, "state": state}})

async def recv_loop():
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/components/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,16 @@ export default function ConnectionStatus({
let timerColor = "#9ca3af";
let timerLabel = "Unknown";

if (timerStatus === "neutral" || !connected) {
if (!connected) {
// When disconnected, always show unknown — no sync is happening
timerIcon = <ClockFading size={size} />;
timerColor = "#9ca3af";
timerLabel = "No sync";
} else if (timerStatus === "unknown" || timerStatus === "neutral") {
timerIcon = <ClockFading size={size} />;
timerColor = "#eab308";
timerLabel = "Syncing";
} else if (timerStatus === "excelent") {
} else if (timerStatus === "excellent") {
timerIcon = <ClockCheck size={size} />;
timerColor = "#22c55e";
timerLabel = "Stable";
Expand Down
89 changes: 53 additions & 36 deletions frontend/src/hooks/useEventStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ type PongSample = { rtt: number; offset: number; t: number };
let samples: PongSample[] = [];
const SAMPLE_WINDOW = 3;
let pingPeriodMs = 2000; // current target period
let hasPongSample = false; // true once we have at least one pong-based measurement

// Allow stoping of the ping loop
// Allow stopping of the ping loop
let pingLooper: number | null = null;
let stableSinceMs: number | null = null;

let timerStatus: "excelent" | "good" | "neutral" | "bad" | "unknown" = "unknown";
export type TimerSyncStatus = "excellent" | "good" | "neutral" | "bad" | "unknown";
let timerStatus: TimerSyncStatus = "unknown";

let offsetEmaAbsErr = 0; // ms, EMA of |offset - offsetMs|
let rttEma = 0; // ms, EMA of RTT
Expand All @@ -54,6 +56,17 @@ function emit() {
for (const fn of listeners) fn(store);
}

/** Reset all clock-sync state. Called on each new WS connection. */
function resetClockSync() {
samples = [];
hasPongSample = false;
offsetEmaAbsErr = 0;
rttEma = 0;
stableSinceMs = null;
timerStatus = "unknown";
pingPeriodMs = 500; // start fast on fresh connection
}

export function serverNowMs() {
return Date.now() + offsetMs;
}
Expand All @@ -71,10 +84,16 @@ function updateOffsetFromPong(clientSendMs: number, serverTimeMs: number) {
let best = samples[0];
for (const s of samples) if (s.rtt < best.rtt) best = s;

// smooth offset toward best sample
const alpha = 0.15;
const prevOffset = offsetMs;
offsetMs = offsetMs * (1 - alpha) + best.offset * alpha;
// On the very first pong, snap directly to the best estimate instead of
// blending with the (potentially stale) bootstrap value.
if (!hasPongSample) {
hasPongSample = true;
offsetMs = best.offset;
} else {
// smooth offset toward best sample
const alpha = 0.15;
offsetMs = offsetMs * (1 - alpha) + best.offset * alpha;
}

// --- track "inaccuracy" ---
// how far the new measurement is from our current estimate
Expand Down Expand Up @@ -104,8 +123,6 @@ function updateOffsetFromPong(clientSendMs: number, serverTimeMs: number) {
stableSinceMs = null;
}

// export type Announcement = { id?: number; created_at_ms: number; type: string; payload: any; };

// If not stable, ping faster
if (offsetEmaAbsErr > BAD_ERR_MS) {
timerStatus = "bad";
Expand All @@ -122,7 +139,7 @@ function updateOffsetFromPong(clientSendMs: number, serverTimeMs: number) {

// If very stable for 10s, slow down
if (stableSinceMs != null && now - stableSinceMs > 10_000) {
timerStatus = "excelent";
timerStatus = "excellent";
startPingLoop(5000);
return;
}
Expand Down Expand Up @@ -158,25 +175,25 @@ function stopPingLoop() {
pingLooper = null;
}

/**
* Rough bootstrap for offset using a server timestamp embedded in a message.
* Only used before the first pong arrives — once we have RTT-based measurements,
* those are strictly more accurate than a one-way timestamp with unknown latency.
*/
function seedOffsetFromServerTime(server_time_ms: number) {
if (hasPongSample) return; // pong-based sync is active, don't corrupt it
const guess = server_time_ms - Date.now();
// gentle blend
// gentle blend in case multiple seeds arrive before first pong
offsetMs = offsetMs * 0.7 + guess * 0.3;
}

function computeRemainingMs(state: State | null): number | null {
if (!state) return null;
const s: any = state as any;

if (s.running) {
if (typeof s.finish_at_server_ms !== "number") return null;
return Math.max(0, s.finish_at_server_ms - serverNowMs());
} else {
if (typeof s.remaining_s === "number") return Math.max(0, s.remaining_s * 1000);
// Back-compat if old server still sends remaining_ms
if (typeof s.remaining_ms === "number") return Math.max(0, s.remaining_ms);
return null;
if (state.running) {
return Math.max(0, state.finish_at_server_ms - serverNowMs());
}
return Math.max(0, state.remaining_ms);
}

async function ensureInitialState() {
Expand Down Expand Up @@ -208,20 +225,20 @@ function ensureSocket() {
wsConnecting = false;
retry = 0;
store.connected = true;

// Reset sync state for the new connection so stale EMA values
// from a previous session don't affect the fresh handshake.
resetClockSync();
emit();

// immediate sync ping + start ping loop
try {
const client_send_ms = Date.now();
ws?.send(JSON.stringify({ type: "ping", payload: { client_send_ms } }));
} catch {
// ignore
}
startPingLoop(pingPeriodMs);
// immediate sync ping + start fast ping loop
sendPing();
startPingLoop(pingPeriodMs); // pingPeriodMs was set to 500 by resetClockSync
};

ws.onclose = () => {
store.connected = false;
timerStatus = "unknown";
emit();
ws = null;
wsConnecting = false;
Expand Down Expand Up @@ -253,22 +270,22 @@ function ensureSocket() {
store.settings = msg.payload.settings;
store.state = msg.payload.state;

const st: any = msg.payload.state as any;
if (typeof st?.server_time_ms === "number") {
seedOffsetFromServerTime(st.server_time_ms);
if (typeof msg.payload.state.server_time_ms === "number") {
seedOffsetFromServerTime(msg.payload.state.server_time_ms);
}

emit();
return;
}

if (msg.type === "tick") {
// Optional: keep merging non-time fields. Time should be derived from finish_at_server_ms.
store.state = store.state ? ({ ...(store.state as any), ...(msg.payload as any) } as State) : (msg.payload as State);
// Merge non-time fields. Time should be derived from finish_at_server_ms.
store.state = store.state
? { ...store.state, ...msg.payload } as State
: msg.payload as State;

const st: any = store.state as any;
if (typeof st?.server_time_ms === "number") {
seedOffsetFromServerTime(st.server_time_ms);
if (typeof store.state?.server_time_ms === "number") {
seedOffsetFromServerTime(store.state.server_time_ms);
}

emit();
Expand Down Expand Up @@ -349,5 +366,5 @@ export function useEventStream() {
};
}, []);

return { settings, state, remainingMs, lastSound, announcements, connected, serverNowMs, timerStatus };
return { settings, state, remainingMs, lastSound, announcements, connected, serverNowMs, timerStatus: (store.connected ? timerStatus : "unknown") as TimerSyncStatus };
}
3 changes: 2 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ export type Settings = {

export type State = {
current_level_index: number;
running: true;
running: boolean;
server_time_ms: number;
remaining_ms: number;
/** Absolute server time the level ends. >0 only when running; 0 when paused. */
finish_at_server_ms: number;
};

Expand Down