diff --git a/docs/reviews/2026-10-06-workout-tracking-repair.md b/docs/reviews/2026-10-06-workout-tracking-repair.md new file mode 100644 index 0000000..0e3e8e3 --- /dev/null +++ b/docs/reviews/2026-10-06-workout-tracking-repair.md @@ -0,0 +1,9 @@ +# Starter workout logging repair + +The screenshot showed a blank tracking type and missing set inputs. Real bundled-template regression reproduces Incline Smith Press starting with no library match, blank tracking and no usable dimensions. The shared start path previously left every unmatched named movement in this state. Kotlin PlanRepository.resolveOrCreateExercise instead creates an unmatched named custom strength movement with normalized tracking. + +Web workout creation now resolves or creates those library movements transactionally and snapshots their metadata. Startup also repairs empty tracking in unlogged, non-imported active sessions, preserving slot IDs, prescriptions, notes and session state. The revision advances once, so another open tab cannot silently overwrite the repair. Recorded sets, queued warmups, imported sessions and nonblank unknown types are preserved. + +Verification: all native starter templates/days have linked, supported tracking; UI logs a 40 kg × 10 set; startup repairs an existing local session while preserving recorded and imported interpretation. Full local suite: 48 files / 242 tests. TypeScript, production build and 185-file output check pass. Chrome actual catalog/program flow shows all PUSH input controls, logs 40 kg × 10 and retains it on reload. Mobile screenshot: web/output/playwright/workout-tracking-repaired.png. CI and production evidence are recorded in the root handoff. + +Native source and APK unchanged. This addresses the workout blocker before continuing the open full-library parity work. diff --git a/web/src/data/store.ts b/web/src/data/store.ts index 02f2dd9..045ab72 100644 --- a/web/src/data/store.ts +++ b/web/src/data/store.ts @@ -22,6 +22,7 @@ import { workoutSchema, } from "./schema"; import { resolveExercise } from "../domain/codecs"; +import { trackingMode } from "../domain/tracking"; import { creditedProof, deriveSnapshot } from "../domain/engine"; import { isoWeekKey } from "../domain/dates"; import { recomputeOnboardingBaseline } from "../domain/onboarding-baseline"; @@ -132,6 +133,8 @@ export async function bootstrap(exercises: Exercise[], database = db) { "rw", database.profiles, database.catalog, + database.exercises, + database.workouts, async () => { const existing = await database.profiles.get("local"); if (!existing) @@ -148,9 +151,58 @@ export async function bootstrap(exercises: Exercise[], database = db) { } // One structured-clone write replaces thousands of individual indexed writes on WebKit. await database.catalog.put({ id: "bundled", exercises: catalog }); + const library = await readExerciseLibrary(database); + // Only empty, unlogged local interpretations are recoverable. Recorded + // and imported snapshots must never change when the catalog is updated. + for (const workout of await database.workouts.where("status").equals("active").toArray()) { + if (workout.imported) continue; + let changed = false; + for (const exercise of workout.exercises) { + if (exercise.tracking.trim() || exercise.loggedSets.length || exercise.pendingWarmups.length) continue; + const resolved = await resolveLocalPlanExercise(exercise, library, database); + if (!resolved?.tracking.trim()) continue; + Object.assign(exercise, sessionLibraryMetadata(resolved)); + changed = true; + } + if (changed) { + workout.revision++; + await database.workouts.put(workoutSchema.parse(workout)); + } + } }, ); } +async function resolveLocalPlanExercise( + slot: { exerciseId: string; name: string }, library: Exercise[], database = db, +): Promise { + const resolved = resolveExercise(slot.exerciseId, slot.name, library); + if (resolved || !slot.name.trim()) return resolved; + // Kotlin PlanRepository recreates unmatched named movements as custom + // strength exercises using its tracking normalizer, rather than blocking logging. + const custom: Exercise = { + id: newId(), name: slot.name.trim(), muscle: "other", equipment: "other", + category: "strength", custom: true, + tracking: trackingMode({ tracking: "weight_reps", name: slot.name, category: "strength" }), + }; + // Native custom-plan defaults use other equipment and rep tracking; bodyweight + // load interpretation belongs to the session controls, not the stored raw type. + if (["bodyweight_reps", "bodyweight_plus_weight_reps", "assisted_bodyweight"].includes(custom.tracking)) + custom.tracking = "weight_reps"; + custom.requiresExternalLoad = ["weight_reps", "duration_weight"].includes(custom.tracking); + await database.exercises.add(custom); + library.push(custom); + return custom; +} +function sessionLibraryMetadata(library: Exercise) { + return { + exerciseId: library.id, tracking: library.tracking, + muscle: library.muscle, equipment: library.equipment, + secondaryMuscles: library.secondaryMuscles, + primaryMuscles: library.primaryMuscles, muscleContributions: library.muscleContributions, + category: library.category, isBodyweight: library.isBodyweight, + requiresExternalLoad: library.requiresExternalLoad, + }; +} export async function readExerciseLibrary(database = db): Promise { return database.transaction( "r", @@ -357,6 +409,15 @@ export async function startWorkout( : plan?.days[0]; if (dayId && !day) throw Error("Plan day not found"); const exercises = await readExerciseLibrary(); + const sessionExercises = []; + for (const slot of day?.exercises ?? []) { + const library = await resolveLocalPlanExercise(slot, exercises); + sessionExercises.push({ + ...slot, + ...(library ? sessionLibraryMetadata(library) : { tracking: "", muscle: "", equipment: "" }), + loggedSets: [], pendingWarmups: [], + }); + } const w: Workout = { id: newId(), planId: plan?.id, @@ -367,21 +428,7 @@ export async function startWorkout( notes: "", restUsed: false, revision: 0, - exercises: (day?.exercises ?? []).map((e) => { - const lib = resolveExercise(e.exerciseId, e.name, exercises); - return { - ...e, - exerciseId: lib?.id ?? e.exerciseId, - tracking: lib?.tracking ?? "", - muscle: lib?.muscle ?? "", - equipment: lib?.equipment ?? "", - secondaryMuscles: lib?.secondaryMuscles, - primaryMuscles: lib?.primaryMuscles, muscleContributions: lib?.muscleContributions, - category: lib?.category, isBodyweight: lib?.isBodyweight, requiresExternalLoad: lib?.requiresExternalLoad, - loggedSets: [], - pendingWarmups: [], - }; - }), + exercises: sessionExercises, }; await db.workouts.add(w); return w; diff --git a/web/tests/tracking-ui.test.tsx b/web/tests/tracking-ui.test.tsx index cc21e7b..ebf0ef7 100644 --- a/web/tests/tracking-ui.test.tsx +++ b/web/tests/tracking-ui.test.tsx @@ -2,7 +2,25 @@ import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/re import { beforeEach, afterEach, expect, it } from 'vitest'; import { App } from '../src/App'; import { bootstrap, db, saveProfile, startWorkout, mutateWorkout, readSnapshot } from '../src/data/store'; +import { savePlan } from '../src/data/store'; +import { instantiatePlan } from '../src/domain/plans'; +import templates from '../src/generated/templates.json'; +import type { Plan } from '../src/domain/types'; +import { trackingDimensions } from '../src/domain/tracking'; beforeEach(async()=>{await db.delete();await db.open();await bootstrap([]);await saveProfile({onboarded:true});});afterEach(cleanup); +it('shows load and reps and logs an unmatched starter movement instead of a tracking warning', async()=>{ + const plan=instantiatePlan(templates[0] as Plan);await savePlan(plan); + const w=await startWorkout(plan.id,plan.days[0].id);window.location.hash='#/workout';render(); + const loads=await screen.findAllByLabelText(/^(KG|Added load \(KG\)|Assistance \(KG\))$/);const reps=screen.getAllByLabelText('Reps'); + expect(loads).toHaveLength(w.exercises.filter(e=>trackingDimensions(e).load).length); + expect(reps).toHaveLength(w.exercises.filter(e=>trackingDimensions(e).reps).length); + expect(screen.queryByText(/Choose a supported tracking type/)).not.toBeInTheDocument(); + fireEvent.change(loads[0],{target:{value:'40'}});fireEvent.change(reps[0],{target:{value:'10'}}); + fireEvent.click(screen.getAllByRole('button',{name:/^Log$/})[0]); + await waitFor(()=>expect(screen.getByText('40 kg × 10')).toBeVisible()); + expect((await readSnapshot()).workouts[0].exercises[0].loggedSets[0]).toMatchObject({weightKg:40,reps:10}); + await waitFor(()=>expect(screen.queryByText('Saving…')).not.toBeInTheDocument()); +}); it('logs and reloads weighted-duration with load and seconds in separate dimensions',async()=>{ const w=await startWorkout();await mutateWorkout(w.id,w.revision,x=>x.exercises.push({id:'hold',exerciseId:'hold',name:'Cable hold',sets:3,reps:'60',restSeconds:0,notes:'',supersetGroup:'',isWarmup:false,tracking:'duration_weight',muscle:'chest',equipment:'cable',pendingWarmups:[],loggedSets:[]})); window.location.hash='#/workout';render(); diff --git a/web/tests/workout-library-repair.test.ts b/web/tests/workout-library-repair.test.ts new file mode 100644 index 0000000..c726288 --- /dev/null +++ b/web/tests/workout-library-repair.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { beforeEach, expect, it } from "vitest"; +import { bootstrap, db, resetData, savePlan, startWorkout, readSnapshot } from "../src/data/store"; +import { instantiatePlan } from "../src/domain/plans"; +import { trackingDimensions } from "../src/domain/tracking"; +import templates from "../src/generated/templates.json"; +import type { Exercise, Plan } from "../src/domain/types"; + +const catalog: Exercise[] = JSON.parse(readFileSync("public/data/exercises.json", "utf8")); +beforeEach(async () => { await resetData(); await bootstrap(catalog); }); + +it("makes every starter-template slot loggable and library linked", async () => { + for (const template of templates) { + const plan = instantiatePlan(template as Plan); + await savePlan(plan); + for (const day of plan.days) { + const workout = await startWorkout(plan.id, day.id); + for (const exercise of workout.exercises) { + expect(trackingDimensions(exercise).known, exercise.name).toBe(true); + expect(exercise.exerciseId, exercise.name).not.toBe(""); + } + await db.workouts.delete(workout.id); + } + } +}, 30_000); + +it("repairs missing tracking in an existing unlogged local session without touching recorded or imported interpretation", async () => { + const plan = instantiatePlan(templates[0] as Plan); + await savePlan(plan); + const workout = await startWorkout(plan.id, plan.days[0].id); + workout.exercises.forEach(exercise => { exercise.tracking = ""; }); + workout.exercises[1].loggedSets.push({ id: "recorded", weightKg: 20, reps: 10, durationSeconds: 0, distanceKm: 0, kind: "normal", notes: "", loggedAt: Date.now() }); + await db.workouts.put(workout); + await bootstrap(catalog); + const repaired = (await readSnapshot()).workouts[0]; + expect(trackingDimensions(repaired.exercises[0]).known).toBe(true); + expect(repaired.exercises[1]).toEqual(workout.exercises[1]); + expect(repaired.revision).toBe(workout.revision + 1); + repaired.imported = true; + repaired.exercises[0].tracking = ""; + await db.workouts.put(repaired); + await bootstrap(catalog); + expect((await readSnapshot()).workouts[0]).toEqual(repaired); +});