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
9 changes: 9 additions & 0 deletions docs/reviews/2026-10-06-workout-tracking-repair.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 62 additions & 15 deletions web/src/data/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand All @@ -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<Exercise | undefined> {
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<Exercise[]> {
return database.transaction(
"r",
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions web/tests/tracking-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App/>);
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(<App/>);
Expand Down
44 changes: 44 additions & 0 deletions web/tests/workout-library-repair.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading