Spaces:
Sleeping
Sleeping
| 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<Activity[]> { | |
| 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}`)); | |
| }, | |
| }); | |
| }); | |
| } | |