glutamatt HF Staff commited on
Commit
f63ce21
·
verified ·
1 Parent(s): 35527e2
index.html CHANGED
@@ -34,27 +34,24 @@
34
  </section>
35
 
36
  <section id="filter-section" class="hidden">
37
- <div class="filter-container">
38
- <label class="filter-checkbox">
39
- <input type="checkbox" id="running-only-filter" />
40
- <span>🏃 Running only</span>
41
- </label>
42
  </div>
43
  </section>
44
 
45
  <section id="charts-section" class="hidden">
46
  <div class="chart-container">
47
- <h2>Distance-based ACWR</h2>
48
  <canvas id="distance-chart"></canvas>
49
  </div>
50
 
51
  <div class="chart-container">
52
- <h2>Duration-based ACWR</h2>
53
  <canvas id="duration-chart"></canvas>
54
  </div>
55
 
56
  <div class="chart-container">
57
- <h2>TSS-based ACWR</h2>
58
  <canvas id="tss-chart"></canvas>
59
  </div>
60
 
@@ -67,6 +64,10 @@
67
  <span class="legend-color optimal"></span>
68
  <span>ACWR 0.8 - 1.3 - Optimal</span>
69
  </div>
 
 
 
 
70
  <div class="legend-item">
71
  <span class="legend-color high"></span>
72
  <span>ACWR &gt; 1.5 - Injury risk</span>
 
34
  </section>
35
 
36
  <section id="filter-section" class="hidden">
37
+ <div class="filter-container" id="filter-container">
38
+ <!-- Activity type filters will be dynamically generated here -->
 
 
 
39
  </div>
40
  </section>
41
 
42
  <section id="charts-section" class="hidden">
43
  <div class="chart-container">
44
+ <h2>🗺️ Distance-based ACWR</h2>
45
  <canvas id="distance-chart"></canvas>
46
  </div>
47
 
48
  <div class="chart-container">
49
+ <h2>⏱️ Duration-based ACWR</h2>
50
  <canvas id="duration-chart"></canvas>
51
  </div>
52
 
53
  <div class="chart-container">
54
+ <h2>🔋 TSS-based ACWR</h2>
55
  <canvas id="tss-chart"></canvas>
56
  </div>
57
 
 
64
  <span class="legend-color optimal"></span>
65
  <span>ACWR 0.8 - 1.3 - Optimal</span>
66
  </div>
67
+ <div class="legend-item">
68
+ <span class="legend-color warning"></span>
69
+ <span>ACWR 1.3 - 1.5 - Warning</span>
70
+ </div>
71
  <div class="legend-item">
72
  <span class="legend-color high"></span>
73
  <span>ACWR &gt; 1.5 - Injury risk</span>
src/components/charts.ts CHANGED
@@ -8,6 +8,97 @@ let distanceChart: Chart | null = null;
8
  let durationChart: Chart | null = null;
