Add motivational training challenges and shared club goals

This commit is contained in:
larssand
2026-08-30 09:16:11 +02:00
parent 27dcbd92aa
commit 694aba5f3d
11 changed files with 799 additions and 13 deletions

View File

@@ -12,6 +12,18 @@ function getTrainingDriverIds(state, driverId, classId = "") {
export function normalizeTrainingState(training, { uid }) {
const source = training && typeof training === "object" ? training : {};
const clubGoalSource = source.clubGoal && typeof source.clubGoal === "object" ? source.clubGoal : null;
const clubGoal = clubGoalSource && Number(clubGoalSource.targetLaps || 0) > 0
? {
id: String(clubGoalSource.id || uid("club_goal")),
title: String(clubGoalSource.title || "").trim(),
reward: String(clubGoalSource.reward || "").trim(),
targetLaps: Math.max(1, Math.round(Number(clubGoalSource.targetLaps || 0) || 0)),
baselineLaps: Math.max(0, Math.round(Number(clubGoalSource.baselineLaps || 0) || 0)),
createdAt: String(clubGoalSource.createdAt || new Date().toISOString()),
celebratedAt: clubGoalSource.celebratedAt ? String(clubGoalSource.celebratedAt) : "",
}
: null;
const goals = Array.isArray(source.goals)
? source.goals
.map((goal) => ({
@@ -88,7 +100,7 @@ export function normalizeTrainingState(training, { uid }) {
.sort((left, right) => left.timestamp - right.timestamp)
: [];
return { goals, ledger, shares, markers };
return { goals, ledger, shares, markers, clubGoal };
}
function getPassingSourceKey(sessionId, passing, index) {
@@ -129,7 +141,13 @@ export function syncTrainingLedger(training, state, { uid }) {
});
});
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares, markers: normalized.markers }, { uid });
return normalizeTrainingState({
goals: normalized.goals,
ledger: [...retained, ...current],
shares: normalized.shares,
markers: normalized.markers,
clubGoal: normalized.clubGoal,
}, { uid });
}
export function getDriverTrainingLaps(state, driverId, classId = "") {
@@ -299,13 +317,19 @@ export function buildTrainingAchievements(stats) {
const definitions = [
["first_session", Number(source.totalSessions || 0), 1],
["laps_100", Number(source.totalLaps || 0), 100],
["laps_250", Number(source.totalLaps || 0), 250],
["laps_500", Number(source.totalLaps || 0), 500],
["laps_1000", Number(source.totalLaps || 0), 1000],
["laps_1500", Number(source.totalLaps || 0), 1500],
["sessions_5", Number(source.totalSessions || 0), 5],
["days_3", Number(source.trainingDays || 0), 3],
["days_10", Number(source.trainingDays || 0), 10],
["controlled_5", Number(source.controlledLapStreak || 0), 5],
["controlled_10", Number(source.controlledLapStreak || 0), 10],
["five_minute", source.bestFiveMinuteResult ? 1 : 0, 1],
["five_minutes_3", Number(source.completedFiveMinuteStints || 0), 3],
["consistency_5", source.recentLapCount >= 30 && source.consistencySpreadPct <= 5 ? 1 : 0, 1],
["pace_gain", source.trendSampleReady && source.averageTrendMs > 0 ? 1 : 0, 1],
];
return definitions.map(([id, current, target]) => ({
id,
@@ -341,6 +365,15 @@ function buildDailySummaries(laps) {
.sort((left, right) => right.latestTimestamp - left.latestTimestamp);
}
function getControlledLapStreak(lapTimes, controlTargetMs) {
let streak = 0;
for (let index = lapTimes.length - 1; index >= 0; index -= 1) {
if (!controlTargetMs || lapTimes[index] > controlTargetMs) break;
streak += 1;
}
return streak;
}
export function splitTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) {
const sorted = (Array.isArray(laps) ? laps : [])
.filter((lap) => Number(lap?.lapMs || 0) > 500 && Number(lap?.timestamp || 0) > 0)
@@ -461,15 +494,16 @@ export function buildDriverTrainingStats(state, driverId, classId = "") {
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 controlledLapStreak = getControlledLapStreak(lapTimes, controlTargetMs);
const dailySummaries = buildDailySummaries(laps);
const stints = splitTrainingStints(laps);
const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length;
const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3);
const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000);
const todayKey = getLocalDayKey(Date.now());
const todayLaps = laps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey);
const todayStints = splitTrainingStints(todayLaps);
const todayLapTimes = todayLaps.map((lap) => lap.lapMs);
const stats = {
totalLaps,
@@ -484,6 +518,11 @@ export function buildDriverTrainingStats(state, driverId, classId = "") {
previousBestMs,
personalBestGainMs,
controlledLapStreak,
todayLaps: todayLaps.length,
todayBestLapMs: todayLapTimes.length ? Math.min(...todayLapTimes) : 0,
todayAverageLapMs: average(todayLapTimes),
todayControlledLapStreak: getControlledLapStreak(todayLapTimes, controlTargetMs),
todayCompletedFiveMinuteStints: todayStints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length,
consistencySpreadPct,
previousConsistencySpreadPct,
consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0,
@@ -492,6 +531,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "") {
lastLapAt: laps.at(-1)?.timestamp || 0,
dailySummaries,
stintCount: stints.length,
completedFiveMinuteStints,
bestThreeConsecutive,
bestFiveMinuteResult,
};
@@ -630,3 +670,48 @@ export function getNextTrainingMilestone(stats) {
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
return getTrainingGoalProgress({ targetLaps }, stats);
}
export function buildTrainingMissions(stats) {
const source = stats && typeof stats === "object" ? stats : {};
const definitions = [
["laps_today", Number(source.todayLaps || 0), 20],
["controlled_today", Number(source.todayControlledLapStreak || 0), 5],
["five_minute_today", Number(source.todayCompletedFiveMinuteStints || 0), 1],
];
return definitions.map(([id, current, target]) => ({
id,
current: Math.max(0, current),
target,
percent: Math.min(100, Math.max(0, (current / target) * 100)),
completed: current >= target,
}));
}
export function buildTrainingHighlights(stats) {
const source = stats && typeof stats === "object" ? stats : {};
const highlights = [];
if (source.todayLaps > 0) highlights.push({ id: "today_laps", value: Number(source.todayLaps) });
if (source.personalBestGainMs > 0) highlights.push({ id: "personal_best", value: Number(source.personalBestGainMs) });
if (source.trendSampleReady && source.averageTrendMs > 0) highlights.push({ id: "pace_gain", value: Number(source.averageTrendMs) });
if (source.trendSampleReady && source.consistencyTrendPct > 0.1) highlights.push({ id: "consistency_gain", value: Number(source.consistencyTrendPct) });
if (source.todayControlledLapStreak >= 5) highlights.push({ id: "controlled_streak", value: Number(source.todayControlledLapStreak) });
if (source.todayCompletedFiveMinuteStints > 0) highlights.push({ id: "five_minute", value: Number(source.todayCompletedFiveMinuteStints) });
if (!highlights.length && source.totalLaps > 0) highlights.push({ id: "keep_building", value: Number(source.totalLaps) });
if (!highlights.length) highlights.push({ id: "ready", value: 0 });
return highlights.slice(0, 3);
}
export function getClubTrainingGoalProgress(training) {
const goal = training?.clubGoal && typeof training.clubGoal === "object" ? training.clubGoal : null;
if (!goal || Number(goal.targetLaps || 0) <= 0) return null;
const totalLaps = Array.isArray(training?.ledger) ? training.ledger.length : 0;
const currentLaps = Math.max(0, totalLaps - Math.max(0, Number(goal.baselineLaps || 0)));
const targetLaps = Math.max(1, Number(goal.targetLaps || 1));
return {
currentLaps,
targetLaps,
remainingLaps: Math.max(0, targetLaps - currentLaps),
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
reached: currentLaps >= targetLaps,
};
}