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-default-bar-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Default bar and gym fallback parity — 6 October 2026

Deleting the final gym formerly cleared only its identity and left its bar and plate inventory materialized in the browser profile. This slice preserves the base setup when first activating a gym, retains it across gym switches and edits, and restores it transactionally when no gym remains. Manual finite inventory, odd spare quantities, empty inventory and legacy unlimited stock remain distinct.

Settings → Training now includes a default bar editor in kilograms with the native 0–100 kg range. Changing it while a gym is active updates the base only; the active gym retains its own bar. Kotlin reference: SettingsScreen.saveBarWeight, GymProfilesScreen deletion and DEFAULT_PLATES, ActiveWorkoutScreen calculator consumers, and AppDataViewModel settings serialization.

Older web active profiles cannot recover a base that previous versions overwrote. If no saved base exists, the fallback explicitly uses Kotlin's 20 kg bar and two pairs of each 20/15/10/5/2.5/1.25 kg plate size. Existing inactive/manual setups are captured as-is when activating a gym. A native backup's canonical global bar comes from the ironlog_settings JSON blob, not a standalone barWeightKg row. The imported blob is retained for re-export so updating the bar does not discard unrelated native preferences. Canonical native changes take precedence over a stale web extension. Unlimited gyms unsupported by Android carry an explicit web-only selection marker; native selection takes precedence whenever present.

Regression tests first reproduced final-gym stale stock, unlimited-base clearing after restore, missing canonical settings transfer and lost unlimited-gym selection. Coverage includes switching/editing, independent bar edits, rejected invalid values, native defaults, failed-write rollback, browser ZIP retention and native JSON round trips with canonical edits/deletion and unrelated preferences retained.

Verification: 47 Vitest files / 239 tests pass; TypeScript and production build pass; all 185 output files verified. Manual Chrome QA at 320 px confirms the editor fits, 101 kg disables save, and 17.5 kg saves visibly. Screenshot: ignored web/output/playwright/default-bar-320.png. CI/deployment evidence is recorded in the closing handoff. No Android source or APK was changed: native already implements this behavior.