9
  let tssChart: Chart | null = null;
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  // Common chart styling inspired by Garmin
12
  const commonOptions = {
13
  responsive: true,
@@ -39,7 +130,9 @@ const commonOptions = {
39
  scales: {
40
  x: {
41
  grid: {
42
- display: false,
 
 
43
  drawBorder: false,
44
  },
45
  ticks: {
@@ -62,33 +155,34 @@ function createDualAxisChart(
62
  data: MetricACWRData,
63
  metricLabel: string,
64
  metricUnit: string,
65
- barColor: string
66
  ): Chart {
67
  const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
68
  if (!canvas) throw new Error(`Canvas ${canvasId} not found`);
69
 
70
  const config: ChartConfiguration = {
71
- type: 'bar',
72
  data: {
73
  labels: data.dates,
74
  datasets: [
75
  {
76
- type: 'bar',
77
  label: `Daily ${metricLabel}`,
78
  data: data.values,
79
- backgroundColor: barColor,
80
- borderWidth: 0,
81
- borderRadius: 2,
82
- barPercentage: 0.8,
 
83
  yAxisID: 'y',
84
  },
85
  {
86
  type: 'line',
87
  label: '7-Day Average',
88
  data: data.average7d,
89
- borderColor: 'rgba(239, 68, 68, 0.9)',
90
- backgroundColor: 'rgba(239, 68, 68, 0.1)',
91
- borderWidth: 2,
92
  pointRadius: 0,
93
  pointHoverRadius: 4,
94
  tension: 0.4,
@@ -99,9 +193,9 @@ function createDualAxisChart(
99
  type: 'line',
100
  label: '28-Day Average',
101
  data: data.average28d,
102
- borderColor: 'rgba(249, 115, 22, 0.9)',
103
- backgroundColor: 'rgba(249, 115, 22, 0.1)',
104
- borderWidth: 2,
105
  pointRadius: 0,
106
  pointHoverRadius: 4,
107
  tension: 0.4,
@@ -112,11 +206,15 @@ function createDualAxisChart(
112
  type: 'line',
113
  label: 'ACWR',
114
  data: data.acwr,
115
- borderColor: 'rgba(139, 92, 246, 1)',
116
- backgroundColor: 'rgba(139, 92, 246, 0.1)',
117
- borderWidth: 3,
118
  pointRadius: 0,
119
  pointHoverRadius: 5,
 
 
 
 
120
  tension: 0.4,
121
  fill: false,
122
  yAxisID: 'y1',
@@ -200,7 +298,7 @@ export function createDistanceChart(data: MetricACWRData): void {
200
  data,
201
  'Distance',
202
  '(km)',
203
- 'rgba(59, 130, 246, 0.6)'
204
  );
205
  }
206
 
@@ -213,7 +311,7 @@ export function createDurationChart(data: MetricACWRData): void {
213
  data,
214
  'Duration',
215
  '(min)',
216
- 'rgba(16, 185, 129, 0.6)'
217
  );
218
  }
219
 
@@ -226,7 +324,7 @@ export function createTSSChart(data: MetricACWRData): void {
226
  data,
227
  'TSS',
228
  '',
229
- 'rgba(245, 158, 11, 0.6)'
230
  );
231
  }
232
 
 
8
  let durationChart: Chart | null = null;
9
  let tssChart: Chart | null = null;
10
 
11
+ // ACWR color zones
12
+ function getACWRColor(value: number | null): string {
13
+ if (value === null || value === undefined) return 'rgba(148, 163, 184, 0.3)';
14
+ if (value < 0.8) return 'rgba(59, 130, 246, 0.9)'; // Blue - Detraining risk
15
+ if (value <= 1.3) return 'rgba(16, 185, 129, 0.9)'; // Green - Optimal
16
+ if (value <= 1.5) return 'rgba(249, 115, 22, 0.9)'; // Orange - Warning
17
+ return 'rgba(239, 68, 68, 0.9)'; // Red - Injury risk
18
+ }
19
+
20
+ // Plugin to draw gradient-colored ACWR line
21
+ const acwrGradientPlugin = {
22
+ id: 'acwrGradient',
23
+ afterDatasetsDraw(chart: Chart) {
24
+ const meta = chart.getDatasetMeta(3); // ACWR is the 4th dataset (index 3)
25
+ if (!meta || meta.hidden) return;
26
+
27
+ const ctx = chart.ctx;
28
+ const data = chart.data.datasets[3].data as (number | null)[];
29
+ const chartArea = chart.chartArea;
30
+
31
+ ctx.save();
32
+
33
+ // First, fill the area under the curve with gradient colors
34
+ for (let i = 0; i < meta.data.length - 1; i++) {
35
+ const point1 = meta.data[i];
36
+ const point2 = meta.data[i + 1];
37
+
38
+ if (!point1 || !point2) continue;
39
+
40
+ const value1 = data[i];
41
+ const value2 = data[i + 1];
42
+
43
+ // Skip if both values are null
44
+ if (value1 === null && value2 === null) continue;
45
+
46
+ // Use the average color of the two points
47
+ const avgValue = value1 !== null && value2 !== null
48
+ ? (value1 + value2) / 2
49
+ : (value1 ?? value2);
50
+
51
+ const color = getACWRColor(avgValue);
52
+ // Make the fill more transparent
53
+ const fillColor = color.replace(/[\d.]+\)$/, '0.15)');
54
+
55
+ ctx.fillStyle = fillColor;
56
+ ctx.beginPath();
57
+ ctx.moveTo(point1.x, point1.y);
58
+ ctx.lineTo(point2.x, point2.y);
59
+ ctx.lineTo(point2.x, chartArea.bottom);
60
+ ctx.lineTo(point1.x, chartArea.bottom);
61
+ ctx.closePath();
62
+ ctx.fill();
63
+ }
64
+
65
+ // Then draw the line on top
66
+ ctx.lineWidth = 4;
67
+ ctx.lineCap = 'round';
68
+ ctx.lineJoin = 'round';
69
+
70
+ // Draw line segments with colors based on values
71
+ for (let i = 0; i < meta.data.length - 1; i++) {
72
+ const point1 = meta.data[i];
73
+ const point2 = meta.data[i + 1];
74
+
75
+ if (!point1 || !point2) continue;
76
+
77
+ const value1 = data[i];
78
+ const value2 = data[i + 1];
79
+
80
+ // Skip if both values are null
81
+ if (value1 === null && value2 === null) continue;
82
+
83
+ // Use the average color of the two points
84
+ const avgValue = value1 !== null && value2 !== null
85
+ ? (value1 + value2) / 2
86
+ : (value1 ?? value2);
87
+
88
+ ctx.strokeStyle = getACWRColor(avgValue);
89
+ ctx.beginPath();
90
+ ctx.moveTo(point1.x, point1.y);
91
+ ctx.lineTo(point2.x, point2.y);
92
+ ctx.stroke();
93
+ }
94
+
95
+ ctx.restore();
96
+ },
97
+ };
98
+
99
+ // Register the custom plugin
100
+ Chart.register(acwrGradientPlugin);
101
+
102
  // Common chart styling inspired by Garmin
103
  const commonOptions = {
104
  responsive: true,
 
130
  scales: {
131
  x: {
132
  grid: {
133
+ display: true,
134
+ color: 'rgba(148, 163, 184, 0.15)',
135
+ lineWidth: 1,
136
  drawBorder: false,
137
  },
138
  ticks: {
 
155
  data: MetricACWRData,
156
  metricLabel: string,
157
  metricUnit: string,
158
+ dotColor: string
159
  ): Chart {
160
  const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
161
  if (!canvas) throw new Error(`Canvas ${canvasId} not found`);
162
 
163
  const config: ChartConfiguration = {
164
+ type: 'line',
165
  data: {
166
  labels: data.dates,
167
  datasets: [
168
  {
169
+ type: 'scatter',
170
  label: `Daily ${metricLabel}`,
171
  data: data.values,
172
+ backgroundColor: dotColor,
173
+ borderColor: dotColor.replace('0.8)', '1)'),
174
+ borderWidth: 1,
175
+ pointRadius: 5,
176
+ pointHoverRadius: 7,
177
  yAxisID: 'y',
178
  },
179
  {
180
  type: 'line',
181
  label: '7-Day Average',
182
  data: data.average7d,
183
+ borderColor: 'rgba(168, 85, 247, 0.5)',
184
+ backgroundColor: 'rgba(168, 85, 247, 0.05)',
185
+ borderWidth: 1.5,
186
  pointRadius: 0,
187
  pointHoverRadius: 4,
188
  tension: 0.4,
 
193
  type: 'line',
194
  label: '28-Day Average',
195
  data: data.average28d,
196
+ borderColor: 'rgba(236, 72, 153, 0.5)',
197
+ backgroundColor: 'rgba(236, 72, 153, 0.05)',
198
+ borderWidth: 1.5,
199
  pointRadius: 0,
200
  pointHoverRadius: 4,
201
  tension: 0.4,
 
206
  type: 'line',
207
  label: 'ACWR',
208
  data: data.acwr,
209
+ borderColor: 'rgba(139, 92, 246, 0)', // Transparent - we draw it in the plugin
210
+ backgroundColor: 'rgba(139, 92, 246, 0)',
211
+ borderWidth: 4,
212
  pointRadius: 0,
213
  pointHoverRadius: 5,
214
+ pointHoverBackgroundColor: (context: any) => {
215
+ const value = context.raw;
216
+ return getACWRColor(value);
217
+ },
218
  tension: 0.4,
219
  fill: false,
220
  yAxisID: 'y1',
 
298
  data,
299
  'Distance',
300
  '(km)',
301
+ 'rgba(234, 179, 8, 0.8)'
302
  );
303
  }
304
 
 
311
  data,
312
  'Duration',
313
  '(min)',
314
+ 'rgba(234, 179, 8, 0.8)'
315
  );
316
  }
317
 
 
324
  data,
325
  'TSS',
326
  '',
327
+ 'rgba(234, 179, 8, 0.8)'
328
  );
329
  }
330
 
src/main.ts CHANGED
@@ -15,14 +15,93 @@ const ftpInput = document.getElementById('ftp-input') as HTMLInputElement;
15
  const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
16
  const chartsSection = document.getElementById('charts-section') as HTMLElement;
17
  const filterSection = document.getElementById('filter-section') as HTMLElement;
18
- const runningOnlyFilter = document.getElementById('running-only-filter') as HTMLInputElement;
19
 
20
  // Store all activities globally
21
  let allActivities: Activity[] = [];
 
22
 
23
  // Event listeners
24
  csvUpload?.addEventListener('change', handleFileUpload);
25
- runningOnlyFilter?.addEventListener('change', handleFilterChange);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  async function handleFileUpload(event: Event): Promise<void> {
28
  const input = event.target as HTMLInputElement;
@@ -46,13 +125,14 @@ async function handleFileUpload(event: Event): Promise<void> {
46
  throw new Error('No valid activities found in the CSV file');
47
  }
48
 
49
- // Reset filter
50
- if (runningOnlyFilter) {
51
- runningOnlyFilter.checked = false;
52
- }
53
 
54
- // Render charts with all activities
55
- renderCharts(allActivities);
 
 
 
56
 
57
  // Show filter and charts sections
58
  filterSection.classList.remove('hidden');
@@ -70,16 +150,6 @@ async function handleFileUpload(event: Event): Promise<void> {
70
  }
71
  }
72
 
73
- function handleFilterChange(): void {
74
- if (allActivities.length === 0) return;
75
-
76
- const filteredActivities = runningOnlyFilter.checked
77
- ? allActivities.filter(activity => activity.activityType === 'Running')
78
- : allActivities;
79
-
80
- renderCharts(filteredActivities);
81
- }
82
-
83
  function renderCharts(activities: Activity[]): void {
84
  // Calculate date range from all activities for consistency
85
  let dateRange: { start: Date; end: Date } | undefined;
 
15
  const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
16
  const chartsSection = document.getElementById('charts-section') as HTMLElement;
17
  const filterSection = document.getElementById('filter-section') as HTMLElement;
18
+ const filterContainer = document.getElementById('filter-container') as HTMLElement;
19
 
20
  // Store all activities globally
21
  let allActivities: Activity[] = [];
22
+ let selectedActivityTypes: Set<string> = new Set(['Running']);
23
 
24
  // Event listeners
25
  csvUpload?.addEventListener('change', handleFileUpload);
26
+
27
+ function createActivityTypeFilters(activities: Activity[]): void {
28
+ // Get unique activity types
29
+ const activityTypes = new Set<string>();
30
+ activities.forEach(activity => {
31
+ if (activity.activityType) {
32
+ activityTypes.add(activity.activityType);
33
+ }
34
+ });
35
+
36
+ // Define priority order
37
+ const priorityOrder = ['Running', 'Cycling', 'Strength Training'];
38
+ const sortedTypes: string[] = [];
39
+
40
+ // Add priority types first (if they exist)
41
+ priorityOrder.forEach(type => {
42
+ if (activityTypes.has(type)) {
43
+ sortedTypes.push(type);
44
+ activityTypes.delete(type);
45
+ }
46
+ });
47
+
48
+ // Add remaining types alphabetically
49
+ sortedTypes.push(...Array.from(activityTypes).sort());
50
+
51
+ // Clear existing filters
52
+ filterContainer.innerHTML = '';
53
+
54
+ // Create checkbox for each activity type
55
+ sortedTypes.forEach(type => {
56
+ const label = document.createElement('label');
57
+ label.className = 'filter-checkbox';
58
+
59
+ const checkbox = document.createElement('input');
60
+ checkbox.type = 'checkbox';
61
+ checkbox.id = `filter-${type.replace(/\s+/g, '-').toLowerCase()}`;
62
+ checkbox.value = type;
63
+ checkbox.checked = selectedActivityTypes.has(type);
64
+ checkbox.addEventListener('change', handleFilterChange);
65
+
66
+ const icon = getActivityIcon(type);
67
+ const span = document.createElement('span');
68
+ span.textContent = `${icon} ${type}`;
69
+
70
+ label.appendChild(checkbox);
71
+ label.appendChild(span);
72
+ filterContainer.appendChild(label);
73
+ });
74
+ }
75
+
76
+ function getActivityIcon(activityType: string): string {
77
+ const icons: { [key: string]: string } = {
78
+ 'Running': '🏃',
79
+ 'Cycling': '🚴',
80
+ 'Strength Training': '🏋️',
81
+ 'HIIT': '💪',
82
+ 'Cardio': '❤️',
83
+ };
84
+ return icons[activityType] || '🏃';
85
+ }
86
+
87
+ function handleFilterChange(): void {
88
+ // Update selected activity types based on checkboxes
89
+ const checkboxes = filterContainer.querySelectorAll('input[type="checkbox"]') as NodeListOf<HTMLInputElement>;
90
+ selectedActivityTypes.clear();
91
+
92
+ checkboxes.forEach(checkbox => {
93
+ if (checkbox.checked) {
94
+ selectedActivityTypes.add(checkbox.value);
95
+ }
96
+ });
97
+
98
+ // Filter and render
99
+ const filteredActivities = selectedActivityTypes.size > 0
100
+ ? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
101
+ : allActivities;
102
+
103
+ renderCharts(filteredActivities);
104
+ }
105
 
106
  async function handleFileUpload(event: Event): Promise<void> {
107
  const input = event.target as HTMLInputElement;
 
125
  throw new Error('No valid activities found in the CSV file');
126
  }
127
 
128
+ // Create dynamic activity type filters
129
+ createActivityTypeFilters(allActivities);
 
 
130
 
131
+ // Initial render with selected activity types
132
+ const filteredActivities = selectedActivityTypes.size > 0
133
+ ? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
134
+ : allActivities;
135
+ renderCharts(filteredActivities);
136
 
137
  // Show filter and charts sections
138
  filterSection.classList.remove('hidden');
 
150
  }
151
  }
152
 
 
 
 
 
 
 
 
 
 
 
153
  function renderCharts(activities: Activity[]): void {
154
  // Calculate date range from all activities for consistency
155
  let dateRange: { start: Date; end: Date } | undefined;
src/style.css CHANGED
@@ -143,6 +143,9 @@ h2 {
143
  font-size: 0.875rem;
144
  color: var(--secondary-color);
145
  min-height: 1.5rem;
 
 
 
146
  }
147
 
148
  #upload-status.success {
@@ -153,6 +156,11 @@ h2 {
153
  #upload-status.error {
154
  color: var(--danger-color);
155
  font-weight: 500;
 
 
 
 
 
156
  }
157
 
158
  /* Filter Section */
@@ -164,10 +172,11 @@ h2 {
164
  margin-bottom: 2rem;
165
  }
166
 
167
- .filter-container {
168
  display: flex;
169
  align-items: center;
170
- gap: 1rem;
 
171
  }
172
 
173
  .filter-checkbox {
@@ -177,6 +186,13 @@ h2 {
177
  cursor: pointer;
178
  font-size: 1rem;
179
  user-select: none;
 
 
 
 
 
 
 
180
  }
181
 
182
  .filter-checkbox input[type="checkbox"] {
@@ -231,18 +247,23 @@ h2 {
231
  }
232
 
233
  .legend-color.low {
234
- background-color: rgba(191, 219, 254, 0.5);
235
- border: 2px solid rgba(59, 130, 246, 0.7);
236
  }
237
 
238
  .legend-color.optimal {
239
- background-color: rgba(187, 247, 208, 0.5);
240
- border: 2px solid rgba(34, 197, 94, 0.7);
 
 
 
 
 
241
  }
242
 
243
  .legend-color.high {
244
- background-color: rgba(254, 202, 202, 0.5);
245
- border: 2px solid rgba(239, 68, 68, 0.7);
246
  }
247
 
248
  /* Responsive Design */
 
143
  font-size: 0.875rem;
144
  color: var(--secondary-color);
145
  min-height: 1.5rem;
146
+ max-width: 800px;
147
+ white-space: pre-wrap;
148
+ text-align: left;
149
  }
150
 
151
  #upload-status.success {
 
156
  #upload-status.error {
157
  color: var(--danger-color);
158
  font-weight: 500;
159
+ background-color: #fef2f2;
160
+ border: 1px solid #fecaca;
161
+ border-radius: 6px;
162
+ padding: 1rem;
163
+ line-height: 1.6;
164
  }
165
 
166
  /* Filter Section */
 
172
  margin-bottom: 2rem;
173
  }
174
 
175
+ #filter-container {
176
  display: flex;
177
  align-items: center;
178
+ flex-wrap: wrap;
179
+ gap: 1.5rem;
180
  }
181
 
182
  .filter-checkbox {
 
186
  cursor: pointer;
187
  font-size: 1rem;
188
  user-select: none;
189
+ padding: 0.5rem 0.75rem;
190
+ border-radius: 6px;
191
+ transition: background-color 0.2s;
192
+ }
193
+
194
+ .filter-checkbox:hover {
195
+ background-color: var(--bg-color);
196
  }
197
 
198
  .filter-checkbox input[type="checkbox"] {
 
247
  }
248
 
249
  .legend-color.low {
250
+ background-color: rgba(59, 130, 246, 0.3);
251
+ border: 2px solid rgba(59, 130, 246, 0.9);
252
  }
253
 
254
  .legend-color.optimal {
255
+ background-color: rgba(16, 185, 129, 0.3);
256
+ border: 2px solid rgba(16, 185, 129, 0.9);
257
+ }
258
+
259
+ .legend-color.warning {
260
+ background-color: rgba(249, 115, 22, 0.3);
261
+ border: 2px solid rgba(249, 115, 22, 0.9);
262
  }
263
 
264
  .legend-color.high {
265
+ background-color: rgba(239, 68, 68, 0.3);
266
+ border: 2px solid rgba(239, 68, 68, 0.9);
267
  }
268
 
269
  /* Responsive Design */
src/utils/csvParser.ts CHANGED
@@ -1,6 +1,7 @@
1
  import Papa from 'papaparse';
2
  import type { Activity } from '@/types';
3
  import { calculateTSS } from './tssCalculator';
 
4
 
5
  export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]> {
6
  return new Promise((resolve, reject) => {
@@ -9,6 +10,25 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
9
  skipEmptyLines: true,
10
  complete: (results) => {
11
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  const activities = results.data.map((row: any) => {
13
  // Parse date - Garmin format: "2025-12-02 09:19:49"
14
  const dateStr = row['Date'] || row['Activity Date'] || row['date'];
@@ -65,15 +85,24 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
65
 
66
  // If no TSS provided, try to calculate from power data
67
  if (!trainingStressScore && durationSeconds) {
68
- // Parse Normalized Power (NP)
69
- const npStr = row['Normalized Power (NP)'] || row['Normalized Power'] || row['NP'];
70
- const normalizedPower = npStr && npStr !== '--' ? parseFloat(npStr) : undefined;
 
 
 
 
 
 
 
 
 
71
 
72
  // Use FTP from CSV if available, otherwise use user-provided FTP
73
  const ftpStr = row['FTP'] || row['Functional Threshold Power'];
74
  const ftp = ftpStr && ftpStr !== '--' ? parseFloat(ftpStr) : userFTP;
75
 
76
- if (normalizedPower && ftp) {
77
  const calculatedTSS = calculateTSS({
78
  durationSeconds,
79
  normalizedPower,
 
1
  import Papa from 'papaparse';
2
  import type { Activity } from '@/types';
3
  import { calculateTSS } from './tssCalculator';
4
+ import { validateCSV } from './csvValidator';
5
 
6
  export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]> {
7
  return new Promise((resolve, reject) => {
 
10
  skipEmptyLines: true,
11
  complete: (results) => {
12
  try {
13
+ // Validate CSV structure first
14
+ const validation = validateCSV(results.data);
15
+
16
+ if (!validation.isValid) {
17
+ const errorMessage = [
18
+ 'CSV Validation Failed:',
19
+ '',
20
+ ...validation.errors.map(err => `❌ ${err}`),
21
+ ...(validation.warnings.length > 0 ? ['', 'Warnings:', ...validation.warnings.map(warn => `⚠️ ${warn}`)] : []),
22
+ ].join('\n');
23
+ reject(new Error(errorMessage));
24
+ return;
25
+ }
26
+
27
+ // Log warnings to console if any
28
+ if (validation.warnings.length > 0) {
29
+ console.warn('CSV Validation Warnings:', validation.warnings);
30
+ }
31
+
32
  const activities = results.data.map((row: any) => {
33
  // Parse date - Garmin format: "2025-12-02 09:19:49"
34
  const dateStr = row['Date'] || row['Activity Date'] || row['date'];
 
85
 
86
  // If no TSS provided, try to calculate from power data
87
  if (!trainingStressScore && durationSeconds) {
88
+ // Parse Normalized Power (NP) - find column that starts with "Normalized Power"
89
+ let normalizedPower: number | undefined;
90
+ const npKey = Object.keys(row).find(key => key.startsWith('Normalized Power'));
91
+ if (npKey) {
92
+ const npStr = row[npKey];
93
+ if (npStr && npStr !== '--' && npStr !== '0.0' && npStr !== '0') {
94
+ normalizedPower = parseFloat(npStr);
95
+ if (isNaN(normalizedPower) || normalizedPower <= 0) {
96
+ normalizedPower = undefined;
97
+ }
98
+ }
99
+ }
100
 
101
  // Use FTP from CSV if available, otherwise use user-provided FTP
102
  const ftpStr = row['FTP'] || row['Functional Threshold Power'];
103
  const ftp = ftpStr && ftpStr !== '--' ? parseFloat(ftpStr) : userFTP;
104
 
105
+ if (normalizedPower && normalizedPower > 0 && ftp && ftp > 0) {
106
  const calculatedTSS = calculateTSS({
107
  durationSeconds,
108
  normalizedPower,
src/utils/csvValidator.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CSV Validation utilities
3
+ * Checks for required fields and data quality
4
+ */
5
+
6
+ export interface ValidationResult {
7
+ isValid: boolean;
8
+ errors: string[];
9
+ warnings: string[];
10
+ }
11
+
12
+ export interface FieldValidation {
13
+ found: boolean;
14
+ fieldName?: string;
15
+ nonNullCount: number;
16
+ nonZeroCount: number;
17
+ totalRows: number;
18
+ }
19
+
20
+ /**
21
+ * Validate CSV structure and required fields
22
+ * @param data - Parsed CSV data array
23
+ * @returns Validation result with errors and warnings
24
+ */
25
+ export function validateCSV(data: any[]): ValidationResult {
26
+ const errors: string[] = [];
27
+ const warnings: string[] = [];
28
+
29
+ if (!data || data.length === 0) {
30
+ errors.push('CSV file is empty or contains no valid data rows.');
31
+ return { isValid: false, errors, warnings };
32
+ }
33
+
34
+ const totalRows = data.length;
35
+
36
+ // Required fields validation
37
+ const dateField = validateField(data, ['Date', 'Activity Date', 'date']);
38
+ const timeField = validateField(data, ['Time', 'Duration', 'Moving Time', 'Elapsed Time']);
39
+ const activityTypeField = validateField(data, ['Activity Type', 'Type', 'Sport']);
40
+
41
+ // Check required fields
42
+ if (!dateField.found) {
43
+ errors.push('Missing required field: "Date". Expected column names: Date, Activity Date, or date.');
44
+ } else if (dateField.nonNullCount === 0) {
45
+ errors.push(`Field "${dateField.fieldName}" has no valid values (all rows are empty or "--").`);
46
+ } else if (dateField.nonNullCount < totalRows) {
47
+ warnings.push(`Field "${dateField.fieldName}": ${totalRows - dateField.nonNullCount} out of ${totalRows} rows have missing dates.`);
48
+ }
49
+
50
+ if (!timeField.found) {
51
+ errors.push('Missing required field: "Time" or "Duration". Expected column names: Time, Duration, Moving Time, or Elapsed Time.');
52
+ } else if (timeField.nonNullCount === 0) {
53
+ errors.push(`Field "${timeField.fieldName}" has no valid values (all rows are empty or "--").`);
54
+ } else if (timeField.nonNullCount < totalRows) {
55
+ warnings.push(`Field "${timeField.fieldName}": ${totalRows - timeField.nonNullCount} out of ${totalRows} rows have missing duration.`);
56
+ }
57
+
58
+ if (!activityTypeField.found) {
59
+ errors.push('Missing required field: "Activity Type". Expected column names: Activity Type, Type, or Sport.');
60
+ } else if (activityTypeField.nonNullCount === 0) {
61
+ errors.push(`Field "${activityTypeField.fieldName}" has no valid values (all rows are empty or "--").`);
62
+ }
63
+
64
+ // Optional but important fields validation
65
+ const distanceField = validateField(data, ['Distance', 'distance']);
66
+ const normalizedPowerField = validateFieldByPrefix(data, 'Normalized Power');
67
+
68
+ // Distance warnings
69
+ if (!distanceField.found) {
70
+ warnings.push('Optional field "Distance" is missing. Distance-based ACWR chart will be empty.');
71
+ } else if (distanceField.nonZeroCount === 0) {
72
+ warnings.push('Field "Distance" exists but all values are 0 or empty. Distance-based ACWR chart will be empty.');
73
+ } else if (distanceField.nonZeroCount < totalRows * 0.5) {
74
+ warnings.push(`Field "Distance": Only ${distanceField.nonZeroCount} out of ${totalRows} activities have distance values.`);
75
+ }
76
+
77
+ // Power/TSS warnings
78
+ const tssField = validateFieldByPrefix(data, 'Training Stress Score');
79
+
80
+ if (!normalizedPowerField.found && !tssField.found) {
81
+ warnings.push('Neither "Normalized Power" nor "Training Stress Score" fields found. TSS will be calculated using default FTP (343W). Ensure activities have Normalized Power data for accurate TSS calculation.');
82
+ } else if (normalizedPowerField.found && normalizedPowerField.nonZeroCount === 0) {
83
+ warnings.push('Field "Normalized Power" exists but all values are 0 or empty. TSS calculation may be limited.');
84
+ } else if (normalizedPowerField.found && normalizedPowerField.nonZeroCount < totalRows * 0.3) {
85
+ warnings.push(`Field "Normalized Power": Only ${normalizedPowerField.nonZeroCount} out of ${totalRows} activities have power data. TSS-based ACWR will have limited data.`);
86
+ }
87
+
88
+ // Summary
89
+ if (errors.length === 0 && warnings.length === 0) {
90
+ warnings.push(`✓ CSV validation passed. Successfully found ${totalRows} activities with all required fields.`);
91
+ }
92
+
93
+ return {
94
+ isValid: errors.length === 0,
95
+ errors,
96
+ warnings,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Validate a field by checking multiple possible column names
102
+ */
103
+ function validateField(data: any[], possibleNames: string[]): FieldValidation {
104
+ for (const name of possibleNames) {
105
+ if (data[0] && name in data[0]) {
106
+ const nonNullCount = data.filter(row => {
107
+ const value = row[name];
108
+ return value && value !== '--' && value !== '';
109
+ }).length;
110
+
111
+ const nonZeroCount = data.filter(row => {
112
+ const value = row[name];
113
+ if (!value || value === '--' || value === '') return false;
114
+ const parsed = parseFloat(value);
115
+ return !isNaN(parsed) && parsed !== 0;
116
+ }).length;
117
+
118
+ return {
119
+ found: true,
120
+ fieldName: name,
121
+ nonNullCount,
122
+ nonZeroCount,
123
+ totalRows: data.length,
124
+ };
125
+ }
126
+ }
127
+
128
+ return {
129
+ found: false,
130
+ nonNullCount: 0,
131
+ nonZeroCount: 0,
132
+ totalRows: data.length,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Validate a field by checking if any column name starts with a prefix
138
+ */
139
+ function validateFieldByPrefix(data: any[], prefix: string): FieldValidation {
140
+ if (!data[0]) {
141
+ return {
142
+ found: false,
143
+ nonNullCount: 0,
144
+ nonZeroCount: 0,
145
+ totalRows: data.length,
146
+ };
147
+ }
148
+
149
+ const fieldName = Object.keys(data[0]).find(key => key.startsWith(prefix));
150
+
151
+ if (!fieldName) {
152
+ return {
153
+ found: false,
154
+ nonNullCount: 0,
155
+ nonZeroCount: 0,
156
+ totalRows: data.length,
157
+ };
158
+ }
159
+
160
+ const nonNullCount = data.filter(row => {
161
+ const value = row[fieldName];
162
+ return value && value !== '--' && value !== '';
163
+ }).length;
164
+
165
+ const nonZeroCount = data.filter(row => {
166
+ const value = row[fieldName];
167
+ if (!value || value === '--' || value === '') return false;
168
+ const parsed = parseFloat(value);
169
+ return !isNaN(parsed) && parsed !== 0;
170
+ }).length;
171
+
172
+ return {
173
+ found: true,
174
+ fieldName,
175
+ nonNullCount,
176
+ nonZeroCount,
177
+ totalRows: data.length,
178
+ };
179
+ }