Files
Live_RC/src/runtime_services.js
larssand fd6efe2c7e Add track layout tracking so lap-time stats stay comparable
Clubs that reconfigure their track (e.g. running indoors in the same
building) can now add named track layouts under Settings, each with
just a start date/time - layouts are contiguous, so a layout's end is
implicitly the next one's start, with no separate field to keep in
sync. Every session freezes which layout was active when it started,
so the "track record" audio cue and the Results Archive (which now
shows each session's layout) only compare laps run on the same
physical track. With no layouts configured, everything behaves
exactly as before - the feature is fully inert until a club uses it.

Design settled through conversation with the user: global scope (not
per class), start-date-only (no redundant stop date), and no changes
needed to driver/transponder mapping, since that's already fully
independent of track geometry in this app.

Deliberately out of scope for this pass: the mobile/driver training
profile's own cross-session personal-best trend stats still compare
across a driver's whole lap ledger regardless of layout - logged in
ROADMAP.md as a real remaining gap for a later pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:17:06 +02:00

464 lines
25 KiB
JavaScript

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.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)),
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 getSessionAnnouncementText(session, phase, { t }) {
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || "").toLowerCase());
if (phase === "start") return t(practice ? "timing.practice_started_voice" : "timing.race_started_voice");
return t(practice ? "timing.practice_finished_voice" : "timing.race_finished_voice");
}
export function speakTextHelper(text, { state, currentLanguage }, options = {}) {
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) return false;
const synth = window.speechSynthesis;
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;
}
export function announcePassingHelper(entry, { state, t, playPassingBeep, speakText }) {
if (!state.settings.audioEnabled) return;
if (state.settings.passingSoundMode === "beep") { playPassingBeep(); return; }
if (state.settings.passingSoundMode === "name") {
const spoken = speakText(entry?.speechName || entry?.displayName || entry?.driverName || t("common.unknown_driver"), { interrupt: false });
if (!spoken) playPassingBeep();
}
}
export function announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }) {
if (!session) return;
pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`, { silentCue: true });
if (state.settings.raceAudio?.enabled) return;
if (!state.settings.audioEnabled) return;
if (state.settings.startSoundEnabled !== false) playStartCue();
if (state.settings.finishVoiceEnabled !== false) {
setTimeout(() => speakText(getSessionAnnouncementText(session, "start", { t }), { interrupt: true }), state.settings.startSoundEnabled === false ? 0 : 650);
}
}
export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }) {
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`, { silentCue: true });
if (state.settings.raceAudio?.enabled) return;
if (!session || !state.settings.audioEnabled) return;
if (state.settings.finishSoundEnabled !== false) playFinishSiren();
if (state.settings.finishVoiceEnabled !== false) {
setTimeout(() => speakText(getSessionAnnouncementText(session, "finish", { t }), { interrupt: true }), state.settings.finishSoundEnabled === false ? 0 : 1150);
}
}
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();
}