From 068e16452d81c27f8429c47597742b6b952a17a3 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sun, 12 Apr 2026 12:01:21 +0200 Subject: [PATCH] fix(plan): parse D1/D2 day headers, em dashes, RIR notation + fix notes display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser couldn't recognize workout plans using the "D1 — UPPER" format (common in coaching programs). Three regex gaps: 1. DAY_HEADER_RE didn't match "D1", "D2" etc. — added `d\d+` pattern 2. Exercise name cleanup didn't strip trailing em dashes (U+2014/2013) from "Bench Press — 4×5–7" format 3. RPE_RE only recognized "RPE" but not "RIR" (Reps In Reserve) — added RIR as an accepted suffix Also fixes the notes fallback display: exercise.notes in the table cell now uses whitespace-pre-wrap so multi-line raw text renders with line breaks instead of a single-line wall of text. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/plan-parser.test.ts | 25 +++++++++++++++++++ .../src/services/workout-plans/plan-parser.ts | 12 ++++----- .../src/components/plan/PlanDayCard.tsx | 2 +- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts b/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts index 305839d..0e87028 100644 --- a/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts +++ b/packages/backend/src/services/workout-plans/__tests__/plan-parser.test.ts @@ -90,6 +90,31 @@ Tricep Pushdown 3x12`; expect(pushdown!.progressionRule).toBe('double'); }); + it('"D1 — UPPER" day header format → parsed as day with exercises', () => { + const text = `D1 — UPPER (HEAVY | ~1 RIR) +Iso-Lateral HS Bench — 4×5–7 @1 RIR +Weighted Pull-Ups — 4×4–6 @1 RIR + +D2 — LEGS (HYPERTROPHY | 1–2 RIR) +Leg Extension — 3×12–15 @1 RIR +Seated Leg Curl — 3×12–15 @1 RIR`; + + const result = parseFreeTextPlan(text); + expect(result.days).toHaveLength(2); + expect(result.days[0].name).toContain('UPPER'); + expect(result.days[0].exercises.length).toBeGreaterThanOrEqual(2); + // Exercise name should NOT include trailing em dash + expect(result.days[0].exercises[0].exerciseName).not.toContain('—'); + expect(result.days[0].exercises[0].exerciseName).toContain('Bench'); + // RIR parsed as RPE value + expect(result.days[0].exercises[0].sets[0].targetRpe).toBe(1); + // Rep ranges parsed correctly + const reps = result.days[0].exercises[0].sets[0].targetReps; + expect(Array.isArray(reps)).toBe(true); + expect((reps as [number, number])[0]).toBe(5); + expect((reps as [number, number])[1]).toBe(7); + }); + it('exercises with RPE targets (e.g. "3×5 @RPE 8") → targetRpe is 8', () => { const text = `Push Bench Press 3x5 @RPE 8`; diff --git a/packages/backend/src/services/workout-plans/plan-parser.ts b/packages/backend/src/services/workout-plans/plan-parser.ts index 7580dad..5935725 100644 --- a/packages/backend/src/services/workout-plans/plan-parser.ts +++ b/packages/backend/src/services/workout-plans/plan-parser.ts @@ -15,11 +15,11 @@ function inferProgressionRule(sfrTier: SfrTier): ProgressionRule { // --------------------------------------------------------------------------- /** - * Matches day header lines — e.g. "Day 1:", "Monday:", "Push A:", "# Pull" + * Matches day header lines — e.g. "Day 1:", "Monday:", "Push A:", "# Pull", "D1 — UPPER" * A day header must NOT look like an exercise line (no set×rep pattern). */ const DAY_HEADER_RE = - /^(?:#{1,3}\s*)?(?:day\s*\d+|monday|tuesday|wednesday|thursday|friday|saturday|sunday|push|pull|legs|upper|lower|full body|chest|back|arms|shoulders|core)\b/i; + /^(?:#{1,3}\s*)?(?:d\d+\b|day\s*\d+|monday|tuesday|wednesday|thursday|friday|saturday|sunday|push|pull|legs|upper|lower|full body|chest|back|arms|shoulders|core)\b/i; /** Matches set×rep patterns — e.g. "3x8", "3×8-12", "4×5", "3 x 10" */ const SET_REP_RE = /(\d+)\s*[x×]\s*(\d+)(?:\s*[–-]\s*(\d+))?/i; @@ -27,8 +27,8 @@ const SET_REP_RE = /(\d+)\s*[x×]\s*(\d+)(?:\s*[–-]\s*(\d+))?/i; /** Matches weight annotation — e.g. "@ 70kg", "@70 kg", "70kg", "70lbs" */ const WEIGHT_RE = /@?\s*([\d.]+)\s*(?:kg|lbs?)/i; -/** Matches RPE annotation — e.g. "@RPE 8", "RPE8", "@8", "@ 8 RPE" */ -const RPE_RE = /@\s*(?:rpe\s*)?(\d(?:\.\d)?)\s*(?:rpe)?(?!\s*k?g)/i; +/** Matches RPE/RIR annotation — e.g. "@RPE 8", "RPE8", "@8", "@ 8 RPE", "@1 RIR", "@1–2 RIR" */ +const RPE_RE = /@\s*(?:(?:rpe|rir)\s*)?(\d(?:\.\d)?)\s*(?:rpe|rir)?(?!\s*k?g)/i; /** Matches a leading bullet or dash */ const BULLET_RE = /^[-*•]\s*/; @@ -79,8 +79,8 @@ function parseExerciseLine(line: string, order: number): PlanExercise | null { // Extract exercise name: everything before the set×rep pattern const nameRaw = stripped.substring(0, stripped.search(SET_REP_RE)).trim(); - // Remove trailing punctuation - const exerciseName = nameRaw.replace(/[,.:]+$/, '').trim() || 'Exercise'; + // Remove trailing punctuation and em dashes (common separator: "Bench Press — 3×8") + const exerciseName = nameRaw.replace(/[,.:\u2014\u2013-]+$/, '').trim() || 'Exercise'; const meta = getExerciseMeta(exerciseName); diff --git a/packages/frontend/src/components/plan/PlanDayCard.tsx b/packages/frontend/src/components/plan/PlanDayCard.tsx index bf8cfe9..eab35f0 100644 --- a/packages/frontend/src/components/plan/PlanDayCard.tsx +++ b/packages/frontend/src/components/plan/PlanDayCard.tsx @@ -70,7 +70,7 @@ export function PlanDayCard({ day, dayIndex: _dayIndex }: PlanDayCardProps) { {exercise.sets.find((s) => s.targetRpe !== undefined)?.targetRpe ?? '—'} - + {exercise.notes ?? '—'}