/** * 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), }; }