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
13 changes: 13 additions & 0 deletions docs/reviews/2026-10-06-exercise-library-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Exercise library management parity

Settings now opens a separate exercise-library route instead of a dismiss-on-pick chooser. It follows Kotlin ExerciseLibraryScreen: All/Favorites/Custom and BW-only scopes, removable active chips, a filter sheet for muscles/equipment/category/movement/difficulty, 180 ms search, gym equipment exclusion, favorite-first/name ordering, recently completed exercise search chips and row navigation to progress. It uses Compose star/filter/delete paths, 72px minimum rows, 44px controls, shared themes and sheet/route transitions.

All results are accessible. Web renders 100 at a time with Show more instead of Android's LazyColumn virtualization. Creation reuses the existing custom form with unmatched query prefilled; saving retains the library destination. Editing and tutorial links remain follow-up work; no new native tracking editor has been assumed.

Favorites persist in the profile and canonical native favorite_exercises JSON setting, including double-encoded older JSON. Native settings override stale web-extension favorites during import. Each favorite mutation reads the current profile in a write transaction. Custom creation validates normalized duplicate names within its transaction, preventing simultaneous creates from duplicating a movement. Existing catalog overrides and recorded snapshots retain their behavior.

Confirmed custom deletion protects built-ins, plan references and every workout status, including imported name-only references. Deletion, restored-catalog removal, favorite cleanup and reminder cleanup commit together. Referenced or failed writes keep the exercise and show the failure within the confirmation sheet. A removed restored custom exercise cannot return on bootstrap.

Verification: 53 files / 263 unit tests, TypeScript/build and 185-file output checks. Tests cover concurrent duplicate creation/favorites, native and browser backup transfer, reference protection, transaction rollback, restored entry deletion, actual Settings navigation, filters, creation/confirmation/error behavior, recent names and results past the old limit. Isolated Chrome at 320px verifies favorite reload, filter scopes, creation/reload/cancel/delete, 200 accessible rows, progress/back origins and no horizontal overflow. Screenshots under ignored web/output/playwright/library*.png. Zero console errors/warnings. An older local preview worker displayed an update notice during the first QA attempt; normal Update & reload resolved that obstruction without changing storage or forcing clicks. Closing CI and deployment evidence is in the root handoff.

P03/P14 remain partially open for tutorial/detail and editor workflows; P16/P17 still require native/web differential visual and motion checks. Native source, signed pre-alpha.9 APK and dirty UnifiedPort remain unchanged.
10 changes: 7 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./features/Progress";
import { Recovery, Ledger } from "./features/Recovery";
import { Settings } from "./features/Settings";
import { ExerciseLibrary } from "./features/ExerciseLibrary";
import { Intelligence } from "./features/Intelligence";
import { HistoricalWorkoutEditor, WorkoutCalendar } from "./features/history/HistoryTools";
import { ExerciseProgress } from "./features/stats/ExerciseProgress";
Expand All @@ -27,6 +28,7 @@ import { backFrom } from "./ui/navigation";
const tabs = ["Home", "Plans", "Log", "Stats", "Settings"];
const tabRoutes = tabs.map((tab) => tab.toLowerCase());
const detailTitles: Record<string, string> = {
library: "EXERCISE LIBRARY",
workout: "ACTIVE WORKOUT",
recovery: "MUSCLE RECOVERY",
ledger: "IRON LEDGER",
Expand Down Expand Up @@ -228,7 +230,7 @@ export function App() {
);
const active = data.workouts.find((w) => w.status === "active");
const detail = !["home", "plans", "log", "stats", "settings"].includes(route);
const selectedTabRoute = route.startsWith("plan/")
const selectedTabRoute = route === "library" ? "settings" : route.startsWith("plan/")
? "plans"
: route.startsWith("history/") || route === "calendar"
? "log"
Expand Down Expand Up @@ -259,6 +261,8 @@ export function App() {
<Stats />
) : route === "settings" ? (
<Settings />
) : route === "library" ? (
<ExerciseLibrary />
) : route === "recovery" ? (
<Recovery />
) : route === "ledger" ? (
Expand Down Expand Up @@ -324,13 +328,13 @@ export function App() {
label="Back"
onClick={() => backFrom(route)}
/>
<strong>
{route === "library" ? <h1 className="detail-screen-title">Exercise library</h1> : <strong>
{route.startsWith("plan/")
? "EDIT PLAN"
: route.startsWith("history/")
? "WORKOUT"
: detailTitles[route] ?? "IRONLOG"}
</strong>
</strong>}
<span aria-hidden="true" />
</header>
)}
Expand Down
1 change: 1 addition & 0 deletions web/src/data/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export const profileSchema = z.object({
plateInventory: z.array(plateStock).optional(),
}).optional(),
nativeSettings: z.record(z.string(), z.unknown()).optional(),
favoriteExerciseIds: z.array(z.string().min(1)).default([]),
keepAwake: z.boolean(),
cardShineEnabled: z.boolean().default(true),
liquidGlassEnabled: z.boolean().default(true),
Expand Down
47 changes: 46 additions & 1 deletion web/src/data/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,52 @@ export function swapExercise(
);
}
export async function saveExercise(e: Exercise) {
await db.exercises.put(exerciseSchema.parse(e));
const valid = exerciseSchema.parse({ ...e, name: e.name.trim() });
if (!exerciseNameKey(valid.name)) throw Error("Enter an exercise name.");
await db.transaction("rw", db.catalog, db.exercises, async () => {
const library = await readExerciseLibrary();
if (valid.custom && library.some(other => other.id !== valid.id && exerciseNameKey(other.name) === exerciseNameKey(valid.name)))
throw Error("An exercise with this name already exists. Choose it from the library.");
await db.exercises.put(valid);
});
}
const exerciseNameKey = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_+$/, "");

