fix tränings länken och race day
This commit is contained in:
@@ -173,12 +173,23 @@ function buildPublicTrainingGoal(goal, currentLaps) {
|
||||
function buildPublicTrainingStats(laps) {
|
||||
const lapTimes = laps.map((lap) => lap.lapMs);
|
||||
const recent = lapTimes.slice(-30);
|
||||
const previous = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30));
|
||||
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
|
||||
const sortedRecent = [...recent].sort((left, right) => left - right);
|
||||
const percentile = (ratio) => sortedRecent.length
|
||||
? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))]
|
||||
: 0;
|
||||
const percentile = (values, ratio) => {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted.length
|
||||
? sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))]
|
||||
: 0;
|
||||
};
|
||||
const spread = (values) => {
|
||||
const mean = average(values);
|
||||
return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0;
|
||||
};
|
||||
const recentAverageMs = average(recent);
|
||||
const previousRecentAverageMs = average(previous);
|
||||
const consistencySpreadPct = spread(recent);
|
||||
const previousConsistencySpreadPct = spread(previous);
|
||||
const trendSampleReady = recent.length >= 10 && previous.length >= 10;
|
||||
const daily = new Map();
|
||||
laps.forEach((lap) => {
|
||||
const date = new Date(lap.timestamp);
|
||||
@@ -211,21 +222,46 @@ function buildPublicTrainingStats(laps) {
|
||||
const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3);
|
||||
const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000);
|
||||
|
||||
return {
|
||||
const stats = {
|
||||
totalLaps: laps.length,
|
||||
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||
trainingDays: daily.size,
|
||||
bestLapMs,
|
||||
averageLapMs: average(lapTimes),
|
||||
recentAverageMs,
|
||||
previousRecentAverageMs,
|
||||
averageTrendMs: trendSampleReady ? previousRecentAverageMs - recentAverageMs : 0,
|
||||
recentLapCount: recent.length,
|
||||
consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0,
|
||||
consistencySpreadPct,
|
||||
previousConsistencySpreadPct,
|
||||
consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0,
|
||||
trendSampleReady,
|
||||
controlledLapStreak,
|
||||
dailySummaries,
|
||||
stintCount: stints.length,
|
||||
bestThreeConsecutive,
|
||||
bestFiveMinuteResult,
|
||||
};
|
||||
return { ...stats, achievements: buildPublicTrainingAchievements(stats) };
|
||||
}
|
||||
|
||||
function buildPublicTrainingAchievements(stats) {
|
||||
const definitions = [
|
||||
["first_session", Number(stats.totalSessions || 0), 1],
|
||||
["laps_100", Number(stats.totalLaps || 0), 100],
|
||||
["laps_500", Number(stats.totalLaps || 0), 500],
|
||||
["laps_1000", Number(stats.totalLaps || 0), 1000],
|
||||
["laps_1500", Number(stats.totalLaps || 0), 1500],
|
||||
["days_3", Number(stats.trainingDays || 0), 3],
|
||||
["days_10", Number(stats.trainingDays || 0), 10],
|
||||
["controlled_5", Number(stats.controlledLapStreak || 0), 5],
|
||||
["five_minute", stats.bestFiveMinuteResult ? 1 : 0, 1],
|
||||
];
|
||||
return definitions.map(([id, current, target]) => ({
|
||||
id,
|
||||
percent: Math.min(100, Math.max(0, (current / target) * 100)),
|
||||
unlocked: current >= target,
|
||||
}));
|
||||
}
|
||||
|
||||
function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) {
|
||||
|
||||
@@ -224,6 +224,89 @@ button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.profile-growth-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.7fr) minmax(0, 1.3fr);
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.profile-growth-card {
|
||||
padding: 17px;
|
||||
}
|
||||
|
||||
.profile-growth-card > strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-family: Orbitron, sans-serif;
|
||||
}
|
||||
|
||||
.profile-growth-card > p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profile-growth-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.profile-achievements {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.profile-achievement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.profile-achievement.is-next {
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
.profile-achievement > span {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background: rgba(241, 42, 36, 0.16);
|
||||
color: #ff8f89;
|
||||
font-family: Orbitron, sans-serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-achievement.is-unlocked > span {
|
||||
background: rgba(67, 213, 139, 0.16);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.profile-achievement strong,
|
||||
.profile-achievement small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-achievement small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.profile-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -546,6 +629,10 @@ button {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-growth-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-performance-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -577,4 +664,8 @@ button {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-achievements {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,24 @@ const messages = {
|
||||
bestSplit: "Bästa mellantid",
|
||||
pitStops: "Depåstopp",
|
||||
totalPit: "Total depåtid",
|
||||
recentProgress: "Din senaste utveckling",
|
||||
averagePace: "Snittempo",
|
||||
collectingTrend: "Samlar jämförbart underlag",
|
||||
fasterTrend: "{lap} snabbare per varv",
|
||||
slowerTrend: "{lap} långsammare just nu",
|
||||
stableTrend: "Stabil nivå",
|
||||
achievements: "Achievements",
|
||||
unlocked: "Upplåst",
|
||||
nextAchievement: "Nästa delmål",
|
||||
achievement_first_session: "Första träningspasset",
|
||||
achievement_laps_100: "100 träningsvarv",
|
||||
achievement_laps_500: "500 träningsvarv",
|
||||
achievement_laps_1000: "1 000 träningsvarv",
|
||||
achievement_laps_1500: "1 500 träningsvarv",
|
||||
achievement_days_3: "3 träningsdagar",
|
||||
achievement_days_10: "10 träningsdagar",
|
||||
achievement_controlled_5: "5 kontrollerade varv i rad",
|
||||
achievement_five_minute: "Fullföljd femminutersstint",
|
||||
},
|
||||
en: {
|
||||
loading: "Loading training profile...",
|
||||
@@ -124,6 +142,24 @@ const messages = {
|
||||
bestSplit: "Best split",
|
||||
pitStops: "Pit stops",
|
||||
totalPit: "Total pit time",
|
||||
recentProgress: "Your recent development",
|
||||
averagePace: "Average pace",
|
||||
collectingTrend: "Collecting comparable data",
|
||||
fasterTrend: "{lap} faster per lap",
|
||||
slowerTrend: "{lap} slower right now",
|
||||
stableTrend: "Stable level",
|
||||
achievements: "Achievements",
|
||||
unlocked: "Unlocked",
|
||||
nextAchievement: "Next milestone",
|
||||
achievement_first_session: "First practice session",
|
||||
achievement_laps_100: "100 practice laps",
|
||||
achievement_laps_500: "500 practice laps",
|
||||
achievement_laps_1000: "1,000 practice laps",
|
||||
achievement_laps_1500: "1,500 practice laps",
|
||||
achievement_days_3: "3 practice days",
|
||||
achievement_days_10: "10 practice days",
|
||||
achievement_controlled_5: "5 controlled laps in a row",
|
||||
achievement_five_minute: "Completed five-minute stint",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -193,6 +229,23 @@ function renderProfile(data) {
|
||||
const timerConfig = getTimerConfig(timerState);
|
||||
const timerDurationMs = getTimerDurationMs(timerConfig);
|
||||
const timerLocked = Boolean(timerState?.startedAt && getTimerElapsed(timerState) < timerDurationMs);
|
||||
const achievements = Array.isArray(stats.achievements) ? stats.achievements : [];
|
||||
const unlockedAchievements = achievements.filter((achievement) => achievement.unlocked);
|
||||
const nextAchievement = achievements.find((achievement) => !achievement.unlocked);
|
||||
const visibleAchievements = [...unlockedAchievements.slice(-5), ...(nextAchievement ? [nextAchievement] : [])];
|
||||
const achievementMarkup = visibleAchievements.map((achievement) => `
|
||||
<article class="profile-achievement ${achievement.unlocked ? "is-unlocked" : "is-next"}">
|
||||
<span>${achievement.unlocked ? "OK" : `${Math.round(Number(achievement.percent || 0))}%`}</span>
|
||||
<div><strong>${escapeHtml(t(`achievement_${achievement.id}`))}</strong><small>${escapeHtml(t(achievement.unlocked ? "unlocked" : "nextAchievement"))}</small></div>
|
||||
</article>
|
||||
`).join("");
|
||||
const paceTrend = !stats.trendSampleReady
|
||||
? t("collectingTrend")
|
||||
: stats.averageTrendMs > 0
|
||||
? t("fasterTrend", { lap: formatLap(stats.averageTrendMs) })
|
||||
: stats.averageTrendMs < 0
|
||||
? t("slowerTrend", { lap: formatLap(Math.abs(stats.averageTrendMs)) })
|
||||
: t("stableTrend");
|
||||
const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "")
|
||||
? `<img src="${escapeHtml(branding.logoDataUrl)}" alt="" />`
|
||||
: `<div class="profile-brand-mark">RC</div>`;
|
||||
@@ -250,6 +303,18 @@ function renderProfile(data) {
|
||||
${renderStat(t("consistency"), stats.recentLapCount ? `${Number(stats.consistencySpreadPct).toFixed(1)}%` : "-")}
|
||||
</section>
|
||||
|
||||
<section class="profile-growth-grid">
|
||||
<article class="profile-card profile-growth-card">
|
||||
<span class="profile-mini-label">${escapeHtml(t("recentProgress"))}</span>
|
||||
<strong>${escapeHtml(t("averagePace"))}</strong>
|
||||
<p>${escapeHtml(paceTrend)}</p>
|
||||
</article>
|
||||
<article class="profile-card profile-growth-card">
|
||||
<div class="profile-growth-head"><span class="profile-mini-label">${escapeHtml(t("achievements"))}</span><strong>${unlockedAchievements.length}/${achievements.length}</strong></div>
|
||||
<div class="profile-achievements">${achievementMarkup}</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="profile-grid">
|
||||
<section class="profile-card">
|
||||
<div class="profile-card-head"><h2>${escapeHtml(t("nextLevel"))}</h2></div>
|
||||
|
||||
133
src/race_day_logic.js
Normal file
133
src/race_day_logic.js
Normal file
@@ -0,0 +1,133 @@
|
||||
function unique(values) {
|
||||
return [...new Set(values.map((value) => String(value || "").trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function duplicateValues(values) {
|
||||
const counts = new Map();
|
||||
values.filter(Boolean).forEach((value) => counts.set(value, (counts.get(value) || 0) + 1));
|
||||
return [...counts.entries()].filter(([, count]) => count > 1).map(([value]) => value);
|
||||
}
|
||||
|
||||
function getEvent(state, session) {
|
||||
return state.events.find((event) => event.id === session?.eventId) || null;
|
||||
}
|
||||
|
||||
function getRaceDriverIds(state, session, event) {
|
||||
if (Array.isArray(session?.driverIds) && session.driverIds.length) {
|
||||
return unique(session.driverIds);
|
||||
}
|
||||
if (event?.raceConfig?.participantsConfigured) {
|
||||
return unique(event.raceConfig.driverIds || []);
|
||||
}
|
||||
return state.drivers
|
||||
.filter((driver) => !event?.classId || driver.classId === event.classId)
|
||||
.map((driver) => driver.id);
|
||||
}
|
||||
|
||||
function check(id, level, data = {}) {
|
||||
return { id, level, ...data };
|
||||
}
|
||||
|
||||
export function buildRaceDayQueue(state) {
|
||||
const sessions = Array.isArray(state?.sessions) ? state.sessions : [];
|
||||
let current = sessions.find((session) => session.id === state?.activeSessionId) || null;
|
||||
if (!current) {
|
||||
current = sessions.find((session) => ["running", "paused"].includes(session.status))
|
||||
|| sessions.find((session) => session.status === "ready")
|
||||
|| null;
|
||||
}
|
||||
if (!current) {
|
||||
return { current: null, next: null, afterNext: null, upcoming: [] };
|
||||
}
|
||||
|
||||
const eventSessions = sessions.filter((session) => session.eventId === current.eventId);
|
||||
const currentIndex = eventSessions.findIndex((session) => session.id === current.id);
|
||||
const upcoming = eventSessions
|
||||
.slice(Math.max(0, currentIndex + 1))
|
||||
.filter((session) => !["finished", "stopped"].includes(session.status));
|
||||
return { current, next: upcoming[0] || null, afterNext: upcoming[1] || null, upcoming };
|
||||
}
|
||||
|
||||
export function buildRaceDayPreflight(state, session, { backendAvailable = false } = {}) {
|
||||
if (!session) {
|
||||
return { ready: false, checks: [check("session", "error")] };
|
||||
}
|
||||
|
||||
const event = getEvent(state, session);
|
||||
const checks = [
|
||||
check("session", "ok", { sessionId: session.id }),
|
||||
check("backend", backendAvailable ? "ok" : "warning"),
|
||||
check("decoder", state?.decoder?.connected ? "ok" : "error"),
|
||||
];
|
||||
|
||||
if (state?.settings?.splitDecoderEnabled) {
|
||||
checks.push(check("split_decoder", state?.decoder?.splitConnected ? "ok" : "warning"));
|
||||
}
|
||||
if (state?.settings?.pitDecoderEnabled) {
|
||||
const pitConnected = state?.decoder?.pitEntryConnected && state?.decoder?.pitExitConnected;
|
||||
checks.push(check("pit_decoders", pitConnected ? "ok" : "warning"));
|
||||
}
|
||||
|
||||
const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0);
|
||||
const maxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 0) || 0);
|
||||
checks.push(check("lap_window", event?.mode !== "race" || (maxLapMs > minLapMs && maxLapMs > 0) ? "ok" : "error", { minLapMs, maxLapMs }));
|
||||
|
||||
let entrantIds = [];
|
||||
let transponders = [];
|
||||
if (session.type === "open_practice") {
|
||||
checks.push(check("participants", "ok", { openPractice: true, count: 0 }));
|
||||
checks.push(check("transponders", "ok", { openPractice: true, count: 0 }));
|
||||
} else if (session.mode === "track") {
|
||||
const assignments = Array.isArray(session.assignments) ? session.assignments : [];
|
||||
checks.push(check("participants", assignments.length ? "ok" : "error", { count: assignments.length }));
|
||||
const cars = assignments.map((assignment) => state.cars.find((car) => car.id === assignment.carId)).filter(Boolean);
|
||||
const missingCount = assignments.length - cars.filter((car) => String(car.transponder || "").trim()).length;
|
||||
transponders = cars.map((car) => String(car.transponder || "").trim()).filter(Boolean);
|
||||
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount }));
|
||||
} else if (session.type === "team_race") {
|
||||
const teams = Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams : [];
|
||||
checks.push(check("participants", teams.length ? "ok" : "error", { count: teams.length, teamRace: true }));
|
||||
const teamTransponders = teams.map((team) => {
|
||||
const driverTransponders = (team.driverIds || [])
|
||||
.map((id) => state.drivers.find((driver) => driver.id === id)?.transponder)
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean);
|
||||
const carTransponders = (team.carIds || [])
|
||||
.map((id) => state.cars.find((car) => car.id === id)?.transponder)
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean);
|
||||
return unique([...driverTransponders, ...carTransponders]);
|
||||
});
|
||||
const missingCount = teamTransponders.filter((values) => !values.length).length;
|
||||
transponders = teamTransponders.flat();
|
||||
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount, teamRace: true }));
|
||||
} else {
|
||||
entrantIds = getRaceDriverIds(state, session, event);
|
||||
checks.push(check("participants", entrantIds.length ? "ok" : "error", { count: entrantIds.length }));
|
||||
const drivers = entrantIds.map((id) => state.drivers.find((driver) => driver.id === id)).filter(Boolean);
|
||||
const missingCount = entrantIds.length - drivers.filter((driver) => String(driver.transponder || "").trim()).length;
|
||||
transponders = drivers.map((driver) => String(driver.transponder || "").trim()).filter(Boolean);
|
||||
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount }));
|
||||
}
|
||||
|
||||
const duplicates = duplicateValues(transponders);
|
||||
checks.push(check("duplicate_transponders", duplicates.length ? "error" : "ok", { duplicates }));
|
||||
|
||||
if (String(session.startMode || "").toLowerCase() === "position" && session.type !== "team_race") {
|
||||
const gridIds = unique(session.manualGridIds || session.driverIds || []);
|
||||
const expectedIds = session.mode === "track"
|
||||
? unique((session.assignments || []).map((assignment) => assignment.driverId))
|
||||
: entrantIds;
|
||||
const gridComplete = expectedIds.length > 0
|
||||
&& gridIds.length === expectedIds.length
|
||||
&& expectedIds.every((id) => gridIds.includes(id));
|
||||
checks.push(check("grid", gridComplete ? "ok" : "error", { count: gridIds.length, expectedCount: expectedIds.length }));
|
||||
} else {
|
||||
checks.push(check("grid", "ok", { notRequired: true }));
|
||||
}
|
||||
|
||||
const raceAudioEnabled = state?.settings?.raceAudio?.enabled !== false;
|
||||
checks.push(check("audio", raceAudioEnabled ? "ok" : "warning"));
|
||||
|
||||
return { ready: !checks.some((item) => item.level === "error"), checks };
|
||||
}
|
||||
139
src/race_day_view.js
Normal file
139
src/race_day_view.js
Normal file
@@ -0,0 +1,139 @@
|
||||
function renderPreflightDetail(item, { t, formatLap, escapeHtml }) {
|
||||
if (item.id === "lap_window") return `${formatLap(item.minLapMs || 0)} / ${formatLap(item.maxLapMs || 0)}`;
|
||||
if (item.id === "participants") return item.openPractice ? t("race_day.open_practice_any") : t("race_day.count", { count: item.count || 0 });
|
||||
if (item.id === "transponders") {
|
||||
return item.openPractice ? t("race_day.open_practice_any") : item.missingCount
|
||||
? t("race_day.missing_count", { count: item.missingCount })
|
||||
: t("race_day.count", { count: item.count || 0 });
|
||||
}
|
||||
if (item.id === "duplicate_transponders" && item.duplicates?.length) return escapeHtml(item.duplicates.join(", "));
|
||||
if (item.id === "grid") return item.notRequired ? t("race_day.not_required") : `${item.count || 0}/${item.expectedCount || 0}`;
|
||||
return t(`race_day.check_${item.level}`);
|
||||
}
|
||||
|
||||
function renderQueueSession(session, positionKey, deps) {
|
||||
const { t, escapeHtml, getEventName, getSessionTypeLabel, getStatusLabel } = deps;
|
||||
if (!session) return `<article class="race-day-queue-card is-empty"><span>${t(positionKey)}</span><strong>${t("race_day.none")}</strong></article>`;
|
||||
return `
|
||||
<article class="race-day-queue-card">
|
||||
<span>${t(positionKey)}</span>
|
||||
<strong>${escapeHtml(session.name)}</strong>
|
||||
<small>${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))}</small>
|
||||
<span class="pill ${session.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(session.status))}</span>
|
||||
<button class="btn btn-mini" type="button" data-race-day-select="${escapeHtml(session.id)}">${t("race_day.select")}</button>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
export function renderRaceDayView(deps) {
|
||||
const {
|
||||
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock,
|
||||
getEventName, getSessionTypeLabel, getStatusLabel, getStartModeLabel,
|
||||
getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight,
|
||||
backendAvailable, selectSession, startSession, finishSession, pauseSession, resumeSession,
|
||||
navigateTo, getStartLightState,
|
||||
} = deps;
|
||||
const queue = buildRaceDayQueue(state);
|
||||
const session = queue.current;
|
||||
const preflight = buildRaceDayPreflight(state, session, { backendAvailable });
|
||||
const timing = session ? getSessionTiming(session) : null;
|
||||
const clock = !timing ? "-" : timing.followUpActive
|
||||
? formatCountdown(timing.followUpRemainingMs)
|
||||
: timing.untimed ? formatElapsedClock(timing.elapsedMs) : formatCountdown(timing.remainingMs);
|
||||
const grid = session ? getSessionGridEntries(session) : [];
|
||||
const isSelected = Boolean(session && state.activeSessionId === session.id);
|
||||
const canStart = Boolean(session && preflight.ready && !["running", "paused"].includes(session.status));
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="race-day-hero ${preflight.ready ? "is-ready" : "has-blockers"}">
|
||||
<div>
|
||||
<span class="race-day-kicker">${t("race_day.current")}</span>
|
||||
<h2>${session ? escapeHtml(session.name) : t("race_day.no_session")}</h2>
|
||||
<p>${session ? `${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))} · ${escapeHtml(getStartModeLabel(session.startMode))}` : t("race_day.no_session_hint")}</p>
|
||||
</div>
|
||||
<div class="race-day-clock"><strong data-race-day-clock>${clock}</strong><span>${session ? escapeHtml(getStatusLabel(session.status)) : t("race_day.none")}</span></div>
|
||||
</section>
|
||||
|
||||
<div class="race-day-layout">
|
||||
<div class="race-day-main">
|
||||
<section class="panel race-day-control-panel">
|
||||
<div class="panel-header">
|
||||
<div><h3>${t("race_day.control")}</h3><p class="hint">${t("race_day.control_hint")}</p></div>
|
||||
<span class="pill ${preflight.ready ? "pill-green" : "pill-red"}">${t(preflight.ready ? "race_day.ready" : "race_day.not_ready")}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
${session && !isSelected ? `<button id="raceDayActivate" class="btn" type="button">${t("race_day.activate")}</button>` : ""}
|
||||
<div class="race-day-actions">
|
||||
<button id="raceDayStart" class="btn btn-primary" type="button" ${canStart ? "" : "disabled"}>${t("timing.start")}</button>
|
||||
<button id="raceDayPause" class="btn" type="button" ${session?.status === "running" ? "" : "disabled"}>${t("audio.race_pause")}</button>
|
||||
<button id="raceDayResume" class="btn" type="button" ${session?.status === "paused" ? "" : "disabled"}>${t("audio.race_resume")}</button>
|
||||
<button id="raceDayFinish" class="btn btn-danger" type="button" ${["running", "paused"].includes(session?.status) ? "" : "disabled"}>${t("race_day.finish")}</button>
|
||||
</div>
|
||||
<div id="raceDayStartLights" class="race-start-lights" data-state="${escapeHtml(getStartLightState())}">
|
||||
${[1, 2, 3, 4, 5].map((light) => `<span data-start-light="${light}"></span>`).join("")}
|
||||
<strong>${escapeHtml(getStartLightState())}</strong>
|
||||
</div>
|
||||
<div class="race-day-shortcuts">
|
||||
<button id="raceDayTiming" class="btn btn-mini" type="button">${t("nav.timing")}</button>
|
||||
<button id="raceDayOverlay" class="btn btn-mini" type="button">${t("nav.overlay")}</button>
|
||||
<button id="raceDayJudging" class="btn btn-mini" type="button">${t("nav.judging")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("race_day.start_list")}</h3><span class="pill">${grid.length}</span></div>
|
||||
<div class="panel-body race-day-grid-list">
|
||||
${grid.length ? grid.map((entry) => `<article><span>${entry.slot}</span><strong>${escapeHtml(entry.name)}</strong><small>${escapeHtml(entry.meta || "-")}</small></article>`).join("") : `<p class="hint">${t("race_day.no_start_list")}</p>`}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="race-day-side">
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("race_day.preflight")}</h3></div>
|
||||
<div class="panel-body race-day-checks">
|
||||
${preflight.checks.map((item) => `<article class="race-day-check is-${item.level}"><span aria-hidden="true"></span><div><strong>${t(`race_day.check_${item.id}`)}</strong><small>${renderPreflightDetail(item, { t, formatLap, escapeHtml })}</small></div></article>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("race_day.heat_queue")}</h3></div>
|
||||
<div class="panel-body race-day-queue">
|
||||
${renderQueueSession(session, "race_day.current", deps)}
|
||||
${renderQueueSession(queue.next, "race_day.next", deps)}
|
||||
${renderQueueSession(queue.afterNext, "race_day.after_next", deps)}
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
const choose = (sessionId) => sessionId && selectSession(sessionId);
|
||||
document.getElementById("raceDayActivate")?.addEventListener("click", () => choose(session?.id));
|
||||
document.querySelectorAll("[data-race-day-select]").forEach((node) => node.addEventListener("click", () => choose(node.dataset.raceDaySelect)));
|
||||
document.getElementById("raceDayStart")?.addEventListener("click", async () => {
|
||||
if (!session || !preflight.ready) return;
|
||||
if (!isSelected) selectSession(session.id, { render: false });
|
||||
const button = document.getElementById("raceDayStart");
|
||||
if (button) button.disabled = true;
|
||||
const updateStartDisplay = (label, lights = 0) => {
|
||||
if (button && label) button.textContent = label;
|
||||
const panel = document.getElementById("raceDayStartLights");
|
||||
if (!panel) return;
|
||||
panel.querySelector("strong")?.replaceChildren(document.createTextNode(label));
|
||||
panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights));
|
||||
};
|
||||
const result = await startSession(session, {
|
||||
onCountdown: (seconds) => updateStartDisplay(String(seconds), 0),
|
||||
onLight: (light) => updateStartDisplay(`LIGHT_${light}`, light),
|
||||
onArmed: () => updateStartDisplay("WAIT_RANDOM", 5),
|
||||
});
|
||||
if (!result.ok && result.message) alert(result.message);
|
||||
if (!result.ok && button) button.disabled = false;
|
||||
});
|
||||
document.getElementById("raceDayPause")?.addEventListener("click", () => pauseSession(session));
|
||||
document.getElementById("raceDayResume")?.addEventListener("click", () => resumeSession(session));
|
||||
document.getElementById("raceDayFinish")?.addEventListener("click", () => finishSession(session));
|
||||
document.getElementById("raceDayTiming")?.addEventListener("click", () => navigateTo("timing"));
|
||||
document.getElementById("raceDayOverlay")?.addEventListener("click", () => navigateTo("overlay"));
|
||||
document.getElementById("raceDayJudging")?.addEventListener("click", () => navigateTo("judging"));
|
||||
}
|
||||
40
src/session_control.js
Normal file
40
src/session_control.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export function beginSession(session, nowTs = Date.now()) {
|
||||
session.status = "running";
|
||||
session.startedAt = nowTs;
|
||||
session.endedAt = null;
|
||||
session.finishedByTimer = false;
|
||||
session.followUpStartedAt = null;
|
||||
session.pausedAt = null;
|
||||
session.totalPausedMs = 0;
|
||||
session.followUpPausedMs = 0;
|
||||
session.audioAnnouncements = [];
|
||||
return session;
|
||||
}
|
||||
|
||||
export function finishSession(session, nowTs = Date.now()) {
|
||||
session.status = "finished";
|
||||
session.endedAt = nowTs;
|
||||
session.finishedByTimer = false;
|
||||
session.followUpStartedAt = null;
|
||||
session.pausedAt = null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export function pauseSession(session, nowTs = Date.now()) {
|
||||
if (session?.status !== "running") return false;
|
||||
session.status = "paused";
|
||||
session.pausedAt = nowTs;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resumeSession(session, nowTs = Date.now()) {
|
||||
if (session?.status !== "paused") return false;
|
||||
const pausedDuration = Math.max(0, nowTs - Number(session.pausedAt || nowTs));
|
||||
session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration;
|
||||
if (session.followUpStartedAt) {
|
||||
session.followUpPausedMs = Math.max(0, Number(session.followUpPausedMs || 0)) + pausedDuration;
|
||||
}
|
||||
session.pausedAt = null;
|
||||
session.status = "running";
|
||||
return true;
|
||||
}
|
||||
@@ -100,6 +100,7 @@ test("public profile contains only the selected driver's sanitized aggregates",
|
||||
assert.equal(profile.goals[0].percent, 80);
|
||||
assert.equal(profile.classProfiles.length, 2);
|
||||
assert.equal(profile.classProfiles[0].stats.totalLaps, 4);
|
||||
assert.equal(profile.classProfiles[0].stats.achievements.find((item) => item.id === "first_session").unlocked, true);
|
||||
assert.deepEqual(profile.classProfiles[0].channelStats, {
|
||||
splitCount: 2,
|
||||
bestSplitMs: 7000,
|
||||
@@ -122,6 +123,26 @@ test("public profile contains only the selected driver's sanitized aggregates",
|
||||
assert.equal(serialized.includes("secret-decoder"), false);
|
||||
});
|
||||
|
||||
test("public profile exposes only class-scoped development trends and achievements", () => {
|
||||
const now = Date.now();
|
||||
const state = buildState(now);
|
||||
state.training.ledger = Array.from({ length: 60 }, (_, index) => ({
|
||||
driverId: "driver_1",
|
||||
sessionId: "practice_2wd",
|
||||
timestamp: now - (60 - index) * 18_000,
|
||||
lapMs: index < 30 ? 18_000 : 17_000,
|
||||
}));
|
||||
const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "");
|
||||
const twoWheelDrive = profile.classProfiles.find((item) => item.classId === "class_1");
|
||||
const fourWheelDrive = profile.classProfiles.find((item) => item.classId === "class_2");
|
||||
|
||||
assert.equal(twoWheelDrive.stats.trendSampleReady, true);
|
||||
assert.equal(twoWheelDrive.stats.averageTrendMs, 1_000);
|
||||
assert.equal(twoWheelDrive.stats.achievements.find((item) => item.id === "controlled_5").unlocked, true);
|
||||
assert.equal(fourWheelDrive.stats.totalLaps, 0);
|
||||
assert.equal(fourWheelDrive.stats.trendSampleReady, false);
|
||||
});
|
||||
|
||||
test("public timer window freezes aggregates after the personal session", () => {
|
||||
const now = Date.now();
|
||||
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "", now - 30 * 60000, 20 * 60000);
|
||||
|
||||
Reference in New Issue
Block a user