Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,12 @@ To ensure the generated text is parsed correctly by the workout builder and disp
- **Descriptions:** Clean workout text only — no `FUEL PER 10:` or `PUMP` prefixes. Notes/flavor text goes before the Warmup section.
- **Workout Naming:** Format is `W{weekNum} {Type}` (e.g., `W01 Easy`, `W05 Long (12km)`, `W12 Short-Intervals`). No suffix.
- Long runs MUST contain "Long" (e.g., `W05 Long (12km)`). DO NOT use "LR".
- Saturday runs MUST include "Bonus" in the name (e.g., `W03 Bonus Easy`). The session type can vary, but must leave energy for Sunday's long run.

## Fuel Taper System

Extended cooldowns serve as a "stop fueling" signal. The Garmin watch vibrates on step transitions — when the runner hears "Cooldown," that's the last fuel. No more carbs after that.

- **Easy runs / Bonus:** 15m cooldown (~2 km at 7:00/km)
- **Easy runs:** 15m cooldown (~2 km at 7:00/km)
- **Long runs:** 2km cooldown
- **Easy + Strides:** 15m cooldown
- **Intervals:** No taper (5m CD unchanged). Interval spikes are hormonal, not carb absorption.
Expand Down
2 changes: 0 additions & 2 deletions app/components/AgendaView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ function msUntilNextMidnight(): number {
function getLeftBorderColor(event: CalendarEvent, isMissed: boolean): string {
if (isMissed) return "border-l-error";
if (event.type === "completed") return "border-l-success";
if (event.type === "race") return "border-l-brand";
if (/bonus|optional/i.test(event.name)) return "border-l-border-subtle";
return "border-l-brand";
}

Expand Down
43 changes: 4 additions & 39 deletions app/components/VolumeTrendChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ interface WeekData {
week: string;
completed: number;
planned: number;
plannedOptional: number;
plannedTotal: number;
isCurrent: boolean;
}

Expand All @@ -62,8 +60,6 @@ export function VolumeTrendChart({
week: `W${String(i + 1).padStart(2, "0")}`,
completed: 0,
planned: 0,
plannedOptional: 0,
plannedTotal: 0,
isCurrent: i === currentWeekIdx,
}));

