Spaces:
Running
Running
target, details
Browse files- index.html +15 -0
- src/components/charts.ts +158 -1
- src/main.ts +15 -8
- src/style.css +83 -0
- src/types/index.ts +2 -0
- src/utils/csvParser.ts +4 -0
- src/utils/metricAcwr.ts +12 -2
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">×</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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
}
|