Remaining P03/P14 work is the actual full exercise library workflow. P15 still needs remaining portable-setting mappings and consumers; retaining native settings does not make Android-only integrations available in a browser. Other parity gaps remain in the September audit.
2 changes: 2 additions & 0 deletions web/docs/parity-2026-09-14/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,5 @@ See [implementation plan](../../../docs/superpowers/plans/2026-09-14-web-native-
24 September 2026 Home-card correction: replaced the CSS pseudo-element approximation with a card-sized SVG user-space gradient matching `CardGradient.kt`: endpoints animate `-600→1800→-600` and `300→2700→300`, the four accent alpha stops are `0.03/0.20/0.28/0.04`, and the full linear reverse cycle is 10 seconds. The overlay now sits above the card fill and below its content, eliminating the old fixed 135-degree geometry and negative stacking mismatch.

5 October 2026 P14/P15 gym checkpoint: replaced comma entry with unit-aware plate rows, pair controls, native colors and equipment exclusions. Added duplicate, default seeding and confirmed deletion; saves and active selection are transactional. Native JSON preserves colors, exclusions and explicit selection, and canonical gym edits/deletions override stale extensions while unchanged odd web spares survive. Kotlin now finds bounded non-greedy plate combinations, rejects nonfinite editor input and respects empty saved inventories. See [the checkpoint](../../../docs/reviews/2026-10-05-gym-parity.md) for verification, workspace preservation and remaining scope. Full library management remains open.

6 October 2026 P14/P15 base-setup checkpoint: added independent default-bar editing, transactional final-gym fallback and preserved base inventories. Native JSON reads/writes the canonical ironlog_settings blob and retains unrelated native preferences. Legacy active profiles without a stored base use explicit native defaults. See [verification and remaining scope](../../../docs/reviews/2026-10-06-default-bar-parity.md).
6 changes: 6 additions & 0 deletions web/src/data/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ export const profileSchema = z.object({
platesKg: z.array(z.number().positive()),
activeGymId: z.string().optional(),
plateInventory: z.array(plateStock).optional(),
basePlateSetup: z.object({
barKg: number,
platesKg: z.array(z.number().finite().positive()),
plateInventory: z.array(plateStock).optional(),
}).optional(),
nativeSettings: z.record(z.string(), z.unknown()).optional(),
keepAwake: z.boolean(),
cardShineEnabled: z.boolean().default(true),
liquidGlassEnabled: z.boolean().default(true),
Expand Down
22 changes: 19 additions & 3 deletions web/src/data/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Dexie, { liveQuery, type Table } from "dexie";
import { basePlateSetup } from "../domain/gym-setup";
import {
defaultProfile,
type AppSnapshot,
Expand Down Expand Up @@ -604,15 +605,17 @@ export async function saveGym(g: Gym, activate = false) {
await db.gyms.put(valid);
const profile = await db.profiles.get("local");
if (activate || profile?.activeGymId === valid.id)
await db.profiles.put({ ...profileSchema.parse({ ...defaultProfile, ...profile, ...gymSetup(valid) }), id: "local" });
await db.profiles.put({ ...profileSchema.parse({ ...defaultProfile, ...profile,
basePlateSetup: basePlateSetup({ ...defaultProfile, ...profile }), ...gymSetup(valid) }), id: "local" });
});
}
export async function selectGym(id: string) {
await db.transaction("rw", db.gyms, db.profiles, async () => {
const gym = await db.gyms.get(id);
if (!gym) throw Error("This gym no longer exists");
const profile = await db.profiles.get("local");
await db.profiles.put({ ...profileSchema.parse({ ...defaultProfile, ...profile, ...gymSetup(gym) }), id: "local" });
await db.profiles.put({ ...profileSchema.parse({ ...defaultProfile, ...profile,
basePlateSetup: basePlateSetup({ ...defaultProfile, ...profile }), ...gymSetup(gym) }), id: "local" });
});
}
export async function deleteGym(id: string) {
Expand All @@ -621,10 +624,23 @@ export async function deleteGym(id: string) {
const profile = await db.profiles.get("local");
if (profile?.activeGymId === id) {
const next = await db.gyms.toCollection().first();
await db.profiles.put({ ...profile, ...(next ? gymSetup(next) : { activeGymId: undefined }) });
const base = basePlateSetup(profile);
await db.profiles.put({ ...profile, basePlateSetup: base,
...(next ? gymSetup(next) : { ...base, activeGymId: undefined }) });
}
});
}
export async function saveDefaultBarWeight(barKg: number) {
if (!Number.isFinite(barKg) || barKg < 0 || barKg > 100)
throw Error("Enter a bar weight between 0 and 100 kg.");
await db.transaction("rw", db.profiles, async () => {
const row = await db.profiles.get("local");
const profile = profileSchema.parse({ ...defaultProfile, ...row });
const base = { ...basePlateSetup(profile), barKg };
await db.profiles.put({ ...profile, basePlateSetup: base,
...(!profile.activeGymId ? { barKg } : {}), id: "local" });
});
}
export async function deleteWorkout(id: string) {
return serialize(id, () => db.workouts.delete(id));
}
Expand Down
30 changes: 26 additions & 4 deletions web/src/domain/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
type Tracking,
} from "./types";
import { localDateKey, parseHistoryDate } from "./dates";
import { snapshotSchema } from "../data/schema";
import { snapshotSchema, profileSchema } from "../data/schema";
import { basePlateSetup, nativeDefaultPlateSetup } from "./gym-setup";
import { z } from "zod";
import { isTimed, trackingMode } from "./tracking";
import { resolveExerciseCandidate, type ExerciseResolution } from "./exercise-resolution";
Expand Down Expand Up @@ -355,6 +356,8 @@ export function encodeAndroidBackup(snapshot: AppSnapshot): string {
data.app_settings = Object.entries(snapshot.profile.exerciseNextNotes ?? {}).map(([key, value]) => ({
id: key, key, value, value_type: "string", ...stamp,
}));
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 });
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 @@ -400,6 +403,7 @@ export function encodeAndroidBackup(snapshot: AppSnapshot): string {
webExtension: {
profile: snapshot.profile,
currentSetupId,
unsupportedActiveGymId: snapshot.gyms.find(g => g.id === snapshot.profile.activeGymId && g.plateInventory === undefined)?.id,
checkins: snapshot.checkins,
gyms: snapshot.gyms,
workouts: snapshot.workouts,
Expand Down Expand Up @@ -758,13 +762,31 @@ export function decodeAndroidBackup(raw: string): {
}),
...extendedGyms.filter(g => g.plateInventory === undefined && !gyms.some(native => native.id === g.id)),
] : extendedGyms;
const selectedGym = mergedGyms.find(g => g.id === activeGymId) ?? activeGym;
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 nativeSetting = table("app_settings").find(s => s.key === "ironlog_settings");
let nativeSettings = sourceProfile.nativeSettings;
const base = ext.profile == null ? nativeDefaultPlateSetup() : basePlateSetup(sourceProfile);
if (nativeSetting) {
let parsed = parse(str(nativeSetting.value));
// Older SettingsRepository JSON rows can contain a quoted JSON object.
if (typeof parsed === "string") parsed = parse(parsed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw Error("Invalid native settings");
nativeSettings = obj(parsed);
const rawBar = nativeSettings.barWeightKg ?? 20;
const bar = Number(rawBar);
if ((typeof rawBar !== "string" && typeof rawBar !== "number") ||
!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(str(rawBar).trim()) || !Number.isFinite(bar) || bar < 0)
throw Error("Invalid native default bar weight");
base.barKg = bar;
}
const gymProfile = gymSetting ? selectedGym
? { activeGymId: selectedGym.id === ext.currentSetupId ? undefined : selectedGym.id,
barKg: selectedGym.barKg, platesKg: selectedGym.platesKg, plateInventory: selectedGym.plateInventory }
: { activeGymId: undefined } : {};
: { ...base, activeGymId: undefined } : !sourceProfile.activeGymId ? base : {};
const snapshot = snapshotSchema.parse({
profile: { ...(ext.profile != null ? obj(ext.profile) : profile), ...gymProfile, exerciseNextNotes: { ...obj(obj(ext.profile).exerciseNextNotes), ...exerciseNextNotes } },
profile: { ...sourceProfile, nativeSettings, basePlateSetup: base, ...gymProfile, exerciseNextNotes: { ...sourceProfile.exerciseNextNotes, ...exerciseNextNotes } },
plans,
workouts: extendedWorkouts ?? workouts,
exercises,
Expand Down
14 changes: 14 additions & 0 deletions web/src/domain/gym-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defaultProfile, type PlateSetup, type Profile } from "./types";

export function nativeDefaultPlateSetup(): PlateSetup {
const platesKg = [...defaultProfile.platesKg];
return { barKg: 20, platesKg, plateInventory: platesKg.map(weightKg => ({ weightKg, quantity: 4 })) };
}

export function basePlateSetup(profile: Profile): PlateSetup {
if (profile.activeGymId)
return profile.basePlateSetup
? { ...profile.basePlateSetup, plateInventory: profile.basePlateSetup.plateInventory }
: nativeDefaultPlateSetup();
return { barKg: profile.barKg, platesKg: profile.platesKg, plateInventory: profile.plateInventory };
}
9 changes: 9 additions & 0 deletions web/src/domain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ export interface Profile {
activeGymId?: string;
/** Total physical plates, shared between both sides. Missing means unlimited. */
plateInventory?: PlateStock[];
/** Setup used without a named gym; active gym values above are materialized separately. */
basePlateSetup?: PlateSetup;
/** Imported native preferences retained for a lossless Android settings round trip. */
nativeSettings?: Record<string, unknown>;
keepAwake: boolean;
cardShineEnabled: boolean;
liquidGlassEnabled: boolean;
Expand Down Expand Up @@ -203,6 +207,11 @@ export interface PlateStock {
quantity: number;
color?: string;
}
export interface PlateSetup {
barKg: number;
platesKg: number[];
plateInventory?: PlateStock[];
}
export interface Gym {
id: string;
name: string;
Expand Down
21 changes: 21 additions & 0 deletions web/src/features/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FontPicker } from "../ui/FontPicker";
import { SpacingPicker } from "../ui/SpacingPicker";
import {
saveProfile,
saveDefaultBarWeight,
restoreSnapshot,
resetData,
bootstrap,
Expand All @@ -31,6 +32,7 @@ import {
import type { AppSnapshot } from "../domain/types";
import { ExercisePicker } from "./Plans";
import { GymProfiles } from "./GymProfiles";
import { basePlateSetup } from "../domain/gym-setup";
import { loadCatalog } from "../catalog";
import {
filterSettingsDestinations,
Expand All @@ -48,6 +50,9 @@ export function Settings() {
const [warnings, setWarnings] = useState<string[]>([]);
const [library, setLibrary] = useState(false);
const [gym, setGym] = useState(false);
const [barEditor, setBarEditor] = useState(false);
const [barDraft, setBarDraft] = useState("");
const validBar = barDraft.trim() !== "" && Number.isFinite(Number(barDraft)) && Number(barDraft) >= 0 && Number(barDraft) <= 100;
const [storage, setStorage] = useState("");
const [settingsQuery, setSettingsQuery] = useState("");
const [destination, setDestination] = useState<Exclude<SettingsDestinationId, "intelligence">>();
Expand Down Expand Up @@ -202,6 +207,12 @@ export function Settings() {
</section>
<section id="training-tools" hidden={destination !== "training"}>
<h2>Training tools</h2>
<button className="list-row" disabled={busy} onClick={() => {
setBarDraft(String(basePlateSetup(p).barKg)); setBarEditor(true);
}}>
<span><strong>Default bar weight</strong><small>{basePlateSetup(p).barKg} kg · used without a gym profile</small></span>
<Icon name="next" />
</button>
<button className="list-row" onClick={() => setGym(true)}>
<strong>Gym & plate setup</strong>
<Icon name="next" />
Expand Down Expand Up @@ -456,6 +467,16 @@ export function Settings() {
/>
)}
{gym && <GymProfiles onClose={() => setGym(false)} />}
{barEditor && <Sheet title="Default bar weight" onClose={() => setBarEditor(false)}>
<Field label="Default bar weight (kg)">
<input type="number" inputMode="decimal" min="0" max="100" step="any" value={barDraft}
disabled={busy} onChange={(event) => setBarDraft(event.target.value)} />
</Field>
<p className="muted">Enter 0–100 kg. Gym profiles keep their own bar weights.</p>
<Button disabled={busy || !validBar} onClick={() => run(async () => {
await saveDefaultBarWeight(Number(barDraft)); setBarEditor(false);
}, "Default bar weight saved")}>Save bar weight</Button>
</Sheet>}
</>
);
}
54 changes: 54 additions & 0 deletions web/tests/gym-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { beforeEach, expect, it, vi } from "vitest";
import { encodeWebBackup, decodeWebBackup } from "../src/domain/codecs";
import {
db,
bootstrap,
saveGym,
selectGym,
deleteGym,
readSnapshot,
saveProfile,
saveDefaultBarWeight,
} from "../src/data/store";
const gym = {
id: "home",
Expand All @@ -20,6 +23,57 @@ beforeEach(async () => {
await db.open();
await bootstrap([]);
});
it("edits the default bar independently and validates it without changing active gym stock", async () => {
await saveGym(gym, true);
await saveDefaultBarWeight(17.5);
expect((await readSnapshot()).profile.barKg).toBe(20);
await expect(saveDefaultBarWeight(NaN)).rejects.toThrow("0 and 100");
await expect(saveDefaultBarWeight(101)).rejects.toThrow("0 and 100");
await deleteGym(gym.id);
expect((await readSnapshot()).profile.barKg).toBe(17.5);
await saveDefaultBarWeight(0);
expect((await readSnapshot()).profile.barKg).toBe(0);
});
it("uses native defaults for old active profiles whose former base was never stored", async () => {
await saveGym(gym);
await saveProfile({ activeGymId: gym.id, barKg: gym.barKg, platesKg: gym.platesKg, plateInventory: gym.plateInventory });
await deleteGym(gym.id);
expect((await readSnapshot()).profile).toMatchObject({ barKg: 20, platesKg: [20, 15, 10, 5, 2.5, 1.25] });
expect((await readSnapshot()).profile.plateInventory).toEqual(
[20, 15, 10, 5, 2.5, 1.25].map(weightKg => ({ weightKg, quantity: 4 })));
});
it("clears gym stock when a restored base setup has unlimited plates", async () => {
await saveGym(gym, true);
await saveProfile({ basePlateSetup: { barKg: 12, platesKg: [3] } });
await deleteGym(gym.id);
expect((await readSnapshot()).profile.barKg).toBe(12);
expect((await readSnapshot()).profile.plateInventory).toBeUndefined();
});
it("restores the base bar and physical stock after switching and deleting every gym", async () => {
const base = { barKg: 12, platesKg: [3], plateInventory: [{ weightKg: 3, quantity: 3, color: "#F9A825" }] };
await saveProfile(base);
await saveGym(gym, true);
await saveGym({ ...gym, id: "other", barKg: 15 }, true);
await saveGym({ ...gym, barKg: 25 });
await deleteGym("other");
expect((await readSnapshot()).profile.barKg).toBe(25);
await deleteGym("home");
const profile = (await readSnapshot()).profile;
expect(profile.activeGymId).toBeUndefined();
expect(profile).toMatchObject(base);
});
it("retains the base setup through a browser ZIP and rolls back failed final deletion", async () => {
await saveProfile({ barKg: 12, platesKg: [3], plateInventory: [] });
await saveGym(gym, true);
const snapshot = await readSnapshot();
expect((await decodeWebBackup(await encodeWebBackup(snapshot))).profile.basePlateSetup)
.toEqual({ barKg: 12, platesKg: [3], plateInventory: [] });
const write = vi.spyOn(db.profiles, "put").mockRejectedValueOnce(Error("disk full"));
await expect(deleteGym(gym.id)).rejects.toThrow("disk full");
write.mockRestore();
expect(await db.gyms.count()).toBe(1);
expect((await readSnapshot()).profile.activeGymId).toBe(gym.id);
});
it("updates stock atomically when an active gym is edited", async () => {
await saveGym(gym, true);
await saveGym({ ...gym, barKg: 15, plateInventory: [] });
Expand Down
Loading
Loading