Expand All @@ -84,13 +80,7 @@ export function VolumeTrendChart({
for (const pe of planEvents) {
const weekIdx = getWeekIdx(pe.start_date_local, planStartMonday);
if (weekIdx < 0 || weekIdx >= totalWeeks) continue;
const km = estimatePlanEventDistance(pe, paceTable, thresholdPace);
const isOptional = /bonus|optional/i.test(pe.name);
if (isOptional) {
weeks[weekIdx].plannedOptional += km;
} else {
weeks[weekIdx].planned += km;
}
weeks[weekIdx].planned += estimatePlanEventDistance(pe, paceTable, thresholdPace);
}

// Completed distances from actual API data
Expand All @@ -101,12 +91,10 @@ export function VolumeTrendChart({
weeks[weekIdx].completed += estimateWorkoutDistance(event, paceTable, thresholdPace);
}

// Compute totals and round
// Round
for (const w of weeks) {
w.completed = Math.round(w.completed * 10) / 10;
w.planned = Math.round(w.planned * 10) / 10;
w.plannedOptional = Math.round(w.plannedOptional * 10) / 10;
w.plannedTotal = Math.round((w.planned + w.plannedOptional) * 10) / 10;
}

return { weeks, currentWeekIdx };
Expand Down Expand Up @@ -135,12 +123,6 @@ export function VolumeTrendChart({
data={data.weeks}
margin={{ top: 5, right: 5, bottom: 0, left: 0 }}
>
<XAxis
xAxisId="plannedTotal"
dataKey="week"
hide
padding={{ left: 2, right: 2 }}
/>
<XAxis
xAxisId="planned"
dataKey="week"
Expand Down Expand Up @@ -174,11 +156,7 @@ export function VolumeTrendChart({
return (
<div className="rounded-lg border border-border bg-surface text-text shadow-lg text-xs px-3 py-2">
<div className="font-medium mb-1">Week {weekNum}</div>
<div className="text-muted">Planned : {d.planned} km</div>
{d.plannedOptional > 0 && (
<div className="text-muted">Optional : {d.plannedOptional} km</div>
)}
<div className="text-text">Total : {d.plannedTotal} km</div>
<div className="text-text">Planned : {d.planned} km</div>
{d.completed > 0 && (
<>
<div className="border-t border-border my-1.5" />
Expand All @@ -199,16 +177,7 @@ export function VolumeTrendChart({
strokeWidth={1.5}
/>
)}
{/* Optional: full height (planned + optional), rendered behind */}
<Bar
xAxisId="plannedTotal"
dataKey="plannedTotal"
fill="var(--color-muted)"
fillOpacity={0.25}
radius={2}
maxBarSize={14}
/>
{/* Planned: mandatory only, rendered on top */}
{/* Planned, rendered behind */}
<Bar
xAxisId="planned"
dataKey="planned"
Expand Down Expand Up @@ -237,10 +206,6 @@ export function VolumeTrendChart({
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-chart-primary/40" />
Planned
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2.5 h-2.5 rounded-sm bg-muted/40" />
Optional
</span>
</div>
</div>
);
Expand Down
1 change: 0 additions & 1 deletion app/screens/IntelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,6 @@ export function IntelScreen() {
let totalRuns = 0;
for (const pe of planEvents) {
if (getWeekIdx(pe.start_date_local, planStartMonday) !== currentWeekIdx) continue;
if (/bonus|optional/i.test(pe.name)) continue;
targetKm += estimatePlanEventDistance(pe, paceTable, thresholdPace);
totalRuns++;
}
Expand Down
19 changes: 0 additions & 19 deletions docs/workout-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,22 +277,3 @@ Cooldown
```

Note: Easy + Strides keeps the warmup/cooldown structure because strides are in a different HR zone (Z5). The 15m cooldown is the fuel taper signal.

### Example G: Bonus Easy

**Name:** `W03 Bonus Easy eco16`
**fuelRate:** `48` (g/h → `carbs_per_hour: 48`)
**Description (threshold ≈ 5:30/km):**

```text
The Saturday bonus. Let's be honest — there's maybe a 20% chance this actually happens. If your legs say no, listen to them. If they say yes, enjoy 20 easy minutes with zero expectations. No pace, no plan. Just a gift to future you.

Warmup
- Warmup 10m 6:15-18:20/km Pace intensity=warmup

Main set
- 20m 6:15-18:20/km Pace intensity=active

Cooldown
- Cooldown 15m 6:15-18:20/km Pace intensity=cooldown
```
6 changes: 0 additions & 6 deletions lib/__tests__/adaptPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,6 @@ describe("reconstructExternalId", () => {
expect(reconstructExternalId("W01 Easy")).toBe("easy-1");
});

it("parses bonus run by keyword", () => {
expect(reconstructExternalId("W03 Bonus Easy")).toBe("bonus-3");
});

it("parses RACE DAY with week number", () => {
expect(reconstructExternalId("W18 RACE DAY")).toBe("race-18");
});
Expand All @@ -298,14 +294,12 @@ describe("reconstructExternalId", () => {
expect(reconstructExternalId("W03 Tue Easy")).toBe("easy-3");
expect(reconstructExternalId("W12 Thu Short-Intervals")).toBe("speed-12");
expect(reconstructExternalId("W05 Sun Long (12km)")).toBe("long-5");
expect(reconstructExternalId("W01 Sat Bonus Easy")).toBe("bonus-1");
});

it("handles legacy names with eco16 suffix", () => {
expect(reconstructExternalId("W12 Short-Intervals eco16")).toBe("speed-12");
expect(reconstructExternalId("W05 Long (12km) eco16")).toBe("long-5");
expect(reconstructExternalId("W01 Easy eco16")).toBe("easy-1");
expect(reconstructExternalId("W03 Bonus Easy eco16")).toBe("bonus-3");
expect(reconstructExternalId("RACE DAY eco16")).toBe("race");
});
});
Expand Down
7 changes: 0 additions & 7 deletions lib/__tests__/eventStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,6 @@ describe("getEventStyle", () => {
vi.useRealTimers();
});

it("returns bonus style with muted border", () => {
vi.useFakeTimers({ now: new Date("2026-02-28T12:00:00") });
const style = getEventStyle(makeEvent({ type: "planned", category: "easy", name: "Bonus Easy eco16" }));
expect(style).toContain("border-subtle");
vi.useRealTimers();
});

it("returns missed style with error border and reduced opacity", () => {
vi.useFakeTimers({ now: new Date("2026-03-10T12:00:00") });
const style = getEventStyle(makeEvent({ type: "planned", date: new Date("2026-03-08") }));
Expand Down
4 changes: 2 additions & 2 deletions lib/__tests__/intervalsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ describe("fetchCalendarData", () => {
id: "456",
start_date: "2026-06-10T10:00:00",
start_date_local: "2026-06-10T10:00:00",
name: "Järfälla - W03 Bonus Easy",
name: "Järfälla - W03 Easy",
type: "Run",
distance: 6352,
moving_time: 3200,
Expand All @@ -220,7 +220,7 @@ describe("fetchCalendarData", () => {
id: 999,
category: "WORKOUT",
start_date_local: "2026-06-10T12:00:00",
name: "W03 Bonus Easy",
name: "W03 Easy",
description: "Easy run.",
},
]);
Expand Down
12 changes: 6 additions & 6 deletions lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,8 @@ Cooldown
expect(segments[0].estimated).toBe(false);
});

it("parses bonus run single-step workout", () => {
const desc = `The Saturday bonus. Just a gift to future you.
it("parses single-step workout with notes", () => {
const desc = `Easy free-form session. Just a gift to future you.

- 45m 68-83% LTHR (115-140 bpm) intensity=active
`;
Expand Down Expand Up @@ -966,8 +966,8 @@ Cooldown
expect(sections[0].steps[0].bpmRange).toBe("115-140 bpm");
});

it("parses single-step workout with bonus run notes", () => {
const desc = `The Saturday bonus. Let's be honest — there's maybe a 20% chance this actually happens.
it("parses single-step workout with multi-sentence notes", () => {
const desc = `Easy free-form session. Let's be honest — there's maybe a 20% chance this actually happens.

- 45m 68-83% LTHR (115-140 bpm) intensity=active
`;
Expand Down Expand Up @@ -1077,13 +1077,13 @@ Warmup
});

it("extracts multi-line notes from single-step workout", () => {
const desc = `The Saturday bonus. Let's be honest — there's maybe a 20% chance this actually happens. If your legs say no, listen to them.
const desc = `Easy free-form session. Let's be honest — there's maybe a 20% chance this actually happens. If your legs say no, listen to them.

- 45m 68-83% LTHR (115-140 bpm) intensity=active
`;

expect(extractNotes(desc)).toBe(
"The Saturday bonus. Let's be honest — there's maybe a 20% chance this actually happens. If your legs say no, listen to them.",
"Easy free-form session. Let's be honest — there's maybe a 20% chance this actually happens. If your legs say no, listen to them.",
);
});

Expand Down
2 changes: 0 additions & 2 deletions lib/adaptPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ function buildEasyStructure(duration: number | undefined): string {
* "W12 Short Intervals" → "speed-12"
* "W05 Long (12km)" → "long-5"
* "W01 Easy" → "easy-1"
* "W03 Bonus Easy" → "bonus-3"
* "W05 Club Run" → "club-5"
* "RACE DAY" → "race"
*
Expand All @@ -200,7 +199,6 @@ export function reconstructExternalId(

// Classify by workout type keywords
if (/\bLong\b/i.test(name)) return `long-${week}`;
if (/\bBonus\b/i.test(name)) return `bonus-${week}`;
if (/Short.?Intervals|Hills|Long.?Intervals|Distance.?Intervals|Race.?Pace.?Intervals/i.test(name)) {
return `speed-${week}`;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/adaptPlanPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Keep sentences short and punchy. No run-on sentences. No filler. Use mmol/L, g/h
Examples of good notes:

Easy run:
"Bonus easy 45 minutes — your last two easy runs averaged 7:15/km at 138 bpm, right in the pocket.
"Easy 45 minutes — your last two easy runs averaged 7:15/km at 138 bpm, right in the pocket.

Fuel holds at **60 g/h** — BG stayed flat on both Mar 14 and Mar 17, starting at 8.1 and finishing at 7.9, so no reason to change."

Expand Down
1 change: 0 additions & 1 deletion lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ export function getWorkoutCategory(
if (lowerName.includes("club")) return "interval";
if (
lowerName.includes("easy") ||
lowerName.includes("bonus") ||
lowerName.includes("strides")
)
return "easy";
Expand Down
Loading
Loading