import Papa from 'papaparse'; import type { Activity } from '@/types'; import { calculateTSS } from './tssCalculator'; import { validateCSV } from './csvValidator'; export function parseCSV(file: File, userFTP: number = 343, thresholdHR: number = 170, restingHR: number = 50): Promise { return new Promise((resolve, reject) => { Papa.parse(file, { header: true, skipEmptyLines: true, complete: (results) => { try { // Validate CSV structure first const validation = validateCSV(results.data); if (!validation.isValid) { const errorMessage = [ 'CSV Validation Failed:', '', ...validation.errors.map(err => `❌ ${err}`), ...(validation.warnings.length > 0 ? ['', 'Warnings:', ...validation.warnings.map(warn => `⚠️ ${warn}`)] : []), ].join('\n'); reject(new Error(errorMessage)); return; } // Log warnings to console if any if (validation.warnings.length > 0) { console.warn('CSV Validation Warnings:', validation.warnings); } const activities = results.data.map((row: any) => { // Parse date - Garmin format: "2025-12-02 09:19:49" const dateStr = row['Date'] || row['Activity Date'] || row['date']; if (!dateStr || dateStr === '--') { return null; } const date = new Date(dateStr); if (isNaN(date.getTime())) { return null; } // Parse distance - Garmin format: "8.34" (in km) let distance: number | undefined; const distanceStr = row['Distance'] || row['distance']; if (distanceStr && distanceStr !== '--' && distanceStr !== '0.00') { const parsed = parseFloat(distanceStr.replace(/,/g, '')); if (!isNaN(parsed) && parsed > 0) { distance = parsed; } } // Parse duration - Garmin format: "00:48:51" (HH:MM:SS) let duration: number | undefined; let durationSeconds: number | undefined; const durationStr = row['Time'] || row['Duration'] || row['Moving Time'] || row['Elapsed Time']; if (durationStr && durationStr !== '--') { if (durationStr.includes(':')) { const parts = durationStr.split(':'); const hours = parseInt(parts[0]) || 0; const minutes = parseInt(parts[1]) || 0; const seconds = parseFloat(parts[2]) || 0; duration = hours * 60 + minutes + seconds / 60; durationSeconds = hours * 3600 + minutes * 60 + seconds; } else { const parsed = parseFloat(durationStr); if (!isNaN(parsed)) { duration = parsed; durationSeconds = parsed * 60; // Assume minutes if no colon } } } // Parse TSS - Try to calculate from power data first let trainingStressScore: number | undefined; // First, check if TSS is already provided in CSV const tssStr = row['Training Stress Score®'] || row['Training Stress Score'] || row['TSS']; if (tssStr && tssStr !== '--' && tssStr !== '0.0' && tssStr !== '0') { const parsed = parseFloat(tssStr.replace(/,/g, '')); if (!isNaN(parsed) && parsed > 0) { trainingStressScore = parsed; } } // If no TSS provided, try to calculate from power data if (!trainingStressScore && durationSeconds) { // Parse Normalized Power (NP) - find column that starts with "Normalized Power" let normalizedPower: number | undefined; const npKey = Object.keys(row).find(key => key.startsWith('Normalized Power')); if (npKey) { const npStr = row[npKey]; if (npStr && npStr !== '--' && npStr !== '0.0' && npStr !== '0') { normalizedPower = parseFloat(npStr.replace(/,/g, '')); if (isNaN(normalizedPower) || normalizedPower <= 0) { normalizedPower = undefined; } } } // Use FTP from CSV if available, otherwise use user-provided FTP const ftpStr = row['FTP'] || row['Functional Threshold Power']; const ftp = ftpStr && ftpStr !== '--' ? parseFloat(ftpStr.replace(/,/g, '')) : userFTP; if (normalizedPower && normalizedPower > 0 && ftp && ftp > 0) { const calculatedTSS = calculateTSS({ durationSeconds, normalizedPower, ftp, }); if (calculatedTSS !== undefined) { trainingStressScore = calculatedTSS; } } } // Get activity type const activityType = row['Activity Type'] || row['Type'] || row['Sport']; // Get activity title const title = row['Title'] || row['Activity Name'] || row['Name']; // Parse heart rate data let averageHR: number | undefined; const avgHRStr = row['Avg HR'] || row['Average HR'] || row['Average Heart Rate']; if (avgHRStr && avgHRStr !== '--') { const parsed = parseFloat(avgHRStr.replace(/,/g, '')); if (!isNaN(parsed) && parsed > 0) { averageHR = parsed; } } let maxHR: number | undefined; const maxHRStr = row['Max HR'] || row['Maximum HR'] || row['Max Heart Rate']; if (maxHRStr && maxHRStr !== '--') { const parsed = parseFloat(maxHRStr.replace(/,/g, '')); if (!isNaN(parsed) && parsed > 0) { maxHR = parsed; } } // Parse total ascent let totalAscent: number | undefined; const ascentStr = row['Total Ascent'] || row['Ascent'] || row['Elevation Gain']; if (ascentStr && ascentStr !== '--') { const parsed = parseFloat(ascentStr.replace(/,/g, '')); if (!isNaN(parsed) && parsed > 0) { totalAscent = parsed; } } // If no TSS calculated from power, try hrTSS from heart rate if (!trainingStressScore && durationSeconds && averageHR) { // hrTSS = duration_hours × HR_ratio² × 100 // HR_ratio = (avg_HR - resting_HR) / (threshold_HR - resting_HR) if (thresholdHR > restingHR && averageHR > restingHR && averageHR < 220) { const hrRatio = (averageHR - restingHR) / (thresholdHR - restingHR); const durationHours = durationSeconds / 3600; trainingStressScore = durationHours * Math.pow(hrRatio, 2) * 100; } } // Parse calories const caloriesStr = row['Calories']; let calories: number | undefined; if (caloriesStr && caloriesStr !== '--') { const parsed = parseFloat(caloriesStr.replace(/,/g, '')); if (!isNaN(parsed) && parsed > 0) { calories = parsed; } } const activity: Activity = { date, activityType, title, distance, duration, trainingStressScore, calories, averageHR, maxHR, totalAscent, }; return activity; }).filter((activity): activity is Activity => activity !== null); // Sort by date activities.sort((a, b) => a.date.getTime() - b.date.getTime()); resolve(activities); } catch (error) { reject(new Error('Failed to parse CSV data')); } }, error: (error) => { reject(new Error(`CSV parsing error: ${error.message}`)); }, }); }); }