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 getPublicDriverProfileId(driver) { const explicitId = String(driver?.profileId || "").trim(); if (explicitId) return explicitId; const name = String(driver?.name || "").trim().toLocaleLowerCase().replace(/\s+/g, " "); return name ? `legacy:${name}` : `driver:${String(driver?.id || "unknown")}`; } function getPublicDriverProfile(drivers, reference) { const normalizedReference = String(reference || ""); const anchor = drivers.find((driver) => ( String(driver?.id || "") === normalizedReference || getPublicDriverProfileId(driver) === normalizedReference )); if (!anchor) return null; const profileId = getPublicDriverProfileId(anchor); return { id: profileId, drivers: drivers.filter((driver) => getPublicDriverProfileId(driver) === profileId), }; } function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) { const share = findPublicTrainingShare(state, shareToken); if (!share) return null; const profileReference = String(share.driverId || ""); const drivers = Array.isArray(state?.drivers) ? state.drivers : []; const driverProfile = getPublicDriverProfile(drivers, profileReference); if (!driverProfile?.drivers.length) return null; const driver = driverProfile.drivers[0]; const classes = Array.isArray(state?.classes) ? state.classes : []; const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]); const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))]; const classProfiles = profileClassIds.map((classId) => { const classDrivers = driverProfile.drivers.filter((item) => String(item.classId || "") === classId); const classDriverIds = new Set(classDrivers.map((item) => String(item.id || ""))); const classLaps = ledger .filter((entry) => classDriverIds.has(String(entry?.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(classLaps); const channelStats = buildPublicTimingChannelStats(state, classDriverIds); const rankedTotals = getRankedTrainingTotals(ledger, drivers, classId); const rankIndex = rankedTotals.findIndex(([profileId]) => profileId === driverProfile.id); const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) .filter((goal) => profileReferences.has(String(goal?.driverId || "")) && (!goal?.classId || String(goal.classId) === classId)) .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); const brands = [...new Set(classDrivers.map((item) => String(item.brand || "").trim()).filter(Boolean))]; return { classId, className: String(classes.find((item) => String(item?.id || "") === classId)?.name || "").trim(), brand: brands.join(" / "), clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, rankedDrivers: rankedTotals.length, stats, channelStats, nextMilestone: getPublicTrainingMilestone(stats.totalLaps), recommendation: getPublicTrainingRecommendation(stats), goals: classGoals, currentSession: sinceTimestamp ? buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs))) : null, }; }); const activeClass = classProfiles[0] || { classId: "", className: "", brand: "", clubRank: 0, rankedDrivers: 0, stats: buildPublicTrainingStats([]), nextMilestone: getPublicTrainingMilestone(0), channelStats: buildPublicTimingChannelStats({}, new Set()), recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, }; const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; return { updatedAt: String(updatedAt || ""), profile: { name: String(driver.name || "Driver").trim(), brand: activeClass.brand, className: activeClass.className, clubRank: activeClass.clubRank, rankedDrivers: activeClass.rankedDrivers, }, branding: { clubName: String(settings.clubName || "JMK RB").trim(), clubTagline: String(settings.clubTagline || "Live Timing & Race Control").trim(), logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), }, classProfiles, stats: activeClass.stats, channelStats: activeClass.channelStats, nextMilestone: activeClass.nextMilestone, recommendation: activeClass.recommendation, goals: activeClass.goals, currentSession: activeClass.currentSession, }; } function buildPublicTimingChannelStats(state, classDriverIds) { const splitTimes = []; const pitTimes = []; Object.values(state?.resultsBySession || {}).forEach((result) => { (Array.isArray(result?.splits) ? result.splits : []).forEach((split) => { const splitMs = Math.round(Number(split?.splitMs || 0) || 0); if (classDriverIds.has(String(split?.driverId || "")) && splitMs > 0) splitTimes.push(splitMs); }); (Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => { const pitStopMs = Math.round(Number(stop?.pitStopMs || 0) || 0); if (classDriverIds.has(String(stop?.driverId || "")) && pitStopMs > 0) pitTimes.push(pitStopMs); }); }); const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; return { splitCount: splitTimes.length, bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0, averageSplitMs: average(splitTimes), pitStopCount: pitTimes.length, totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0), bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0, averagePitStopMs: average(pitTimes), }; } 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, drivers, classId = "") { const profileByDriverId = new Map(drivers .filter((driver) => !classId || String(driver?.classId || "") === String(classId)) .map((driver) => [String(driver?.id || ""), getPublicDriverProfileId(driver)])); const totals = new Map(); ledger.forEach((entry) => { const driverId = String(entry?.driverId || ""); const profileId = profileByDriverId.get(driverId); if (!profileId || Number(entry?.lapMs || 0) <= 500) return; totals.set(profileId, (totals.get(profileId) || 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; } const stints = splitPublicTrainingStints(laps); const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3); const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000); 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, stintCount: stints.length, bestThreeConsecutive, bestFiveMinuteResult, }; } function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) { const stints = []; (Array.isArray(laps) ? laps : []).forEach((lap) => { const previousStint = stints.at(-1); const previousLap = previousStint?.laps.at(-1); const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0)); const breakMs = previousLap ? Math.max(0, Number(lap.timestamp || 0) - Number(previousLap.timestamp || 0)) : 0; const startsNewStint = !previousStint || String(previousLap.sessionId || "") !== String(lap.sessionId || "") || breakMs > breakThresholdMs; const normalizedLap = { ...lap, excludedFromPace: Boolean(previousLap && breakMs > breakThresholdMs && Number(lap.lapMs || 0) > breakThresholdMs), }; if (startsNewStint) { stints.push({ sessionId: String(lap.sessionId || ""), laps: [normalizedLap] }); } else { previousStint.laps.push(normalizedLap); } }); return stints; } function getPublicBestConsecutiveLaps(stints, lapCount) { let best = null; (Array.isArray(stints) ? stints : []).forEach((stint) => { const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); for (let index = 0; index <= paceLaps.length - lapCount; index += 1) { const laps = paceLaps.slice(index, index + lapCount); const totalMs = laps.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0); const candidate = { lapCount, totalMs, averageLapMs: Math.round(totalMs / lapCount) }; if (!best || candidate.totalMs < best.totalMs) best = candidate; } }); return best; } function getPublicBestDurationResult(stints, targetMs) { let best = null; (Array.isArray(stints) ? stints : []).forEach((stint) => { const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); for (let startIndex = 0; startIndex < paceLaps.length; startIndex += 1) { const firstLap = paceLaps[startIndex]; const startedAt = Math.max(0, Number(firstLap.timestamp || 0) - Number(firstLap.lapMs || 0)); const cutoffAt = startedAt + targetMs; const counted = []; for (let index = startIndex; index < paceLaps.length; index += 1) { const lap = paceLaps[index]; const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0)); if (lapStartedAt >= cutoffAt) break; counted.push(lap); } const lastLap = counted.at(-1); if (!lastLap || Number(lastLap.timestamp || 0) < cutoffAt) continue; const totalMs = Number(lastLap.timestamp || 0) - startedAt; const lapCount = counted.length; const candidate = { targetMs, lapCount, totalMs, averageLapMs: Math.round(counted.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0) / lapCount), }; if (!best || candidate.lapCount > best.lapCount || (candidate.lapCount === best.lapCount && candidate.totalMs < best.totalMs)) best = candidate; } }); return best; } 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, splitPublicTrainingStints, getPublicBestConsecutiveLaps, getPublicBestDurationResult, };