Spaces:
Running
Running
predict today checkbox
Browse files- index.html +6 -0
- src/components/charts.ts +1 -1
- src/main.ts +50 -10
- src/utils/metricAcwr.ts +35 -5
index.html
CHANGED
|
@@ -23,6 +23,12 @@
|
|
| 23 |
<input type="number" id="target-acwr-input" value="1.3" min="0.5" max="3" step="0.1"
|
| 24 |
placeholder="1.3" />
|
| 25 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
<div class="ftp-input-container">
|
| 27 |
<label for="threshold-hr-input">Threshold HR</label>
|
| 28 |
<input type="number" id="threshold-hr-input" value="190" min="100" max="220" step="1"
|
|
|
|
| 23 |
<input type="number" id="target-acwr-input" value="1.3" min="0.5" max="3" step="0.1"
|
| 24 |
placeholder="1.3" />
|
| 25 |
</div>
|
| 26 |
+
<div class="ftp-input-container">
|
| 27 |
+
<label for="predict-today-input" style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
| 28 |
+
<input type="checkbox" id="predict-today-input" style="width: auto; margin: 0;" />
|
| 29 |
+
<span>Predict today</span>
|
| 30 |
+
</label>
|
| 31 |
+
</div>
|
| 32 |
<div class="ftp-input-container">
|
| 33 |
<label for="threshold-hr-input">Threshold HR</label>
|
| 34 |
<input type="number" id="threshold-hr-input" value="190" min="100" max="220" step="1"
|
src/components/charts.ts
CHANGED
|
@@ -59,7 +59,7 @@ const acwrGradientPlugin = {
|
|
| 59 |
// Find the ACWR dataset by label
|
| 60 |
const acwrDatasetIndex = chart.data.datasets.findIndex(ds => ds.label === 'ACWR');
|
| 61 |
if (acwrDatasetIndex === -1) return;
|
| 62 |
-
|
| 63 |
const meta = chart.getDatasetMeta(acwrDatasetIndex);
|
| 64 |
if (!meta || meta.hidden) return;
|
| 65 |
|
|
|
|
| 59 |
// Find the ACWR dataset by label
|
| 60 |
const acwrDatasetIndex = chart.data.datasets.findIndex(ds => ds.label === 'ACWR');
|
| 61 |
if (acwrDatasetIndex === -1) return;
|
| 62 |
+
|
| 63 |
const meta = chart.getDatasetMeta(acwrDatasetIndex);
|
| 64 |
if (!meta || meta.hidden) return;
|
| 65 |
|
src/main.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
| 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 thresholdHrInput = document.getElementById('threshold-hr-input') as HTMLInputElement;
|
| 18 |
const restingHrInput = document.getElementById('resting-hr-input') as HTMLInputElement;
|
| 19 |
const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
|
|
@@ -31,6 +32,7 @@ let selectedActivityTypes: Set<string> = new Set(['Running']);
|
|
| 31 |
// Event listeners
|
| 32 |
csvUpload?.addEventListener('change', handleFileUpload);
|
| 33 |
targetAcwrInput?.addEventListener('input', handleTargetAcwrChange);
|
|
|
|
| 34 |
helpButton?.addEventListener('click', () => helpPopover?.classList.remove('hidden'));
|
| 35 |
helpClose?.addEventListener('click', () => helpPopover?.classList.add('hidden'));
|
| 36 |
helpPopover?.addEventListener('click', (e) => {
|
|
@@ -49,7 +51,22 @@ function handleTargetAcwrChange(): void {
|
|
| 49 |
: allActivities;
|
| 50 |
|
| 51 |
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
function createActivityTypeFilters(activities: Activity[]): void {
|
|
@@ -129,7 +146,8 @@ function handleFilterChange(): void {
|
|
| 129 |
: allActivities;
|
| 130 |
|
| 131 |
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 132 |
-
|
|
|
|
| 133 |
}
|
| 134 |
|
| 135 |
async function handleFileUpload(event: Event): Promise<void> {
|
|
@@ -164,7 +182,8 @@ async function handleFileUpload(event: Event): Promise<void> {
|
|
| 164 |
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
|
| 165 |
: allActivities;
|
| 166 |
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 167 |
-
|
|
|
|
| 168 |
|
| 169 |
// Show filter and charts sections
|
| 170 |
filterSection.classList.remove('hidden');
|
|
@@ -182,15 +201,32 @@ async function handleFileUpload(event: Event): Promise<void> {
|
|
| 182 |
}
|
| 183 |
}
|
| 184 |
|
| 185 |
-
function renderCharts(activities: Activity[], targetAcwr: number): void {
|
| 186 |
// Calculate date range from first activity to today
|
| 187 |
let dateRange: { start: Date; end: Date } | undefined;
|
| 188 |
if (allActivities.length > 0) {
|
| 189 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
const endDate = new Date();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
endDate.setHours(23, 59, 59, 999);
|
| 192 |
|
| 193 |
-
// Calculate start date as 1.5 times chronic workload period (28 days) before
|
| 194 |
const startDate = new Date(endDate);
|
| 195 |
startDate.setDate(startDate.getDate() - Math.floor(28 * 1.5));
|
| 196 |
startDate.setHours(0, 0, 0, 0);
|
|
@@ -206,25 +242,29 @@ function renderCharts(activities: Activity[], targetAcwr: number): void {
|
|
| 206 |
activities,
|
| 207 |
(activity) => activity.distance,
|
| 208 |
dateRange,
|
| 209 |
-
targetAcwr
|
|
|
|
| 210 |
);
|
| 211 |
const durationData = calculateMetricACWR(
|
| 212 |
activities,
|
| 213 |
(activity) => activity.duration,
|
| 214 |
dateRange,
|
| 215 |
-
targetAcwr
|
|
|
|
| 216 |
);
|
| 217 |
const tssData = calculateMetricACWR(
|
| 218 |
activities,
|
| 219 |
(activity) => activity.trainingStressScore,
|
| 220 |
dateRange,
|
| 221 |
-
targetAcwr
|
|
|
|
| 222 |
);
|
| 223 |
const caloriesData = calculateMetricACWR(
|
| 224 |
activities,
|
| 225 |
(activity) => activity.calories,
|
| 226 |
dateRange,
|
| 227 |
-
targetAcwr
|
|
|
|
| 228 |
);
|
| 229 |
|
| 230 |
// Destroy existing charts
|
|
|
|
| 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 predictTodayInput = document.getElementById('predict-today-input') as HTMLInputElement;
|
| 18 |
const thresholdHrInput = document.getElementById('threshold-hr-input') as HTMLInputElement;
|
| 19 |
const restingHrInput = document.getElementById('resting-hr-input') as HTMLInputElement;
|
| 20 |
const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
|
|
|
|
| 32 |
// Event listeners
|
| 33 |
csvUpload?.addEventListener('change', handleFileUpload);
|
| 34 |
targetAcwrInput?.addEventListener('input', handleTargetAcwrChange);
|
| 35 |
+
predictTodayInput?.addEventListener('change', handlePredictTodayChange);
|
| 36 |
helpButton?.addEventListener('click', () => helpPopover?.classList.remove('hidden'));
|
| 37 |
helpClose?.addEventListener('click', () => helpPopover?.classList.add('hidden'));
|
| 38 |
helpPopover?.addEventListener('click', (e) => {
|
|
|
|
| 51 |
: allActivities;
|
| 52 |
|
| 53 |
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 54 |
+
const predictToday = predictTodayInput?.checked || false;
|
| 55 |
+
renderCharts(filteredActivities, targetAcwr, predictToday);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function handlePredictTodayChange(): void {
|
| 59 |
+
// Only refresh if we have activities loaded
|
| 60 |
+
if (allActivities.length === 0) return;
|
| 61 |
+
|
| 62 |
+
// Filter and render with new predict today setting
|
| 63 |
+
const filteredActivities = selectedActivityTypes.size > 0
|
| 64 |
+
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
|
| 65 |
+
: allActivities;
|
| 66 |
+
|
| 67 |
+
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 68 |
+
const predictToday = predictTodayInput?.checked || false;
|
| 69 |
+
renderCharts(filteredActivities, targetAcwr, predictToday);
|
| 70 |
}
|
| 71 |
|
| 72 |
function createActivityTypeFilters(activities: Activity[]): void {
|
|
|
|
| 146 |
: allActivities;
|
| 147 |
|
| 148 |
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 149 |
+
const predictToday = predictTodayInput?.checked || false;
|
| 150 |
+
renderCharts(filteredActivities, targetAcwr, predictToday);
|
| 151 |
}
|
| 152 |
|
| 153 |
async function handleFileUpload(event: Event): Promise<void> {
|
|
|
|
| 182 |
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
|
| 183 |
: allActivities;
|
| 184 |
const targetAcwr = parseFloat(targetAcwrInput.value) || 1.3;
|
| 185 |
+
const predictToday = predictTodayInput?.checked || false;
|
| 186 |
+
renderCharts(filteredActivities, targetAcwr, predictToday);
|
| 187 |
|
| 188 |
// Show filter and charts sections
|
| 189 |
filterSection.classList.remove('hidden');
|
|
|
|
| 201 |
}
|
| 202 |
}
|
| 203 |
|
| 204 |
+
function renderCharts(activities: Activity[], targetAcwr: number, predictToday: boolean = false): void {
|
| 205 |
// Calculate date range from first activity to today
|
| 206 |
let dateRange: { start: Date; end: Date } | undefined;
|
| 207 |
if (allActivities.length > 0) {
|
| 208 |
+
// Determine end date based on whether we'll include today in predictions
|
| 209 |
+
const today = new Date();
|
| 210 |
+
today.setHours(0, 0, 0, 0);
|
| 211 |
+
const todayStr = today.toISOString().split('T')[0];
|
| 212 |
+
|
| 213 |
+
// Check if today has activities
|
| 214 |
+
const todayHasActivities = activities.some(activity => {
|
| 215 |
+
const activityDate = new Date(activity.date);
|
| 216 |
+
activityDate.setHours(0, 0, 0, 0);
|
| 217 |
+
return activityDate.getTime() === today.getTime();
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
// If predictToday is checked and today has no activities, end range at yesterday
|
| 221 |
+
// This prevents today from appearing twice (once as null, once as prediction)
|
| 222 |
const endDate = new Date();
|
| 223 |
+
if (predictToday && !todayHasActivities) {
|
| 224 |
+
// End at yesterday so today only appears in predictions
|
| 225 |
+
endDate.setDate(endDate.getDate() - 1);
|
| 226 |
+
}
|
| 227 |
endDate.setHours(23, 59, 59, 999);
|
| 228 |
|
| 229 |
+
// Calculate start date as 1.5 times chronic workload period (28 days) before end date
|
| 230 |
const startDate = new Date(endDate);
|
| 231 |
startDate.setDate(startDate.getDate() - Math.floor(28 * 1.5));
|
| 232 |
startDate.setHours(0, 0, 0, 0);
|
|
|
|
| 242 |
activities,
|
| 243 |
(activity) => activity.distance,
|
| 244 |
dateRange,
|
| 245 |
+
targetAcwr,
|
| 246 |
+
predictToday
|
| 247 |
);
|
| 248 |
const durationData = calculateMetricACWR(
|
| 249 |
activities,
|
| 250 |
(activity) => activity.duration,
|
| 251 |
dateRange,
|
| 252 |
+
targetAcwr,
|
| 253 |
+
predictToday
|
| 254 |
);
|
| 255 |
const tssData = calculateMetricACWR(
|
| 256 |
activities,
|
| 257 |
(activity) => activity.trainingStressScore,
|
| 258 |
dateRange,
|
| 259 |
+
targetAcwr,
|
| 260 |
+
predictToday
|
| 261 |
);
|
| 262 |
const caloriesData = calculateMetricACWR(
|
| 263 |
activities,
|
| 264 |
(activity) => activity.calories,
|
| 265 |
dateRange,
|
| 266 |
+
targetAcwr,
|
| 267 |
+
predictToday
|
| 268 |
);
|
| 269 |
|
| 270 |
// Destroy existing charts
|
src/utils/metricAcwr.ts
CHANGED
|
@@ -13,12 +13,18 @@ function formatDateLocal(date: Date): string {
|
|
| 13 |
/**
|
| 14 |
* Calculate optimal activity values for the next 7 days to reach and maintain target ACWR
|
| 15 |
* Uses a greedy algorithm that optimizes each day to get closer to target ACWR
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
*/
|
| 17 |
function calculateOptimalFutureDays(
|
| 18 |
allDates: Date[],
|
| 19 |
dailyValues: Map<string, number>,
|
| 20 |
targetACWR: number,
|
| 21 |
-
numFutureDays: number = 7
|
|
|
|
| 22 |
): {
|
| 23 |
futureDates: string[];
|
| 24 |
futureValues: number[];
|
|
@@ -39,9 +45,12 @@ function calculateOptimalFutureDays(
|
|
| 39 |
// Get the last date in the data
|
| 40 |
const lastDate = new Date(allDates[allDates.length - 1]);
|
| 41 |
|
| 42 |
-
for (let dayOffset =
|
| 43 |
const futureDate = new Date(lastDate);
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
| 45 |
const futureDateStr = formatDateLocal(futureDate);
|
| 46 |
|
| 47 |
futureDates.push(futureDateStr);
|
|
@@ -128,12 +137,15 @@ function calculateOptimalFutureDays(
|
|
| 128 |
* @param activities - Array of activities
|
| 129 |
* @param metricExtractor - Function to extract the metric value from an activity
|
| 130 |
* @param dateRange - Optional date range to maintain consistency
|
|
|
|
|
|
|
| 131 |
*/
|
| 132 |
export function calculateMetricACWR(
|
| 133 |
activities: Activity[],
|
| 134 |
metricExtractor: (activity: Activity) => number | undefined,
|
| 135 |
dateRange?: { start: Date; end: Date },
|
| 136 |
-
targetACWR: number = 1.3
|
|
|
|
| 137 |
): MetricACWRData {
|
| 138 |
if (activities.length === 0 && !dateRange) {
|
| 139 |
return {
|
|
@@ -341,6 +353,24 @@ export function calculateMetricACWR(
|
|
| 341 |
todayValue: undefined,
|
| 342 |
activitiesByDate,
|
| 343 |
// Add future predictions if we have enough data
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
};
|
| 346 |
}
|
|
|
|
| 13 |
/**
|
| 14 |
* Calculate optimal activity values for the next 7 days to reach and maintain target ACWR
|
| 15 |
* Uses a greedy algorithm that optimizes each day to get closer to target ACWR
|
| 16 |
+
* @param allDates - Array of all historical dates
|
| 17 |
+
* @param dailyValues - Map of date strings to activity values
|
| 18 |
+
* @param targetACWR - Target ACWR to achieve
|
| 19 |
+
* @param numFutureDays - Number of future days to predict (default 7)
|
| 20 |
+
* @param startOffset - Day offset to start predictions (0 for today, 1 for tomorrow)
|
| 21 |
*/
|
| 22 |
function calculateOptimalFutureDays(
|
| 23 |
allDates: Date[],
|
| 24 |
dailyValues: Map<string, number>,
|
| 25 |
targetACWR: number,
|
| 26 |
+
numFutureDays: number = 7,
|
| 27 |
+
startOffset: number = 1
|
| 28 |
): {
|
| 29 |
futureDates: string[];
|
| 30 |
futureValues: number[];
|
|
|
|
| 45 |
// Get the last date in the data
|
| 46 |
const lastDate = new Date(allDates[allDates.length - 1]);
|
| 47 |
|
| 48 |
+
for (let dayOffset = 0; dayOffset < numFutureDays; dayOffset++) {
|
| 49 |
const futureDate = new Date(lastDate);
|
| 50 |
+
// Add 1 + startOffset + dayOffset to go beyond lastDate
|
| 51 |
+
// startOffset=0: start from day after lastDate (tomorrow if lastDate is today)
|
| 52 |
+
// startOffset=1: skip one day, start from 2 days after lastDate
|
| 53 |
+
futureDate.setDate(lastDate.getDate() + 1 + startOffset + dayOffset);
|
| 54 |
const futureDateStr = formatDateLocal(futureDate);
|
| 55 |
|
| 56 |
futureDates.push(futureDateStr);
|
|
|
|
| 137 |
* @param activities - Array of activities
|
| 138 |
* @param metricExtractor - Function to extract the metric value from an activity
|
| 139 |
* @param dateRange - Optional date range to maintain consistency
|
| 140 |
+
* @param targetACWR - Target ACWR value for predictions
|
| 141 |
+
* @param predictToday - If true, include today in predictions; if false, predictions start tomorrow
|
| 142 |
*/
|
| 143 |
export function calculateMetricACWR(
|
| 144 |
activities: Activity[],
|
| 145 |
metricExtractor: (activity: Activity) => number | undefined,
|
| 146 |
dateRange?: { start: Date; end: Date },
|
| 147 |
+
targetACWR: number = 1.3,
|
| 148 |
+
predictToday: boolean = false
|
| 149 |
): MetricACWRData {
|
| 150 |
if (activities.length === 0 && !dateRange) {
|
| 151 |
return {
|
|
|
|
| 353 |
todayValue: undefined,
|
| 354 |
activitiesByDate,
|
| 355 |
// Add future predictions if we have enough data
|
| 356 |
+
// Determine start offset based on predictToday and whether today has activities
|
| 357 |
+
...(allDates.length >= 28 ? (() => {
|
| 358 |
+
const today = new Date();
|
| 359 |
+
const todayStr = formatDateLocal(today);
|
| 360 |
+
const lastDateStr = formatDateLocal(allDates[allDates.length - 1]);
|
| 361 |
+
|
| 362 |
+
// Check if today is the last date in our data and if it has any activities
|
| 363 |
+
const todayHasActivities = todayStr === lastDateStr && dailyValues.has(todayStr);
|
| 364 |
+
|
| 365 |
+
// startOffset determines which day to start predictions from relative to lastDate
|
| 366 |
+
// startOffset=0: predictions start from lastDate + 1 (next day after last data)
|
| 367 |
+
// startOffset=1: predictions start from lastDate + 2 (skip one day)
|
| 368 |
+
// predictToday unchecked: start from next day (offset 0)
|
| 369 |
+
// predictToday checked and today has activities: start from next day (offset 0)
|
| 370 |
+
// predictToday checked and today has no activities: start from next day (offset 0), but lastDate was yesterday
|
| 371 |
+
const startOffset = 0;
|
| 372 |
+
|
| 373 |
+
return calculateOptimalFutureDays(allDates, dailyValues, targetACWR, 7, startOffset);
|
| 374 |
+
})() : {}),
|
| 375 |
};
|
| 376 |
}
|