Add persistent driver training history and progression
This commit is contained in:
56
src/app.js
56
src/app.js
@@ -17,7 +17,7 @@ import { renderEventManagerMarkup } from "./event_manager_view.js";
|
||||
import { renderEventManagerView } from "./event_manager_controller.js";
|
||||
import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, speakTextHelper, announcePassingHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js";
|
||||
import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js";
|
||||
import { normalizeTrainingState as normalizeTrainingStateLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic } from "./training_logic.js";
|
||||
import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js";
|
||||
import { renderTrainingView } from "./training_views.js";
|
||||
|
||||
|
||||
@@ -198,10 +198,15 @@ const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, f
|
||||
const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, pushOverlayEvent, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } });
|
||||
const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } });
|
||||
const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid });
|
||||
const syncTrainingLedger = () => {
|
||||
state.training = syncTrainingLedgerLogic(state.training, state, { uid });
|
||||
return state.training;
|
||||
};
|
||||
const buildDriverTrainingStats = (trainingState, driverId) => buildDriverTrainingStatsLogic(trainingState, driverId);
|
||||
const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats);
|
||||
const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats);
|
||||
const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, uid, saveState, renderView, normalizeTrainingState, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } });
|
||||
const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats);
|
||||
const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } });
|
||||
const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState });
|
||||
const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing });
|
||||
const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState });
|
||||
@@ -314,6 +319,21 @@ const TRANSLATIONS = {
|
||||
"training.goal_reached": "Målet är nått",
|
||||
"training.goal_claimed": "Belöning utdelad",
|
||||
"training.claim_reward": "Markera belöning utdelad",
|
||||
"training.training_days": "Träningsdagar",
|
||||
"training.sessions_count": "{sessions} sparade sessioner",
|
||||
"training.next_level": "Nästa automatiska nivå",
|
||||
"training.next_level_target": "Nå {laps} varv",
|
||||
"training.average_lap": "Snittvarv",
|
||||
"training.recent_average": "Snitt senaste 30",
|
||||
"training.controlled_streak": "Kontrollerad serie",
|
||||
"training.lap_count": "{laps} varv",
|
||||
"training.last_activity": "Senaste aktivitet",
|
||||
"training.no_activity": "Ingen aktivitet ännu",
|
||||
"training.history": "Träningshistorik per dag",
|
||||
"training.no_history": "Ingen träningshistorik finns ännu.",
|
||||
"training.date": "Datum",
|
||||
"training.laps": "Varv",
|
||||
"training.pb_improvement": "Senaste 30: {lap} snabbare",
|
||||
"mode.track": "Sponsor Event",
|
||||
"mode.race": "Race",
|
||||
"dashboard.events": "Event",
|
||||
@@ -936,6 +956,7 @@ const TRANSLATIONS = {
|
||||
"guide.card_sponsor_blurb": "Delade bilar, roterande förare och heat/finaler för sponsor- och prova-på-event.",
|
||||
"guide.card_race_blurb": "Personliga transpondrar, kval/finaler, raceformat och seedning för riktiga tävlingsrace.",
|
||||
"guide.card_team_blurb": "Lagrace och endurance med team, stintlogg och summerade varv över lång tid.",
|
||||
"guide.card_training_blurb": "Förarutveckling, beständig varvhistorik, träningsmål och belöningar i mobilvänlig vy.",
|
||||
"guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux och lokal SQLite-lagring.",
|
||||
"guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar",
|
||||
"guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.",
|
||||
@@ -1007,6 +1028,13 @@ const TRANSLATIONS = {
|
||||
"guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.",
|
||||
"guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.",
|
||||
"guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.",
|
||||
"guide.training_title": "Träning, utveckling och belöningar",
|
||||
"guide.training_1": "1. Registrera föraren och koppla den personliga transpondern under Förare. Endast passeringar med ett registrerat förar-ID kan räknas i träningsprofilen.",
|
||||
"guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.",
|
||||
"guide.training_3": "3. Öppna Träning och välj förare. Sidan visar totalvarv, träningsdagar, personbästa, snitt, senaste 30 varv, kontrollerad serie och historik per dag.",
|
||||
"guide.training_4": "4. Nästa automatiska nivå räknas utan konfiguration. Skapa egna delmål om klubben eller en förälder vill koppla en fysisk belöning till exempelvis 500, 1000 eller 1500 varv.",
|
||||
"guide.training_5": "5. När målet nåtts kan belöningen markeras som utdelad. Direktlänken `/?view=training&driver=<id>` kan bokmärkas på förarens mobil.",
|
||||
"guide.training_6": "6. Träningsloggen uppdateras av domarkorrigeringar medan sessionen finns, men behålls när sessionen senare raderas. Full dataexport i Inställningar inkluderar både logg och mål.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||
"overlay.no_active": "Ingen aktiv session vald.",
|
||||
@@ -1163,6 +1191,21 @@ const TRANSLATIONS = {
|
||||
"training.goal_reached": "Milestone reached",
|
||||
"training.goal_claimed": "Reward delivered",
|
||||
"training.claim_reward": "Mark reward delivered",
|
||||
"training.training_days": "Practice days",
|
||||
"training.sessions_count": "{sessions} recorded sessions",
|
||||
"training.next_level": "Next automatic level",
|
||||
"training.next_level_target": "Reach {laps} laps",
|
||||
"training.average_lap": "Average lap",
|
||||
"training.recent_average": "Latest 30 average",
|
||||
"training.controlled_streak": "Controlled streak",
|
||||
"training.lap_count": "{laps} laps",
|
||||
"training.last_activity": "Latest activity",
|
||||
"training.no_activity": "No activity yet",
|
||||
"training.history": "Daily practice history",
|
||||
"training.no_history": "No practice history is available yet.",
|
||||
"training.date": "Date",
|
||||
"training.laps": "Laps",
|
||||
"training.pb_improvement": "Latest 30: {lap} faster",
|
||||
"mode.track": "Track Event",
|
||||
"mode.race": "Race",
|
||||
"dashboard.events": "Events",
|
||||
@@ -1785,6 +1828,7 @@ const TRANSLATIONS = {
|
||||
"guide.card_sponsor_blurb": "Shared cars, rotating drivers and heat/final flow for sponsor or try-out events.",
|
||||
"guide.card_race_blurb": "Personal transponders, qualifying/finals, race format and seeding for competition racing.",
|
||||
"guide.card_team_blurb": "Team race and endurance with teams, stint logs and total laps over long sessions.",
|
||||
"guide.card_training_blurb": "Driver development, durable lap history, practice goals and rewards in a mobile-friendly view.",
|
||||
"guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux and local SQLite storage.",
|
||||
"guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars",
|
||||
"guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.",
|
||||
@@ -1856,6 +1900,13 @@ const TRANSLATIONS = {
|
||||
"guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.",
|
||||
"guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.",
|
||||
"guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.",
|
||||
"guide.training_title": "Practice, development and rewards",
|
||||
"guide.training_1": "1. Register the driver and personal transponder under Drivers. Only passings linked to a registered driver ID count toward the practice profile.",
|
||||
"guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.",
|
||||
"guide.training_3": "3. Open Training and select a driver. The page shows total laps, practice days, personal best, averages, latest 30 laps, controlled streak and daily history.",
|
||||
"guide.training_4": "4. The next automatic level needs no configuration. Add custom milestones when the club or a parent wants to attach a physical reward to targets such as 500, 1000 or 1500 laps.",
|
||||
"guide.training_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=<id>` on the driver's phone for direct access.",
|
||||
"guide.training_6": "6. The training ledger follows judging corrections while the source session exists, but remains when that session is later deleted. Full data export in Settings includes both ledger and goals.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "External leaderboard screen",
|
||||
"overlay.no_active": "No active session selected.",
|
||||
@@ -2472,6 +2523,7 @@ function setupLanguageControl() {
|
||||
}
|
||||
|
||||
function buildPersistableState() {
|
||||
syncTrainingLedger();
|
||||
return {
|
||||
classes: state.classes,
|
||||
drivers: state.drivers.map((driver) => normalizeDriver(driver)),
|
||||
|
||||
@@ -31,6 +31,7 @@ export function renderGuideView({ dom, t }) {
|
||||
${renderGuideOverviewCard("guide.sponsor_title", "guide.card_sponsor_blurb", { t })}
|
||||
${renderGuideOverviewCard("guide.race_title", "guide.card_race_blurb", { t })}
|
||||
${renderGuideOverviewCard("guide.team_title", "guide.card_team_blurb", { t })}
|
||||
${renderGuideOverviewCard("guide.training_title", "guide.card_training_blurb", { t })}
|
||||
${renderGuideOverviewCard("guide.windows_title", "guide.card_decoder_blurb", { t })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,11 +64,15 @@ export function renderGuideView({ dom, t }) {
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"], "", { t })}
|
||||
${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })}
|
||||
${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6"], "", { t })}
|
||||
</div>
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })}
|
||||
${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })}
|
||||
</div>
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"], "", { t })}
|
||||
</div>
|
||||
|
||||
|
||||
136
src/styles.css
136
src/styles.css
@@ -200,6 +200,12 @@ body {
|
||||
background: rgba(225, 6, 0, 0.15);
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
color: color-mix(in srgb, #47d98a 70%, var(--text) 30%);
|
||||
border-color: color-mix(in srgb, #47d98a 55%, var(--line) 45%);
|
||||
background: color-mix(in srgb, #47d98a 14%, transparent);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px 24px 26px;
|
||||
}
|
||||
@@ -340,6 +346,107 @@ body {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.training-insight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.training-milestone-card,
|
||||
.training-insights {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.training-milestone-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 8px 18px;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(115deg, color-mix(in srgb, var(--accent) 14%, transparent), transparent 52%),
|
||||
var(--panel);
|
||||
}
|
||||
|
||||
.training-milestone-card h3,
|
||||
.training-milestone-card p {
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
|
||||
.training-milestone-card p,
|
||||
.training-insights span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.training-milestone-card > strong {
|
||||
font-family: Orbitron, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.training-milestone-card .training-progress {
|
||||
grid-column: 1 / -1;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.training-insights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.training-insights > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.training-insights > div:nth-child(odd) {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.training-insights > div:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.training-insights span {
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.training-insights strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.training-history-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.training-history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.training-history-table th,
|
||||
.training-history-table td {
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.training-history-table th {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.training-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||
@@ -883,7 +990,7 @@ body {
|
||||
}
|
||||
|
||||
.guide-overview-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
}
|
||||
|
||||
.guide-overview-card {
|
||||
@@ -2278,6 +2385,10 @@ select:focus {
|
||||
.training-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.training-insight-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
@@ -2332,6 +2443,29 @@ select:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.training-milestone-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.training-milestone-card .training-progress {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.training-insights {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.training-insights > div,
|
||||
.training-insights > div:nth-child(odd),
|
||||
.training-insights > div:nth-last-child(-n + 2) {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.training-insights > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.field-card-checkbox {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@@ -14,28 +14,89 @@ export function normalizeTrainingState(training, { uid }) {
|
||||
.filter((goal) => goal.driverId && goal.targetLaps > 0)
|
||||
: [];
|
||||
|
||||
return { goals };
|
||||
const seenLedgerKeys = new Set();
|
||||
const ledger = Array.isArray(source.ledger)
|
||||
? source.ledger
|
||||
.map((entry) => {
|
||||
const timestamp = Math.max(0, Number(entry?.timestamp || 0) || 0);
|
||||
return {
|
||||
id: String(entry?.id || uid("training_lap")),
|
||||
sourceKey: String(entry?.sourceKey || ""),
|
||||
sessionId: String(entry?.sessionId || ""),
|
||||
sessionName: String(entry?.sessionName || "").trim(),
|
||||
eventId: String(entry?.eventId || ""),
|
||||
eventName: String(entry?.eventName || "").trim(),
|
||||
driverId: String(entry?.driverId || ""),
|
||||
timestamp,
|
||||
lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)),
|
||||
recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())),
|
||||
};
|
||||
})
|
||||
.filter((entry) => {
|
||||
const key = entry.sourceKey || entry.id;
|
||||
if (!entry.driverId || entry.lapMs <= 500 || seenLedgerKeys.has(key)) return false;
|
||||
seenLedgerKeys.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
: [];
|
||||
|
||||
return { goals, ledger };
|
||||
}
|
||||
|
||||
function getPassingSourceKey(sessionId, passing, index) {
|
||||
const competitor = String(passing?.competitorKey || passing?.driverId || passing?.transponder || "unknown");
|
||||
return `${sessionId}:${Number(passing?.timestamp || 0) || 0}:${competitor}:${index}`;
|
||||
}
|
||||
|
||||
export function syncTrainingLedger(training, state, { uid }) {
|
||||
const normalized = normalizeTrainingState(training, { uid });
|
||||
const results = state?.resultsBySession && typeof state.resultsBySession === "object" ? state.resultsBySession : {};
|
||||
const currentSessionIds = new Set(Object.keys(results));
|
||||
const retained = normalized.ledger.filter((entry) => !currentSessionIds.has(entry.sessionId));
|
||||
const sessions = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id), session]));
|
||||
const events = new Map((Array.isArray(state?.events) ? state.events : []).map((event) => [String(event.id), event]));
|
||||
const current = [];
|
||||
|
||||
Object.entries(results).forEach(([sessionId, result]) => {
|
||||
const session = sessions.get(String(sessionId));
|
||||
const event = session ? events.get(String(session.eventId || "")) : null;
|
||||
(Array.isArray(result?.passings) ? result.passings : []).forEach((passing, index) => {
|
||||
const driverId = String(passing?.driverId || "");
|
||||
const lapMs = Math.round(Number(passing?.lapMs || 0) || 0);
|
||||
if (!driverId || passing?.validLap === false || lapMs <= 500) return;
|
||||
const timestamp = Math.max(0, Number(passing?.timestamp || 0) || 0);
|
||||
const sourceKey = getPassingSourceKey(sessionId, passing, index);
|
||||
current.push({
|
||||
id: `training_${sourceKey}`,
|
||||
sourceKey,
|
||||
sessionId: String(sessionId),
|
||||
sessionName: String(session?.name || "").trim(),
|
||||
eventId: String(session?.eventId || ""),
|
||||
eventName: String(event?.name || "").trim(),
|
||||
driverId,
|
||||
timestamp,
|
||||
lapMs,
|
||||
recordedAt: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current] }, { uid });
|
||||
}
|
||||
|
||||
export function getDriverTrainingLaps(state, driverId) {
|
||||
const laps = [];
|
||||
Object.entries(state?.resultsBySession || {}).forEach(([sessionId, result]) => {
|
||||
(Array.isArray(result?.passings) ? result.passings : []).forEach((passing) => {
|
||||
const lapMs = Number(passing?.lapMs || 0);
|
||||
if (
|
||||
String(passing?.driverId || "") === String(driverId || "") &&
|
||||
passing?.validLap !== false &&
|
||||
lapMs > 500
|
||||
) {
|
||||
laps.push({
|
||||
sessionId,
|
||||
timestamp: Number(passing.timestamp || 0) || 0,
|
||||
lapMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return laps.sort((left, right) => left.timestamp - right.timestamp);
|
||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||
return ledger
|
||||
.filter((entry) => String(entry?.driverId || "") === String(driverId || "") && Number(entry?.lapMs || 0) > 500)
|
||||
.map((entry) => ({
|
||||
sessionId: String(entry.sessionId || ""),
|
||||
sessionName: String(entry.sessionName || ""),
|
||||
eventName: String(entry.eventName || ""),
|
||||
timestamp: Number(entry.timestamp || 0) || 0,
|
||||
lapMs: Number(entry.lapMs || 0),
|
||||
}))
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
function percentile(values, ratio) {
|
||||
@@ -45,10 +106,36 @@ function percentile(values, ratio) {
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
function getLocalDayKey(timestamp) {
|
||||
const date = new Date(Number(timestamp || 0));
|
||||
if (Number.isNaN(date.getTime())) return "unknown";
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function buildDailySummaries(laps) {
|
||||
const byDay = new Map();
|
||||
laps.forEach((lap) => {
|
||||
const key = getLocalDayKey(lap.timestamp);
|
||||
const current = byDay.get(key) || { dateKey: key, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
|
||||
current.laps += 1;
|
||||
current.totalMs += lap.lapMs;
|
||||
current.bestLapMs = current.bestLapMs ? Math.min(current.bestLapMs, lap.lapMs) : lap.lapMs;
|
||||
current.latestTimestamp = Math.max(current.latestTimestamp, lap.timestamp);
|
||||
byDay.set(key, current);
|
||||
});
|
||||
return [...byDay.values()]
|
||||
.map((day) => ({ ...day, averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0 }))
|
||||
.sort((left, right) => right.latestTimestamp - left.latestTimestamp);
|
||||
}
|
||||
|
||||
export function buildDriverTrainingStats(state, driverId) {
|
||||
const laps = getDriverTrainingLaps(state, driverId);
|
||||
const lapTimes = laps.map((lap) => lap.lapMs);
|
||||
const recentLapTimes = lapTimes.slice(-30);
|
||||
const earlierLapTimes = lapTimes.slice(0, Math.max(0, lapTimes.length - recentLapTimes.length));
|
||||
const totalLaps = laps.length;
|
||||
const bestLapMs = totalLaps ? Math.min(...lapTimes) : 0;
|
||||
const averageLapMs = totalLaps ? Math.round(lapTimes.reduce((sum, value) => sum + value, 0) / totalLaps) : 0;
|
||||
@@ -60,16 +147,32 @@ export function buildDriverTrainingStats(state, driverId) {
|
||||
const consistencySpreadPct = recentAverageMs
|
||||
? ((recentP90Ms - recentP10Ms) / recentAverageMs) * 100
|
||||
: 0;
|
||||
const previousBestMs = earlierLapTimes.length ? Math.min(...earlierLapTimes) : 0;
|
||||
const recentBestMs = recentLapTimes.length ? Math.min(...recentLapTimes) : 0;
|
||||
const personalBestGainMs = previousBestMs && recentBestMs ? Math.max(0, previousBestMs - recentBestMs) : 0;
|
||||
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
||||
let controlledLapStreak = 0;
|
||||
for (let index = lapTimes.length - 1; index >= 0; index -= 1) {
|
||||
if (!controlTargetMs || lapTimes[index] > controlTargetMs) break;
|
||||
controlledLapStreak += 1;
|
||||
}
|
||||
const dailySummaries = buildDailySummaries(laps);
|
||||
|
||||
return {
|
||||
totalLaps,
|
||||
totalSessions: new Set(laps.map((lap) => lap.sessionId)).size,
|
||||
trainingDays: dailySummaries.length,
|
||||
bestLapMs,
|
||||
averageLapMs,
|
||||
recentAverageMs,
|
||||
recentBestMs,
|
||||
previousBestMs,
|
||||
personalBestGainMs,
|
||||
controlledLapStreak,
|
||||
consistencySpreadPct,
|
||||
recentLapCount: recentLapTimes.length,
|
||||
lastLapAt: laps.at(-1)?.timestamp || 0,
|
||||
dailySummaries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,3 +214,10 @@ export function getTrainingGoalProgress(goal, stats) {
|
||||
reached: currentLaps >= targetLaps,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextTrainingMilestone(stats) {
|
||||
const currentLaps = Math.max(0, Number(stats?.totalLaps || 0));
|
||||
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
|
||||
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
|
||||
return getTrainingGoalProgress({ targetLaps }, stats);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ export function renderTrainingView(deps) {
|
||||
saveState,
|
||||
renderView,
|
||||
normalizeTrainingState,
|
||||
syncTrainingLedger,
|
||||
buildDriverTrainingStats,
|
||||
buildTrainingRecommendation,
|
||||
getTrainingGoalProgress,
|
||||
getNextTrainingMilestone,
|
||||
getSelectedDriverId,
|
||||
setSelectedDriverId,
|
||||
} = deps;
|
||||
|
||||
state.training = normalizeTrainingState(state.training);
|
||||
syncTrainingLedger();
|
||||
const drivers = [...state.drivers].sort((left, right) => String(left.name).localeCompare(String(right.name)));
|
||||
let selectedDriverId = String(getSelectedDriverId() || "");
|
||||
if (!drivers.some((driver) => driver.id === selectedDriverId)) {
|
||||
@@ -37,6 +40,7 @@ export function renderTrainingView(deps) {
|
||||
const selectedDriver = drivers.find((driver) => driver.id === selectedDriverId);
|
||||
const stats = buildDriverTrainingStats(state, selectedDriverId);
|
||||
const recommendation = buildTrainingRecommendation(stats);
|
||||
const nextMilestone = getNextTrainingMilestone(stats);
|
||||
const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId);
|
||||
const driverOptions = drivers
|
||||
.map((driver) => `<option value="${escapeHtml(driver.id)}" ${driver.id === selectedDriverId ? "selected" : ""}>${escapeHtml(driver.name)}</option>`)
|
||||
@@ -76,6 +80,19 @@ export function renderTrainingView(deps) {
|
||||
const targetText = recommendation.targetLapMs
|
||||
? t("training.plan_target", { lap: formatLap(recommendation.targetLapMs) })
|
||||
: t("training.plan_no_target");
|
||||
const lastActivity = stats.lastLapAt
|
||||
? new Date(stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
|
||||
: t("training.no_activity");
|
||||
const dailyRows = stats.dailySummaries.length
|
||||
? stats.dailySummaries.slice(0, 10).map((day) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}</td>
|
||||
<td>${day.laps}</td>
|
||||
<td>${formatLap(day.bestLapMs)}</td>
|
||||
<td>${formatLap(day.averageLapMs)}</td>
|
||||
</tr>
|
||||
`).join("")
|
||||
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="training-hero">
|
||||
@@ -92,11 +109,29 @@ export function renderTrainingView(deps) {
|
||||
|
||||
<div class="grid cols-4 training-stats">
|
||||
${trainingStat(t("training.total_laps"), String(stats.totalLaps), t("training.valid_laps"))}
|
||||
${trainingStat(t("training.sessions"), String(stats.totalSessions), t("training.recorded_sessions"))}
|
||||
${trainingStat(t("training.training_days"), String(stats.trainingDays), t("training.sessions_count", { sessions: stats.totalSessions }))}
|
||||
${trainingStat(t("training.best_lap"), formatLap(stats.bestLapMs), t("training.personal_best"))}
|
||||
${trainingStat(t("training.consistency"), stats.recentLapCount ? `${stats.consistencySpreadPct.toFixed(1)}%` : "-", t("training.last_30_laps"))}
|
||||
</div>
|
||||
|
||||
<div class="training-insight-grid">
|
||||
<section class="training-milestone-card">
|
||||
<div>
|
||||
<span class="training-kicker">${escapeHtml(t("training.next_level"))}</span>
|
||||
<h3>${escapeHtml(t("training.next_level_target", { laps: nextMilestone.targetLaps }))}</h3>
|
||||
<p>${escapeHtml(t("training.goal_remaining", { laps: nextMilestone.remainingLaps }))}</p>
|
||||
</div>
|
||||
<strong>${nextMilestone.currentLaps} / ${nextMilestone.targetLaps}</strong>
|
||||
<div class="training-progress"><span style="width:${nextMilestone.percent.toFixed(2)}%"></span></div>
|
||||
</section>
|
||||
<section class="training-insights">
|
||||
${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))}
|
||||
${trainingInsight(t("training.recent_average"), formatLap(stats.recentAverageMs))}
|
||||
${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: stats.controlledLapStreak }))}
|
||||
${trainingInsight(t("training.last_activity"), lastActivity)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="training-layout">
|
||||
<section class="panel training-plan">
|
||||
<div class="panel-header"><h3>${escapeHtml(t("training.recommendation"))}</h3><span class="badge">20 min</span></div>
|
||||
@@ -127,6 +162,19 @@ export function renderTrainingView(deps) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header">
|
||||
<h3>${escapeHtml(t("training.history"))}</h3>
|
||||
${stats.personalBestGainMs ? `<span class="badge success">${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}</span>` : ""}
|
||||
</div>
|
||||
<div class="panel-body table-wrap training-history-wrap">
|
||||
<table class="training-history-table">
|
||||
<thead><tr><th>${escapeHtml(t("training.date"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.average_lap"))}</th></tr></thead>
|
||||
<tbody>${dailyRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${escapeHtml(t("training.goals"))}</h3><span class="badge">${goals.length}</span></div>
|
||||
<div class="panel-body training-goals">${goalMarkup}</div>
|
||||
@@ -179,3 +227,7 @@ export function renderTrainingView(deps) {
|
||||
function trainingStat(label, value, note) {
|
||||
return `<article class="stat-card"><p>${label}</p><h3>${value}</h3><small>${note}</small></article>`;
|
||||
}
|
||||
|
||||
function trainingInsight(label, value) {
|
||||
return `<div><span>${label}</span><strong>${value}</strong></div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user