running-dashboard / src /utils /weekUtils.js
lewtun's picture
lewtun HF Staff
Align pain chart points vertically by switching to categorical LineChart
81c5264
/**
* 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')}`;
}