glutamatt HF Staff commited on
Commit
35527e2
·
verified ·
0 Parent(s):
.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 &lt; 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 &gt; 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
+ });