/** * Returns the ISO week number and year for a given date string "YYYY-MM-DD". * ISO weeks start on Monday, and week 1 contains the year's first Thursday. */ function getISOWeekData(dateString) { const date = new Date(dateString + 'T00:00:00'); const dayOfWeek = date.getDay() || 7; // Convert Sunday=0 to 7 // Set to nearest Thursday (current date + 4 - current day number) const thursday = new Date(date); thursday.setDate(date.getDate() + 4 - dayOfWeek); const yearStart = new Date(thursday.getFullYear(), 0, 1); const weekNumber = Math.ceil(((thursday - yearStart) / 86400000 + 1) / 7); return { year: thursday.getFullYear(), week: weekNumber }; } /** * Returns a week key string like "2026-W10" for a given date string. */ export function getWeekKey(dateString) { const { year, week } = getISOWeekData(dateString); return `${year}-W${String(week).padStart(2, '0')}`; } /** * Groups an array of run entries by their ISO week key. * Returns an object like { "2026-W10": [run, run], "2026-W09": [run] } */ export function groupByWeek(runs) { const groups = {}; for (const run of runs) { const key = getWeekKey(run.date); if (!groups[key]) groups[key] = []; groups[key].push(run); } return groups; } /** * Computes a weekly summary from an array of runs for a single week. */ export function computeWeeklySummary(weekRuns) { if (!weekRuns || weekRuns.length === 0) { return { total_distance_km: 0, total_training_load: 0, run_count: 0, avg_pace: 0 }; } const total_distance_km = weekRuns.reduce((s, r) => s + r.distance_km, 0); const total_time = weekRuns.reduce((s, r) => s + r.time_minutes, 0); const total_training_load = weekRuns.reduce((s, r) => s + r.distance_km * r.rpe, 0); return { total_distance_km, total_training_load, run_count: weekRuns.length, avg_pace: total_distance_km > 0 ? total_time / total_distance_km : 0, }; } /** * Computes next-week recommendation using the Acute vs Chronic Workload Ratio (ACWR). * * Takes an array of weekly summaries sorted chronologically (oldest first), * representing the most recent weeks of training (up to 4). * * - Chronic workload = rolling average of the last N weeks (up to 4) * - Upper limit = chronic_avg × 1.3 (or × 1.2 if only 1 week of data) * - Lower limit = chronic_avg × 0.8 * * Applies to both distance (km) and training load (distance × RPE). */ export function computeRecommendation(weeklySummaries) { if (!weeklySummaries || weeklySummaries.length === 0) { return { min_distance: 0, max_distance: 0, min_load: 0, max_load: 0 }; } const n = weeklySummaries.length; const avgDistance = weeklySummaries.reduce((s, w) => s + w.total_distance_km, 0) / n; const avgLoad = weeklySummaries.reduce((s, w) => s + w.total_training_load, 0) / n; // Week 1 uses a tighter upper multiplier (1.2), weeks 2+ use 1.3 const upperMultiplier = n === 1 ? 1.2 : 1.3; const lowerMultiplier = 0.8; return { min_distance: +(avgDistance * lowerMultiplier).toFixed(1), max_distance: +(avgDistance * upperMultiplier).toFixed(1), min_load: +(avgLoad * lowerMultiplier).toFixed(0), max_load: +(avgLoad * upperMultiplier).toFixed(0), }; } /** * Builds chart-ready data: sorted array of { week, distance, training_load }. */ export function buildChartData(runs) { const grouped = groupByWeek(runs); return Object.entries(grouped) .map(([weekKey, weekRuns]) => { const summary = computeWeeklySummary(weekRuns); return { week: weekKey, distance: +summary.total_distance_km.toFixed(1), training_load: +summary.total_training_load.toFixed(1), }; }) .sort((a, b) => a.week.localeCompare(b.week)); } /** * Builds pain chart data as a sorted array of { label, during, after, date } per location. * Uses a categorical x-axis (date labels) so during/after share exact positions. * * Returns { locations: { left_knee: { points }, ... } } */ export function buildPainChartData(runs) { const locations = ['left_knee', 'right_knee']; const result = {}; for (const loc of locations) { const painRuns = runs .filter((r) => { const d = r[`${loc}_during`] ?? (r.injury_location === loc ? r.pain_during : null); const a = r[`${loc}_after`] ?? (r.injury_location === loc ? r.pain_after : null); return d != null || a != null; }) .sort((a, b) => a.date.localeCompare(b.date)); const points = painRuns.map((r) => { const during = r[`${loc}_during`] ?? (r.injury_location === loc ? r.pain_during : null); const after = r[`${loc}_after`] ?? (r.injury_location === loc ? r.pain_after : null); const d = new Date(r.date + 'T00:00:00'); const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return { label, during: during ?? undefined, after: after ?? undefined, date: r.date, }; }); result[loc] = { points }; } return { locations: result }; } /** * Computes the longest single run (km) in the last 30 days, and * the +10% injury threshold based on the BJSM finding that exceeding * 10% of the longest run in the past 30 days increases overuse injury risk. * * Returns { longest_km, threshold_km } or null if no runs in the window. */ export function computeLongestRunThreshold(runs, todayStr) { const today = new Date(todayStr + 'T00:00:00'); const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - 30); const recentRuns = runs.filter((r) => { const d = new Date(r.date + 'T00:00:00'); return d >= cutoff && d <= today; }); if (recentRuns.length === 0) return null; const longest_km = Math.max(...recentRuns.map((r) => r.distance_km)); return { longest_km: +longest_km.toFixed(2), threshold_km: +(longest_km * 1.10).toFixed(2), }; } /** * Formats pace as M:SS string given time in minutes and distance in km. */ export function formatPace(time_minutes, distance_km) { if (!distance_km || distance_km === 0) return '--'; const pace = time_minutes / distance_km; const mins = Math.floor(pace); const secs = Math.round((pace - mins) * 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }