climbing-dashboard / src /utils /weekUtils.js
lewtun's picture
lewtun HF Staff
Cap grades at 7B and add during/after injury pain tracking
d917493
/**
* Returns the ISO week number and year for a date string (YYYY-MM-DD).
*/
function getISOWeekData(dateString) {
const date = new Date(`${dateString}T00:00:00`);
const dayOfWeek = date.getDay() || 7;
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 };
}
export function getWeekKey(dateString) {
const { year, week } = getISOWeekData(dateString);
return `${year}-W${String(week).padStart(2, '0')}`;
}
export function groupByWeek(sessions) {
const groups = {};
for (const session of sessions) {
const key = getWeekKey(session.date);
if (!groups[key]) groups[key] = [];
groups[key].push(session);
}
return groups;
}
export const RPE_CRITERIA = {
1: {
intensity: 'Very easy',
grades: '4-5',
pump_level: 'None',
suggested_session: 'Warm-up / cool-down',
},
2: {
intensity: 'Easy',
grades: '5-5+',
pump_level: 'None',
suggested_session: 'Easy mileage',
},
3: {
intensity: 'Moderate easy',
grades: '5+-6A',
pump_level: 'Light',
suggested_session: 'Technique, movement',
},
4: {
intensity: 'Comfortable endurance',
grades: '6A',
pump_level: 'Light-moderate',
suggested_session: 'Easy endurance',
},
5: {
intensity: 'Steady endurance',
grades: '6A-6A+',
pump_level: 'Moderate',
suggested_session: 'Base endurance sessions',
},
6: {
intensity: 'Hard steady',
grades: '6A+-6B',
pump_level: 'Moderate-high',
suggested_session: 'General training sessions',
},
7: {
intensity: 'Near-max onsight',
grades: '6B-6B+',
pump_level: 'High',
suggested_session: 'Onsight attempts / hard endurance',
},
8: {
intensity: 'Above max',
grades: '6C-7A',
pump_level: 'Very high',
suggested_session: 'Projecting / limit attempts',
},
9: {
intensity: 'Maximal',
grades: '6C+-7A+',
pump_level: 'Very high / failure',
suggested_session: 'Limit redpoint work',
},
10: {
intensity: 'Absolute limit',
grades: '7b and above',
pump_level: '—',
suggested_session: 'Max testing / peak projecting',
},
};
export const CLIMB_GRADE_SCALE = [
'4',
'4+',
'5',
'5+',
'6A',
'6A+',
'6B',
'6B+',
'6C',
'6C+',
'7A',
'7A+',
'7B',
'7B+',
'7C',
'7C+',
'8A',
'8A+',
'8B',
'8B+',
'8C',
'8C+',
'9A',
'9A+',
'9B',
'9B+',
'9C',
'9C+',
];
export const MAX_ALLOWED_GRADE = '7B';
const MAX_ALLOWED_GRADE_INDEX = CLIMB_GRADE_SCALE.indexOf(MAX_ALLOWED_GRADE);
export const TRACKED_GRADE_SCALE = CLIMB_GRADE_SCALE.slice(0, MAX_ALLOWED_GRADE_INDEX + 1);
export const INJURY_TRACKERS = [
{ key: 'left_elbow', label: 'Left Elbow' },
{ key: 'right_shoulder', label: 'Right Shoulder' },
];
export function normalizeGrade(rawGrade) {
return String(rawGrade || '').trim().toUpperCase().replace(/\s+/g, '');
}
export function getGradeScore(rawGrade) {
const normalized = normalizeGrade(rawGrade);
const idx = CLIMB_GRADE_SCALE.indexOf(normalized);
return idx === -1 ? null : idx + 1;
}
export function capGradeAtMax(rawGrade) {
const normalized = normalizeGrade(rawGrade);
const idx = CLIMB_GRADE_SCALE.indexOf(normalized);
if (idx === -1) return normalized;
if (idx > MAX_ALLOWED_GRADE_INDEX) return MAX_ALLOWED_GRADE;
return normalized;
}
export function computeWeeklySummary(weekSessions) {
if (!weekSessions || weekSessions.length === 0) {
return {
total_routes: 0,
total_training_load: 0,
session_count: 0,
avg_rpe: 0,
};
}
const total_routes = weekSessions.reduce((sum, session) => sum + session.routes_count, 0);
const total_training_load = weekSessions.reduce(
(sum, session) => sum + session.routes_count * session.rpe,
0
);
return {
total_routes,
total_training_load,
session_count: weekSessions.length,
avg_rpe: weekSessions.reduce((sum, session) => sum + session.rpe, 0) / weekSessions.length,
};
}
/**
* ACWR-style recommendation using rolling 1-4 week averages.
*/
export function computeRecommendation(weeklySummaries) {
if (!weeklySummaries || weeklySummaries.length === 0) {
return { min_routes: 0, max_routes: 0, min_load: 0, max_load: 0 };
}
const n = weeklySummaries.length;
const avgRoutes = weeklySummaries.reduce((sum, week) => sum + week.total_routes, 0) / n;
const avgLoad = weeklySummaries.reduce((sum, week) => sum + week.total_training_load, 0) / n;
const upperMultiplier = n === 1 ? 1.2 : 1.3;
const lowerMultiplier = 0.8;
return {
min_routes: +(avgRoutes * lowerMultiplier).toFixed(0),
max_routes: +(avgRoutes * upperMultiplier).toFixed(0),
min_load: +(avgLoad * lowerMultiplier).toFixed(0),
max_load: +(avgLoad * upperMultiplier).toFixed(0),
};
}
export function buildChartData(sessions) {
const grouped = groupByWeek(sessions);
return Object.entries(grouped)
.map(([week, weekSessions]) => {
const summary = computeWeeklySummary(weekSessions);
return {
week,
routes: summary.total_routes,
training_load: +summary.total_training_load.toFixed(0),
avg_rpe: +summary.avg_rpe.toFixed(1),
};
})
.sort((a, b) => a.week.localeCompare(b.week));
}
/**
* Builds date-based max-grade progress with separate lead and bouldering lines.
*/
export function buildGradeProgressData(sessions) {
const byDate = {};
for (const session of sessions) {
if (!session?.date || !session?.max_grade) continue;
if (session.session_type !== 'lead' && session.session_type !== 'bouldering') continue;
const cappedGrade = capGradeAtMax(session.max_grade);
const score = getGradeScore(cappedGrade);
if (score == null) continue;
if (!byDate[session.date]) {
const dateObj = new Date(`${session.date}T00:00:00`);
byDate[session.date] = {
date: session.date,
label: dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
};
}
const scoreKey = `${session.session_type}_score`;
const gradeKey = `${session.session_type}_grade`;
const normalizedGrade = cappedGrade;
if (byDate[session.date][scoreKey] == null || score > byDate[session.date][scoreKey]) {
byDate[session.date][scoreKey] = score;
byDate[session.date][gradeKey] = normalizedGrade;
}
}
return Object.values(byDate).sort((a, b) => a.date.localeCompare(b.date));
}
export function buildPainChartData(sessions) {
const locations = {};
for (const tracker of INJURY_TRACKERS) {
const duringKey = `${tracker.key}_during`;
const afterKey = `${tracker.key}_after`;
const legacyKey = `${tracker.key}_pain`;
const points = sessions
.filter((session) => {
const during = session?.[duringKey] ?? session?.[legacyKey];
const after = session?.[afterKey];
return (during != null && during !== '') || (after != null && after !== '');
})
.sort((a, b) => a.date.localeCompare(b.date))
.map((session) => {
const during = session?.[duringKey] ?? session?.[legacyKey];
const after = session?.[afterKey];
const dateObj = new Date(`${session.date}T00:00:00`);
return {
date: session.date,
label: dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
during: during != null && during !== '' ? Number(during) : undefined,
after: after != null && after !== '' ? Number(after) : undefined,
};
});
locations[tracker.key] = {
label: tracker.label,
points,
};
}
return { locations };
}
/**
* 30-day max-routes threshold (+10%) for single session planning.
*/
export function computeMaxRoutesThreshold(sessions, todayStr) {
const today = new Date(`${todayStr}T00:00:00`);
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - 30);
const recentSessions = sessions.filter((session) => {
const sessionDate = new Date(`${session.date}T00:00:00`);
return sessionDate >= cutoff && sessionDate <= today;
});
if (recentSessions.length === 0) return null;
const max_routes = Math.max(...recentSessions.map((session) => session.routes_count));
return {
max_routes,
threshold_routes: Math.round(max_routes * 1.1),
};
}