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