Spaces:
Running
Running
Commit
·
35527e2
verified
·
0
Parent(s):
init
Browse files- .dockerignore +21 -0
- .gitignore +40 -0
- Activities.csv +36 -0
- Dockerfile +31 -0
- README.md +115 -0
- docker-compose.yml +11 -0
- index.html +82 -0
- nginx.conf +32 -0
- package.json +22 -0
- src/components/charts.ts +246 -0
- src/main.ts +126 -0
- src/style.css +273 -0
- src/types/index.ts +23 -0
- src/utils/acwr.ts +95 -0
- src/utils/csvParser.ts +115 -0
- src/utils/dataProcessor.ts +95 -0
- src/utils/metricAcwr.ts +132 -0
- src/utils/tssCalculator.ts +51 -0
- tsconfig.json +34 -0
- vite.config.ts +14 -0
.dockerignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
dist-ssr
|
| 4 |
+
*.local
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
| 7 |
+
README.md
|
| 8 |
+
.vscode
|
| 9 |
+
.idea
|
| 10 |
+
.DS_Store
|
| 11 |
+
*.log
|
| 12 |
+
npm-debug.log*
|
| 13 |
+
yarn-debug.log*
|
| 14 |
+
yarn-error.log*
|
| 15 |
+
pnpm-debug.log*
|
| 16 |
+
coverage
|
| 17 |
+
*.tmp
|
| 18 |
+
*.temp
|
| 19 |
+
.env
|
| 20 |
+
.env.local
|
| 21 |
+
.env.*.local
|
.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules
|
| 3 |
+
.pnpm-store
|
| 4 |
+
|
| 5 |
+
# Build output
|
| 6 |
+
dist
|
| 7 |
+
dist-ssr
|
| 8 |
+
*.local
|
| 9 |
+
|
| 10 |
+
# Logs
|
| 11 |
+
*.log
|
| 12 |
+
npm-debug.log*
|
| 13 |
+
yarn-debug.log*
|
| 14 |
+
yarn-error.log*
|
| 15 |
+
pnpm-debug.log*
|
| 16 |
+
lerna-debug.log*
|
| 17 |
+
|
| 18 |
+
# Editor directories and files
|
| 19 |
+
.vscode/*
|
| 20 |
+
!.vscode/extensions.json
|
| 21 |
+
.idea
|
| 22 |
+
.DS_Store
|
| 23 |
+
*.suo
|
| 24 |
+
*.ntvs*
|
| 25 |
+
*.njsproj
|
| 26 |
+
*.sln
|
| 27 |
+
*.sw?
|
| 28 |
+
|
| 29 |
+
# Environment variables
|
| 30 |
+
.env
|
| 31 |
+
.env.local
|
| 32 |
+
.env.*.local
|
| 33 |
+
|
| 34 |
+
# Test coverage
|
| 35 |
+
coverage
|
| 36 |
+
*.lcov
|
| 37 |
+
|
| 38 |
+
# Temporary files
|
| 39 |
+
*.tmp
|
| 40 |
+
*.temp
|
Activities.csv
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Activity Type,Date,Favorite,Title,Distance,Calories,Time,Avg HR,Max HR,Aerobic TE,Avg Run Cadence,Max Run Cadence,Avg Pace,Best Pace,Total Ascent,Total Descent,Avg Stride Length,Avg Vertical Ratio,Avg Vertical Oscillation,Avg Ground Contact Time,Avg GAP,Normalized Power® (NP®),Training Stress Score®,Avg Power,Max Power,Steps,Total Reps,Total Sets,Body Battery Drain,Min Temp,Decompression,Best Lap Time,Number of Laps,Max Temp,Moving Time,Elapsed Time,Min Elevation,Max Elevation
|
| 2 |
+
Running,2025-12-02 09:19:49,false,"Torcy - Threshold","8.34","565","00:48:51","166","195","3.9","168","204","5:51","4:29","67","73","1.00","8.1","8.0","251","5:47","302","0.0","275","481","8,366","--","--","-15","12.0","No","00:00:21.8","11","24.0","00:48:46","00:48:51","37","98"
|
| 3 |
+
Strength Training,2025-12-01 21:29:04,false,"renfo 🦵🦿🏋️♀️","0.00","114","00:20:51","112","141","0.5","--","--","--","--","--","--","--","--","--","--","--","--","0.0","--","--","208","104","12","-2","27.0","No","00:20:51","1","29.0","00:16:55","00:20:51","--","--"
|
| 4 |
+
Running,2025-11-30 10:37:54,false,"Torcy - reprise TFL W2S1🩼🤕","10.34","697","01:09:46","146","166","3.4","161","182","6:45","5:16","71","76","0.93","9.1","8.2","276","6:44","250","0.0","242","350","11,628","--","--","-17","14.0","No","00:00:57.1","12","25.0","01:09:31","01:38:54","66","101"
|
| 5 |
+
Cycling,2025-11-29 12:58:40,false,"Torcy Cycling","28.54","806","01:53:46","124","189","3.0","--","--","15.1","42.8","204","209","--","--","--","--","--","--","0.0","--","--","--","--","--","-20","17.0","No","00:15:05","6","26.0","01:40:30","03:17:42","44","133"
|
| 6 |
+
Running,2025-11-28 09:18:56,false,"Torcy - reprise TFL W1S3🩼🤕","5.87","394","00:41:30","145","167","2.7","165","200","7:04","3:46","--","7","0.85","9.4","7.8","272","7:09","232","0.0","223","438","6,980","--","--","-9","15.0","No","00:00:20","17","27.0","00:41:14","00:41:30","88","95"
|
| 7 |
+
Strength Training,2025-11-27 21:48:52,false,"renfo 🦵🦿🏋️♀️","0.00","85","00:15:57","112","127","0.4","--","--","--","--","--","--","--","--","--","--","--","--","0.0","--","--","114","57","11","-1","28.0","No","00:15:57","1","30.0","00:13:00","00:15:57","--","--"
|
| 8 |
+
Running,2025-11-27 09:21:57,false,"Torcy - reprise TFL W1S2🩼🤕","4.14","293","00:27:00","158","168","2.8","169","184","6:32","5:28","31","32","0.92","8.9","7.9","265","6:30","253","0.0","247","356","4,688","--","--","-10","18.0","No","00:02:00.3","5","24.0","00:26:58","00:27:02","73","103"
|
| 9 |
+
Cycling,2025-11-26 12:46:57,false,"Torcy - 🫀🫁📈🌬️🚴 VO2max 3","20.06","516","00:54:45","159","190","3.6","--","--","22.0","46.5","181","185","--","--","--","--","--","--","0.0","--","--","--","--","--","-11","16.0","No","00:00:30","43","26.0","00:53:11","00:54:45","82","109"
|
| 10 |
+
Running,2025-11-25 17:11:49,false,"Torcy - reprise TFL 🩼🤕","2.94","193","00:21:11","132","155","2.1","145","239","7:12","5:39","--","6","0.92","8.5","7.7","275","7:12","226","0.0","205","306","3,214","--","--","-5","15.0","No","00:00:10.9","15","26.0","00:21:11","00:21:11","86","94"
|
| 11 |
+
Cycling,2025-11-23 10:20:06,false,"Torcy Cycling","36.89","997","02:21:32","141","181","3.1","--","--","15.6","41.6","280","279","--","--","--","--","--","--","0.0","--","--","--","--","--","-19","11.0","No","00:08:15","8","27.0","02:11:25","02:21:32","37","108"
|
| 12 |
+
Cycling,2025-11-22 08:36:16,false,"Torcy Cycling","15.86","451","00:40:53","160","176","3.2","--","--","23.3","42.3","150","151","--","--","--","--","--","--","0.0","--","--","--","--","--","-9","8.0","No","00:02:36.5","4","26.0","00:40:38","00:40:53","82","108"
|
| 13 |
+
Cycling,2025-11-20 08:49:23,false,"Torcy Cycling","17.43","510","00:47:36","158","185","3.3","--","--","22.0","45.6","103","104","--","--","--","--","--","--","0.0","--","--","--","--","--","-11","13.0","No","00:07:52.7","4","25.0","00:46:08","00:47:36","35","95"
|
| 14 |
+
Cycling,2025-11-19 07:52:32,false,"Torcy Cycling","25.92","755","01:12:13","160","181","3.6","--","--","21.5","43.0","235","235","--","--","--","--","--","--","0.0","--","--","--","--","--","-16","13.0","No","00:02:56.3","6","27.0","01:11:47","01:12:19","37","127"
|
| 15 |
+
Running,2025-11-15 16:35:54,false,"Torcy - Base","5.34","377","00:36:53","154","164","2.6","159","176","6:54","5:30","12","13","0.95","9.1","8.2","277","6:54","234","0.0","231","329","6,146","--","--","-6","17.0","No","00:02:17.9","6","28.0","00:36:51","00:36:53","92","99"
|
| 16 |
+
Running,2025-11-13 08:50:52,false,"Torcy - Base","6.31","462","00:41:17","160","183","3.2","166","212","6:33","5:03","77","78","0.95","8.7","7.9","266","6:24","262","0.0","251","364","7,154","--","--","-9","19.0","No","00:02:16.9","7","29.0","00:41:15","00:41:17","39","95"
|
| 17 |
+
Running,2025-11-12 07:59:43,false,"Torcy - Base","9.66","685","01:01:56","160","174","3.5","169","187","6:25","5:24","76","75","0.94","8.9","8.1","265","6:23","262","0.0","257","341","10,706","--","--","-11","14.0","No","00:00:03.1","11","27.0","01:01:50","01:01:56","81","104"
|
| 18 |
+
Cycling,2025-11-11 10:20:08,false,"Torcy Cycling","36.36","892","01:56:39","142","183","3.1","--","--","18.7","45.4","334","334","--","--","--","--","--","--","0.0","--","--","--","--","--","-20","16.0","No","00:04:53.9","8","27.0","01:53:43","01:56:44","35","101"
|
| 19 |
+
Running,2025-11-09 10:01:02,false,"Montigny-le-Bretonneux - 5km-10km PARCOURIR MONTIGNY (10 km)","10.06","681","00:50:58","185","205","5.0","181","200","5:04","3:26","21","22","1.09","7.7","8.3","238","5:05","324","0.0","320","484","9,244","--","--","-15","16.0","No","00:00:13.1","11","21.0","00:50:56","00:50:58","156","166"
|
| 20 |
+
Running,2025-11-08 10:36:30,false,"Torcy - Sprint","4.75","323","00:30:43","154","188","2.7","157","226","6:28","2:39","10","58","0.96","8.9","8.0","271","6:33","245","0.0","226","604","5,124","--","--","-11","19.0","No","00:00:15","14","27.0","00:30:19","00:31:47","36","92"
|
| 21 |
+
Running,2025-11-06 07:29:02,false,"Torcy - Base","6.85","494","00:42:34","162","181","3.4","168","211","6:13","4:56","60","58","0.98","8.4","8.0","259","6:08","268","0.0","261","504","7,518","--","--","-11","19.0","No","00:01:32.9","8","27.0","00:42:31","00:42:34","76","105"
|
| 22 |
+
Running,2025-11-05 08:25:58,false,"Torcy - Tempo","8.52","598","00:50:51","167","181","3.8","170","188","5:58","4:52","49","47","0.99","8.6","8.3","260","5:57","286","0.0","279","427","8,806","--","--","-15","17.0","No","00:02:00","12","25.0","00:50:48","00:50:51","70","102"
|
| 23 |
+
Running,2025-11-04 08:44:09,false,"Torcy - Base","6.43","448","00:41:17","157","190","3.3","163","185","6:25","5:02","66","65","0.97","8.9","8.4","271","6:22","278","0.0","262","447","6,982","--","--","-11","16.0","No","00:00:08.2","8","23.0","00:41:10","00:41:17","41","94"
|
| 24 |
+
Running,2025-11-03 08:49:43,false,"Torcy - Base","5.97","416","00:36:31","163","183","3.4","171","192","6:07","4:38","34","34","0.98","8.4","7.9","257","6:06","270","0.0","265","398","6,460","--","--","-13","15.0","No","00:05:26.3","6","27.0","00:36:30","00:36:31","87","103"
|
| 25 |
+
Running,2025-11-02 10:14:20,false,"Torcy Running","6.94","483","00:44:13","168","201","3.4","163","202","6:22","3:34","15","15","0.94","9.0","8.2","266","6:24","296","0.0","259","495","7,280","--","--","-12","19.0","No","00:00:20.8","13","23.0","00:43:56","00:44:13","40","48"
|
| 26 |
+
Running,2025-11-01 08:35:12,false,"Torcy Running","8.23","564","00:45:09","179","199","4.5","177","205","5:29","4:20","70","72","1.05","7.8","8.0","245","5:27","309","0.0","303","482","8,182","--","--","-15","21.0","No","00:01:01.3","9","26.0","00:45:06","00:45:09","68","99"
|
| 27 |
+
Running,2025-10-31 07:46:16,false,"Torcy Running","12.25","856","01:28:59","154","166","3.3","159","212","7:16","4:31","115","114","0.87","9.7","8.3","287","7:13","241","0.0","236","328","14,504","--","--","-17","15.0","No","00:01:51.9","13","26.0","01:28:49","01:28:59","45","116"
|
| 28 |
+
Cardio,2025-10-30 09:24:59,false,"pyramid direct on top 😜","0.00","217","00:18:50","171","206","3.0","--","--","--","--","--","--","--","--","--","--","--","--","0.0","--","--","544","165","31","-4","17.0","No","00:18:50","1","18.0","00:00:00","00:18:50","--","--"
|
| 29 |
+
Running,2025-10-30 09:07:07,false,"Torcy Running","2.35","158","00:14:25","155","190","2.5","155","207","6:08","3:47","46","57","1.15","7.4","7.5","253","5:48","275","0.0","249","440","2,582","--","--","-4","17.0","No","00:02:50.4","3","24.0","00:14:16","00:14:25","41","98"
|
| 30 |
+
Running,2025-10-29 07:47:13,false,"Torcy Running","10.01","674","00:56:16","179","197","5.0","176","189","5:37","4:49","77","78","1.02","8.0","8.1","248","5:36","296","0.0","291","379","10,084","--","--","-17","18.0","No","00:00:01.9","11","27.0","00:56:08","00:56:16","86","112"
|
| 31 |
+
HIIT,2025-10-27 21:50:56,false,"Shadow boxing 20 min","0.00","207","00:20:13","159","182","3.0","--","--","--","--","--","--","--","--","--","--","--","--","0.0","--","--","328","164","27","-6","24.0","No","00:20:13","1","27.0","00:19:39","00:20:13","--","--"
|
| 32 |
+
Running,2025-10-26 12:08:23,false,"Lognes Running","3.06","207","00:17:21","178","198","3.4","172","200","5:41","2:59","31","14","1.03","8.2","8.4","252","5:33","310","0.0","302","504","3,048","--","--","-4","18.0","No","00:00:15.4","4","24.0","00:17:15","00:17:21","76","101"
|
| 33 |
+
Running,2025-10-26 10:52:19,false,"Lognes Running","4.01","286","00:22:56","184","206","4.3","161","192","5:43","4:04","11","13","1.07","8.4","8.9","255","5:45","316","0.0","294","434","3,756","--","--","-9","16.0","No","00:00:03.1","5","23.0","00:21:41","00:22:56","76","81"
|
| 34 |
+
Running,2025-10-26 10:19:17,false,"Torcy Running","3.06","204","00:17:35","172","186","3.7","170","188","5:45","5:00","8","29","1.03","8.4","8.6","257","5:47","299","0.0","292","388","3,040","--","--","-6","20.0","No","00:00:20.3","4","27.0","00:17:33","00:17:35","72","99"
|
| 35 |
+
Cycling,2025-10-25 09:26:19,false,"Torcy Cycling","50.19","1,061","02:10:58","152","173","4.6","--","--","23.0","43.7","269","268","--","--","--","--","--","--","0.0","--","--","--","--","--","-29","16.0","No","00:01:33.8","11","24.0","02:10:02","02:10:58","31","104"
|
| 36 |
+
Running,2025-10-23 19:16:02,false,"10km Parc des droits de l'enfant ","10.05","869","00:58:16","179","199","5.0","170","179","5:48","2:39","32","36","1.01","8.6","8.7","261","5:48","302","0.0","298","377","9,920","--","--","-30","17.0","No","00:00:29.8","11","28.0","00:58:08","00:58:40","90","99"
|
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build stage
|
| 2 |
+
FROM node:20-alpine AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy package files
|
| 7 |
+
COPY package*.json ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm install
|
| 11 |
+
|
| 12 |
+
# Copy source code
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Build the application
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
# Production stage
|
| 19 |
+
FROM nginx:alpine
|
| 20 |
+
|
| 21 |
+
# Copy built assets from builder stage
|
| 22 |
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
| 23 |
+
|
| 24 |
+
# Copy nginx configuration
|
| 25 |
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
| 26 |
+
|
| 27 |
+
# Expose port 80
|
| 28 |
+
EXPOSE 80
|
| 29 |
+
|
| 30 |
+
# Start nginx
|
| 31 |
+
CMD ["nginx", "-g", "daemon off;"]
|
README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Training Load Data Visualization
|
| 2 |
+
|
| 3 |
+
A full browser JavaScript/TypeScript app for visualizing Garmin activity data.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- Upload CSV file of Garmin activities history
|
| 8 |
+
- Display 3 graphs of Acute-Chronic Workload Ratio (ACWR) over time
|
| 9 |
+
- based on Distance
|
| 10 |
+
- based on Duration
|
| 11 |
+
- based on Training Stress Score (TSS)
|
| 12 |
+
|
| 13 |
+
the 3 graphs x axis show each day, from first activity day, to last activity day
|
| 14 |
+
each day show to sum of value of the day ( left y axis )
|
| 15 |
+
one curve show the last 7 days average ( left y axis )
|
| 16 |
+
one curve show the last 28 days average ( left y axis )
|
| 17 |
+
another curve show the ACWR ( right y axis )
|
| 18 |
+
|
| 19 |
+
## Running Locally
|
| 20 |
+
|
| 21 |
+
### Option 1: With Node.js
|
| 22 |
+
|
| 23 |
+
**Prerequisites:**
|
| 24 |
+
- Node.js 20+ and npm
|
| 25 |
+
|
| 26 |
+
**Steps:**
|
| 27 |
+
```bash
|
| 28 |
+
# Install dependencies
|
| 29 |
+
npm install
|
| 30 |
+
|
| 31 |
+
# Run development server
|
| 32 |
+
npm run dev
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
The app will be available at `http://localhost:3000`
|
| 36 |
+
|
| 37 |
+
### Option 2: With Docker
|
| 38 |
+
|
| 39 |
+
**Prerequisites:**
|
| 40 |
+
- Docker installed on your system
|
| 41 |
+
|
| 42 |
+
**Using Docker directly:**
|
| 43 |
+
```bash
|
| 44 |
+
# Build the Docker image
|
| 45 |
+
docker build -t training-load-dataviz .
|
| 46 |
+
|
| 47 |
+
# Run the container
|
| 48 |
+
docker run -d -p 8080:80 --name training-load-app training-load-dataviz
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
The app will be available at `http://localhost:8080`
|
| 52 |
+
|
| 53 |
+
**Using Docker Compose (recommended):**
|
| 54 |
+
```bash
|
| 55 |
+
# Start the application
|
| 56 |
+
docker compose up -d
|
| 57 |
+
|
| 58 |
+
# View logs
|
| 59 |
+
docker compose logs -f
|
| 60 |
+
|
| 61 |
+
# Stop the application
|
| 62 |
+
docker compose down
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
The app will be available at `http://localhost:8080`
|
| 66 |
+
|
| 67 |
+
**Docker Commands:**
|
| 68 |
+
```bash
|
| 69 |
+
# Rebuild after code changes
|
| 70 |
+
docker compose up -d --build
|
| 71 |
+
|
| 72 |
+
# Stop and remove containers
|
| 73 |
+
docker compose down
|
| 74 |
+
|
| 75 |
+
# View running containers
|
| 76 |
+
docker ps
|
| 77 |
+
|
| 78 |
+
# Stop the container
|
| 79 |
+
docker stop training-load-app
|
| 80 |
+
|
| 81 |
+
# Remove the container
|
| 82 |
+
docker rm training-load-app
|
| 83 |
+
|
| 84 |
+
# Remove the image
|
| 85 |
+
docker rmi training-load-dataviz
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
## Building for Production
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
# Build the application
|
| 92 |
+
npm run build
|
| 93 |
+
|
| 94 |
+
# Preview production build
|
| 95 |
+
npm run preview
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
The built files will be in the `dist/` directory.
|
| 99 |
+
|
| 100 |
+
## Project Structure
|
| 101 |
+
|
| 102 |
+
```
|
| 103 |
+
training-load-dataviz/
|
| 104 |
+
├── src/
|
| 105 |
+
│ ├── components/ # Chart components
|
| 106 |
+
│ ├── types/ # TypeScript interfaces
|
| 107 |
+
│ ├── utils/ # Utility functions (CSV parser, ACWR calculator)
|
| 108 |
+
│ ├── main.ts # Application entry point
|
| 109 |
+
│ └── style.css # Styles
|
| 110 |
+
├── public/ # Static assets
|
| 111 |
+
├── index.html # HTML entry point
|
| 112 |
+
├── Dockerfile # Docker build configuration
|
| 113 |
+
├── docker-compose.yml # Docker Compose configuration
|
| 114 |
+
└── package.json # Dependencies and scripts
|
| 115 |
+
```
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
training-load-app:
|
| 5 |
+
build:
|
| 6 |
+
context: .
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "8080:80"
|
| 10 |
+
container_name: training-load-dataviz
|
| 11 |
+
restart: unless-stopped
|
index.html
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>Training Load Data Visualization</title>
|
| 8 |
+
<link rel="stylesheet" href="/src/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
|
| 11 |
+
<body>
|
| 12 |
+
<div id="app">
|
| 13 |
+
<header>
|
| 14 |
+
<h1>Training Load Data Visualization</h1>
|
| 15 |
+
</header>
|
| 16 |
+
|
| 17 |
+
<main>
|
| 18 |
+
<section id="upload-section">
|
| 19 |
+
<h2>Upload Garmin Activities CSV</h2>
|
| 20 |
+
<div class="upload-container">
|
| 21 |
+
<div class="ftp-input-container">
|
| 22 |
+
<label for="ftp-input">
|
| 23 |
+
Functional Threshold Power (FTP)
|
| 24 |
+
</label>
|
| 25 |
+
<input type="number" id="ftp-input" value="343" min="0" step="1" placeholder="343" />
|
| 26 |
+
<span class="ftp-unit">Watts</span>
|
| 27 |
+
</div>
|
| 28 |
+
<input type="file" id="csv-upload" accept=".csv" />
|
| 29 |
+
<label for="csv-upload" class="upload-label">
|
| 30 |
+
Choose CSV File
|
| 31 |
+
</label>
|
| 32 |
+
<div id="upload-status"></div>
|
| 33 |
+
</div>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section id="filter-section" class="hidden">
|
| 37 |
+
<div class="filter-container">
|
| 38 |
+
<label class="filter-checkbox">
|
| 39 |
+
<input type="checkbox" id="running-only-filter" />
|
| 40 |
+
<span>🏃 Running only</span>
|
| 41 |
+
</label>
|
| 42 |
+
</div>
|
| 43 |
+
</section>
|
| 44 |
+
|
| 45 |
+
<section id="charts-section" class="hidden">
|
| 46 |
+
<div class="chart-container">
|
| 47 |
+
<h2>Distance-based ACWR</h2>
|
| 48 |
+
<canvas id="distance-chart"></canvas>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="chart-container">
|
| 52 |
+
<h2>Duration-based ACWR</h2>
|
| 53 |
+
<canvas id="duration-chart"></canvas>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div class="chart-container">
|
| 57 |
+
<h2>TSS-based ACWR</h2>
|
| 58 |
+
<canvas id="tss-chart"></canvas>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div class="legend">
|
| 62 |
+
<div class="legend-item">
|
| 63 |
+
<span class="legend-color low"></span>
|
| 64 |
+
<span>ACWR < 0.8 - Detraining risk</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="legend-item">
|
| 67 |
+
<span class="legend-color optimal"></span>
|
| 68 |
+
<span>ACWR 0.8 - 1.3 - Optimal</span>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="legend-item">
|
| 71 |
+
<span class="legend-color high"></span>
|
| 72 |
+
<span>ACWR > 1.5 - Injury risk</span>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</section>
|
| 76 |
+
</main>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<script type="module" src="/src/main.ts"></script>
|
| 80 |
+
</body>
|
| 81 |
+
|
| 82 |
+
</html>
|
nginx.conf
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server {
|
| 2 |
+
listen 80;
|
| 3 |
+
server_name localhost;
|
| 4 |
+
root /usr/share/nginx/html;
|
| 5 |
+
index index.html;
|
| 6 |
+
|
| 7 |
+
# Enable gzip compression
|
| 8 |
+
gzip on;
|
| 9 |
+
gzip_vary on;
|
| 10 |
+
gzip_min_length 1024;
|
| 11 |
+
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
| 12 |
+
|
| 13 |
+
# Serve static files
|
| 14 |
+
location / {
|
| 15 |
+
try_files $uri $uri/ /index.html;
|
| 16 |
+
add_header Cache-Control "no-cache";
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
# Cache static assets
|
| 20 |
+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
| 21 |
+
expires 1y;
|
| 22 |
+
add_header Cache-Control "public, immutable";
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
# Handle 404 errors
|
| 26 |
+
error_page 404 /index.html;
|
| 27 |
+
|
| 28 |
+
# Security headers
|
| 29 |
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
| 30 |
+
add_header X-Content-Type-Options "nosniff" always;
|
| 31 |
+
add_header X-XSS-Protection "1; mode=block" always;
|
| 32 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "training-load-dataviz",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Training load data visualization from Garmin CSV activities",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"type-check": "tsc --noEmit"
|
| 11 |
+
},
|
| 12 |
+
"devDependencies": {
|
| 13 |
+
"@types/node": "^20.10.0",
|
| 14 |
+
"typescript": "^5.3.0",
|
| 15 |
+
"vite": "^5.0.0"
|
| 16 |
+
},
|
| 17 |
+
"dependencies": {
|
| 18 |
+
"chart.js": "^4.4.0",
|
| 19 |
+
"papaparse": "^5.4.1",
|
| 20 |
+
"@types/papaparse": "^5.3.14"
|
| 21 |
+
}
|
| 22 |
+
}
|
src/components/charts.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
| 6 |
+
|
| 7 |
+
let distanceChart: Chart | null = null;
|
| 8 |
+
let durationChart: Chart | null = null;
|
| 9 |
+
let tssChart: Chart | null = null;
|
| 10 |
+
|
| 11 |
+
// Common chart styling inspired by Garmin
|
| 12 |
+
const commonOptions = {
|
| 13 |
+
responsive: true,
|
| 14 |
+
maintainAspectRatio: true,
|
| 15 |
+
plugins: {
|
| 16 |
+
legend: {
|
| 17 |
+
display: true,
|
| 18 |
+
position: 'top' as const,
|
| 19 |
+
labels: {
|
| 20 |
+
usePointStyle: true,
|
| 21 |
+
padding: 15,
|
| 22 |
+
font: {
|
| 23 |
+
size: 12,
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
},
|
| 27 |
+
tooltip: {
|
| 28 |
+
mode: 'index' as const,
|
| 29 |
+
intersect: false,
|
| 30 |
+
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
| 31 |
+
titleColor: '#1e293b',
|
| 32 |
+
bodyColor: '#475569',
|
| 33 |
+
borderColor: '#e2e8f0',
|
| 34 |
+
borderWidth: 1,
|
| 35 |
+
padding: 12,
|
| 36 |
+
displayColors: true,
|
| 37 |
+
},
|
| 38 |
+
},
|
| 39 |
+
scales: {
|
| 40 |
+
x: {
|
| 41 |
+
grid: {
|
| 42 |
+
display: false,
|
| 43 |
+
drawBorder: false,
|
| 44 |
+
},
|
| 45 |
+
ticks: {
|
| 46 |
+
maxRotation: 45,
|
| 47 |
+
minRotation: 45,
|
| 48 |
+
padding: 8,
|
| 49 |
+
color: '#64748b',
|
| 50 |
+
font: {
|
| 51 |
+
size: 10,
|
| 52 |
+
},
|
| 53 |
+
autoSkip: true,
|
| 54 |
+
maxTicksLimit: 20,
|
| 55 |
+
},
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
function createDualAxisChart(
|
| 61 |
+
canvasId: string,
|
| 62 |
+
data: MetricACWRData,
|
| 63 |
+
metricLabel: string,
|
| 64 |
+
metricUnit: string,
|
| 65 |
+
barColor: string
|
| 66 |
+
): Chart {
|
| 67 |
+
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
| 68 |
+
if (!canvas) throw new Error(`Canvas ${canvasId} not found`);
|
| 69 |
+
|
| 70 |
+
const config: ChartConfiguration = {
|
| 71 |
+
type: 'bar',
|
| 72 |
+
data: {
|
| 73 |
+
labels: data.dates,
|
| 74 |
+
datasets: [
|
| 75 |
+
{
|
| 76 |
+
type: 'bar',
|
| 77 |
+
label: `Daily ${metricLabel}`,
|
| 78 |
+
data: data.values,
|
| 79 |
+
backgroundColor: barColor,
|
| 80 |
+
borderWidth: 0,
|
| 81 |
+
borderRadius: 2,
|
| 82 |
+
barPercentage: 0.8,
|
| 83 |
+
yAxisID: 'y',
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
type: 'line',
|
| 87 |
+
label: '7-Day Average',
|
| 88 |
+
data: data.average7d,
|
| 89 |
+
borderColor: 'rgba(239, 68, 68, 0.9)',
|
| 90 |
+
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
| 91 |
+
borderWidth: 2,
|
| 92 |
+
pointRadius: 0,
|
| 93 |
+
pointHoverRadius: 4,
|
| 94 |
+
tension: 0.4,
|
| 95 |
+
fill: false,
|
| 96 |
+
yAxisID: 'y',
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
type: 'line',
|
| 100 |
+
label: '28-Day Average',
|
| 101 |
+
data: data.average28d,
|
| 102 |
+
borderColor: 'rgba(249, 115, 22, 0.9)',
|
| 103 |
+
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
| 104 |
+
borderWidth: 2,
|
| 105 |
+
pointRadius: 0,
|
| 106 |
+
pointHoverRadius: 4,
|
| 107 |
+
tension: 0.4,
|
| 108 |
+
fill: false,
|
| 109 |
+
yAxisID: 'y',
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
type: 'line',
|
| 113 |
+
label: 'ACWR',
|
| 114 |
+
data: data.acwr,
|
| 115 |
+
borderColor: 'rgba(139, 92, 246, 1)',
|
| 116 |
+
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
| 117 |
+
borderWidth: 3,
|
| 118 |
+
pointRadius: 0,
|
| 119 |
+
pointHoverRadius: 5,
|
| 120 |
+
tension: 0.4,
|
| 121 |
+
fill: false,
|
| 122 |
+
yAxisID: 'y1',
|
| 123 |
+
},
|
| 124 |
+
],
|
| 125 |
+
},
|
| 126 |
+
options: {
|
| 127 |
+
...commonOptions,
|
| 128 |
+
scales: {
|
| 129 |
+
...commonOptions.scales,
|
| 130 |
+
y: {
|
| 131 |
+
type: 'linear',
|
| 132 |
+
position: 'left',
|
| 133 |
+
beginAtZero: true,
|
| 134 |
+
border: {
|
| 135 |
+
display: false,
|
| 136 |
+
},
|
| 137 |
+
grid: {
|
| 138 |
+
color: 'rgba(148, 163, 184, 0.1)',
|
| 139 |
+
},
|
| 140 |
+
ticks: {
|
| 141 |
+
padding: 8,
|
| 142 |
+
color: '#64748b',
|
| 143 |
+
font: {
|
| 144 |
+
size: 11,
|
| 145 |
+
},
|
| 146 |
+
},
|
| 147 |
+
title: {
|
| 148 |
+
display: true,
|
| 149 |
+
text: `${metricLabel} ${metricUnit}`,
|
| 150 |
+
color: '#64748b',
|
| 151 |
+
font: {
|
| 152 |
+
size: 12,
|
| 153 |
+
weight: 500,
|
| 154 |
+
},
|
| 155 |
+
},
|
| 156 |
+
},
|
| 157 |
+
y1: {
|
| 158 |
+
type: 'linear',
|
| 159 |
+
position: 'right',
|
| 160 |
+
beginAtZero: true,
|
| 161 |
+
suggestedMin: 0,
|
| 162 |
+
suggestedMax: 2,
|
| 163 |
+
grid: {
|
| 164 |
+
drawOnChartArea: false,
|
| 165 |
+
},
|
| 166 |
+
ticks: {
|
| 167 |
+
padding: 8,
|
| 168 |
+
color: '#8b5cf6',
|
| 169 |
+
font: {
|
| 170 |
+
size: 11,
|
| 171 |
+
},
|
| 172 |
+
},
|
| 173 |
+
title: {
|
| 174 |
+
display: true,
|
| 175 |
+
text: 'ACWR',
|
| 176 |
+
color: '#8b5cf6',
|
| 177 |
+
font: {
|
| 178 |
+
size: 12,
|
| 179 |
+
weight: 500,
|
| 180 |
+
},
|
| 181 |
+
},
|
| 182 |
+
},
|
| 183 |
+
},
|
| 184 |
+
interaction: {
|
| 185 |
+
mode: 'index' as const,
|
| 186 |
+
intersect: false,
|
| 187 |
+
},
|
| 188 |
+
},
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
return new Chart(canvas, config);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
export function createDistanceChart(data: MetricACWRData): void {
|
| 195 |
+
if (distanceChart) {
|
| 196 |
+
distanceChart.destroy();
|
| 197 |
+
}
|
| 198 |
+
distanceChart = createDualAxisChart(
|
| 199 |
+
'distance-chart',
|
| 200 |
+
data,
|
| 201 |
+
'Distance',
|
| 202 |
+
'(km)',
|
| 203 |
+
'rgba(59, 130, 246, 0.6)'
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
export function createDurationChart(data: MetricACWRData): void {
|
| 208 |
+
if (durationChart) {
|
| 209 |
+
durationChart.destroy();
|
| 210 |
+
}
|
| 211 |
+
durationChart = createDualAxisChart(
|
| 212 |
+
'duration-chart',
|
| 213 |
+
data,
|
| 214 |
+
'Duration',
|
| 215 |
+
'(min)',
|
| 216 |
+
'rgba(16, 185, 129, 0.6)'
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
export function createTSSChart(data: MetricACWRData): void {
|
| 221 |
+
if (tssChart) {
|
| 222 |
+
tssChart.destroy();
|
| 223 |
+
}
|
| 224 |
+
tssChart = createDualAxisChart(
|
| 225 |
+
'tss-chart',
|
| 226 |
+
data,
|
| 227 |
+
'TSS',
|
| 228 |
+
'',
|
| 229 |
+
'rgba(245, 158, 11, 0.6)'
|
| 230 |
+
);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
export function destroyAllCharts(): void {
|
| 234 |
+
if (distanceChart) {
|
| 235 |
+
distanceChart.destroy();
|
| 236 |
+
distanceChart = null;
|
| 237 |
+
}
|
| 238 |
+
if (durationChart) {
|
| 239 |
+
durationChart.destroy();
|
| 240 |
+
durationChart = null;
|
| 241 |
+
}
|
| 242 |
+
if (tssChart) {
|
| 243 |
+
tssChart.destroy();
|
| 244 |
+
tssChart = null;
|
| 245 |
+
}
|
| 246 |
+
}
|
src/main.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './style.css';
|
| 2 |
+
import { parseCSV } from './utils/csvParser';
|
| 3 |
+
import { calculateMetricACWR } from './utils/metricAcwr';
|
| 4 |
+
import type { Activity } from './types';
|
| 5 |
+
import {
|
| 6 |
+
createDistanceChart,
|
| 7 |
+
createDurationChart,
|
| 8 |
+
createTSSChart,
|
| 9 |
+
destroyAllCharts,
|
| 10 |
+
} from './components/charts';
|
| 11 |
+
|
| 12 |
+
// DOM elements
|
| 13 |
+
const csvUpload = document.getElementById('csv-upload') as HTMLInputElement;
|
| 14 |
+
const ftpInput = document.getElementById('ftp-input') as HTMLInputElement;
|
| 15 |
+
const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
|
| 16 |
+
const chartsSection = document.getElementById('charts-section') as HTMLElement;
|
| 17 |
+
const filterSection = document.getElementById('filter-section') as HTMLElement;
|
| 18 |
+
const runningOnlyFilter = document.getElementById('running-only-filter') as HTMLInputElement;
|
| 19 |
+
|
| 20 |
+
// Store all activities globally
|
| 21 |
+
let allActivities: Activity[] = [];
|
| 22 |
+
|
| 23 |
+
// Event listeners
|
| 24 |
+
csvUpload?.addEventListener('change', handleFileUpload);
|
| 25 |
+
runningOnlyFilter?.addEventListener('change', handleFilterChange);
|
| 26 |
+
|
| 27 |
+
async function handleFileUpload(event: Event): Promise<void> {
|
| 28 |
+
const input = event.target as HTMLInputElement;
|
| 29 |
+
const file = input.files?.[0];
|
| 30 |
+
|
| 31 |
+
if (!file) {
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
uploadStatus.textContent = 'Processing CSV file...';
|
| 36 |
+
uploadStatus.className = '';
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
// Get FTP value from input
|
| 40 |
+
const ftp = parseInt(ftpInput.value) || 343;
|
| 41 |
+
|
| 42 |
+
// Parse CSV with user-provided FTP
|
| 43 |
+
allActivities = await parseCSV(file, ftp);
|
| 44 |
+
|
| 45 |
+
if (allActivities.length === 0) {
|
| 46 |
+
throw new Error('No valid activities found in the CSV file');
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Reset filter
|
| 50 |
+
if (runningOnlyFilter) {
|
| 51 |
+
runningOnlyFilter.checked = false;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Render charts with all activities
|
| 55 |
+
renderCharts(allActivities);
|
| 56 |
+
|
| 57 |
+
// Show filter and charts sections
|
| 58 |
+
filterSection.classList.remove('hidden');
|
| 59 |
+
chartsSection.classList.remove('hidden');
|
| 60 |
+
|
| 61 |
+
// Update status
|
| 62 |
+
uploadStatus.textContent = `Successfully loaded ${allActivities.length} activities`;
|
| 63 |
+
uploadStatus.className = 'success';
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.error('Error processing file:', error);
|
| 66 |
+
uploadStatus.textContent = error instanceof Error ? error.message : 'Failed to process CSV file';
|
| 67 |
+
uploadStatus.className = 'error';
|
| 68 |
+
filterSection.classList.add('hidden');
|
| 69 |
+
chartsSection.classList.add('hidden');
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function handleFilterChange(): void {
|
| 74 |
+
if (allActivities.length === 0) return;
|
| 75 |
+
|
| 76 |
+
const filteredActivities = runningOnlyFilter.checked
|
| 77 |
+
? allActivities.filter(activity => activity.activityType === 'Running')
|
| 78 |
+
: allActivities;
|
| 79 |
+
|
| 80 |
+
renderCharts(filteredActivities);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function renderCharts(activities: Activity[]): void {
|
| 84 |
+
// Calculate date range from all activities for consistency
|
| 85 |
+
let dateRange: { start: Date; end: Date } | undefined;
|
| 86 |
+
if (allActivities.length > 0) {
|
| 87 |
+
const sortedAll = [...allActivities].sort((a, b) => a.date.getTime() - b.date.getTime());
|
| 88 |
+
// Normalize to midnight to avoid timezone/time comparison issues
|
| 89 |
+
const startDate = new Date(sortedAll[0].date);
|
| 90 |
+
startDate.setHours(0, 0, 0, 0);
|
| 91 |
+
const endDate = new Date(sortedAll[sortedAll.length - 1].date);
|
| 92 |
+
endDate.setHours(23, 59, 59, 999);
|
| 93 |
+
dateRange = {
|
| 94 |
+
start: startDate,
|
| 95 |
+
end: endDate,
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Calculate ACWR for each metric with consistent date range
|
| 100 |
+
const distanceData = calculateMetricACWR(
|
| 101 |
+
activities,
|
| 102 |
+
(activity) => activity.distance,
|
| 103 |
+
dateRange
|
| 104 |
+
);
|
| 105 |
+
const durationData = calculateMetricACWR(
|
| 106 |
+
activities,
|
| 107 |
+
(activity) => activity.duration,
|
| 108 |
+
dateRange
|
| 109 |
+
);
|
| 110 |
+
const tssData = calculateMetricACWR(
|
| 111 |
+
activities,
|
| 112 |
+
(activity) => activity.trainingStressScore,
|
| 113 |
+
dateRange
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
// Destroy existing charts
|
| 117 |
+
destroyAllCharts();
|
| 118 |
+
|
| 119 |
+
// Create new charts
|
| 120 |
+
createDistanceChart(distanceData);
|
| 121 |
+
createDurationChart(durationData);
|
| 122 |
+
createTSSChart(tssData);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Initialize
|
| 126 |
+
console.log('Training Load Data Visualization initialized');
|
src/style.css
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--primary-color: #2563eb;
|
| 3 |
+
--secondary-color: #64748b;
|
| 4 |
+
--success-color: #10b981;
|
| 5 |
+
--warning-color: #f59e0b;
|
| 6 |
+
--danger-color: #ef4444;
|
| 7 |
+
--bg-color: #f8fafc;
|
| 8 |
+
--card-bg: #ffffff;
|
| 9 |
+
--text-color: #1e293b;
|
| 10 |
+
--border-color: #e2e8f0;
|
| 11 |
+
--optimal-color: rgba(16, 185, 129, 0.2);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
* {
|
| 15 |
+
margin: 0;
|
| 16 |
+
padding: 0;
|
| 17 |
+
box-sizing: border-box;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
body {
|
| 21 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
| 22 |
+
background-color: var(--bg-color);
|
| 23 |
+
color: var(--text-color);
|
| 24 |
+
line-height: 1.6;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
#app {
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
header {
|
| 34 |
+
background-color: var(--card-bg);
|
| 35 |
+
border-bottom: 1px solid var(--border-color);
|
| 36 |
+
padding: 1.5rem 2rem;
|
| 37 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
header h1 {
|
| 41 |
+
font-size: 1.75rem;
|
| 42 |
+
color: var(--primary-color);
|
| 43 |
+
font-weight: 700;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
main {
|
| 47 |
+
flex: 1;
|
| 48 |
+
padding: 2rem;
|
| 49 |
+
max-width: 1400px;
|
| 50 |
+
margin: 0 auto;
|
| 51 |
+
width: 100%;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
section {
|
| 55 |
+
margin-bottom: 2rem;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
h2 {
|
| 59 |
+
font-size: 1.5rem;
|
| 60 |
+
margin-bottom: 1rem;
|
| 61 |
+
color: var(--text-color);
|
| 62 |
+
font-weight: 600;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.hidden {
|
| 66 |
+
display: none;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Upload Section */
|
| 70 |
+
#upload-section {
|
| 71 |
+
background-color: var(--card-bg);
|
| 72 |
+
border-radius: 8px;
|
| 73 |
+
padding: 2rem;
|
| 74 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.upload-container {
|
| 78 |
+
display: flex;
|
| 79 |
+
flex-direction: column;
|
| 80 |
+
align-items: center;
|
| 81 |
+
gap: 1rem;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.ftp-input-container {
|
| 85 |
+
display: flex;
|
| 86 |
+
align-items: center;
|
| 87 |
+
gap: 0.75rem;
|
| 88 |
+
padding: 1rem;
|
| 89 |
+
background-color: var(--bg-color);
|
| 90 |
+
border-radius: 6px;
|
| 91 |
+
border: 1px solid var(--border-color);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.ftp-input-container label {
|
| 95 |
+
font-size: 0.875rem;
|
| 96 |
+
font-weight: 500;
|
| 97 |
+
color: var(--text-color);
|
| 98 |
+
white-space: nowrap;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
#ftp-input {
|
| 102 |
+
width: 100px;
|
| 103 |
+
padding: 0.5rem;
|
| 104 |
+
border: 1px solid var(--border-color);
|
| 105 |
+
border-radius: 4px;
|
| 106 |
+
font-size: 1rem;
|
| 107 |
+
text-align: center;
|
| 108 |
+
background-color: var(--card-bg);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#ftp-input:focus {
|
| 112 |
+
outline: none;
|
| 113 |
+
border-color: var(--primary-color);
|
| 114 |
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.ftp-unit {
|
| 118 |
+
font-size: 0.875rem;
|
| 119 |
+
color: var(--secondary-color);
|
| 120 |
+
font-weight: 500;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
#csv-upload {
|
| 124 |
+
display: none;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.upload-label {
|
| 128 |
+
display: inline-block;
|
| 129 |
+
padding: 0.75rem 2rem;
|
| 130 |
+
background-color: var(--primary-color);
|
| 131 |
+
color: white;
|
| 132 |
+
border-radius: 6px;
|
| 133 |
+
cursor: pointer;
|
| 134 |
+
font-weight: 500;
|
| 135 |
+
transition: background-color 0.2s;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.upload-label:hover {
|
| 139 |
+
background-color: #1d4ed8;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
#upload-status {
|
| 143 |
+
font-size: 0.875rem;
|
| 144 |
+
color: var(--secondary-color);
|
| 145 |
+
min-height: 1.5rem;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
#upload-status.success {
|
| 149 |
+
color: var(--success-color);
|
| 150 |
+
font-weight: 500;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
#upload-status.error {
|
| 154 |
+
color: var(--danger-color);
|
| 155 |
+
font-weight: 500;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Filter Section */
|
| 159 |
+
#filter-section {
|
| 160 |
+
background-color: var(--card-bg);
|
| 161 |
+
border-radius: 8px;
|
| 162 |
+
padding: 1rem 2rem;
|
| 163 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 164 |
+
margin-bottom: 2rem;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.filter-container {
|
| 168 |
+
display: flex;
|
| 169 |
+
align-items: center;
|
| 170 |
+
gap: 1rem;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.filter-checkbox {
|
| 174 |
+
display: flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
gap: 0.5rem;
|
| 177 |
+
cursor: pointer;
|
| 178 |
+
font-size: 1rem;
|
| 179 |
+
user-select: none;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.filter-checkbox input[type="checkbox"] {
|
| 183 |
+
width: 18px;
|
| 184 |
+
height: 18px;
|
| 185 |
+
cursor: pointer;
|
| 186 |
+
accent-color: var(--primary-color);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.filter-checkbox span {
|
| 190 |
+
font-weight: 500;
|
| 191 |
+
color: var(--text-color);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* Charts Section */
|
| 195 |
+
#charts-section {
|
| 196 |
+
display: grid;
|
| 197 |
+
gap: 2rem;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.chart-container {
|
| 201 |
+
background-color: var(--card-bg);
|
| 202 |
+
border-radius: 8px;
|
| 203 |
+
padding: 1.5rem;
|
| 204 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.chart-container canvas {
|
| 208 |
+
max-height: 400px;
|
| 209 |
+
width: 100%;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/* Legend */
|
| 213 |
+
.legend {
|
| 214 |
+
margin-top: 1rem;
|
| 215 |
+
display: flex;
|
| 216 |
+
gap: 1.5rem;
|
| 217 |
+
flex-wrap: wrap;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.legend-item {
|
| 221 |
+
display: flex;
|
| 222 |
+
align-items: center;
|
| 223 |
+
gap: 0.5rem;
|
| 224 |
+
font-size: 0.875rem;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.legend-color {
|
| 228 |
+
width: 20px;
|
| 229 |
+
height: 20px;
|
| 230 |
+
border-radius: 4px;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.legend-color.low {
|
| 234 |
+
background-color: rgba(191, 219, 254, 0.5);
|
| 235 |
+
border: 2px solid rgba(59, 130, 246, 0.7);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.legend-color.optimal {
|
| 239 |
+
background-color: rgba(187, 247, 208, 0.5);
|
| 240 |
+
border: 2px solid rgba(34, 197, 94, 0.7);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.legend-color.high {
|
| 244 |
+
background-color: rgba(254, 202, 202, 0.5);
|
| 245 |
+
border: 2px solid rgba(239, 68, 68, 0.7);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/* Responsive Design */
|
| 249 |
+
@media (max-width: 768px) {
|
| 250 |
+
header {
|
| 251 |
+
padding: 1rem;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
header h1 {
|
| 255 |
+
font-size: 1.25rem;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
main {
|
| 259 |
+
padding: 1rem;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
h2 {
|
| 263 |
+
font-size: 1.25rem;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
#upload-section {
|
| 267 |
+
padding: 1.5rem;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.chart-container {
|
| 271 |
+
padding: 1rem;
|
| 272 |
+
}
|
| 273 |
+
}
|
src/types/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface Activity {
|
| 2 |
+
date: Date;
|
| 3 |
+
activityType?: string;
|
| 4 |
+
distance?: number; // in kilometers
|
| 5 |
+
duration?: number; // in minutes
|
| 6 |
+
trainingStressScore?: number;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export interface ProcessedData {
|
| 10 |
+
activities: Activity[];
|
| 11 |
+
dates: string[];
|
| 12 |
+
distances: (number | null)[];
|
| 13 |
+
durations: (number | null)[];
|
| 14 |
+
tss: (number | null)[];
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface MetricACWRData {
|
| 18 |
+
dates: string[];
|
| 19 |
+
values: (number | null)[]; // Daily sum values
|
| 20 |
+
average7d: (number | null)[]; // 7-day rolling average
|
| 21 |
+
average28d: (number | null)[]; // 28-day rolling average
|
| 22 |
+
acwr: (number | null)[]; // ACWR based on this metric
|
| 23 |
+
}
|
src/utils/acwr.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Activity } from '@/types';
|
| 2 |
+
|
| 3 |
+
// This file is deprecated - functionality moved to metricAcwr.ts
|
| 4 |
+
// Kept for reference only
|
| 5 |
+
|
| 6 |
+
interface ACWRData {
|
| 7 |
+
dates: string[];
|
| 8 |
+
acwr: (number | null)[];
|
| 9 |
+
acuteLoad: (number | null)[];
|
| 10 |
+
chronicLoad: (number | null)[];
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Calculate Acute-Chronic Workload Ratio (ACWR)
|
| 15 |
+
* Acute load: 7-day rolling average
|
| 16 |
+
* Chronic load: 28-day rolling average
|
| 17 |
+
* ACWR = Acute / Chronic
|
| 18 |
+
* Optimal range: 0.8 - 1.3
|
| 19 |
+
*/
|
| 20 |
+
export function calculateACWR(activities: Activity[], dateRange?: { start: Date; end: Date }): ACWRData {
|
| 21 |
+
if (activities.length === 0 && !dateRange) {
|
| 22 |
+
return { dates: [], acwr: [], acuteLoad: [], chronicLoad: [] };
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const dates: string[] = [];
|
| 26 |
+
const acwr: (number | null)[] = [];
|
| 27 |
+
const acuteLoad: (number | null)[] = [];
|
| 28 |
+
const chronicLoad: (number | null)[] = [];
|
| 29 |
+
|
| 30 |
+
// Create a map of date to total load (using TSS, or distance as fallback)
|
| 31 |
+
const dailyLoad = new Map<string, number>();
|
| 32 |
+
|
| 33 |
+
activities.forEach(activity => {
|
| 34 |
+
const dateKey = activity.date.toISOString().split('T')[0];
|
| 35 |
+
const load = activity.trainingStressScore || activity.distance || 0;
|
| 36 |
+
|
| 37 |
+
if (!dailyLoad.has(dateKey)) {
|
| 38 |
+
dailyLoad.set(dateKey, 0);
|
| 39 |
+
}
|
| 40 |
+
dailyLoad.set(dateKey, dailyLoad.get(dateKey)! + load);
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
// Get all dates in range
|
| 44 |
+
const startDate = dateRange?.start || (activities.length > 0 ? activities[0].date : new Date());
|
| 45 |
+
const endDate = dateRange?.end || (activities.length > 0 ? activities[activities.length - 1].date : new Date());
|
| 46 |
+
const allDates: Date[] = [];
|
| 47 |
+
|
| 48 |
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
| 49 |
+
allDates.push(new Date(d));
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Calculate ACWR for each date
|
| 53 |
+
allDates.forEach((date, index) => {
|
| 54 |
+
const dateKey = date.toISOString().split('T')[0];
|
| 55 |
+
dates.push(dateKey);
|
| 56 |
+
|
| 57 |
+
// Need at least 28 days of data for chronic load
|
| 58 |
+
if (index < 27) {
|
| 59 |
+
acuteLoad.push(null);
|
| 60 |
+
chronicLoad.push(null);
|
| 61 |
+
acwr.push(null);
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Calculate acute load (7-day average)
|
| 66 |
+
let acuteSum = 0;
|
| 67 |
+
for (let i = 0; i < 7; i++) {
|
| 68 |
+
const d = allDates[index - i];
|
| 69 |
+
const key = d.toISOString().split('T')[0];
|
| 70 |
+
acuteSum += dailyLoad.get(key) || 0;
|
| 71 |
+
}
|
| 72 |
+
const acuteAvg = acuteSum / 7;
|
| 73 |
+
|
| 74 |
+
// Calculate chronic load (28-day average)
|
| 75 |
+
let chronicSum = 0;
|
| 76 |
+
for (let i = 0; i < 28; i++) {
|
| 77 |
+
const d = allDates[index - i];
|
| 78 |
+
const key = d.toISOString().split('T')[0];
|
| 79 |
+
chronicSum += dailyLoad.get(key) || 0;
|
| 80 |
+
}
|
| 81 |
+
const chronicAvg = chronicSum / 28;
|
| 82 |
+
|
| 83 |
+
acuteLoad.push(acuteAvg);
|
| 84 |
+
chronicLoad.push(chronicAvg);
|
| 85 |
+
|
| 86 |
+
// Calculate ACWR
|
| 87 |
+
if (chronicAvg > 0) {
|
| 88 |
+
acwr.push(acuteAvg / chronicAvg);
|
| 89 |
+
} else {
|
| 90 |
+
acwr.push(null);
|
| 91 |
+
}
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
return { dates, acwr, acuteLoad, chronicLoad };
|
| 95 |
+
}
|
src/utils/csvParser.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Papa from 'papaparse';
|
| 2 |
+
import type { Activity } from '@/types';
|
| 3 |
+
import { calculateTSS } from './tssCalculator';
|
| 4 |
+
|
| 5 |
+
export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]> {
|
| 6 |
+
return new Promise((resolve, reject) => {
|
| 7 |
+
Papa.parse(file, {
|
| 8 |
+
header: true,
|
| 9 |
+
skipEmptyLines: true,
|
| 10 |
+
complete: (results) => {
|
| 11 |
+
try {
|
| 12 |
+
const activities = results.data.map((row: any) => {
|
| 13 |
+
// Parse date - Garmin format: "2025-12-02 09:19:49"
|
| 14 |
+
const dateStr = row['Date'] || row['Activity Date'] || row['date'];
|
| 15 |
+
if (!dateStr || dateStr === '--') {
|
| 16 |
+
return null;
|
| 17 |
+
}
|
| 18 |
+
const date = new Date(dateStr);
|
| 19 |
+
if (isNaN(date.getTime())) {
|
| 20 |
+
return null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Parse distance - Garmin format: "8.34" (in km)
|
| 24 |
+
let distance: number | undefined;
|
| 25 |
+
const distanceStr = row['Distance'] || row['distance'];
|
| 26 |
+
if (distanceStr && distanceStr !== '--' && distanceStr !== '0.00') {
|
| 27 |
+
const parsed = parseFloat(distanceStr.replace(/,/g, ''));
|
| 28 |
+
if (!isNaN(parsed) && parsed > 0) {
|
| 29 |
+
distance = parsed;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Parse duration - Garmin format: "00:48:51" (HH:MM:SS)
|
| 34 |
+
let duration: number | undefined;
|
| 35 |
+
let durationSeconds: number | undefined;
|
| 36 |
+
const durationStr = row['Time'] || row['Duration'] || row['Moving Time'] || row['Elapsed Time'];
|
| 37 |
+
if (durationStr && durationStr !== '--') {
|
| 38 |
+
if (durationStr.includes(':')) {
|
| 39 |
+
const parts = durationStr.split(':');
|
| 40 |
+
const hours = parseInt(parts[0]) || 0;
|
| 41 |
+
const minutes = parseInt(parts[1]) || 0;
|
| 42 |
+
const seconds = parseFloat(parts[2]) || 0;
|
| 43 |
+
duration = hours * 60 + minutes + seconds / 60;
|
| 44 |
+
durationSeconds = hours * 3600 + minutes * 60 + seconds;
|
| 45 |
+
} else {
|
| 46 |
+
const parsed = parseFloat(durationStr);
|
| 47 |
+
if (!isNaN(parsed)) {
|
| 48 |
+
duration = parsed;
|
| 49 |
+
durationSeconds = parsed * 60; // Assume minutes if no colon
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Parse TSS - Try to calculate from power data first
|
| 55 |
+
let trainingStressScore: number | undefined;
|
| 56 |
+
|
| 57 |
+
// First, check if TSS is already provided in CSV
|
| 58 |
+
const tssStr = row['Training Stress Score®'] || row['Training Stress Score'] || row['TSS'];
|
| 59 |
+
if (tssStr && tssStr !== '--' && tssStr !== '0.0' && tssStr !== '0') {
|
| 60 |
+
const parsed = parseFloat(tssStr);
|
| 61 |
+
if (!isNaN(parsed) && parsed > 0) {
|
| 62 |
+
trainingStressScore = parsed;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// If no TSS provided, try to calculate from power data
|
| 67 |
+
if (!trainingStressScore && durationSeconds) {
|
| 68 |
+
// Parse Normalized Power (NP)
|
| 69 |
+
const npStr = row['Normalized Power (NP)'] || row['Normalized Power'] || row['NP'];
|
| 70 |
+
const normalizedPower = npStr && npStr !== '--' ? parseFloat(npStr) : undefined;
|
| 71 |
+
|
| 72 |
+
// Use FTP from CSV if available, otherwise use user-provided FTP
|
| 73 |
+
const ftpStr = row['FTP'] || row['Functional Threshold Power'];
|
| 74 |
+
const ftp = ftpStr && ftpStr !== '--' ? parseFloat(ftpStr) : userFTP;
|
| 75 |
+
|
| 76 |
+
if (normalizedPower && ftp) {
|
| 77 |
+
const calculatedTSS = calculateTSS({
|
| 78 |
+
durationSeconds,
|
| 79 |
+
normalizedPower,
|
| 80 |
+
ftp,
|
| 81 |
+
});
|
| 82 |
+
if (calculatedTSS !== undefined) {
|
| 83 |
+
trainingStressScore = calculatedTSS;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Get activity type
|
| 89 |
+
const activityType = row['Activity Type'] || row['Type'] || row['Sport'];
|
| 90 |
+
|
| 91 |
+
const activity: Activity = {
|
| 92 |
+
date,
|
| 93 |
+
activityType,
|
| 94 |
+
distance,
|
| 95 |
+
duration,
|
| 96 |
+
trainingStressScore,
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
return activity;
|
| 100 |
+
}).filter((activity): activity is Activity => activity !== null);
|
| 101 |
+
|
| 102 |
+
// Sort by date
|
| 103 |
+
activities.sort((a, b) => a.date.getTime() - b.date.getTime());
|
| 104 |
+
|
| 105 |
+
resolve(activities);
|
| 106 |
+
} catch (error) {
|
| 107 |
+
reject(new Error('Failed to parse CSV data'));
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
error: (error) => {
|
| 111 |
+
reject(new Error(`CSV parsing error: ${error.message}`));
|
| 112 |
+
},
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
}
|
src/utils/dataProcessor.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Activity, ProcessedData } from '@/types';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Format date to YYYY-MM-DD in local timezone (not UTC)
|
| 5 |
+
*/
|
| 6 |
+
function formatDateLocal(date: Date): string {
|
| 7 |
+
const year = date.getFullYear();
|
| 8 |
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
| 9 |
+
const day = String(date.getDate()).padStart(2, '0');
|
| 10 |
+
return `${year}-${month}-${day}`;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function processActivities(activities: Activity[], dateRange?: { start: Date; end: Date }): ProcessedData {
|
| 14 |
+
if (activities.length === 0 && !dateRange) {
|
| 15 |
+
return {
|
| 16 |
+
activities: [],
|
| 17 |
+
dates: [],
|
| 18 |
+
distances: [],
|
| 19 |
+
durations: [],
|
| 20 |
+
tss: [],
|
| 21 |
+
};
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Sort activities by date
|
| 25 |
+
const sortedActivities = [...activities].sort((a, b) => a.date.getTime() - b.date.getTime());
|
| 26 |
+
|
| 27 |
+
// Get date range
|
| 28 |
+
const startDate = dateRange?.start || (sortedActivities.length > 0 ? new Date(sortedActivities[0].date) : new Date());
|
| 29 |
+
const endDate = dateRange?.end || (sortedActivities.length > 0 ? new Date(sortedActivities[sortedActivities.length - 1].date) : new Date());
|
| 30 |
+
|
| 31 |
+
// Create a map of date -> activities
|
| 32 |
+
const activityMap = new Map<string, Activity[]>();
|
| 33 |
+
sortedActivities.forEach(activity => {
|
| 34 |
+
const dateStr = formatDateLocal(activity.date);
|
| 35 |
+
if (!activityMap.has(dateStr)) {
|
| 36 |
+
activityMap.set(dateStr, []);
|
| 37 |
+
}
|
| 38 |
+
activityMap.get(dateStr)!.push(activity);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// Generate all dates in range
|
| 42 |
+
const dates: string[] = [];
|
| 43 |
+
const distances: (number | null)[] = [];
|
| 44 |
+
const durations: (number | null)[] = [];
|
| 45 |
+
const tss: (number | null)[] = [];
|
| 46 |
+
|
| 47 |
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
| 48 |
+
const dateStr = formatDateLocal(d);
|
| 49 |
+
dates.push(dateStr);
|
| 50 |
+
|
| 51 |
+
const dayActivities = activityMap.get(dateStr) || [];
|
| 52 |
+
|
| 53 |
+
if (dayActivities.length === 0) {
|
| 54 |
+
// No activity this day
|
| 55 |
+
distances.push(null);
|
| 56 |
+
durations.push(null);
|
| 57 |
+
tss.push(null);
|
| 58 |
+
} else {
|
| 59 |
+
// Sum up activities for the day
|
| 60 |
+
let totalDistance = 0;
|
| 61 |
+
let totalDuration = 0;
|
| 62 |
+
let totalTss = 0;
|
| 63 |
+
let hasDistance = false;
|
| 64 |
+
let hasDuration = false;
|
| 65 |
+
let hasTss = false;
|
| 66 |
+
|
| 67 |
+
dayActivities.forEach(activity => {
|
| 68 |
+
if (activity.distance !== undefined) {
|
| 69 |
+
totalDistance += activity.distance;
|
| 70 |
+
hasDistance = true;
|
| 71 |
+
}
|
| 72 |
+
if (activity.duration !== undefined) {
|
| 73 |
+
totalDuration += activity.duration;
|
| 74 |
+
hasDuration = true;
|
| 75 |
+
}
|
| 76 |
+
if (activity.trainingStressScore !== undefined) {
|
| 77 |
+
totalTss += activity.trainingStressScore;
|
| 78 |
+
hasTss = true;
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
distances.push(hasDistance ? totalDistance : null);
|
| 83 |
+
durations.push(hasDuration ? totalDuration : null);
|
| 84 |
+
tss.push(hasTss ? totalTss : null);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
activities: sortedActivities,
|
| 90 |
+
dates,
|
| 91 |
+
distances,
|
| 92 |
+
durations,
|
| 93 |
+
tss,
|
| 94 |
+
};
|
| 95 |
+
}
|
src/utils/metricAcwr.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Activity, MetricACWRData } from '@/types';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Format date to YYYY-MM-DD in local timezone (not UTC)
|
| 5 |
+
*/
|
| 6 |
+
function formatDateLocal(date: Date): string {
|
| 7 |
+
const year = date.getFullYear();
|
| 8 |
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
| 9 |
+
const day = String(date.getDate()).padStart(2, '0');
|
| 10 |
+
return `${year}-${month}-${day}`;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Calculate ACWR for a specific metric (distance, duration, or TSS)
|
| 15 |
+
* @param activities - Array of activities
|
| 16 |
+
* @param metricExtractor - Function to extract the metric value from an activity
|
| 17 |
+
* @param dateRange - Optional date range to maintain consistency
|
| 18 |
+
*/
|
| 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 {
|
| 26 |
+
dates: [],
|
| 27 |
+
values: [],
|
| 28 |
+
average7d: [],
|
| 29 |
+
average28d: [],
|
| 30 |
+
acwr: [],
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Sort activities by date
|
| 35 |
+
const sortedActivities = [...activities].sort((a, b) => a.date.getTime() - b.date.getTime());
|
| 36 |
+
|
| 37 |
+
// Get date range
|
| 38 |
+
const startDate = dateRange?.start || (sortedActivities.length > 0 ? new Date(sortedActivities[0].date) : new Date());
|
| 39 |
+
const endDate = dateRange?.end || (sortedActivities.length > 0 ? new Date(sortedActivities[sortedActivities.length - 1].date) : new Date());
|
| 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[] = [];
|
| 52 |
+
const values: (number | null)[] = [];
|
| 53 |
+
const average7d: (number | null)[] = [];
|
| 54 |
+
const average28d: (number | null)[] = [];
|
| 55 |
+
const acwr: (number | null)[] = [];
|
| 56 |
+
|
| 57 |
+
// Generate all dates in range
|
| 58 |
+
const allDates: Date[] = [];
|
| 59 |
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
| 60 |
+
allDates.push(new Date(d));
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Calculate for each date
|
| 64 |
+
allDates.forEach((date, index) => {
|
| 65 |
+
const dateStr = formatDateLocal(date);
|
| 66 |
+
dates.push(dateStr);
|
| 67 |
+
|
| 68 |
+
// Get daily value
|
| 69 |
+
const dailyValue = dailyValues.get(dateStr) || null;
|
| 70 |
+
values.push(dailyValue);
|
| 71 |
+
|
| 72 |
+
// Calculate 7-day rolling average (acute load)
|
| 73 |
+
let acuteSum = 0;
|
| 74 |
+
let acuteCount = 0;
|
| 75 |
+
for (let i = Math.max(0, index - 6); i <= index; i++) {
|
| 76 |
+
const checkDateStr = formatDateLocal(allDates[i]);
|
| 77 |
+
const val = dailyValues.get(checkDateStr);
|
| 78 |
+
if (val !== undefined) {
|
| 79 |
+
acuteSum += val;
|
| 80 |
+
acuteCount++;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
const acuteAvg = acuteCount > 0 ? acuteSum / 7 : null;
|
| 84 |
+
average7d.push(acuteAvg);
|
| 85 |
+
|
| 86 |
+
// Calculate 28-day rolling average
|
| 87 |
+
let chronicSum = 0;
|
| 88 |
+
let chronicCount = 0;
|
| 89 |
+
for (let i = Math.max(0, index - 27); i <= index; i++) {
|
| 90 |
+
const checkDateStr = formatDateLocal(allDates[i]);
|
| 91 |
+
const val = dailyValues.get(checkDateStr);
|
| 92 |
+
if (val !== undefined) {
|
| 93 |
+
chronicSum += val;
|
| 94 |
+
chronicCount++;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
const chronicAvg = chronicCount > 0 ? chronicSum / 28 : null;
|
| 98 |
+
average28d.push(chronicAvg);
|
| 99 |
+
|
| 100 |
+
// Calculate ACWR (need at least 28 days of data)
|
| 101 |
+
if (index < 27) {
|
| 102 |
+
acwr.push(null);
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// For ACWR, use the full 28-day sum (not average)
|
| 107 |
+
let acwrChronicSum = 0;
|
| 108 |
+
for (let i = index - 27; i <= index; i++) {
|
| 109 |
+
const checkDateStr = formatDateLocal(allDates[i]);
|
| 110 |
+
const val = dailyValues.get(checkDateStr);
|
| 111 |
+
if (val !== undefined) {
|
| 112 |
+
acwrChronicSum += val;
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
const acwrChronicAvg = acwrChronicSum / 28;
|
| 116 |
+
|
| 117 |
+
// Calculate ACWR
|
| 118 |
+
if (acwrChronicAvg > 0 && acuteAvg !== null) {
|
| 119 |
+
acwr.push(acuteAvg / acwrChronicAvg);
|
| 120 |
+
} else {
|
| 121 |
+
acwr.push(null);
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
dates,
|
| 127 |
+
values,
|
| 128 |
+
average7d,
|
| 129 |
+
average28d,
|
| 130 |
+
acwr,
|
| 131 |
+
};
|
| 132 |
+
}
|
src/utils/tssCalculator.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Training Stress Score (TSS) Calculator
|
| 3 |
+
*
|
| 4 |
+
* TSS is a concept developed by Andrew Coggan to quantify the physiological
|
| 5 |
+
* load and fatigue of a given training session, taking into account both
|
| 6 |
+
* its duration and intensity.
|
| 7 |
+
*
|
| 8 |
+
* Formula for running:
|
| 9 |
+
* TSS = (t × NP × IF) / (FTP × 3600) × 100
|
| 10 |
+
*
|
| 11 |
+
* Where:
|
| 12 |
+
* - t: Duration of activity in seconds
|
| 13 |
+
* - NP (Normalized Power®): Power you could have maintained if effort was perfectly regular
|
| 14 |
+
* - IF (Intensity Factor): NP / FTP
|
| 15 |
+
* - FTP (Functional Threshold Power): Maximum power you can maintain for about 1 hour
|
| 16 |
+
* - 3600: Conversion of one hour to seconds
|
| 17 |
+
*
|
| 18 |
+
* In practice:
|
| 19 |
+
* - TSS = 100: represents 1 hour at threshold intensity (FTP)
|
| 20 |
+
* - TSS < 100: easy or moderate training
|
| 21 |
+
* - TSS > 100: very long or very intense training
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
export interface TSSInputs {
|
| 25 |
+
durationSeconds: number;
|
| 26 |
+
normalizedPower?: number; // NP in watts
|
| 27 |
+
ftp?: number; // Functional Threshold Power in watts
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Calculate Training Stress Score (TSS)
|
| 32 |
+
*
|
| 33 |
+
* @param inputs - Duration, Normalized Power, and FTP
|
| 34 |
+
* @returns TSS value or undefined if required data is missing
|
| 35 |
+
*/
|
| 36 |
+
export function calculateTSS(inputs: TSSInputs): number | undefined {
|
| 37 |
+
const { durationSeconds, normalizedPower, ftp } = inputs;
|
| 38 |
+
|
| 39 |
+
// Need all three values to calculate TSS
|
| 40 |
+
if (!normalizedPower || !ftp || ftp === 0) {
|
| 41 |
+
return undefined;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// IF (Intensity Factor) = NP / FTP
|
| 45 |
+
const intensityFactor = normalizedPower / ftp;
|
| 46 |
+
|
| 47 |
+
// TSS = (t × NP × IF) / (FTP × 3600) × 100
|
| 48 |
+
const tss = (durationSeconds * normalizedPower * intensityFactor) / (ftp * 3600) * 100;
|
| 49 |
+
|
| 50 |
+
return tss;
|
| 51 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"lib": [
|
| 7 |
+
"ES2020",
|
| 8 |
+
"DOM",
|
| 9 |
+
"DOM.Iterable"
|
| 10 |
+
],
|
| 11 |
+
"skipLibCheck": true,
|
| 12 |
+
/* Bundler mode */
|
| 13 |
+
"moduleResolution": "bundler",
|
| 14 |
+
"allowImportingTsExtensions": true,
|
| 15 |
+
"resolveJsonModule": true,
|
| 16 |
+
"isolatedModules": true,
|
| 17 |
+
"noEmit": true,
|
| 18 |
+
/* Linting */
|
| 19 |
+
"strict": true,
|
| 20 |
+
"noUnusedLocals": true,
|
| 21 |
+
"noUnusedParameters": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true,
|
| 23 |
+
/* Paths */
|
| 24 |
+
"baseUrl": ".",
|
| 25 |
+
"paths": {
|
| 26 |
+
"@/*": [
|
| 27 |
+
"src/*"
|
| 28 |
+
]
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"include": [
|
| 32 |
+
"src"
|
| 33 |
+
]
|
| 34 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
resolve: {
|
| 6 |
+
alias: {
|
| 7 |
+
'@': path.resolve(__dirname, './src'),
|
| 8 |
+
},
|
| 9 |
+
},
|
| 10 |
+
server: {
|
| 11 |
+
port: 3000,
|
| 12 |
+
open: true,
|
| 13 |
+
},
|
| 14 |
+
});
|