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
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
12 changes: 6 additions & 6 deletions packages/backend/src/services/workout-plans/plan-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ 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;

/** 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*/;
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/plan/PlanDayCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function PlanDayCard({ day, dayIndex: _dayIndex }: PlanDayCardProps) {
<td className="py-1.5 pr-4 text-muted-foreground hidden sm:table-cell">
{exercise.sets.find((s) => s.targetRpe !== undefined)?.targetRpe ?? '—'}
</td>
<td className="py-1.5 text-muted-foreground hidden md:table-cell">
<td className="py-1.5 text-muted-foreground hidden md:table-cell whitespace-pre-wrap max-w-md">
{exercise.notes ?? '—'}
</td>
</tr>
Expand Down
Loading