/** Read current favorites inside the write transaction so different tabs cannot lose additions. */
export async function setExerciseFavorite(id: string, selected: boolean) {
await db.transaction("rw", db.profiles, db.catalog, db.exercises, async () => {
if (!(await readExerciseLibrary()).some(exercise => exercise.id === id)) throw Error("Exercise no longer exists.");
const row = await db.profiles.get("local");
const favorites = new Set(row?.favoriteExerciseIds ?? []);
if (selected) favorites.add(id); else favorites.delete(id);
const profile = profileSchema.parse({ ...defaultProfile, ...row, favoriteExerciseIds: [...favorites].sort() });
await db.profiles.put({ ...profile, id: "local" });
});
}

/** Match Kotlin reference protection; name-only imported references are protected too. */
export async function deleteCustomExercise(id: string) {
await db.transaction("rw", db.catalog, db.exercises, db.profiles, db.plans, db.workouts, async () => {
const target = (await readExerciseLibrary()).find(exercise => exercise.id === id);
if (!target) throw Error("Exercise no longer exists.");
const bundled = await db.catalog.get("bundled");
if (!target.custom || bundled?.exercises.some(exercise => exercise.id === id)) throw Error("Built-in exercises cannot be deleted.");
const references = (exercise: { exerciseId: string; name: string }) => exercise.exerciseId === id ||
(!exercise.exerciseId.trim() && exerciseNameKey(exercise.name) === exerciseNameKey(target.name));
if ((await db.plans.toArray()).some(plan => plan.days.some(day => day.exercises.some(references))))
throw Error("This exercise is used in a plan. Remove it from the plan before deleting.");
if ((await db.workouts.toArray()).some(workout => workout.exercises.some(references)))
throw Error("This exercise is used in a workout. Keep it to preserve your history.");
await db.exercises.delete(id);
const restored = await db.catalog.get("restored");
if (restored) await db.catalog.put({ ...restored, exercises: restored.exercises.filter(exercise => exercise.id !== id) });
const row = await db.profiles.get("local");
const notes = { ...row?.exerciseNextNotes };
delete notes[`exercise_next_note:${id}`];
const profile = profileSchema.parse({ ...defaultProfile, ...row, exerciseNextNotes: notes,
favoriteExerciseIds: (row?.favoriteExerciseIds ?? []).filter(value => value !== id) });
await db.profiles.put({ ...profile, id: "local" });
});
}
export async function saveMeasurement(m: Measurement) {
await db.measurements.put(snapshotSchema.shape.measurements.element.parse(m));
Expand Down
8 changes: 8 additions & 0 deletions web/src/domain/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export function encodeAndroidBackup(snapshot: AppSnapshot): string {
}));
data.app_settings.push({ id: "ironlog_settings", key: "ironlog_settings",
value: JSON.stringify({ ...snapshot.profile.nativeSettings, barWeightKg: basePlateSetup(snapshot.profile).barKg }), value_type: "json", ...stamp });
data.app_settings.push({ id: "favorite_exercises", key: "favorite_exercises",
value: JSON.stringify([...new Set(snapshot.profile.favoriteExerciseIds ?? [])].sort()), value_type: "json", ...stamp });
const finiteGyms = snapshot.gyms.filter(g => g.plateInventory !== undefined);
let activeGym = snapshot.profile.activeGymId
? finiteGyms.find(g => g.id === snapshot.profile.activeGymId)
Expand Down Expand Up @@ -771,6 +773,12 @@ export function decodeAndroidBackup(raw: string): {
const selectedGym = mergedGyms.find(g => g.id === activeGymId) ?? activeGym ??
(!activeGymId ? mergedGyms.find(g => g.id === ext.unsupportedActiveGymId && g.plateInventory === undefined) : undefined);
const sourceProfile = profileSchema.parse({ ...profile, ...obj(ext.profile) });
const favoriteSetting = table("app_settings").find(s => s.key === "favorite_exercises");
if (favoriteSetting) {
let favorites = parse(str(favoriteSetting.value));
if (typeof favorites === "string") favorites = parse(favorites);
sourceProfile.favoriteExerciseIds = [...new Set(z.array(z.string().min(1)).parse(favorites))].sort();
}
const nativeSetting = table("app_settings").find(s => s.key === "ironlog_settings");
let nativeSettings = sourceProfile.nativeSettings;
const base = ext.profile == null ? nativeDefaultPlateSetup() : basePlateSetup(sourceProfile);
Expand Down
2 changes: 2 additions & 0 deletions web/src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface Profile {
liquidGlassEnabled: boolean;
planExerciseNotesVisible: boolean;
exerciseNextNotes?: Record<string, string>;
favoriteExerciseIds?: string[];
badgeUnlocks: Record<string, number>;
ledgerBaseline?: OnboardingLedgerBaseline;
recoveryWeeks: string[];
Expand Down Expand Up @@ -273,6 +274,7 @@ export const defaultProfile: Profile = {
liquidGlassEnabled: true,
planExerciseNotesVisible: true,
exerciseNextNotes: {},
favoriteExerciseIds: [],
badgeUnlocks: {},
recoveryWeeks: [],
};
Loading
Loading