glutamatt HF Staff commited on
Commit
bccd229
·
verified ·
1 Parent(s): 0f92957

target, details

Browse files
index.html CHANGED
@@ -28,6 +28,13 @@
28
  <input type="number" id="ftp-input" value="343" min="0" step="1" placeholder="343" />
29
  <span class="ftp-unit">Watts</span>
30
  </div>
 
 
 
 
 
 
 
31
  <input type="file" id="csv-upload" accept=".csv" />
32
  <label for="csv-upload" class="upload-label">
33
  Choose CSV File
@@ -128,6 +135,14 @@
128
  </div>
129
  </div>
130
 
 
 
 
 
 
 
 
 
131
  <script type="module" src="/src/main.ts"></script>
132
  </body>
133
 
 
28
  <input type="number" id="ftp-input" value="343" min="0" step="1" placeholder="343" />
29
  <span class="ftp-unit">Watts</span>
30
  </div>
31
+ <div class="ftp-input-container">
32
+ <label for="target-acwr-input">
33
+ Target ACWR
34
+ </label>
35
+ <input type="number" id="target-acwr-input" value="1.3" min="0.5" max="3" step="0.1" placeholder="1.3" />
36
+ <span class="ftp-unit"></span>
37
+ </div>
38
  <input type="file" id="csv-upload" accept=".csv" />
39
  <label for="csv-upload" class="upload-label">
40
  Choose CSV File
 
135
  </div>
136
  </div>
137
 
138
+ <div id="activity-popover" class="activity-popover hidden">
139
+ <div class="activity-popover-content">
140
+ <button id="activity-close" class="help-close">&times;</button>
141
+ <h2 id="activity-date-title"></h2>
142
+ <div id="activity-list"></div>
143
+ </div>
144
+ </div>
145
+
146
  <script type="module" src="/src/main.ts"></script>
147
  </body>
148
 
src/components/charts.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { Chart, ChartConfiguration, registerables } from 'chart.js';
2
- import type { MetricACWRData } from '@/types';
3
 
4
  // Register all Chart.js components
5
  Chart.register(...registerables);
