Initial commit
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Data Processor Module
|
||||
* Handles parsing and processing of CSV data for health metrics and workouts
|
||||
*/
|
||||
|
||||
class DataProcessor {
|
||||
constructor() {
|
||||
this.metricsData = null;
|
||||
this.workoutsData = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV string into array of objects
|
||||
* @param {string} csvString - Raw CSV data as string
|
||||
* @returns {Array} - Array of objects with headers as keys
|
||||
*/
|
||||
parseCSV(csvString) {
|
||||
// Split the CSV string into lines
|
||||
const lines = csvString.split('\n');
|
||||
|
||||
// Extract headers (first line) and remove quotes and any extra characters
|
||||
const headers = lines[0].split(',').map(header => {
|
||||
// Clean up header - remove quotes, carriage returns, etc.
|
||||
return header.replace(/^"(.*)"$/, '$1').replace(/\r$/, '');
|
||||
});
|
||||
|
||||
// Process each data line
|
||||
const results = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
// Skip empty lines
|
||||
if (!lines[i].trim()) continue;
|
||||
|
||||
// Split the line into values, handling quoted values with commas
|
||||
const values = this.splitCSVLine(lines[i]);
|
||||
|
||||
// Create an object with headers as keys and values
|
||||
const entry = {};
|
||||
headers.forEach((header, index) => {
|
||||
// Remove quotes if present and convert to appropriate type
|
||||
let value = values[index] ? values[index].replace(/^"(.*)"$/, '$1').replace(/\r$/, '') : '';
|
||||
|
||||
// Try to convert to number if it looks like one
|
||||
if (!isNaN(value) && value.trim() !== '') {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
entry[header] = value;
|
||||
});
|
||||
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split CSV line handling quoted values with commas
|
||||
* @param {string} line - CSV line to split
|
||||
* @returns {Array} - Array of values
|
||||
*/
|
||||
splitCSVLine(line) {
|
||||
const values = [];
|
||||
let inQuotes = false;
|
||||
let currentValue = '';
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(currentValue);
|
||||
currentValue = '';
|
||||
} else {
|
||||
currentValue += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last value
|
||||
values.push(currentValue);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metrics data from CSV
|
||||
* @param {string} csvString - Raw CSV data as string
|
||||
* @returns {Object} - Processed metrics data
|
||||
*/
|
||||
processMetricsData(csvString) {
|
||||
const rawData = this.parseCSV(csvString);
|
||||
|
||||
// Log the first few entries to verify structure
|
||||
console.log('First few entries from metrics CSV:', rawData.slice(0, 5));
|
||||
|
||||
// Group metrics by date
|
||||
const metricsByDate = {};
|
||||
|
||||
rawData.forEach(entry => {
|
||||
// Extract date from timestamp (format: "2025-04-14 00:00:00")
|
||||
const date = entry.Timestamp.split(' ')[0];
|
||||
|
||||
if (!metricsByDate[date]) {
|
||||
metricsByDate[date] = {};
|
||||
}
|
||||
|
||||
// Add metric to the date group
|
||||
// Ensure Type is trimmed to handle any extra spaces
|
||||
const metricType = entry.Type.trim();
|
||||
|
||||
// Get the value from the correct column (might be '"Value"' instead of 'Value')
|
||||
let metricValue = null;
|
||||
if (entry.Value !== undefined) {
|
||||
metricValue = entry.Value;
|
||||
} else if (entry['"Value"'] !== undefined) {
|
||||
metricValue = entry['"Value"'];
|
||||
}
|
||||
|
||||
// Convert Value to number if it's numeric
|
||||
if (metricValue !== null && !isNaN(metricValue) && metricValue.toString().trim() !== '') {
|
||||
metricValue = parseFloat(metricValue);
|
||||
}
|
||||
|
||||
metricsByDate[date][metricType] = metricValue;
|
||||
});
|
||||
|
||||
// Log the metrics by date to verify structure
|
||||
console.log('Sample of metrics by date:', Object.keys(metricsByDate).slice(0, 3).map(date => ({
|
||||
date,
|
||||
metrics: metricsByDate[date]
|
||||
})));
|
||||
|
||||
// Convert to array and sort by date
|
||||
const processedData = Object.keys(metricsByDate).map(date => {
|
||||
return {
|
||||
date,
|
||||
metrics: metricsByDate[date]
|
||||
};
|
||||
}).sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
this.metricsData = processedData;
|
||||
return processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process workouts data from CSV
|
||||
* @param {string} csvString - Raw CSV data as string
|
||||
* @returns {Array} - Processed workouts data
|
||||
*/
|
||||
processWorkoutsData(csvString) {
|
||||
const rawData = this.parseCSV(csvString);
|
||||
|
||||
// Process each workout entry
|
||||
const processedData = rawData.map(workout => {
|
||||
// Convert workout day to date object for easier comparison
|
||||
const workoutDate = new Date(workout.WorkoutDay);
|
||||
|
||||
return {
|
||||
date: workout.WorkoutDay,
|
||||
type: workout.WorkoutType,
|
||||
title: workout.Title,
|
||||
distance: workout.DistanceInMeters,
|
||||
duration: workout.TimeTotalInHours,
|
||||
heartRate: {
|
||||
average: workout.HeartRateAverage,
|
||||
max: workout.HeartRateMax
|
||||
},
|
||||
power: {
|
||||
average: workout.PowerAverage,
|
||||
max: workout.PowerMax
|
||||
},
|
||||
energy: workout.Energy,
|
||||
zones: {
|
||||
heartRate: this.extractHeartRateZones(workout),
|
||||
power: this.extractPowerZones(workout)
|
||||
},
|
||||
rpe: workout.Rpe,
|
||||
feeling: workout.Feeling,
|
||||
cadence: {
|
||||
average: workout.CadenceAverage,
|
||||
max: workout.CadenceMax
|
||||
},
|
||||
velocity: {
|
||||
average: workout.VelocityAverage,
|
||||
max: workout.VelocityMax
|
||||
},
|
||||
comments: {
|
||||
athlete: workout.AthleteComments,
|
||||
coach: workout.CoachComments
|
||||
}
|
||||
};
|
||||
}).sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
this.workoutsData = processedData;
|
||||
return processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract heart rate zone data from workout entry
|
||||
* @param {Object} workout - Workout data entry
|
||||
* @returns {Array} - Heart rate zone minutes
|
||||
*/
|
||||
extractHeartRateZones(workout) {
|
||||
const zones = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const zoneKey = `HRZone${i}Minutes`;
|
||||
if (workout[zoneKey] !== undefined && workout[zoneKey] !== '') {
|
||||
zones.push({
|
||||
zone: i,
|
||||
minutes: parseFloat(workout[zoneKey])
|
||||
});
|
||||
}
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract power zone data from workout entry
|
||||
* @param {Object} workout - Workout data entry
|
||||
* @returns {Array} - Power zone minutes
|
||||
*/
|
||||
extractPowerZones(workout) {
|
||||
const zones = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const zoneKey = `PWRZone${i}Minutes`;
|
||||
if (workout[zoneKey] !== undefined && workout[zoneKey] !== '') {
|
||||
zones.push({
|
||||
zone: i,
|
||||
minutes: parseFloat(workout[zoneKey])
|
||||
});
|
||||
}
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sample data from files
|
||||
* @returns {Promise} - Promise resolving when data is loaded
|
||||
*/
|
||||
async loadSampleData() {
|
||||
try {
|
||||
// Load metrics data using XMLHttpRequest to avoid CORS issues with file:// URLs
|
||||
const metricsCSV = await this.loadFileWithXHR(CONFIG.sampleData.metricsPath);
|
||||
this.processMetricsData(metricsCSV);
|
||||
|
||||
// Load workouts data
|
||||
const workoutsCSV = await this.loadFileWithXHR(CONFIG.sampleData.workoutsPath);
|
||||
this.processWorkoutsData(workoutsCSV);
|
||||
|
||||
return {
|
||||
metrics: this.metricsData,
|
||||
workouts: this.workoutsData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading sample data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a file using XMLHttpRequest (works with file:// URLs)
|
||||
* @param {string} filePath - Path to the file
|
||||
* @returns {Promise<string>} - Promise resolving with file contents
|
||||
*/
|
||||
loadFileWithXHR(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', filePath, true);
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) {
|
||||
resolve(xhr.responseText);
|
||||
} else {
|
||||
reject(new Error(`Failed to load file: ${filePath}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
reject(new Error(`Network error loading file: ${filePath}`));
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process file data from file inputs
|
||||
* @param {File} metricsFile - Metrics CSV file
|
||||
* @param {File} workoutsFile - Workouts CSV file
|
||||
* @returns {Promise} - Promise resolving with processed data
|
||||
*/
|
||||
async processFiles(metricsFile, workoutsFile) {
|
||||
try {
|
||||
// Process metrics file
|
||||
const metricsData = await this.readFile(metricsFile);
|
||||
this.processMetricsData(metricsData);
|
||||
|
||||
// Process workouts file
|
||||
const workoutsData = await this.readFile(workoutsFile);
|
||||
this.processWorkoutsData(workoutsData);
|
||||
|
||||
return {
|
||||
metrics: this.metricsData,
|
||||
workouts: this.workoutsData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as text
|
||||
* @param {File} file - File to read
|
||||
* @returns {Promise} - Promise resolving with file contents
|
||||
*/
|
||||
readFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
|
||||
reader.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data for OpenAI analysis
|
||||
* @returns {Object} - Data formatted for analysis
|
||||
*/
|
||||
prepareDataForAnalysis() {
|
||||
if (!this.metricsData || !this.workoutsData) {
|
||||
throw new Error('Data not loaded. Please load data first.');
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: this.metricsData,
|
||||
workouts: this.workoutsData,
|
||||
summary: this.generateDataSummary()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate summary statistics from the data
|
||||
* @returns {Object} - Summary statistics
|
||||
*/
|
||||
generateDataSummary() {
|
||||
if (!this.metricsData || !this.workoutsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log all available metric types from the first few days to help diagnose issues
|
||||
console.log('Available metric types in first few days:');
|
||||
this.metricsData.slice(0, 3).forEach(day => {
|
||||
console.log(`Day ${day.date} metrics:`, Object.keys(day.metrics));
|
||||
});
|
||||
|
||||
// Find sleep hours data with case-insensitive matching
|
||||
const sleepData = [];
|
||||
const sleepHoursKey = 'Sleep Hours'; // The expected key
|
||||
|
||||
this.metricsData.forEach(day => {
|
||||
// Try to find the sleep hours key (case-insensitive)
|
||||
let foundSleepHours = false;
|
||||
|
||||
// First try the exact key
|
||||
if (day.metrics[sleepHoursKey] !== undefined) {
|
||||
sleepData.push(parseFloat(day.metrics[sleepHoursKey]));
|
||||
foundSleepHours = true;
|
||||
} else {
|
||||
// If not found, try case-insensitive search
|
||||
for (const key in day.metrics) {
|
||||
if (key.toLowerCase() === sleepHoursKey.toLowerCase()) {
|
||||
sleepData.push(parseFloat(day.metrics[key]));
|
||||
foundSleepHours = true;
|
||||
console.log(`Found sleep hours with different case: "${key}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundSleepHours) {
|
||||
console.log(`No sleep hours found for day ${day.date}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${sleepData.length} days with sleep data out of ${this.metricsData.length} total days`);
|
||||
|
||||
const sleepStats = sleepData.length > 0 ? {
|
||||
average: this.calculateAverage(sleepData),
|
||||
min: Math.min(...sleepData),
|
||||
max: Math.max(...sleepData)
|
||||
} : null;
|
||||
|
||||
console.log('Sleep stats:', sleepStats);
|
||||
|
||||
// Calculate stress statistics
|
||||
const stressQualifiers = this.metricsData
|
||||
.filter(day => day.metrics['Stress Qualifier'] !== undefined)
|
||||
.map(day => day.metrics['Stress Qualifier']);
|
||||
|
||||
const stressQualifierCounts = {};
|
||||
stressQualifiers.forEach(qualifier => {
|
||||
stressQualifierCounts[qualifier] = (stressQualifierCounts[qualifier] || 0) + 1;
|
||||
});
|
||||
|
||||
// Calculate workout statistics
|
||||
const workoutDistances = this.workoutsData
|
||||
.map(workout => parseFloat(workout.distance));
|
||||
|
||||
const workoutStats = workoutDistances.length > 0 ? {
|
||||
count: this.workoutsData.length,
|
||||
totalDistance: this.calculateSum(workoutDistances),
|
||||
averageDistance: this.calculateAverage(workoutDistances),
|
||||
averageHeartRate: this.calculateAverage(
|
||||
this.workoutsData.map(w => parseFloat(w.heartRate.average))
|
||||
)
|
||||
} : null;
|
||||
|
||||
return {
|
||||
dateRange: {
|
||||
start: this.metricsData[0]?.date,
|
||||
end: this.metricsData[this.metricsData.length - 1]?.date
|
||||
},
|
||||
sleep: sleepStats,
|
||||
stress: {
|
||||
qualifiers: stressQualifierCounts
|
||||
},
|
||||
workouts: workoutStats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average of array values
|
||||
* @param {Array} values - Array of numbers
|
||||
* @returns {number} - Average value
|
||||
*/
|
||||
calculateAverage(values) {
|
||||
if (!values || values.length === 0) return 0;
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate sum of array values
|
||||
* @param {Array} values - Array of numbers
|
||||
* @returns {number} - Sum of values
|
||||
*/
|
||||
calculateSum(values) {
|
||||
if (!values || values.length === 0) return 0;
|
||||
return values.reduce((sum, val) => sum + val, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a global instance
|
||||
const dataProcessor = new DataProcessor();
|
||||
Reference in New Issue
Block a user