Spaces:
Running
Running
| /** | |
| * 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')}`; | |
| } | |