From af17af240d00a20f592a415b9e0b0001a42b595a Mon Sep 17 00:00:00 2001 From: DeccanHYD Date: Tue, 6 Oct 2026 01:28:53 +0200 Subject: [PATCH] fix: preserve default bar setup across gym activation and deletion --- docs/reviews/2026-10-06-default-bar-parity.md | 13 +++++ web/docs/parity-2026-09-14/audit.md | 2 + web/src/data/schema.ts | 6 +++ web/src/data/store.ts | 22 ++++++-- web/src/domain/codecs.ts | 30 +++++++++-- web/src/domain/gym-setup.ts | 14 +++++ web/src/domain/types.ts | 9 ++++ web/src/features/Settings.tsx | 21 ++++++++ web/tests/gym-store.test.ts | 54 +++++++++++++++++++ web/tests/plate-codecs.test.ts | 37 +++++++++++++ web/tests/settings-navigation.test.tsx | 14 ++++- 11 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 docs/reviews/2026-10-06-default-bar-parity.md create mode 100644 web/src/domain/gym-setup.ts diff --git a/docs/reviews/2026-10-06-default-bar-parity.md b/docs/reviews/2026-10-06-default-bar-parity.md new file mode 100644 index 0000000..6b5fcf9 --- /dev/null +++ b/docs/reviews/2026-10-06-default-bar-parity.md @@ -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. diff --git a/web/docs/parity-2026-09-14/audit.md b/web/docs/parity-2026-09-14/audit.md index aad0e3c..7c64240 100644 --- a/web/docs/parity-2026-09-14/audit.md +++ b/web/docs/parity-2026-09-14/audit.md @@ -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). diff --git a/web/src/data/schema.ts b/web/src/data/schema.ts index 1655fc5..fad5f1d 100644 --- a/web/src/data/schema.ts +++ b/web/src/data/schema.ts @@ -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), diff --git a/web/src/data/store.ts b/web/src/data/store.ts index b13d46c..02f2dd9 100644 --- a/web/src/data/store.ts +++ b/web/src/data/store.ts @@ -1,4 +1,5 @@ import Dexie, { liveQuery, type Table } from "dexie"; +import { basePlateSetup } from "../domain/gym-setup"; import { defaultProfile, type AppSnapshot, @@ -604,7 +605,8 @@ 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) { @@ -612,7 +614,8 @@ export async function selectGym(id: string) { 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) { @@ -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)); } diff --git a/web/src/domain/codecs.ts b/web/src/domain/codecs.ts index c821d38..286dc9a 100644 --- a/web/src/domain/codecs.ts +++ b/web/src/domain/codecs.ts @@ -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"; @@ -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) @@ -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, @@ -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, diff --git a/web/src/domain/gym-setup.ts b/web/src/domain/gym-setup.ts new file mode 100644 index 0000000..3a4fb39 --- /dev/null +++ b/web/src/domain/gym-setup.ts @@ -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 }; +} diff --git a/web/src/domain/types.ts b/web/src/domain/types.ts index 5c3dde7..d1275e0 100644 --- a/web/src/domain/types.ts +++ b/web/src/domain/types.ts @@ -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; keepAwake: boolean; cardShineEnabled: boolean; liquidGlassEnabled: boolean; @@ -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; diff --git a/web/src/features/Settings.tsx b/web/src/features/Settings.tsx index db8eb0d..bf5d2aa 100644 --- a/web/src/features/Settings.tsx +++ b/web/src/features/Settings.tsx @@ -14,6 +14,7 @@ import { FontPicker } from "../ui/FontPicker"; import { SpacingPicker } from "../ui/SpacingPicker"; import { saveProfile, + saveDefaultBarWeight, restoreSnapshot, resetData, bootstrap, @@ -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, @@ -48,6 +50,9 @@ export function Settings() { const [warnings, setWarnings] = useState([]); 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>(); @@ -202,6 +207,12 @@ export function Settings() {