Files
Live_RC/src/runtime_services.js
larssand 9a9379aa83 feat(series): add series/cup championships with points tables and standings
Group any number of races in freely chosen classes into a series with a
points table (ÖRC/100-80-70/F1/custom), best-N or drop-X counting, a
club filter that re-ranks members without guests, tie-breaks, rounds
created from a date list, and per-class standings with print and PDF.

Also fixes PDF export of wide tables: they are compacted and switched
to landscape instead of truncating names and dropping columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:49:23 +02:00

459 lines
25 KiB
JavaScript

import { normalizeSeries } from "./series_logic.js";
import { speakWithPiper, isPiperSpeaking } from "./piper_speech.js";
export function createDefaultAmmcConfigHelper() {
return {
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
splitEnabled: false,
splitDecoderHost: "",
splitWsPort: 9001,
pitEnabled: false,
pitEntryDecoderHost: "",
pitEntryWsPort: 9002,
pitExitDecoderHost: "",
pitExitWsPort: 9003,
executablePath: "",
workingDirectory: "",
extraArgs: "",
};
}
function migrateBrandDefault(value, legacyValues, fallback) {
const normalized = String(value || "").trim();
return !normalized || legacyValues.includes(normalized) ? fallback : normalized;
}
export function getManagedWsUrlHelper({ ammc, getBackendUrl }, role = "main") {
const portKey = role === "split" ? "splitWsPort" : role === "pit_entry" ? "pitEntryWsPort" : role === "pit_exit" ? "pitExitWsPort" : "wsPort";
const defaultPort = role === "split" ? 9001 : role === "pit_entry" ? 9002 : role === "pit_exit" ? 9003 : 9000;
const port = Number(ammc.config?.[portKey] || defaultPort);
try {
const backendUrl = new URL(getBackendUrl());
return `ws://${backendUrl.hostname}:${port}`;
} catch {
return `ws://127.0.0.1:${port}`;
}
}
export async function loadAmmcConfigFromBackendHelper({ ammc, t, getBackendUrl, createDefaultAmmcConfig }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/config`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
ammc.config = { ...createDefaultAmmcConfig(), ...(payload.config || {}) };
ammc.status = payload.status || null;
ammc.lastError = "";
ammc.loaded = true;
} catch (error) {
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
ammc.loaded = false;
}
}
export async function saveAmmcConfigToBackendHelper(config, { ammc, t, getBackendUrl, createDefaultAmmcConfig }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config) });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.config = { ...createDefaultAmmcConfig(), ...(payload.config || {}) };
ammc.status = payload.status || ammc.status;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_save_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function refreshAmmcStatusHelper({ ammc, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/status`);
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.status = payload;
ammc.lastError = "";
ammc.loaded = true;
} catch (error) {
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function startManagedAmmcHelper({ ammc, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/start`, { method: "POST" });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.status = payload.status || null;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_start_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function stopManagedAmmcHelper({ ammc, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/stop`, { method: "POST" });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.status = payload.status || null;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_stop_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export function applyPersistedStateHelper(persisted, deps) {
const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings = (value = {}) => value || {}, normalizeTrackLayouts = (value) => (Array.isArray(value) ? value : []), applyTheme } = deps;
state.classes = persisted.classes || [];
state.drivers = (persisted.drivers || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name);
state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name);
state.events = (persisted.events || []).map((event) => normalizeEvent(event));
state.sessions = (persisted.sessions || []).map((session) => normalizeSession(session));
state.resultsBySession = persisted.resultsBySession || {};
state.audioEventLog = Array.isArray(persisted.audioEventLog) ? persisted.audioEventLog.slice(0, 500) : [];
state.training = normalizeTrainingState(persisted.training);
state.activeSessionId = persisted.activeSessionId || null;
state.trackLayouts = normalizeTrackLayouts(persisted.trackLayouts);
state.series = (Array.isArray(persisted.series) ? persisted.series : []).map((series) => normalizeSeries(series)).filter((series) => series.id);
state.settings = {
wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000",
splitDecoderEnabled: persisted.settings?.splitDecoderEnabled === true,
splitWsUrl: String(persisted.settings?.splitWsUrl || state.settings.splitWsUrl || "ws://127.0.0.1:9001"),
pitDecoderEnabled: persisted.settings?.pitDecoderEnabled === true,
pitEntryWsUrl: String(persisted.settings?.pitEntryWsUrl || state.settings.pitEntryWsUrl || "ws://127.0.0.1:9002"),
pitExitWsUrl: String(persisted.settings?.pitExitWsUrl || state.settings.pitExitWsUrl || "ws://127.0.0.1:9003"),
backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(),
publicBaseUrl: String(persisted.settings?.publicBaseUrl || state.settings.publicBaseUrl || ""),
language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE,
theme: AVAILABLE_THEMES.includes(String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase()) ? String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase() : DEFAULT_THEME,
autoReconnect: persisted.settings?.autoReconnect !== false,
audioEnabled: persisted.settings?.audioEnabled !== false,
passingSoundMode: persisted.settings?.passingSoundMode || state.settings.passingSoundMode || "beep",
finishVoiceEnabled: persisted.settings?.finishVoiceEnabled !== false,
startSoundEnabled: persisted.settings?.startSoundEnabled !== false,
finishSoundEnabled: persisted.settings?.finishSoundEnabled !== false,
speechVoiceUri: String(persisted.settings?.speechVoiceUri || state.settings.speechVoiceUri || ""),
speechRate: Math.max(0.65, Math.min(1.35, Number(persisted.settings?.speechRate || state.settings.speechRate || 0.95) || 0.95)),
speechPitch: Math.max(0.7, Math.min(1.3, Number(persisted.settings?.speechPitch || state.settings.speechPitch || 1) || 1)),
ttsEngine: persisted.settings?.ttsEngine === "piper" ? "piper" : "browser",
ttsVoiceSv: String(persisted.settings?.ttsVoiceSv || ""),
ttsVoiceEn: String(persisted.settings?.ttsVoiceEn || ""),
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
speakerBestLapCueEnabled: persisted.settings?.speakerBestLapCueEnabled !== false,
speakerTop3CueEnabled: persisted.settings?.speakerTop3CueEnabled === true,
speakerSessionStartCueEnabled: persisted.settings?.speakerSessionStartCueEnabled !== false,
clubName: migrateBrandDefault(persisted.settings?.clubName || state.settings.clubName, ["JMK RB RaceController", "RC RaceCore"], "JMK RB"),
clubTagline: migrateBrandDefault(persisted.settings?.clubTagline || state.settings.clubTagline, ["RC Timing System"], "Live Timing & Race Control"),
pdfFooter: migrateBrandDefault(persisted.settings?.pdfFooter || state.settings.pdfFooter, ["Generated by JMK RB RaceController"], "Generated by RC RaceCore"),
pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic",
logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "",
raceAudio: normalizeRaceAudioSettings(persisted.settings?.raceAudio || state.settings?.raceAudio),
racePresets: Array.isArray(persisted.settings?.racePresets) ? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : Array.isArray(state.settings?.racePresets) ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [],
obsOverlay: normalizeObsOverlaySettings(persisted.settings?.obsOverlay || state.settings?.obsOverlay),
};
applyTheme();
}
export async function hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState }) {
try {
const res = await fetch(`${getBackendUrl()}/api/state`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (payload && payload.state && typeof payload.state === "object") {
applyPersistedState(payload.state);
saveState({ skipBackend: true });
backend.lastSyncAt = payload.updatedAt || new Date().toISOString();
backend.lastLoadedAt = new Date().toISOString();
}
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.backend_offline", { msg: error instanceof Error ? error.message : String(error) });
}
}
export function scheduleBackendSyncHelper({ getBackendSyncTimer, setBackendSyncTimer, syncStateToBackend }) {
clearTimeout(getBackendSyncTimer());
setBackendSyncTimer(setTimeout(() => { syncStateToBackend(); }, 350));
}
export async function syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion, getSyncedStateVersion, setSyncedStateVersion, setLocalStateDirty }) {
backend.pendingWrites = (backend.pendingWrites || 0) + 1;
const syncVersion = getLocalStateVersion();
try {
const res = await fetch(`${getBackendUrl()}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildPersistableState()) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
backend.available = true;
backend.lastError = "";
backend.lastSyncAt = new Date().toISOString();
backend.lastSavedAt = backend.lastSyncAt;
backend.writeError = "";
setSyncedStateVersion(Math.max(getSyncedStateVersion(), syncVersion));
if (syncVersion === getLocalStateVersion()) {
setLocalStateDirty(false);
backend.passingError = "";
}
} catch (error) {
backend.available = false;
backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) });
backend.writeError = backend.lastError;
} finally {
backend.pendingWrites = Math.max(0, (backend.pendingWrites || 1) - 1);
}
}
export async function pingBackendHelper({ backend, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/health`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.health_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion, setBaselineAppVersion, getPendingAppVersion, setPendingAppVersion, getLocalStateDirty, onVersion = () => {}, onContact = () => {} }) {
try {
const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" });
if (!res.ok) { onContact(false); return; }
const payload = await res.json();
onContact(true);
if (typeof payload.version === "string" && payload.version) onVersion(payload.version);
const key = `${payload.revision}:${payload.updatedAt}`;
if (!getBaselineAppVersion()) {
setBaselineAppVersion(key);
setPendingAppVersion("");
return;
}
if (key === getBaselineAppVersion()) {
setPendingAppVersion("");
return;
}
if (getPendingAppVersion() !== key) {
setPendingAppVersion(key);
return;
}
if (getLocalStateDirty()) {
return;
}
window.location.reload();
} catch { onContact(false); }
}
export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer, setAppVersionPollTimer }) {
if (!window.location.protocol.startsWith("http")) return;
clearInterval(getAppVersionPollTimer());
checkAppVersion();
setAppVersionPollTimer(setInterval(checkAppVersion, 5000));
}
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
clearInterval(getOverlaySyncTimer());
setOverlaySyncTimer(setInterval(async () => {
if (getCurrentView() !== "overlay") return;
if (!getLocalStateDirty()) await hydrateFromBackend();
renderView();
}, 800));
}
// A browser left open on any other view never re-fetches state on its own, so the copy it
// would push on its next save only gets staler over time - the longer it sits, the more of
// another browser's meanwhile-made edits its next save can silently discard (see
// syncStateToBackendHelper/the whole-document overwrite it does). This keeps that copy fresh
// in the background without ever touching the DOM, so it can never interrupt an open, unsaved
// edit form the way calling renderView() here could. Overlay already refreshes and renders
// itself on its own, faster cycle above, so it's left alone.
export function startBackgroundHydrationHelper({ getBackgroundHydrationTimer, setBackgroundHydrationTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, intervalMs = 15000 }) {
clearInterval(getBackgroundHydrationTimer());
setBackgroundHydrationTimer(setInterval(async () => {
if (getCurrentView() === "overlay") return;
if (getLocalStateDirty()) return;
await hydrateFromBackend();
}, intervalMs));
}
export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlayRotationTimer, getOverlayRotationIndex, setOverlayRotationIndex, getCurrentView, getOverlayViewMode, renderView }) {
clearInterval(getOverlayRotationTimer());
setOverlayRotationTimer(setInterval(() => {
setOverlayRotationIndex((getOverlayRotationIndex() + 1) % 3);
if (getCurrentView() === "overlay" && getOverlayViewMode() === "leaderboard") renderView();
}, 8000));
}
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, refreshOverlayDynamics }) {
clearInterval(getOverlayLiveRefreshTimer());
setOverlayLiveRefreshTimer(setInterval(() => {
if (getCurrentView() === "overlay" && ["leaderboard", "speaker", "results", "tv", "team", "obs"].includes(getOverlayViewMode())) refreshOverlayDynamics();
}, 250));
}
export function ensureAudioContextHelper({ state, getAudioCtx, setAudioCtx }) {
if (!state.settings.audioEnabled) return null;
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
if (!getAudioCtx()) setAudioCtx(new Ctx());
const ctx = getAudioCtx();
if (ctx.state === "suspended") ctx.resume().catch(() => {});
return ctx;
}
function playTone({ ensureAudioContext, type, startFreq, ramps = [], attack = 0.01, peak = 0.08, release = 0.2, stopAt = 0.25 }) {
const ctx = ensureAudioContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(startFreq, ctx.currentTime);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(peak, ctx.currentTime + attack);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + release);
ramps.forEach(([freq, at, kind]) => {
if (kind === 'linear') osc.frequency.linearRampToValueAtTime(freq, ctx.currentTime + at);
else osc.frequency.setValueAtTime(freq, ctx.currentTime + at);
});
osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + stopAt);
}
export function playPassingBeepHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1480, peak: 0.07, release: 0.12, stopAt: 0.14, ramps: [[1760,0.08,'linear']] }); }
export function playFinishSirenHelper({ ensureAudioContext }) {
playTone({ ensureAudioContext, type: 'triangle', startFreq: 980, peak: 0.12, release: 0.58, stopAt: 0.62, attack: 0.02, ramps: [[620,0.56,'linear']] });
setTimeout(() => playTone({ ensureAudioContext, type: 'triangle', startFreq: 980, peak: 0.1, release: 0.72, stopAt: 0.76, attack: 0.02, ramps: [[520,0.68,'linear']] }), 520);
}
export function playLeaderCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'triangle', startFreq: 880, peak: 0.09, release: 0.26, stopAt: 0.28, ramps: [[1320,0.12,'linear'],[1760,0.24,'linear']] }); }
export function playStartCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'square', startFreq: 760, peak: 0.1, release: 0.62, stopAt: 0.66, attack: 0.015, ramps: [[1520,0.16,'linear'],[1520,0.54,'linear']] }); }
export function playBestLapCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1540, peak: 0.07, release: 0.22, stopAt: 0.24, ramps: [[1980,0.2,'linear']] }); }
export function pushOverlayEventHelper(type, label, deps, options = {}) {
const { uid, state, t, getOverlayEvents, setOverlayEvents, getOverlayMode, getCurrentView, getOverlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue } = deps;
const events = getOverlayEvents();
events.unshift({ id: uid("overlay"), type, label, ts: Date.now() });
setOverlayEvents(events.length > 12 ? events.slice(0, 12) : events);
if (!options.silentCue && getOverlayMode() && getCurrentView() === "overlay" && getOverlayViewMode() === "speaker") {
if (type === "leader" && state.settings.speakerLeaderCueEnabled) playLeaderCue();
else if (type === "passing" && state.settings.speakerPassingCueEnabled) playPassingBeep();
else if (type === "finish" && state.settings.speakerFinishCueEnabled) playFinishSiren();
else if (type === "start" && state.settings.speakerSessionStartCueEnabled) playStartCue();
else if (type === "bestlap" && state.settings.speakerBestLapCueEnabled) playBestLapCue();
else if (type === "top3" && state.settings.speakerTop3CueEnabled) playLeaderCue();
}
}
export function getSpeechVoicesHelper() {
if (!("speechSynthesis" in window)) return [];
return window.speechSynthesis.getVoices().map((voice) => ({
name: voice.name,
lang: voice.lang,
voiceURI: voice.voiceURI,
localService: voice.localService,
}));
}
export function selectSpeechVoice(voices, voiceUri, language) {
const items = Array.isArray(voices) ? voices : [];
const selected = items.find((voice) => voice.voiceURI === voiceUri);
if (selected) return selected;
const prefix = String(language || "").toLowerCase().split("-")[0];
return items.find((voice) => String(voice.lang || "").toLowerCase().startsWith(prefix) && voice.localService !== false)
|| items.find((voice) => String(voice.lang || "").toLowerCase().startsWith(prefix))
|| null;
}
export function speakTextHelper(text, { state, currentLanguage }, options = {}) {
if (!text) return false;
if (state.settings.ttsEngine === "piper" && !options.skipPiper) {
const piperLanguage = String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en";
return speakWithPiper(text, {
language: piperLanguage,
voice: piperLanguage === "sv" ? state.settings.ttsVoiceSv : state.settings.ttsVoiceEn,
rate: Math.max(0.65, Math.min(1.35, Number(options.rate ?? state.settings.speechRate ?? 0.95) || 0.95)),
volume: options.volume ?? 1,
interrupt: options.interrupt,
onEnd: options.onEnd,
onError: options.onError,
fallback: () => speakTextHelper(text, { state, currentLanguage }, { ...options, skipPiper: true, interrupt: true }),
});
}
if (!("speechSynthesis" in window)) return false;
const synth = window.speechSynthesis;
if (!options.interrupt && isPiperSpeaking()) return false;
if (!options.interrupt && (synth.speaking || synth.pending)) return false;
if (options.interrupt) synth.cancel();
const utterance = new SpeechSynthesisUtterance(text);
const language = options.language || currentLanguage();
utterance.lang = String(language).toLowerCase().startsWith("sv") ? "sv-SE" : "en-US";
utterance.rate = Math.max(0.65, Math.min(1.35, Number(options.rate ?? state.settings.speechRate ?? 0.95) || 0.95));
utterance.pitch = Math.max(0.7, Math.min(1.3, Number(options.pitch ?? state.settings.speechPitch ?? 1) || 1));
utterance.volume = Math.max(0, Math.min(1, Number(options.volume ?? 1)));
if (typeof options.onEnd === "function") utterance.onend = options.onEnd;
if (typeof options.onError === "function") utterance.onerror = options.onError;
const configuredVoiceUri = options.voiceUri !== undefined ? options.voiceUri : state.settings.speechVoiceUri;
const voice = selectSpeechVoice(synth.getVoices(), String(configuredVoiceUri || ""), utterance.lang);
if (voice) utterance.voice = voice;
synth.speak(utterance);
return true;
}
// Race audio (race_audio.js) owns every start/finish sound; these only log the overlay event.
export function announceRaceStartedHelper(session, { t, pushOverlayEvent }) {
if (!session) return;
pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`, { silentCue: true });
}
export function announceRaceFinishedHelper({ t, getActiveSession, pushOverlayEvent }) {
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`, { silentCue: true });
}
export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId, announceRaceFinished, announceRaceClockEvent = () => {} }) {
const active = getActiveSession();
if (!active || active.status !== "running") return { changed: false };
if (isUntimedSession(active)) return { changed: false };
const timing = getSessionTiming(active);
if (!Array.isArray(active.audioAnnouncements)) active.audioAnnouncements = [];
let changed = false;
const announceOnce = (marker, eventKey) => {
if (active.audioAnnouncements.includes(marker)) return;
active.audioAnnouncements.push(marker);
announceRaceClockEvent(eventKey, active);
changed = true;
};
if (timing.remainingMs > 0 && timing.remainingMs <= 60000) announceOnce("remaining_60", "one_minute_remaining");
if (timing.remainingMs > 0 && timing.remainingMs <= 30000) announceOnce("remaining_30", "thirty_seconds_remaining");
if (timing.remainingMs > 0 && timing.remainingMs <= 10000) announceOnce("remaining_10", "ten_seconds_remaining");
if (timing.remainingMs > 0) {
if (changed) saveState();
return { changed };
}
announceOnce("time_expired", "time_expired");
if (!["open_practice", "free_practice", "practice"].includes(String(active.type || ""))) announceOnce("final_lap", "final_lap");
if (Number(active.followUpSec || 0) > 0) {
if (!active.followUpStartedAt) { active.followUpStartedAt = Date.now(); saveState(); return { changed: true }; }
if (timing.followUpRemainingMs > 0) { if (changed) saveState(); return { changed }; }
}
active.status = "finished";
active.endedAt = Date.now();
active.finishedByTimer = true;
active.followUpStartedAt = null;
if (getLastFinishAnnouncementSessionId() !== active.id) { announceRaceFinished(); setLastFinishAnnouncementSessionId(active.id); }
saveState();
return { changed: true };
}
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, refreshTimingDynamics = () => {}, updateHeaderState }) {
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
const timerState = handleSessionTimerTick();
const active = getActiveSession();
if (["timing", "race_day"].includes(getCurrentView()) && active) {
if (timerState.changed) renderView();
else if (["running", "paused"].includes(active.status)) refreshTimingDynamics();
}
if (getCurrentView() === "dashboard" && timerState.changed) renderView();
if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView();
if (timerState.changed) updateHeaderState();
}