Skip to content

Commit 1fb41d8

Browse files
committed
Add Garmin HR zones, SpO2, respiration, race predictions
1 parent 378ba9d commit 1fb41d8

File tree

8 files changed

+650
-13
lines changed

8 files changed

+650
-13
lines changed

frontend/src/features/dashboard/TrainingsPage.tsx

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "../../components/ui/card";
1111
import { LoadingState } from "../../components/ui/loading-state";
1212
import { ErrorCard } from "../../components/ui/error-card";
13-
import { Dumbbell, Calendar, Flame, Activity } from "lucide-react";
13+
import { Dumbbell, Calendar, Flame, Activity, Heart } from "lucide-react";
1414
import { format, parseISO } from "date-fns";
1515
import type {
1616
WorkoutExerciseDetail,
@@ -168,6 +168,99 @@ function StrengthWorkoutInline({
168168
);
169169
}
170170

171+
const WHOOP_ZONE_COLORS = [
172+
"bg-gray-400",
173+
"bg-blue-400",
174+
"bg-green-400",
175+
"bg-yellow-400",
176+
"bg-orange-400",
177+
"bg-red-500",
178+
] as const;
179+
180+
const GARMIN_ZONE_COLORS = [
181+
"bg-blue-400",
182+
"bg-green-400",
183+
"bg-yellow-400",
184+
"bg-orange-400",
185+
"bg-red-500",
186+
] as const;
187+
188+
interface HRZoneEntry {
189+
label: string;
190+
seconds: number;
191+
color: string;
192+
}
193+
194+
function HRZoneBar({ zones }: { zones: HRZoneEntry[] }) {
195+
const nonZero = zones.filter((z) => z.seconds > 0);
196+
if (nonZero.length === 0) return null;
197+
198+
const totalSeconds = nonZero.reduce((sum, z) => sum + z.seconds, 0);
199+
if (totalSeconds === 0) return null;
200+
201+
return (
202+
<div className="mt-2 space-y-1">
203+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
204+
<Heart className="h-3 w-3" />
205+
<span>HR Zones</span>
206+
</div>
207+
<div className="flex h-3 rounded-full overflow-hidden">
208+
{nonZero.map((z) => {
209+
const pct = (z.seconds / totalSeconds) * 100;
210+
if (pct < 0.5) return null;
211+
return (
212+
<div
213+
key={z.label}
214+
className={`${z.color} transition-all`}
215+
style={{ width: `${String(pct)}%` }}
216+
title={`${z.label}: ${String(Math.round(z.seconds / 60))}m (${pct.toFixed(0)}%)`}
217+
/>
218+
);
219+
})}
220+
</div>
221+
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
222+
{nonZero.map((z) => (
223+
<span key={z.label} className="flex items-center gap-1">
224+
<span className={`inline-block w-2 h-2 rounded-full ${z.color}`} />
225+
{z.label}: {String(Math.round(z.seconds / 60))}m
226+
</span>
227+
))}
228+
</div>
229+
</div>
230+
);
231+
}
232+
233+
function buildWhoopZones(workout: WhoopWorkoutData): HRZoneEntry[] {
234+
const millis = [
235+
workout.zone_zero_millis,
236+
workout.zone_one_millis,
237+
workout.zone_two_millis,
238+
workout.zone_three_millis,
239+
workout.zone_four_millis,
240+
workout.zone_five_millis,
241+
];
242+
return millis.map((m, i) => ({
243+
label: `Zone ${String(i)}`,
244+
seconds: (m ?? 0) / 1000,
245+
color: WHOOP_ZONE_COLORS[i],
246+
}));
247+
}
248+
249+
function buildGarminZones(activity: GarminActivityData): HRZoneEntry[] {
250+
const secs = [
251+
activity.hr_zone_one_seconds,
252+
activity.hr_zone_two_seconds,
253+
activity.hr_zone_three_seconds,
254+
activity.hr_zone_four_seconds,
255+
activity.hr_zone_five_seconds,
256+
];
257+
return secs.map((s, i) => ({
258+
label: `Zone ${String(i + 1)}`,
259+
seconds: s ?? 0,
260+
color: GARMIN_ZONE_COLORS[i],
261+
}));
262+
}
263+
171264
function WhoopWorkoutInline({ workout }: { workout: WhoopWorkoutData }) {
172265
const startTime = workout.start_time
173266
? format(parseISO(workout.start_time), "h:mm a")
@@ -179,8 +272,23 @@ function WhoopWorkoutInline({ workout }: { workout: WhoopWorkoutData }) {
179272
? (workout.distance_meters / 1000).toFixed(2)
180273
: null;
181274

275+
let durationStr: string | null = null;
276+
if (workout.start_time && workout.end_time) {
277+
const durationSec = Math.round(
278+
(new Date(workout.end_time).getTime() -
279+
new Date(workout.start_time).getTime()) /
280+
1000,
281+
);
282+
if (durationSec > 0) {
283+
durationStr = formatDuration(durationSec);
284+
}
285+
}
286+
182287
const details: string[] = [];
183288

289+
if (durationStr !== null) {
290+
details.push(durationStr);
291+
}
184292
if (workout.strain !== null) {
185293
details.push(
186294
`Strain ${workout.strain.toFixed(1)}/${String(WHOOP_MAX_STRAIN)}`,
@@ -195,6 +303,9 @@ function WhoopWorkoutInline({ workout }: { workout: WhoopWorkoutData }) {
195303
if (distanceKm !== null) {
196304
details.push(`${distanceKm} km`);
197305
}
306+
if (workout.percent_recorded !== null && workout.percent_recorded < 100) {
307+
details.push(`${workout.percent_recorded.toFixed(0)}% recorded`);
308+
}
198309

199310
return (
200311
<div className={`border-l-2 ${ACTIVITY_COLORS.cardioBorder} pl-4`}>
@@ -211,6 +322,7 @@ function WhoopWorkoutInline({ workout }: { workout: WhoopWorkoutData }) {
211322
<p className="text-sm text-muted-foreground mt-1">
212323
{details.join(" · ")}
213324
</p>
325+
<HRZoneBar zones={buildWhoopZones(workout)} />
214326
</div>
215327
);
216328
}
@@ -272,6 +384,7 @@ function GarminActivityInline({ activity }: { activity: GarminActivityData }) {
272384
<p className="text-sm text-muted-foreground mt-1">
273385
{details.join(" · ")}
274386
</p>
387+
<HRZoneBar zones={buildGarminZones(activity)} />
275388
</div>
276389
);
277390
}

frontend/src/types/api.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface HealthData {
2929
whoop_cycle: WhoopCycleData[];
3030
garmin_training_status: GarminTrainingStatusData[];
3131
garmin_activity: GarminActivityData[];
32+
garmin_race_prediction: GarminRacePredictionData[];
3233
}
3334

3435
export interface SleepData {
@@ -69,6 +70,11 @@ export interface HeartRateData {
6970
resting_hr: number | null;
7071
max_hr: number | null;
7172
avg_hr: number | null;
73+
spo2_avg: number | null;
74+
spo2_min: number | null;
75+
waking_respiratory_rate: number | null;
76+
lowest_respiratory_rate: number | null;
77+
highest_respiratory_rate: number | null;
7278
}
7379

7480
export interface StressData {
@@ -120,18 +126,34 @@ export interface WhoopSleepData {
120126
rem_sleep_minutes: number | null;
121127
awake_minutes: number | null;
122128
respiratory_rate: number | null;
129+
sleep_need_baseline_minutes: number | null;
130+
sleep_need_debt_minutes: number | null;
131+
sleep_need_strain_minutes: number | null;
132+
sleep_need_nap_minutes: number | null;
133+
sleep_cycle_count: number | null;
134+
disturbance_count: number | null;
135+
no_data_minutes: number | null;
123136
}
124137

125138
export interface WhoopWorkoutData {
126139
date: string;
127140
start_time: string | null;
141+
end_time: string | null;
128142
strain: number | null;
129143
avg_heart_rate: number | null;
130144
max_heart_rate: number | null;
131145
kilojoules: number | null;
132146
distance_meters: number | null;
133147
altitude_gain_meters: number | null;
134148
sport_name: string | null;
149+
percent_recorded: number | null;
150+
altitude_change_meters: number | null;
151+
zone_zero_millis: number | null;
152+
zone_one_millis: number | null;
153+
zone_two_millis: number | null;
154+
zone_three_millis: number | null;
155+
zone_four_millis: number | null;
156+
zone_five_millis: number | null;
135157
}
136158

137159
export interface WhoopCycleData {
@@ -335,4 +357,18 @@ export interface GarminActivityData {
335357
training_effect_aerobic: number | null;
336358
training_effect_anaerobic: number | null;
337359
vo2_max_value: number | null;
360+
hr_zone_one_seconds: number | null;
361+
hr_zone_two_seconds: number | null;
362+
hr_zone_three_seconds: number | null;
363+
hr_zone_four_seconds: number | null;
364+
hr_zone_five_seconds: number | null;
365+
}
366+
367+
export interface GarminRacePredictionData {
368+
date: string;
369+
prediction_5k_seconds: number | null;
370+
prediction_10k_seconds: number | null;
371+
prediction_half_marathon_seconds: number | null;
372+
prediction_marathon_seconds: number | null;
373+
vo2_max_value: number | null;
338374
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Add Garmin HR zones, SpO2, respiration, and race predictions
2+
3+
Revision ID: 017_add_garmin_fields
4+
Revises: 016_add_whoop_fields
5+
Create Date: 2026-02-21
6+
7+
Activities: 5 HR zone duration columns
8+
HeartRate: SpO2 avg/min, respiratory rate (waking/lowest/highest)
9+
New table: garmin_race_predictions
10+
"""
11+
12+
from alembic import op
13+
import sqlalchemy as sa
14+
15+
revision = "017_add_garmin_fields"
16+
down_revision = "016_add_whoop_fields"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
op.add_column("garmin_activities", sa.Column("hr_zone_one_seconds", sa.Integer()))
23+
op.add_column("garmin_activities", sa.Column("hr_zone_two_seconds", sa.Integer()))
24+
op.add_column("garmin_activities", sa.Column("hr_zone_three_seconds", sa.Integer()))
25+
op.add_column("garmin_activities", sa.Column("hr_zone_four_seconds", sa.Integer()))
26+
op.add_column("garmin_activities", sa.Column("hr_zone_five_seconds", sa.Integer()))
27+
28+
op.add_column("heart_rate", sa.Column("spo2_avg", sa.Float()))
29+
op.add_column("heart_rate", sa.Column("spo2_min", sa.Float()))
30+
op.add_column("heart_rate", sa.Column("waking_respiratory_rate", sa.Float()))
31+
op.add_column("heart_rate", sa.Column("lowest_respiratory_rate", sa.Float()))
32+
op.add_column("heart_rate", sa.Column("highest_respiratory_rate", sa.Float()))
33+
34+
op.create_check_constraint(
35+
"valid_hr_spo2_avg_range", "heart_rate",
36+
"(spo2_avg >= 50 AND spo2_avg <= 100) OR spo2_avg IS NULL",
37+
)
38+
op.create_check_constraint(
39+
"valid_hr_spo2_min_range", "heart_rate",
40+
"(spo2_min >= 50 AND spo2_min <= 100) OR spo2_min IS NULL",
41+
)
42+
op.create_check_constraint(
43+
"valid_waking_resp_rate_range", "heart_rate",
44+
"(waking_respiratory_rate >= 5 AND waking_respiratory_rate <= 50) OR waking_respiratory_rate IS NULL",
45+
)
46+
op.create_check_constraint(
47+
"valid_lowest_resp_rate_range", "heart_rate",
48+
"(lowest_respiratory_rate >= 5 AND lowest_respiratory_rate <= 50) OR lowest_respiratory_rate IS NULL",
49+
)
50+
op.create_check_constraint(
51+
"valid_highest_resp_rate_range", "heart_rate",
52+
"(highest_respiratory_rate >= 5 AND highest_respiratory_rate <= 50) OR highest_respiratory_rate IS NULL",
53+
)
54+
55+
op.create_table(
56+
"garmin_race_predictions",
57+
sa.Column("id", sa.Integer(), primary_key=True),
58+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False, index=True),
59+
sa.Column("date", sa.Date(), nullable=False, index=True),
60+
sa.Column("prediction_5k_seconds", sa.Integer()),
61+
sa.Column("prediction_10k_seconds", sa.Integer()),
62+
sa.Column("prediction_half_marathon_seconds", sa.Integer()),
63+
sa.Column("prediction_marathon_seconds", sa.Integer()),
64+
sa.Column("vo2_max_value", sa.Float()),
65+
sa.Column("created_at", sa.DateTime()),
66+
sa.UniqueConstraint("user_id", "date", name="_user_garmin_race_pred_date_uc"),
67+
sa.CheckConstraint("(prediction_5k_seconds > 0) OR prediction_5k_seconds IS NULL", name="valid_pred_5k"),
68+
sa.CheckConstraint("(prediction_10k_seconds > 0) OR prediction_10k_seconds IS NULL", name="valid_pred_10k"),
69+
sa.CheckConstraint("(prediction_half_marathon_seconds > 0) OR prediction_half_marathon_seconds IS NULL", name="valid_pred_half"),
70+
sa.CheckConstraint("(prediction_marathon_seconds > 0) OR prediction_marathon_seconds IS NULL", name="valid_pred_marathon"),
71+
sa.CheckConstraint("(vo2_max_value >= 10 AND vo2_max_value <= 100) OR vo2_max_value IS NULL", name="valid_race_pred_vo2_max"),
72+
)
73+
op.create_index(
74+
"idx_garmin_race_pred_user_date",
75+
"garmin_race_predictions",
76+
["user_id", sa.text("date DESC")],
77+
)
78+
79+
80+
def downgrade() -> None:
81+
op.drop_index("idx_garmin_race_pred_user_date", table_name="garmin_race_predictions")
82+
op.drop_table("garmin_race_predictions")
83+
84+
op.drop_constraint("valid_highest_resp_rate_range", "heart_rate", type_="check")
85+
op.drop_constraint("valid_lowest_resp_rate_range", "heart_rate", type_="check")
86+
op.drop_constraint("valid_waking_resp_rate_range", "heart_rate", type_="check")
87+
op.drop_constraint("valid_hr_spo2_min_range", "heart_rate", type_="check")
88+
op.drop_constraint("valid_hr_spo2_avg_range", "heart_rate", type_="check")
89+
90+
op.drop_column("heart_rate", "highest_respiratory_rate")
91+
op.drop_column("heart_rate", "lowest_respiratory_rate")
92+
op.drop_column("heart_rate", "waking_respiratory_rate")
93+
op.drop_column("heart_rate", "spo2_min")
94+
op.drop_column("heart_rate", "spo2_avg")
95+
96+
op.drop_column("garmin_activities", "hr_zone_five_seconds")
97+
op.drop_column("garmin_activities", "hr_zone_four_seconds")
98+
op.drop_column("garmin_activities", "hr_zone_three_seconds")
99+
op.drop_column("garmin_activities", "hr_zone_two_seconds")
100+
op.drop_column("garmin_activities", "hr_zone_one_seconds")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ requires-python = ">=3.11"
4747
dependencies = [
4848
# Life-as-Code Core Dependencies
4949
"pandas>=2.3.1",
50-
"garminconnect>=0.2.28",
50+
"garminconnect>=0.2.38",
5151
"requests>=2.32.4",
5252
"python-dotenv>=1.1.0",
5353
"pyyaml>=6.0.2",

src/data_loaders.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
HRV,
1111
Energy,
1212
GarminActivity,
13+
GarminRacePrediction,
1314
GarminTrainingStatus,
1415
HeartRate,
1516
Sleep,
@@ -45,6 +46,7 @@ def load_data_for_user(start_date, end_date, user_id):
4546
(WhoopCycle, "whoop_cycle"),
4647
(GarminTrainingStatus, "garmin_training_status"),
4748
(GarminActivity, "garmin_activity"),
49+
(GarminRacePrediction, "garmin_race_prediction"),
4850
]
4951

5052
with read_engine.connect() as conn:
@@ -194,6 +196,31 @@ def get_garmin_activities_data(start_date, end_date, user_id):
194196
"vo2_max_value": (
195197
float(row["vo2_max_value"]) if pd.notna(row["vo2_max_value"]) else None
196198
),
199+
"hr_zone_one_seconds": (
200+
int(row["hr_zone_one_seconds"])
201+
if pd.notna(row["hr_zone_one_seconds"])
202+
else None
203+
),
204+
"hr_zone_two_seconds": (
205+
int(row["hr_zone_two_seconds"])
206+
if pd.notna(row["hr_zone_two_seconds"])
207+
else None
208+
),
209+
"hr_zone_three_seconds": (
210+
int(row["hr_zone_three_seconds"])
211+
if pd.notna(row["hr_zone_three_seconds"])
212+
else None
213+
),
214+
"hr_zone_four_seconds": (
215+
int(row["hr_zone_four_seconds"])
216+
if pd.notna(row["hr_zone_four_seconds"])
217+
else None
218+
),
219+
"hr_zone_five_seconds": (
220+
int(row["hr_zone_five_seconds"])
221+
if pd.notna(row["hr_zone_five_seconds"])
222+
else None
223+
),
197224
}
198225
result.append(activity)
199226

0 commit comments

Comments
 (0)