Add motivational training challenges and shared club goals
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user