@@ -96,6 +96,143 @@ const acwrGradientPlugin = {
96
  // Register the custom plugin
97
  Chart.register(acwrGradientPlugin);
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  // Common chart styling inspired by Garmin
100
  const commonOptions = {
101
  responsive: true,
@@ -220,6 +357,26 @@ function createDualAxisChart(
220
  },
221
  options: {
222
  ...commonOptions,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  scales: {
224
  ...commonOptions.scales,
225
  y: {
 
1
  import { Chart, ChartConfiguration, registerables } from 'chart.js';
2
+ import type { MetricACWRData, Activity } from '@/types';
3
 
4
  // Register all Chart.js components
5
  Chart.register(...registerables);
 
96
  // Register the custom plugin
97
  Chart.register(acwrGradientPlugin);
98
 
99
+ // Function to get activity emoji based on type
100
+ function getActivityEmoji(activityType: string): string {
101
+ const type = activityType.toLowerCase();
102
+ if (type.includes('run')) return '🏃';
103
+ if (type.includes('cycling') || type.includes('bike') || type.includes('ride')) return '🚴';
104
+ if (type.includes('swim')) return '🏊';
105
+ if (type.includes('walk') || type.includes('hiking')) return '🚶';
106
+ if (type.includes('strength') || type.includes('gym')) return '💪';
107
+ if (type.includes('yoga')) return '🧘';
108
+ if (type.includes('row')) return '🚣';
109
+ if (type.includes('ski')) return '⛷️';
110
+ return '⚡'; // Default for other activities
111
+ }
112
+
113
+ // Function to show activity details popover
114
+ function showActivityDetails(dateStr: string, activities: Activity[]): void {
115
+ const popover = document.getElementById('activity-popover');
116
+ const dateTitle = document.getElementById('activity-date-title');
117
+ const activityList = document.getElementById('activity-list');
118
+
119
+ if (!popover || !dateTitle || !activityList) return;
120
+
121
+ // Format date
122
+ const date = new Date(dateStr);
123
+ const formattedDate = date.toLocaleDateString('en-US', {
124
+ weekday: 'long',
125
+ year: 'numeric',
126
+ month: 'long',
127
+ day: 'numeric'
128
+ });
129
+
130
+ dateTitle.textContent = formattedDate;
131
+
132
+ // Clear previous activities
133
+ activityList.innerHTML = '';
134
+
135
+ // Add each activity
136
+ activities.forEach(activity => {
137
+ const activityItem = document.createElement('div');
138
+ activityItem.className = 'activity-item';
139
+
140
+ const displayName = activity.title || activity.activityType || 'Activity';
141
+ const emoji = getActivityEmoji(activity.activityType || 'Activity');
142
+
143
+ const header = document.createElement('div');
144
+ header.className = 'activity-item-header';
145
+ header.innerHTML = `<span>${emoji}</span><span>${displayName}</span>`;
146
+
147
+ const details = document.createElement('div');
148
+ details.className = 'activity-item-details';
149
+
150
+ // Distance
151
+ if (activity.distance !== undefined && activity.distance > 0) {
152
+ const distanceDetail = document.createElement('div');
153
+ distanceDetail.className = 'activity-detail';
154
+ distanceDetail.innerHTML = `
155
+ <span class="activity-detail-label">Distance</span>
156
+ <span class="activity-detail-value">${activity.distance.toFixed(2)} km</span>
157
+ `;
158
+ details.appendChild(distanceDetail);
159
+ }
160
+
161
+ // Duration
162
+ if (activity.duration !== undefined && activity.duration > 0) {
163
+ const durationDetail = document.createElement('div');
164
+ durationDetail.className = 'activity-detail';
165
+ const durationMin = Math.round(activity.duration);
166
+ durationDetail.innerHTML = `
167
+ <span class="activity-detail-label">Duration</span>
168
+ <span class="activity-detail-value">${durationMin} min</span>
169
+ `;
170
+ details.appendChild(durationDetail);
171
+ }
172
+
173
+ // TSS
174
+ if (activity.trainingStressScore !== undefined && activity.trainingStressScore > 0) {
175
+ const tssDetail = document.createElement('div');
176
+ tssDetail.className = 'activity-detail';
177
+ tssDetail.innerHTML = `
178
+ <span class="activity-detail-label">TSS</span>
179
+ <span class="activity-detail-value">${activity.trainingStressScore.toFixed(0)}</span>
180
+ `;
181
+ details.appendChild(tssDetail);
182
+ }
183
+
184
+ // Calories
185
+ if (activity.calories !== undefined && activity.calories > 0) {
186
+ const caloriesDetail = document.createElement('div');
187
+ caloriesDetail.className = 'activity-detail';
188
+ caloriesDetail.innerHTML = `
189
+ <span class="activity-detail-label">Calories</span>
190
+ <span class="activity-detail-value">${activity.calories.toFixed(0)} kcal</span>
191
+ `;
192
+ details.appendChild(caloriesDetail);
193
+ }
194
+
195
+ activityItem.appendChild(header);
196
+ activityItem.appendChild(details);
197
+ activityList.appendChild(activityItem);
198
+ });
199
+
200
+ // Show popover
201
+ popover.classList.remove('hidden');
202
+ }
203
+
204
+ // Setup activity popover close handlers
205
+ function setupActivityPopoverHandlers(): void {
206
+ const popover = document.getElementById('activity-popover');
207
+ const closeButton = document.getElementById('activity-close');
208
+
209
+ if (!popover || !closeButton) return;
210
+
211
+ // Close button click
212
+ closeButton.addEventListener('click', () => {
213
+ popover.classList.add('hidden');
214
+ });
215
+
216
+ // Click outside to close
217
+ popover.addEventListener('click', (e) => {
218
+ if (e.target === popover) {
219
+ popover.classList.add('hidden');
220
+ }
221
+ });
222
+
223
+ // Escape key to close
224
+ document.addEventListener('keydown', (e) => {
225
+ if (e.key === 'Escape' && !popover.classList.contains('hidden')) {
226
+ popover.classList.add('hidden');
227
+ }
228
+ });
229
+ }
230
+
231
+ // Initialize handlers on load
232
+ if (typeof window !== 'undefined') {
233
+ setupActivityPopoverHandlers();
234
+ }
235
+
236
  // Common chart styling inspired by Garmin
237
  const commonOptions = {
238
  responsive: true,
 
357
  },
358
  options: {
359
  ...commonOptions,
360
+ onClick: (_event: any, elements: any[]) => {
361
+ if (elements.length > 0) {
362
+ const element = elements[0];
363
+ const datasetIndex = element.datasetIndex;
364
+
365
+ // Only handle clicks on the scatter (daily values) dataset
366
+ if (datasetIndex === 0) {
367
+ const index = element.index;
368
+ const dateStr = data.dates[index];
369
+
370
+ // Get activities for this date
371
+ if (data.activitiesByDate) {
372
+ const activities = data.activitiesByDate.get(dateStr);
373
+ if (activities && activities.length > 0) {
374
+ showActivityDetails(dateStr, activities);
375
+ }
376
+ }
377
+ }
378
+ }
379
+ },
380
  scales: {
381
  ...commonOptions.scales,
382
  y: {
src/main.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
  // DOM elements
14
  const csvUpload = document.getElementById('csv-upload') as HTMLInputElement;
15
  const ftpInput = document.getElementById('ftp-input') as HTMLInputElement;
 
16
  const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
17
  const chartsSection = document.getElementById('charts-section') as HTMLElement;
18
  const filterSection = document.getElementById('filter-section') as HTMLElement;
@@ -110,8 +111,9 @@ function handleFilterChange(): void {
110
  const filteredActivities = selectedActivityTypes.size > 0
111
  ? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
112
  : allActivities;
113
-
114
- renderCharts(filteredActivities);
 
115
  }
116
 
117
  async function handleFileUpload(event: Event): Promise<void> {
@@ -143,7 +145,8 @@ async function handleFileUpload(event: Event): Promise<void> {
143
  const filteredActivities = selectedActivityTypes.size > 0
144
  ? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
145
  : allActivities;
146
- renderCharts(filteredActivities);
 
147
 
148
  // Show filter and charts sections
149
  filterSection.classList.remove('hidden');
@@ -161,7 +164,7 @@ async function handleFileUpload(event: Event): Promise<void> {
161
  }
162
  }
163
 
164
- function renderCharts(activities: Activity[]): void {
165
  // Calculate date range from all activities for consistency
166
  let dateRange: { start: Date; end: Date } | undefined;
167
  if (allActivities.length > 0) {
@@ -181,22 +184,26 @@ function renderCharts(activities: Activity[]): void {
181
  const distanceData = calculateMetricACWR(
182
  activities,
183
  (activity) => activity.distance,
184
- dateRange
 
185
  );
186
  const durationData = calculateMetricACWR(
187
  activities,
188
  (activity) => activity.duration,
189
- dateRange
 
190
  );
191
  const tssData = calculateMetricACWR(
192
  activities,
193
  (activity) => activity.trainingStressScore,
194
- dateRange
 
195
  );
196
  const caloriesData = calculateMetricACWR(
197
  activities,
198
  (activity) => activity.calories,
199
- dateRange
 
200
  );
201
 
202
  // Destroy existing charts
 
13
  // DOM elements
14
  const csvUpload = document.getElementById('csv-upload') as HTMLInputElement;
15
  const ftpInput = document.getElementById('ftp-input') as HTMLInputElement;
16
+ const targetAcwrInput = document.getElementById('target-acwr-input') as HTMLInputElement;
17
  const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
18
  const chartsSection = document.getElementById('charts-section') as HTMLElement;
19
  const filterSection = document.getElementById('filter-section') as HTMLElement;
 
111
  const filteredActivities = selectedActivityTypes.size > 0
112
  ? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
113
  : allActivities;
114
+
115
+ const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
116
+ renderCharts(filteredActivities, targetAcwr);
117
  }
118
 
119
  async function handleFileUpload(event: Event): Promise<void> {
 
145
  const filteredActivities = selectedActivityTypes.size > 0
146
  ? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
147
  : allActivities;
148
+ const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
149
+ renderCharts(filteredActivities, targetAcwr);
150
 
151
  // Show filter and charts sections
152
  filterSection.classList.remove('hidden');
 
164
  }
165
  }
166
 
167
+ function renderCharts(activities: Activity[], targetAcwr: number): void {
168
  // Calculate date range from all activities for consistency
169
  let dateRange: { start: Date; end: Date } | undefined;
170
  if (allActivities.length > 0) {
 
184
  const distanceData = calculateMetricACWR(
185
  activities,
186
  (activity) => activity.distance,
187
+ dateRange,
188
+ targetAcwr
189
  );
190
  const durationData = calculateMetricACWR(
191
  activities,
192
  (activity) => activity.duration,
193
+ dateRange,
194
+ targetAcwr
195
  );
196
  const tssData = calculateMetricACWR(
197
  activities,
198
  (activity) => activity.trainingStressScore,
199
+ dateRange,
200
+ targetAcwr
201
  );
202
  const caloriesData = calculateMetricACWR(
203
  activities,
204
  (activity) => activity.calories,
205
+ dateRange,
206
+ targetAcwr
207
  );
208
 
209
  // Destroy existing charts
src/style.css CHANGED
@@ -166,6 +166,89 @@ header h1 {
166
  font-weight: 600;
167
  }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  main {
170
  flex: 1;
171
  padding: 2rem;
 
166
  font-weight: 600;
167
  }
168
 
169
+ .activity-popover {
170
+ position: fixed;
171
+ top: 0;
172
+ left: 0;
173
+ width: 100%;
174
+ height: 100%;
175
+ background-color: rgba(0, 0, 0, 0.5);
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ z-index: 1000;
180
+ padding: 1rem;
181
+ }
182
+
183
+ .activity-popover.hidden {
184
+ display: none;
185
+ }
186
+
187
+ .activity-popover-content {
188
+ background-color: var(--card-bg);
189
+ border-radius: 12px;
190
+ padding: 2rem;
191
+ max-width: 600px;
192
+ max-height: 90vh;
193
+ overflow-y: auto;
194
+ position: relative;
195
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
196
+ }
197
+
198
+ #activity-date-title {
199
+ margin-bottom: 1.5rem;
200
+ color: var(--primary-color);
201
+ font-size: 1.5rem;
202
+ }
203
+
204
+ #activity-list {
205
+ display: flex;
206
+ flex-direction: column;
207
+ gap: 1rem;
208
+ }
209
+
210
+ .activity-item {
211
+ background-color: var(--bg-color);
212
+ border-radius: 8px;
213
+ padding: 1rem;
214
+ border-left: 4px solid var(--primary-color);
215
+ }
216
+
217
+ .activity-item-header {
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 0.5rem;
221
+ margin-bottom: 0.75rem;
222
+ font-size: 1.125rem;
223
+ font-weight: 600;
224
+ color: var(--text-color);
225
+ }
226
+
227
+ .activity-item-details {
228
+ display: grid;
229
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
230
+ gap: 0.5rem;
231
+ font-size: 0.875rem;
232
+ }
233
+
234
+ .activity-detail {
235
+ display: flex;
236
+ flex-direction: column;
237
+ }
238
+
239
+ .activity-detail-label {
240
+ color: var(--secondary-color);
241
+ font-size: 0.75rem;
242
+ text-transform: uppercase;
243
+ letter-spacing: 0.5px;
244
+ }
245
+
246
+ .activity-detail-value {
247
+ color: var(--text-color);
248
+ font-weight: 600;
249
+ font-size: 1rem;
250
+ }
251
+
252
  main {
253
  flex: 1;
254
  padding: 2rem;
src/types/index.ts CHANGED
@@ -1,6 +1,7 @@
1
  export interface Activity {
2
  date: Date;
3
  activityType?: string;
 
4
  distance?: number; // in kilometers
5
  duration?: number; // in minutes
6
  trainingStressScore?: number;
@@ -23,4 +24,5 @@ export interface MetricACWRData {
23
  acwr: (number | null)[]; // ACWR based on this metric
24
  targetTomorrowValue?: number | null; // Value needed tomorrow to reach target ACWR of 1.3
25
  targetACWR?: number; // The target ACWR value used for calculation
 
26
  }
 
1
  export interface Activity {
2
  date: Date;
3
  activityType?: string;
4
+ title?: string;
5
  distance?: number; // in kilometers
6
  duration?: number; // in minutes
7
  trainingStressScore?: number;
 
24
  acwr: (number | null)[]; // ACWR based on this metric
25
  targetTomorrowValue?: number | null; // Value needed tomorrow to reach target ACWR of 1.3
26
  targetACWR?: number; // The target ACWR value used for calculation
27
+ activitiesByDate?: Map<string, Activity[]>; // Activities grouped by date
28
  }
src/utils/csvParser.ts CHANGED
@@ -117,6 +117,9 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
117
  // Get activity type
118
  const activityType = row['Activity Type'] || row['Type'] || row['Sport'];
119
 
 
 
 
120
  // Parse calories
121
  const caloriesStr = row['Calories'];
122
  let calories: number | undefined;
@@ -130,6 +133,7 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
130
  const activity: Activity = {
131
  date,
132
  activityType,
 
133
  distance,
134
  duration,
135
  trainingStressScore,
 
117
  // Get activity type
118
  const activityType = row['Activity Type'] || row['Type'] || row['Sport'];
119
 
120
+ // Get activity title
121
+ const title = row['Title'] || row['Activity Name'] || row['Name'];
122
+
123
  // Parse calories
124
  const caloriesStr = row['Calories'];
125
  let calories: number | undefined;
 
133
  const activity: Activity = {
134
  date,
135
  activityType,
136
+ title,
137
  distance,
138
  duration,
139
  trainingStressScore,
src/utils/metricAcwr.ts CHANGED
@@ -19,7 +19,8 @@ function formatDateLocal(date: Date): string {
19
  export function calculateMetricACWR(
20
  activities: Activity[],
21
  metricExtractor: (activity: Activity) => number | undefined,
22
- dateRange?: { start: Date; end: Date }
 
23
  ): MetricACWRData {
24
  if (activities.length === 0 && !dateRange) {
25
  return {
@@ -40,12 +41,21 @@ export function calculateMetricACWR(
40
 
41
  // Create a map of date -> daily sum
42
  const dailyValues = new Map<string, number>();
 
 
 
43
  sortedActivities.forEach(activity => {
44
  const dateStr = formatDateLocal(activity.date);
45
  const value = metricExtractor(activity);
46
  if (value !== undefined) {
47
  dailyValues.set(dateStr, (dailyValues.get(dateStr) || 0) + value);
48
  }
 
 
 
 
 
 
49
  });
50
 
51
  const dates: string[] = [];
@@ -124,7 +134,6 @@ export function calculateMetricACWR(
124
 
125
  // Calculate tomorrow's required value to reach ACWR
126
  let targetTomorrowValue: number | null = null;
127
- const targetACWR = 1.3;
128
 
129
  if (allDates.length >= 28) {
130
 
@@ -179,5 +188,6 @@ export function calculateMetricACWR(
179
  acwr,
180
  targetTomorrowValue,
181
  targetACWR: allDates.length >= 28 ? targetACWR : undefined,
 
182
  };
183
  }
 
19
  export function calculateMetricACWR(
20
  activities: Activity[],
21
  metricExtractor: (activity: Activity) => number | undefined,
22
+ dateRange?: { start: Date; end: Date },
23
+ targetACWR: number = 1.3
24
  ): MetricACWRData {
25
  if (activities.length === 0 && !dateRange) {
26
  return {
 
41
 
42
  // Create a map of date -> daily sum
43
  const dailyValues = new Map<string, number>();
44
+ // Create a map of date -> activities
45
+ const activitiesByDate = new Map<string, Activity[]>();
46
+
47
  sortedActivities.forEach(activity => {
48
  const dateStr = formatDateLocal(activity.date);
49
  const value = metricExtractor(activity);
50
  if (value !== undefined) {
51
  dailyValues.set(dateStr, (dailyValues.get(dateStr) || 0) + value);
52
  }
53
+
54
+ // Group activities by date
55
+ if (!activitiesByDate.has(dateStr)) {
56
+ activitiesByDate.set(dateStr, []);
57
+ }
58
+ activitiesByDate.get(dateStr)!.push(activity);
59
  });
60
 
61
  const dates: string[] = [];
 
134
 
135
  // Calculate tomorrow's required value to reach ACWR
136
  let targetTomorrowValue: number | null = null;
 
137
 
138
  if (allDates.length >= 28) {
139
 
 
188
  acwr,
189
  targetTomorrowValue,
190
  targetACWR: allDates.length >= 28 ? targetACWR : undefined,
191
+ activitiesByDate,
192
  };
193
  }