glutamatt HF Staff commited on
Commit
d831612
·
verified ·
1 Parent(s): d58e2d3

dev with hot reload , TSS fallback computation from HR , UI

Browse files
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 value from input
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: 600px;
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: true,
13
  },
14
  });
 
9
  },
10
  server: {
11
  port: 3000,
12
+ open: false,
13
  },
14
  });