Spaces:
Running
Running
| 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'); | |