Spaces:
Sleeping
Sleeping
File size: 6,255 Bytes
3d4aa49 49699c7 3d4aa49 49699c7 3d4aa49 49699c7 3d4aa49 359f0ff 81c5264 359f0ff 81c5264 359f0ff 73a5a82 81c5264 359f0ff 81c5264 73a5a82 81c5264 359f0ff ac30c1e 3d4aa49 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | /**
* 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')}`;
}
|