Spaces:
Running
Running
dev with hot reload , TSS fallback computation from HR , UI
Browse files- Dockerfile.dev +17 -0
- README.md +14 -1
- docker-compose.dev.yml +17 -0
- index.html +10 -0
- src/components/charts.ts +22 -0
- src/main.ts +7 -3
- src/style.css +1 -1
- src/types/index.ts +3 -0
- src/utils/csvParser.ts +44 -1
- vite.config.ts +1 -1
Dockerfile.dev
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Development Dockerfile with hot reload
|
| 2 |
+
FROM node:20-alpine
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy package files
|
| 7 |
+
COPY package*.json ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm install
|
| 11 |
+
|
| 12 |
+
# Expose Vite dev server port
|
| 13 |
+
EXPOSE 5173
|
| 14 |
+
|
| 15 |
+
# Start Vite dev server
|
| 16 |
+
# Source files will be mounted via docker-compose volumes
|
| 17 |
+
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
README.md
CHANGED
|
@@ -62,6 +62,8 @@ docker run -d -p 8080:8080 --name training-load-app training-load-dataviz
|
|
| 62 |
The app will be available at `http://localhost:8080`
|
| 63 |
|
| 64 |
**Using Docker Compose (recommended):**
|
|
|
|
|
|
|
| 65 |
```bash
|
| 66 |
# Start the application
|
| 67 |
docker compose up -d
|
|
@@ -75,9 +77,20 @@ docker compose down
|
|
| 75 |
|
| 76 |
The app will be available at `http://localhost:8080`
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
**Docker Commands:**
|
| 79 |
```bash
|
| 80 |
-
# Rebuild after code changes
|
| 81 |
docker compose up -d --build
|
| 82 |
|
| 83 |
# Stop and remove containers
|
|
|
|
| 62 |
The app will be available at `http://localhost:8080`
|
| 63 |
|
| 64 |
**Using Docker Compose (recommended):**
|
| 65 |
+
|
| 66 |
+
**Production Mode:**
|
| 67 |
```bash
|
| 68 |
# Start the application
|
| 69 |
docker compose up -d
|
|
|
|
| 77 |
|
| 78 |
The app will be available at `http://localhost:8080`
|
| 79 |
|
| 80 |
+
**Development Mode (with hot reload):**
|
| 81 |
+
```bash
|
| 82 |
+
# Start in development mode with hot reload
|
| 83 |
+
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
| 84 |
+
|
| 85 |
+
# Rebuild dev container after package.json changes
|
| 86 |
+
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
The dev server will be available at `http://localhost:5173` with hot reload enabled. Changes to `src/`, `index.html`, or config files will automatically refresh the browser.
|
| 90 |
+
|
| 91 |
**Docker Commands:**
|
| 92 |
```bash
|
| 93 |
+
# Rebuild after code changes (production)
|
| 94 |
docker compose up -d --build
|
| 95 |
|
| 96 |
# Stop and remove containers
|
docker-compose.dev.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
training-load-app:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile.dev
|
| 8 |
+
ports:
|
| 9 |
+
- "5173:5173"
|
| 10 |
+
volumes:
|
| 11 |
+
- ./src:/app/src
|
| 12 |
+
- ./index.html:/app/index.html
|
| 13 |
+
- ./vite.config.ts:/app/vite.config.ts
|
| 14 |
+
- ./tsconfig.json:/app/tsconfig.json
|
| 15 |
+
environment:
|
| 16 |
+
- NODE_ENV=development
|
| 17 |
+
command: npm run dev -- --host 0.0.0.0
|
index.html
CHANGED
|
@@ -22,6 +22,16 @@
|
|
| 22 |
<label for="target-acwr-input">Target ACWR</label>
|
| 23 |
<input type="number" id="target-acwr-input" value="1.3" min="0.5" max="3" step="0.1" placeholder="1.3" />
|
| 24 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
<input type="file" id="csv-upload" accept=".csv" />
|
| 26 |
<label for="csv-upload" class="upload-label">
|
| 27 |
📁 Upload CSV
|
|
|
|
| 22 |
<label for="target-acwr-input">Target ACWR</label>
|
| 23 |
<input type="number" id="target-acwr-input" value="1.3" min="0.5" max="3" step="0.1" placeholder="1.3" />
|
| 24 |
</div>
|
| 25 |
+
<div class="ftp-input-container">
|
| 26 |
+
<label for="threshold-hr-input">Threshold HR</label>
|
| 27 |
+
<input type="number" id="threshold-hr-input" value="170" min="100" max="220" step="1" placeholder="170" />
|
| 28 |
+
<span class="ftp-unit">bpm</span>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="ftp-input-container">
|
| 31 |
+
<label for="resting-hr-input">Resting HR</label>
|
| 32 |
+
<input type="number" id="resting-hr-input" value="50" min="30" max="100" step="1" placeholder="50" />
|
| 33 |
+
<span class="ftp-unit">bpm</span>
|
| 34 |
+
</div>
|
| 35 |
<input type="file" id="csv-upload" accept=".csv" />
|
| 36 |
<label for="csv-upload" class="upload-label">
|
| 37 |
📁 Upload CSV
|
src/components/charts.ts
CHANGED
|
@@ -192,6 +192,28 @@ function showActivityDetails(dateStr: string, activities: Activity[]): void {
|
|
| 192 |
details.appendChild(caloriesDetail);
|
| 193 |
}
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
activityItem.appendChild(header);
|
| 196 |
activityItem.appendChild(details);
|
| 197 |
activityList.appendChild(activityItem);
|
|
|
|
| 192 |
details.appendChild(caloriesDetail);
|
| 193 |
}
|
| 194 |
|
| 195 |
+
// Average HR
|
| 196 |
+
if (activity.averageHR !== undefined && activity.averageHR > 0) {
|
| 197 |
+
const avgHRDetail = document.createElement('div');
|
| 198 |
+
avgHRDetail.className = 'activity-detail';
|
| 199 |
+
avgHRDetail.innerHTML = `
|
| 200 |
+
<span class="activity-detail-label">Avg HR</span>
|
| 201 |
+
<span class="activity-detail-value">${activity.averageHR.toFixed(0)} bpm</span>
|
| 202 |
+
`;
|
| 203 |
+
details.appendChild(avgHRDetail);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// Max HR
|
| 207 |
+
if (activity.maxHR !== undefined && activity.maxHR > 0) {
|
| 208 |
+
const maxHRDetail = document.createElement('div');
|
| 209 |
+
maxHRDetail.className = 'activity-detail';
|
| 210 |
+
maxHRDetail.innerHTML = `
|
| 211 |
+
<span class="activity-detail-label">Max HR</span>
|
| 212 |
+
<span class="activity-detail-value">${activity.maxHR.toFixed(0)} bpm</span>
|
| 213 |
+
`;
|
| 214 |
+
details.appendChild(maxHRDetail);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
activityItem.appendChild(header);
|
| 218 |
activityItem.appendChild(details);
|
| 219 |
activityList.appendChild(activityItem);
|
src/main.ts
CHANGED
|
@@ -14,6 +14,8 @@ 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 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;
|
|
@@ -128,11 +130,13 @@ async function handleFileUpload(event: Event): Promise<void> {
|
|
| 128 |
uploadStatus.className = '';
|
| 129 |
|
| 130 |
try {
|
| 131 |
-
// Get FTP
|
| 132 |
const ftp = parseInt(ftpInput.value) || 343;
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
// Parse CSV with user-provided FTP
|
| 135 |
-
allActivities = await parseCSV(file, ftp);
|
| 136 |
|
| 137 |
if (allActivities.length === 0) {
|
| 138 |
throw new Error('No valid activities found in the CSV file');
|
|
|
|
| 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;
|
| 20 |
const chartsSection = document.getElementById('charts-section') as HTMLElement;
|
| 21 |
const filterSection = document.getElementById('filter-section') as HTMLElement;
|
|
|
|
| 130 |
uploadStatus.className = '';
|
| 131 |
|
| 132 |
try {
|
| 133 |
+
// Get FTP and HR values from inputs
|
| 134 |
const ftp = parseInt(ftpInput.value) || 343;
|
| 135 |
+
const thresholdHR = parseInt(thresholdHrInput.value) || 170;
|
| 136 |
+
const restingHR = parseInt(restingHrInput.value) || 50;
|
| 137 |
|
| 138 |
+
// Parse CSV with user-provided FTP and HR values
|
| 139 |
+
allActivities = await parseCSV(file, ftp, thresholdHR, restingHR);
|
| 140 |
|
| 141 |
if (allActivities.length === 0) {
|
| 142 |
throw new Error('No valid activities found in the CSV file');
|
src/style.css
CHANGED
|
@@ -197,7 +197,7 @@ header h1 {
|
|
| 197 |
background-color: var(--card-bg);
|
| 198 |
border-radius: 12px;
|
| 199 |
padding: 2rem;
|
| 200 |
-
max-width:
|
| 201 |
max-height: 90vh;
|
| 202 |
overflow-y: auto;
|
| 203 |
position: relative;
|
|
|
|
| 197 |
background-color: var(--card-bg);
|
| 198 |
border-radius: 12px;
|
| 199 |
padding: 2rem;
|
| 200 |
+
max-width: 800px;
|
| 201 |
max-height: 90vh;
|
| 202 |
overflow-y: auto;
|
| 203 |
position: relative;
|
src/types/index.ts
CHANGED
|
@@ -6,6 +6,9 @@ export interface Activity {
|
|
| 6 |
duration?: number; // in minutes
|
| 7 |
trainingStressScore?: number;
|
| 8 |
calories?: number;
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
export interface ProcessedData {
|
|
|
|
| 6 |
duration?: number; // in minutes
|
| 7 |
trainingStressScore?: number;
|
| 8 |
calories?: number;
|
| 9 |
+
averageHR?: number;
|
| 10 |
+
maxHR?: number;
|
| 11 |
+
totalAscent?: number; // in meters
|
| 12 |
}
|
| 13 |
|
| 14 |
export interface ProcessedData {
|
src/utils/csvParser.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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) => {
|
| 8 |
Papa.parse(file, {
|
| 9 |
header: true,
|
|
@@ -120,6 +120,46 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
|
|
| 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;
|
|
@@ -138,6 +178,9 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
|
|
| 138 |
duration,
|
| 139 |
trainingStressScore,
|
| 140 |
calories,
|
|
|
|
|
|
|
|
|
|
| 141 |
};
|
| 142 |
|
| 143 |
return activity;
|
|
|
|
| 3 |
import { calculateTSS } from './tssCalculator';
|
| 4 |
import { validateCSV } from './csvValidator';
|
| 5 |
|
| 6 |
+
export function parseCSV(file: File, userFTP: number = 343, thresholdHR: number = 170, restingHR: number = 50): Promise<Activity[]> {
|
| 7 |
return new Promise((resolve, reject) => {
|
| 8 |
Papa.parse(file, {
|
| 9 |
header: true,
|
|
|
|
| 120 |
// Get activity title
|
| 121 |
const title = row['Title'] || row['Activity Name'] || row['Name'];
|
| 122 |
|
| 123 |
+
// Parse heart rate data
|
| 124 |
+
let averageHR: number | undefined;
|
| 125 |
+
const avgHRStr = row['Avg HR'] || row['Average HR'] || row['Average Heart Rate'];
|
| 126 |
+
if (avgHRStr && avgHRStr !== '--') {
|
| 127 |
+
const parsed = parseFloat(avgHRStr);
|
| 128 |
+
if (!isNaN(parsed) && parsed > 0) {
|
| 129 |
+
averageHR = parsed;
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
let maxHR: number | undefined;
|
| 134 |
+
const maxHRStr = row['Max HR'] || row['Maximum HR'] || row['Max Heart Rate'];
|
| 135 |
+
if (maxHRStr && maxHRStr !== '--') {
|
| 136 |
+
const parsed = parseFloat(maxHRStr);
|
| 137 |
+
if (!isNaN(parsed) && parsed > 0) {
|
| 138 |
+
maxHR = parsed;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// Parse total ascent
|
| 143 |
+
let totalAscent: number | undefined;
|
| 144 |
+
const ascentStr = row['Total Ascent'] || row['Ascent'] || row['Elevation Gain'];
|
| 145 |
+
if (ascentStr && ascentStr !== '--') {
|
| 146 |
+
const parsed = parseFloat(ascentStr.replace(/,/g, ''));
|
| 147 |
+
if (!isNaN(parsed) && parsed > 0) {
|
| 148 |
+
totalAscent = parsed;
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// If no TSS calculated from power, try hrTSS from heart rate
|
| 153 |
+
if (!trainingStressScore && durationSeconds && averageHR) {
|
| 154 |
+
// hrTSS = duration_hours × HR_ratio² × 100
|
| 155 |
+
// HR_ratio = (avg_HR - resting_HR) / (threshold_HR - resting_HR)
|
| 156 |
+
if (thresholdHR > restingHR && averageHR > restingHR && averageHR < 220) {
|
| 157 |
+
const hrRatio = (averageHR - restingHR) / (thresholdHR - restingHR);
|
| 158 |
+
const durationHours = durationSeconds / 3600;
|
| 159 |
+
trainingStressScore = durationHours * Math.pow(hrRatio, 2) * 100;
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
// Parse calories
|
| 164 |
const caloriesStr = row['Calories'];
|
| 165 |
let calories: number | undefined;
|
|
|
|
| 178 |
duration,
|
| 179 |
trainingStressScore,
|
| 180 |
calories,
|
| 181 |
+
averageHR,
|
| 182 |
+
maxHR,
|
| 183 |
+
totalAscent,
|
| 184 |
};
|
| 185 |
|
| 186 |
return activity;
|
vite.config.ts
CHANGED
|
@@ -9,6 +9,6 @@ export default defineConfig({
|
|
| 9 |
},
|
| 10 |
server: {
|
| 11 |
port: 3000,
|
| 12 |
-
open:
|
| 13 |
},
|
| 14 |
});
|
|
|
|
| 9 |
},
|
| 10 |
server: {
|
| 11 |
port: 3000,
|
| 12 |
+
open: false,
|
| 13 |
},
|
| 14 |
});
|