function findPublicTrainingShare(state, shareToken) { if (!shareToken || shareToken.length < 24) return null; const shares = Array.isArray(state?.training?.shares) ? state.training.shares : []; return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null; } function hasPublicTrainingShare(state, shareToken) { return Boolean(findPublicTrainingShare(state, shareToken)); } function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) { const share = findPublicTrainingShare(state, shareToken); if (!share) return null; const driverId = String(share.driverId || ""); const drivers = Array.isArray(state?.drivers) ? state.drivers : []; const driver = drivers.find((item) => String(item?.id || "") === driverId); if (!driver) return null; const classes = Array.isArray(state?.classes) ? state.classes : []; const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || "")); const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; const driverLaps = ledger .filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500) .map((entry) => ({ timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), sessionId: String(entry.sessionId || ""), })) .sort((left, right) => left.timestamp - right.timestamp); const stats = buildPublicTrainingStats(driverLaps); const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) .filter((goal) => String(goal?.driverId || "") === driverId) .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean))); const rankIndex = rankedTotals.findIndex(([id]) => id === driverId); const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; const currentSession = sinceTimestamp ? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs))) : null; return { updatedAt: String(updatedAt || ""), profile: { name: String(driver.name || "Driver").trim(), brand: String(driver.brand || "").trim(), className: String(driverClass?.name || "").trim(), clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, rankedDrivers: rankedTotals.length, }, branding: { clubName: String(settings.clubName || "JMK RB RaceController").trim(), clubTagline: String(settings.clubTagline || "RC Timing System").trim(), logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), }, stats, nextMilestone: getPublicTrainingMilestone(stats.totalLaps), recommendation: getPublicTrainingRecommendation(stats), goals, currentSession, }; } function normalizePublicTrainingSince(value, startedAgoValue) { const now = Date.now(); if (startedAgoValue !== undefined) { const startedAgoMs = Math.round(Number(startedAgoValue)); if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) { return now - startedAgoMs; } } const timestamp = Math.round(Number(value || 0) || 0); return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0; } function normalizePublicTrainingWindow(value) { const windowMs = Math.round(Number(value || 0) || 0); return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs)); } function buildPublicTrainingWindowStats(laps) { const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500); return { laps: lapTimes.length, bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, }; } function buildPublicTrainingGoal(goal, currentLaps) { const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1)); return { title: String(goal?.title || "").trim(), reward: String(goal?.reward || "").trim(), targetLaps, currentLaps, remainingLaps: Math.max(0, targetLaps - currentLaps), percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), reached: currentLaps >= targetLaps, claimed: Boolean(goal?.claimedAt), }; } function getRankedTrainingTotals(ledger, registeredDriverIds) { const totals = new Map(); ledger.forEach((entry) => { const driverId = String(entry?.driverId || ""); if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return; totals.set(driverId, (totals.get(driverId) || 0) + 1); }); return [...totals.entries()].sort((left, right) => right[1] - left[1]); } function buildPublicTrainingStats(laps) { const lapTimes = laps.map((lap) => lap.lapMs); const recent = lapTimes.slice(-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 recentAverageMs = average(recent); const daily = new Map(); laps.forEach((lap) => { const date = new Date(lap.timestamp); const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; day.laps += 1; day.totalMs += lap.lapMs; day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs; day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp); daily.set(dateKey, day); }); const dailySummaries = [...daily.values()] .map((day) => ({ dateKey: day.dateKey, laps: day.laps, bestLapMs: day.bestLapMs, latestTimestamp: day.latestTimestamp, averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0, })) .sort((left, right) => right.latestTimestamp - left.latestTimestamp) .slice(0, 30); const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 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; } return { totalLaps: laps.length, totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size, trainingDays: daily.size, bestLapMs, averageLapMs: average(lapTimes), recentAverageMs, recentLapCount: recent.length, consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0, controlledLapStreak, dailySummaries, }; } function normalizePublicLogoDataUrl(value) { const logo = String(value || "").trim(); return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : ""; } function getPublicTrainingMilestone(currentLaps) { const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000; return { currentLaps, targetLaps, remainingLaps: Math.max(0, targetLaps - currentLaps), percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), }; } function getPublicTrainingRecommendation(stats) { if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 }; if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) { return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) }; } return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) }; } module.exports = { buildPublicTrainingProfile, findPublicTrainingShare, hasPublicTrainingShare, normalizePublicTrainingSince, normalizePublicTrainingWindow, };