glutamatt's picture
glutamatt HF Staff
dev with hot reload , TSS fallback computation from HR , UI
d831612 verified
raw
history blame
10.4 kB
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);
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);
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) : 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);
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);
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);
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}`));
},
});
});
}