glutamatt's picture
glutamatt HF Staff
simpler to be accurate
58c055f verified
raw
history blame
15.2 kB
import './style.css';
import { parseCSV } from './utils/csvParser';
import { calculateMetricACWR } from './utils/metricAcwr';
import type { Activity } from './types';
import {
createDistanceChart,
createDurationChart,
createTSSChart,
createCaloriesChart,
destroyAllCharts,
} from './components/charts';
// DOM elements
const csvUpload = document.getElementById('csv-upload') as HTMLInputElement;
const ftpInput = document.getElementById('ftp-input') as HTMLInputElement;
const targetAcwrInput = document.getElementById('target-acwr-input') as HTMLInputElement;
const predictTodayInput = document.getElementById('predict-today-input') as HTMLInputElement;
const thresholdHrInput = document.getElementById('threshold-hr-input') as HTMLInputElement;
const restingHrInput = document.getElementById('resting-hr-input') as HTMLInputElement;
const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
const chartsSection = document.getElementById('charts-section') as HTMLElement;
const filterSection = document.getElementById('filter-section') as HTMLElement;
const filterContainer = document.getElementById('filter-container') as HTMLElement;
const helpButton = document.getElementById('help-button') as HTMLButtonElement;
const helpPopover = document.getElementById('help-popover') as HTMLElement;
const helpClose = document.getElementById('help-close') as HTMLButtonElement;
// Store all activities globally
let allActivities: Activity[] = [];
let selectedActivityTypes: Set<string> = new Set(['Running']);
// Local storage keys
const STORAGE_KEYS = {
CSV_DATA: 'trainingload_csv_data',
FTP: 'trainingload_ftp',
THRESHOLD_HR: 'trainingload_threshold_hr',
RESTING_HR: 'trainingload_resting_hr',
TARGET_ACWR: 'trainingload_target_acwr',
PREDICT_TODAY: 'trainingload_predict_today',
SELECTED_FILTERS: 'trainingload_selected_filters',
};
// Load saved settings on startup
function loadSavedSettings(): void {
// Load FTP
const savedFtp = localStorage.getItem(STORAGE_KEYS.FTP);
if (savedFtp && ftpInput) {
ftpInput.value = savedFtp;
}
// Load Threshold HR
const savedThresholdHr = localStorage.getItem(STORAGE_KEYS.THRESHOLD_HR);
if (savedThresholdHr && thresholdHrInput) {
thresholdHrInput.value = savedThresholdHr;
}
// Load Resting HR
const savedRestingHr = localStorage.getItem(STORAGE_KEYS.RESTING_HR);
if (savedRestingHr && restingHrInput) {
restingHrInput.value = savedRestingHr;
}
// Load Target ACWR
const savedTargetAcwr = localStorage.getItem(STORAGE_KEYS.TARGET_ACWR);
if (savedTargetAcwr && targetAcwrInput) {
targetAcwrInput.value = savedTargetAcwr;
}
// Load Predict Today
const savedPredictToday = localStorage.getItem(STORAGE_KEYS.PREDICT_TODAY);
if (savedPredictToday && predictTodayInput) {
predictTodayInput.checked = savedPredictToday === 'true';
}
// Load Selected Filters
const savedFilters = localStorage.getItem(STORAGE_KEYS.SELECTED_FILTERS);
if (savedFilters) {
try {
const filters = JSON.parse(savedFilters);
selectedActivityTypes = new Set(filters);
} catch (e) {
console.error('Failed to parse saved filters', e);
}
}
}
// Save settings to local storage
function saveSettings(): void {
if (ftpInput) localStorage.setItem(STORAGE_KEYS.FTP, ftpInput.value);
if (thresholdHrInput) localStorage.setItem(STORAGE_KEYS.THRESHOLD_HR, thresholdHrInput.value);
if (restingHrInput) localStorage.setItem(STORAGE_KEYS.RESTING_HR, restingHrInput.value);
if (targetAcwrInput) localStorage.setItem(STORAGE_KEYS.TARGET_ACWR, targetAcwrInput.value);
if (predictTodayInput) localStorage.setItem(STORAGE_KEYS.PREDICT_TODAY, String(predictTodayInput.checked));
localStorage.setItem(STORAGE_KEYS.SELECTED_FILTERS, JSON.stringify(Array.from(selectedActivityTypes)));
}
// Load CSV from local storage
async function loadSavedCSV(): Promise<void> {
const savedCSV = localStorage.getItem(STORAGE_KEYS.CSV_DATA);
if (!savedCSV) return;
uploadStatus.textContent = 'Loading saved data...';
uploadStatus.className = '';
try {
const ftp = parseInt(ftpInput.value) || 343;
const thresholdHR = parseInt(thresholdHrInput.value) || 170;
const restingHR = parseInt(restingHrInput.value) || 50;
// Create a Blob from the saved CSV string
const blob = new Blob([savedCSV], { type: 'text/csv' });
const file = new File([blob], 'saved_activities.csv', { type: 'text/csv' });
allActivities = await parseCSV(file, ftp, thresholdHR, restingHR);
if (allActivities.length === 0) {
throw new Error('No valid activities found in saved data');
}
createActivityTypeFilters(allActivities);
const filteredActivities = selectedActivityTypes.size > 0
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
: allActivities;
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
const predictToday = predictTodayInput?.checked || false;
renderCharts(filteredActivities, targetAcwr, predictToday);
filterSection.classList.remove('hidden');
chartsSection.classList.remove('hidden');
uploadStatus.textContent = `Successfully loaded ${allActivities.length} activities from saved data`;
uploadStatus.className = 'success';
} catch (error) {
console.error('Error loading saved data:', error);
// Clear corrupted data
localStorage.removeItem(STORAGE_KEYS.CSV_DATA);
uploadStatus.textContent = '';
uploadStatus.className = '';
}
}
// Event listeners
csvUpload?.addEventListener('change', handleFileUpload);
targetAcwrInput?.addEventListener('input', handleTargetAcwrChange);
predictTodayInput?.addEventListener('change', handlePredictTodayChange);
ftpInput?.addEventListener('input', saveSettings);
thresholdHrInput?.addEventListener('input', saveSettings);
restingHrInput?.addEventListener('input', saveSettings);
helpButton?.addEventListener('click', () => helpPopover?.classList.remove('hidden'));
helpClose?.addEventListener('click', () => helpPopover?.classList.add('hidden'));
helpPopover?.addEventListener('click', (e) => {
if (e.target === helpPopover) {
helpPopover.classList.add('hidden');
}
});
function handleTargetAcwrChange(): void {
// Only refresh if we have activities loaded
if (allActivities.length === 0) return;
// Save to local storage
saveSettings();
// Filter and render with new target ACWR
const filteredActivities = selectedActivityTypes.size > 0
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
: allActivities;
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
const predictToday = predictTodayInput?.checked || false;
renderCharts(filteredActivities, targetAcwr, predictToday);
}
function handlePredictTodayChange(): void {
// Only refresh if we have activities loaded
if (allActivities.length === 0) return;
// Save to local storage
saveSettings();
// Filter and render with new predict today setting
const filteredActivities = selectedActivityTypes.size > 0
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
: allActivities;
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
const predictToday = predictTodayInput?.checked || false;
renderCharts(filteredActivities, targetAcwr, predictToday);
}
function createActivityTypeFilters(activities: Activity[]): void {
// Get unique activity types
const activityTypes = new Set<string>();
activities.forEach(activity => {
if (activity.activityType) {
activityTypes.add(activity.activityType);
}
});
// Define priority order
const priorityOrder = ['Running', 'Cycling', 'Strength Training'];
const sortedTypes: string[] = [];
// Add priority types first (if they exist)
priorityOrder.forEach(type => {
if (activityTypes.has(type)) {
sortedTypes.push(type);
activityTypes.delete(type);
}
});
// Add remaining types alphabetically
sortedTypes.push(...Array.from(activityTypes).sort());
// Clear existing filters
filterContainer.innerHTML = '';
// Create checkbox for each activity type
sortedTypes.forEach(type => {
const label = document.createElement('label');
label.className = 'filter-checkbox';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `filter-${type.replace(/\s+/g, '-').toLowerCase()}`;
checkbox.value = type;
checkbox.checked = selectedActivityTypes.has(type);
checkbox.addEventListener('change', handleFilterChange);
const icon = getActivityIcon(type);
const span = document.createElement('span');
span.textContent = `${icon} ${type}`;
label.appendChild(checkbox);
label.appendChild(span);
filterContainer.appendChild(label);
});
}
function getActivityIcon(activityType: string): string {
const icons: { [key: string]: string } = {
'Running': '🏃',
'Cycling': '🚴',
'Strength Training': '🏋️',
'HIIT': '💪',
'Cardio': '❤️',
};
return icons[activityType] || '🏃';
}
function handleFilterChange(): void {
// Update selected activity types based on checkboxes
const checkboxes = filterContainer.querySelectorAll('input[type="checkbox"]') as NodeListOf<HTMLInputElement>;
selectedActivityTypes.clear();
checkboxes.forEach(checkbox => {
if (checkbox.checked) {
selectedActivityTypes.add(checkbox.value);
}
});
// Save to local storage
saveSettings();
// Filter and render
const filteredActivities = selectedActivityTypes.size > 0
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
: allActivities;
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
const predictToday = predictTodayInput?.checked || false;
renderCharts(filteredActivities, targetAcwr, predictToday);
}
async function handleFileUpload(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadStatus.textContent = 'Processing CSV file...';
uploadStatus.className = '';
try {
// Get FTP and HR values from inputs
const ftp = parseInt(ftpInput.value) || 343;
const thresholdHR = parseInt(thresholdHrInput.value) || 170;
const restingHR = parseInt(restingHrInput.value) || 50;
// Read file content to save it
const csvContent = await file.text();
// Parse CSV with user-provided FTP and HR values
allActivities = await parseCSV(file, ftp, thresholdHR, restingHR);
if (allActivities.length === 0) {
throw new Error('No valid activities found in the CSV file');
}
// Save CSV content and settings to local storage
localStorage.setItem(STORAGE_KEYS.CSV_DATA, csvContent);
saveSettings();
// Create dynamic activity type filters
createActivityTypeFilters(allActivities);
// Initial render with selected activity types
const filteredActivities = selectedActivityTypes.size > 0
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
: allActivities;
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
const predictToday = predictTodayInput?.checked || false;
renderCharts(filteredActivities, targetAcwr, predictToday);
// Show filter and charts sections
filterSection.classList.remove('hidden');
chartsSection.classList.remove('hidden');
// Update status
uploadStatus.textContent = `Successfully loaded ${allActivities.length} activities`;
uploadStatus.className = 'success';
} catch (error) {
console.error('Error processing file:', error);
uploadStatus.textContent = error instanceof Error ? error.message : 'Failed to process CSV file';
uploadStatus.className = 'error';
filterSection.classList.add('hidden');
chartsSection.classList.add('hidden');
}
}
function renderCharts(activities: Activity[], targetAcwr: number, predictToday: boolean = false): void {
// Calculate date range from first activity to today
let dateRange: { start: Date; end: Date } | undefined;
if (allActivities.length > 0) {
// Determine end date based on whether we'll include today in predictions
const today = new Date();
today.setHours(0, 0, 0, 0);
// Check if today has activities
const todayHasActivities = activities.some(activity => {
const activityDate = new Date(activity.date);
activityDate.setHours(0, 0, 0, 0);
return activityDate.getTime() === today.getTime();
});
// If predictToday is checked and today has no activities, end range at yesterday
// This prevents today from appearing twice (once as null, once as prediction)
const endDate = new Date();
if (predictToday && !todayHasActivities) {
// End at yesterday so today only appears in predictions
endDate.setDate(endDate.getDate() - 1);
}
endDate.setHours(23, 59, 59, 999);
// Calculate start date: always go back 56 days (2 x 28 days chronic period)
// This ensures we have enough historical data for accurate ACWR calculation
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 56);
startDate.setHours(0, 0, 0, 0);
dateRange = {
start: startDate,
end: endDate,
};
}
// Calculate ACWR for each metric with consistent date range
const distanceData = calculateMetricACWR(
activities,
(activity) => activity.distance,
dateRange,
targetAcwr
);
const durationData = calculateMetricACWR(
activities,
(activity) => activity.duration,
dateRange,
targetAcwr
);
const tssData = calculateMetricACWR(
activities,
(activity) => activity.trainingStressScore,
dateRange,
targetAcwr
);
const caloriesData = calculateMetricACWR(
activities,
(activity) => activity.calories,
dateRange,
targetAcwr
);
// Destroy existing charts
destroyAllCharts();
// Create new charts
createDistanceChart(distanceData);
createDurationChart(durationData);
createTSSChart(tssData);
createCaloriesChart(caloriesData);
}
// Initialize
loadSavedSettings();
loadSavedCSV();
console.log('Training Load Data Visualization initialized');