From 88a91b53da2b6d1c99e7752b35cf7b9caf19c430 Mon Sep 17 00:00:00 2001 From: DeccanHYD Date: Thu, 8 Oct 2026 05:38:57 +0200 Subject: [PATCH 1/4] fix: preserve detail navigation drafts and native exercise identity --- .gitignore | 2 + .../app/data/repository/ExerciseRepository.kt | 85 +++++++----- .../ExerciseRepositoryIntegrityTest.kt | 71 ++++++++++ .../2026-10-08-cloud-transport-evidence.md | 14 ++ .../2026-10-08-native-exercise-integrity.md | 12 ++ .../2026-10-08-push-navigation-parity.md | 56 ++++++++ .../2026-10-08-remaining-parity-execution.md | 32 +++++ web/src/App.tsx | 23 ++-- web/src/features/Plans.tsx | 26 +++- web/src/styles.css | 20 ++- web/src/ui/navigation.ts | 122 +++++++++++++++++- web/tests/navigation-parity.test.ts | 74 +++++++++++ web/tests/push-navigation.test.tsx | 46 +++++++ 13 files changed, 533 insertions(+), 50 deletions(-) create mode 100644 app/src/test/java/com/ironlog/app/data/repository/ExerciseRepositoryIntegrityTest.kt create mode 100644 docs/reviews/2026-10-08-cloud-transport-evidence.md create mode 100644 docs/reviews/2026-10-08-native-exercise-integrity.md create mode 100644 docs/reviews/2026-10-08-push-navigation-parity.md create mode 100644 docs/reviews/2026-10-08-remaining-parity-execution.md create mode 100644 web/tests/push-navigation.test.tsx diff --git a/.gitignore b/.gitignore index 7467f62..05456d8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ Thumbs.db .agents/ .impeccable/ artifacts/ +# Browser automation session snapshots and console logs +.playwright-cli/ # Local CI artifact downloads and rendered previews output/ PROJECT.md diff --git a/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt index a0920d5..4b42faa 100644 --- a/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt +++ b/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt @@ -49,14 +49,14 @@ class ExerciseRepository( observeQuery { exercisesBox.query().build() }.map { rows -> filterRows(rows, filters) } suspend fun getExercisesSnapshot(filters: ExerciseFilters = ExerciseFilters()): List = - withContext(Dispatchers.IO) { filterRows(exercisesBox.all, filters).map(::mapExerciseRowToLegacyShape) } + withContext(Dispatchers.IO) { catalogRows(filters) } suspend fun getCompactCatalogMarkdown(): String = withContext(Dispatchers.IO) { val canonical = context?.let { catalogMarkdownFile(it) } if (canonical != null && canonical.exists()) { return@withContext canonical.readText() } - val generated = buildCompactCatalogMarkdown(filterRows(exercisesBox.all, ExerciseFilters()).map(::mapExerciseRowToLegacyShape)) + val generated = buildCompactCatalogMarkdown(catalogRows()) canonical?.let { it.parentFile?.mkdirs() it.writeText(generated) @@ -80,8 +80,6 @@ class ExerciseRepository( requireNonEmpty(input.equipment, "equipment") requireNonEmpty(input.category, "category") val normalized = normalizeExerciseNameKey(name) - val duplicate = exercisesBox.query(ExerciseEntity_.normalizedName.equal(normalized)).build().use { it.find() } - if (duplicate.isNotEmpty()) error("Exercise with this name already exists.") // Validate every child before the parent is written so a bad contribution cannot leave // behind a partially-created exercise. @@ -111,6 +109,7 @@ class ExerciseRepository( secondaryMusclesJson = input.secondaryMuscles?.let { org.json.JSONArray(it).toString() } } store.runInTx { + requireUniqueName(normalized) exercisesBox.put(created) val muscleCreates = input.muscles.orEmpty().map { m -> ExerciseMuscleEntity().apply { @@ -131,22 +130,26 @@ class ExerciseRepository( suspend fun updateExercise(id: String, input: UpdateExerciseInput): ExerciseEntity = withContext(Dispatchers.IO) { requireNonEmpty(id, "id") - val exercise = findExerciseByUidOrThrow(id) - val now = System.currentTimeMillis() - if (!input.name.isNullOrEmpty()) { - val updatedName = normalizeExerciseName(input.name) - exercise.name = updatedName - // Use normalizeExerciseName first to get a non-null String, then call the - // String overload of normalizeExerciseNameKey (underscore format) — consistent - // with createCustomExercise which also stores in underscore format. - exercise.normalizedName = normalizeExerciseNameKey(updatedName) + val updatedName = input.name?.let { normalizeExerciseName(it).also { name -> requireNonEmpty(name, "name") } } + input.primaryMuscle?.let { requireNonEmpty(it.trim(), "primary_muscle") } + input.equipment?.let { requireNonEmpty(it.trim(), "equipment") } + input.category?.let { requireNonEmpty(it.trim(), "category") } + lateinit var exercise: ExerciseEntity + store.runInTx { + exercise = findExerciseByUidOrThrow(id) + if (updatedName != null) { + val normalized = normalizeExerciseNameKey(updatedName) + requireUniqueName(normalized, exercise.objectBoxId) + exercise.name = updatedName + exercise.normalizedName = normalized + } + input.primaryMuscle?.let { exercise.primaryMuscle = it.trim() } + input.equipment?.let { exercise.equipment = it.trim() } + input.category?.let { exercise.category = it.trim() } + input.notes?.let { exercise.notes = it } + exercise.updatedAt = System.currentTimeMillis() + exercisesBox.put(exercise) } - if (!input.primaryMuscle.isNullOrEmpty()) exercise.primaryMuscle = input.primaryMuscle - if (!input.equipment.isNullOrEmpty()) exercise.equipment = input.equipment - if (!input.category.isNullOrEmpty()) exercise.category = input.category - if (input.notes != null) exercise.notes = input.notes.ifEmpty { "" } - exercise.updatedAt = now - exercisesBox.put(exercise) syncCompactCatalogMarkdown() exercise } @@ -184,7 +187,7 @@ class ExerciseRepository( suspend fun buildExerciseAlternatives(exerciseId: String, filters: ExerciseFilters = ExerciseFilters()): SubstitutionEngine.AlternativesResult = withContext(Dispatchers.IO) { requireNonEmpty(exerciseId, "exerciseId") - val exercises = exercisesBox.all.map(::mapExerciseRowToLegacyShape) + val exercises = catalogRows() val target = exercises.find { it.id == exerciseId || it.exerciseId == exerciseId } ?: return@withContext SubstitutionEngine.AlternativesResult.EMPTY SubstitutionEngine.buildExerciseAlternatives(target, exercises, filters, filters.limit ?: 12) @@ -197,8 +200,33 @@ class ExerciseRepository( internal fun findExerciseByName(name: String): ExerciseEntity? = exercisesBox.query(ExerciseEntity_.name.equal(name)).build().use { it.findFirst() } - internal fun mapExerciseRowToLegacyShape(row: ExerciseEntity): LegacyExerciseShape { - val primary = if (row.primaryMuscle.isNotBlank()) listOf(row.primaryMuscle) else emptyList() + /** Called inside the owning write transaction; duplicate legacy rows are not rewritten. */ + private fun requireUniqueName(normalized: String, exceptId: Long = 0) { + val duplicate = exercisesBox.query(ExerciseEntity_.normalizedName.equal(normalized)).build().use { query -> + query.find().any { it.objectBoxId != exceptId } + } + if (duplicate) error("Exercise with this name already exists.") + } + + /** Batch-load relationships once, avoiding a lazy relation query per catalog entry. */ + private fun catalogRows(filters: ExerciseFilters = ExerciseFilters()): List = store.callInReadTx { + mapExerciseRows(filterRows(exercisesBox.all, filters)) + } + + private fun mapExerciseRows(rows: List): List { + val primaryByUid = musclesBox.all.filter { it.role.equals("primary", true) }.groupBy { it.exerciseUid } + return rows.map { row -> mapExerciseRowToLegacyShape(row, primaryByUid[row.uid].orEmpty().map { it.muscle }) } + } + + private fun metadataStrings(json: String?): List = json?.let { + runCatching { + val array = org.json.JSONArray(it) + (0 until array.length()).mapNotNull { index -> (array.opt(index) as? String)?.trim()?.takeIf(String::isNotBlank) }.distinct() + }.getOrElse { emptyList() } + }.orEmpty() + + internal fun mapExerciseRowToLegacyShape(row: ExerciseEntity, primaryRelationships: List = emptyList()): LegacyExerciseShape { + val primary = (listOf(row.primaryMuscle) + primaryRelationships).map(String::trim).filter(String::isNotBlank).distinct() val category = row.category.ifBlank { "strength" } val trackingType = ExerciseTrackingTypeNormalizer.normalize( name = row.name, @@ -212,23 +240,18 @@ class ExerciseRepository( name = row.name, primaryMuscles = primary, primaryMuscle = row.primaryMuscle.ifBlank { null }, - secondaryMuscles = row.secondaryMusclesJson?.let { json -> - runCatching { - val arr = org.json.JSONArray(json) - (0 until arr.length()).map { arr.getString(it) } - }.getOrElse { emptyList() } - } ?: emptyList(), + secondaryMuscles = metadataStrings(row.secondaryMusclesJson), equipment = row.equipment.ifBlank { "Other" }, category = category, trackingType = trackingType, isCustom = row.isCustom, - aliases = emptyList(), + aliases = metadataStrings(row.aliasesJson), isBodyweight = row.isBodyweight || row.equipment.lowercase() == "bodyweight", movementPattern = row.movementPattern, difficulty = row.difficulty, apparatus = row.apparatusJson, equipmentDetail = row.equipmentDetail, - sourceTags = emptyList(), + sourceTags = metadataStrings(row.sourceTagsJson), notes = row.notes, ) } @@ -244,7 +267,7 @@ class ExerciseRepository( private fun syncCompactCatalogMarkdown() { val ctx = context ?: return val file = catalogMarkdownFile(ctx) - val rows = filterRows(exercisesBox.all, ExerciseFilters()).map(::mapExerciseRowToLegacyShape) + val rows = catalogRows() file.parentFile?.mkdirs() file.writeText(buildCompactCatalogMarkdown(rows)) } diff --git a/app/src/test/java/com/ironlog/app/data/repository/ExerciseRepositoryIntegrityTest.kt b/app/src/test/java/com/ironlog/app/data/repository/ExerciseRepositoryIntegrityTest.kt new file mode 100644 index 0000000..fade5c4 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/data/repository/ExerciseRepositoryIntegrityTest.kt @@ -0,0 +1,71 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.model.CreateExerciseInput +import com.ironlog.app.data.model.UpdateExerciseInput +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity +import com.ironlog.app.data.objectbox.MyObjectBox +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class ExerciseRepositoryIntegrityTest { + @get:Rule val temporary = TemporaryFolder() + private fun input(name: String) = CreateExerciseInput(name = name, primaryMuscle = "Chest", equipment = "Cable", category = "strength") + + @Test fun `concurrent normalized creates persist exactly one identity`() = runBlocking { + val store = MyObjectBox.builder().directory(temporary.newFolder()).build() + try { + val repo = ExerciseRepository(boxStore = store) + val results = coroutineScope { + (0 until 32).map { index -> async { runCatching { repo.createCustomExercise(input(if (index % 2 == 0) " Cable Press " else "Cable Press")) } } }.awaitAll() + } + assertEquals(1, results.count { it.isSuccess }) + assertEquals(1L, store.boxFor(ExerciseEntity::class.java).count()) + } finally { store.close() } + } + + @Test fun `blank and colliding renames fail without modifying stored row`() = runBlocking { + val store = MyObjectBox.builder().directory(temporary.newFolder()).build() + try { + val repo = ExerciseRepository(boxStore = store) + val first = repo.createCustomExercise(input("Cable Press")) + repo.createCustomExercise(input("Cable Fly")) + for (name in listOf("", " ", " cable fly ")) { + assertTrue(runCatching { repo.updateExercise(first.uid, UpdateExerciseInput(name = name, notes = "should not save")) }.isFailure) + val unchanged = repo.getExerciseById(first.uid) + assertEquals("Cable Press", unchanged.name) + assertEquals("", unchanged.notes) + assertEquals(first.updatedAt, unchanged.updatedAt) + } + repo.updateExercise(first.uid, UpdateExerciseInput(name = " CABLE PRESS ", notes = "valid edit")) + assertEquals("valid edit", repo.getExerciseById(first.uid).notes) + } finally { store.close() } + } + + @Test fun `catalog preserves aliases source tags and all primary muscle relationships`() = runBlocking { + val store = MyObjectBox.builder().directory(temporary.newFolder()).build() + try { + val row = ExerciseEntity().apply { + uid = "multi"; name = "Compound Press"; primaryMuscle = "Chest"; equipment = "Cable" + aliasesJson = "[\"Cable Compound\",\" Alternate press \"]"; sourceTagsJson = "[\"seed\",\"compound\"]" + secondaryMusclesJson = "[\"Triceps\"]" + } + store.boxFor(ExerciseEntity::class.java).put(row) + store.boxFor(ExerciseMuscleEntity::class.java).put(listOf("Chest", "Shoulders").map { name -> ExerciseMuscleEntity().apply { exercise.target = row; exerciseUid = row.uid; muscle = name; role = "primary"; contributionFraction = 0.5 } }) + val mapped = ExerciseRepository(boxStore = store).getExercisesSnapshot().single() + assertEquals(listOf("Chest", "Shoulders"), mapped.primaryMuscles) + assertEquals(listOf("Cable Compound", "Alternate press"), mapped.aliases) + assertEquals(listOf("seed", "compound"), mapped.sourceTags) + assertEquals(listOf("Triceps"), mapped.secondaryMuscles) + val exported = ImportExportRepository(store, temporary.newFolder()).exportDatabase().getJSONObject("data") + assertEquals(2, exported.getJSONArray("exercise_muscles").length()) + assertEquals(2, org.json.JSONArray(exported.getJSONArray("exercises").getJSONObject(0).getString("aliases_json")).length()) + } finally { store.close() } + } +} diff --git a/docs/reviews/2026-10-08-cloud-transport-evidence.md b/docs/reviews/2026-10-08-cloud-transport-evidence.md new file mode 100644 index 0000000..444620f --- /dev/null +++ b/docs/reviews/2026-10-08-cloud-transport-evidence.md @@ -0,0 +1,14 @@ +# Browser cloud transport evidence — 8 October 2026 + +Official sources checked during whole-plan implementation: +- OpenAI API overview: https://developers.openai.com/api/reference/overview +- OpenAI TypeScript SDK browser support: https://github.com/openai/openai-node#requirements +- Google API key security: https://ai.google.dev/gemini-api/docs/api-key +- Anthropic TypeScript SDK browser support: https://github.com/anthropics/anthropic-sdk-typescript#requirements +- OpenRouter REST fetch example: https://openrouter.ai/docs/quickstart + +OpenAI and Anthropic SDKs document opt-in browser support while warning about credential exposure. Google advises server-side protection of production keys. This app must never distribute a project/provider credential in its bundle. A user's optional own credential can be entered explicitly for a session and used only for an action they request; the UI must disclose that browser scripts can access it. Configuration can be portable, credentials cannot. No arbitrary-URL credential proxy is authorized or required. + +Provider presets are configuration, not a guarantee that an endpoint accepts the deployed browser origin. Transport failures (including CORS) must preserve manual prompt/paste, offer retry or configuration changes, and never claim generation succeeded. Custom endpoints need HTTPS (loopback HTTP only for local development), no URL credentials/query keys, no redirects carrying auth, bounded responses, timeout/cancellation and obsolete-result suppression. Requests must use headers for keys, avoid logs/raw provider error echo, and exclude photos/unrelated historical data. Built-in mode makes zero requests. + +Use native protocols/presets as the parity reference; let users edit current model IDs. The above verification does not prove every region, provider plan, model or live paid account works. Tests use synthetic responses; no owner key or paid request is used in QA. diff --git a/docs/reviews/2026-10-08-native-exercise-integrity.md b/docs/reviews/2026-10-08-native-exercise-integrity.md new file mode 100644 index 0000000..a93ce18 --- /dev/null +++ b/docs/reviews/2026-10-08-native-exercise-integrity.md @@ -0,0 +1,12 @@ +# Native exercise integrity — 8 October 2026 + +Confirmed against real temporary ObjectBox stores, not source-string assertions: +1. 32 concurrent creates for whitespace/case-normalized identity could persist duplicates. +2. Blank and colliding renames were accepted, allowing invalid catalog identity. +3. Catalog mapping discarded aliases/source tags and reduced multiple primary relationships to the scalar first muscle. + +All three regression tests failed before implementation. After the change all three pass, including persisted-row rollback, exact-one create and export metadata checks. The duplicate check and parent/child write share one write transaction. Rename validates before mutation, re-reads in the transaction and excludes only the edited identity. Catalog reads batch primary relationships in one read transaction; historical workout snapshots and ObjectBox schema IDs are untouched. + +Focused final command on JDK21: :app:testDebugUnitTest --tests com.ironlog.app.data.repository.ExerciseRepositoryIntegrityTest --no-daemon; BUILD SUCCESSFUL in34s, 3 tests /0 failures /0 errors. Full gates and signed release follow integration. + +ObjectBox diagnostics: the initial red and first green run emitted cached read-cursor destruction warnings during JVM teardown. Final focused run after catalog read transaction emitted none; this is a narrow observation, not a claim that every repository/whole-suite teardown is fixed. Official lifecycle documentation identifies cached per-thread readers and closeThreadResources: https://objectbox.io/docfiles/java/current/io/objectbox/BoxStore.html . Retain the full-suite diagnostics investigation until the final gate. diff --git a/docs/reviews/2026-10-08-push-navigation-parity.md b/docs/reviews/2026-10-08-push-navigation-parity.md new file mode 100644 index 0000000..43208aa --- /dev/null +++ b/docs/reviews/2026-10-08-push-navigation-parity.md @@ -0,0 +1,56 @@ +# Push navigation parity — 2026-10-08 + +## Native ownership evidence + +Source: `app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt` in this worktree. `NavHost` registers `Tabs` at line 232. The five-page `Tabs` implementation starts at line 819. Its floating active-workout pill is rendered at lines 989–1007 and its tab bar at lines 1009 onward. Consequently neither control belongs to root destinations pushed above `Tabs`. Root screens use `popBackStack()` rather than recreating a tab destination. The web now follows that ownership for its dedicated detail routes. + +| Web route | Native counterpart / evidence | Shell owner | Direct-entry Back fallback | +| --- | --- | --- | --- | +| `home` | Tabs/Home | Tabs | — | +| `plans` | Tabs/Plans | Tabs | — | +| `log` | Tabs/Log | Tabs | — | +| `stats` | Tabs/Stats | Tabs | — | +| `settings` | Tabs/Settings | Tabs | — | +| `workout` | ActiveWorkout variants, lines 243–316 | Root | Home | +| `plan/:id` | PlanEditor, lines 317–334 | Root | Plans | +| `library` | ExerciseLibrary, lines 335–345 | Root; feature owns header | Settings | +| `exercise/:identity` | ExerciseProgress, lines 359–364; opened from Stats and Library | Root | Stats | +| `body` | BodyWeight, lines 365–395 | Root | Stats | +| `calendar` | WorkoutCalendar, lines 396–404; opened from Stats | Root | Stats | +| `history/new[/:date]` | HistoricalWorkout, lines 405–413 | Root | Log | +| `measurements` | BodyMeasurements, lines 414–435 | Root | Stats | +| `analytics` | VolumeAnalytics, lines 436–441 | Root | Stats | +| `recovery` | RecoveryMap, lines 442–447 | Root | Home | +| `intelligence` | TrainingIntelligence, lines 448–460 | Root | Home | +| `photos` | ProgressPhotos, line 476; opened from Log and Stats | Root | Stats | +| `ledger` | statusWindow, lines 590 onward; opened from Home and Stats | Root | Home | +| `history/:id` | Native HistoryCard expands inside HistoryScreen; web has a dedicated detail page | Web root adaptation | Log | +| `research` | Web-specific research page | Web root adaptation | Home | + +Actual recorded origins override these direct-entry fallbacks. For example, Library → Exercise Progress returns to Library; a photo route opened from Log returns to Log. Fallback routes do not claim that native itself has browser deep-link parents. + +## Shell and history behavior + +`App.tsx` now renders the bottom navigation and resume pill only on the five tab routes. The pill floats above the bottom bar. Tab content with an active session reserves space for both controls. Root content reserves ordinary bottom spacing plus its safe-area inset, without a hidden-tab gap. Library's own header handles the top inset once. + +The shell Back action, local plan Back action, and browser Back/Forward share the plan editor's discard prompt. Keeping edits retains the mounted draft. Explicit discard replays the requested destination. Successful save, confirmed deletion and duplication release the guard before their existing exit. Save failures retain the guard and draft; optimistic plan revision checking remains in `savePlanIfUnchanged`. + +Browser entries carry an index, epoch and scroll position. Indexed cancellation returns to the original editor entry; confirmed navigation traverses the existing chain. Return navigation restores the source document scroll position. Tab-to-tab motion still follows tab ordering; root pushes and returns now report their own forward/back direction rather than inheriting the last tab swipe direction. + +Unknown legacy entries cannot reveal traversal distance or direction. At that boundary the router retains the requested destination and pushes a replacement editor entry in a new epoch. This keeps the unsaved draft editable and makes explicit discard reach the destination. **The unknown forward tail is replaced at this exceptional boundary.** It is not possible to promise exact preservation of an arbitrary preexisting unindexed chain. Indexed navigation retains its chain. A restoration that cannot deliver its expected event falls back after 300 ms; it never silently discards the draft. Completion of restoration requires the matching index, epoch and editor route. + +## Verification performed + +- TDD route matrix: 15 dedicated detail routes hide tab controls and resume pill; five tab routes retain both; persisted active workout survives detail navigation. +- TDD regressions reproduced shell Back bypass, incorrect Calendar/Exercise Progress deep-link parents, missing source scroll recording, missing push/return direction, unindexed predecessor Back and a stalled restoration receiving an unrelated matching-index event. +- Focused Vitest run: **45 tests pass** across push-navigation, navigation-parity, existing plan-editor parity and existing motion parity suites. +- TypeScript check and production build pass. Build still reports existing font-resolution and large-chunk warnings. +- Real Chrome and WebKit CLI workflows on synthetic data at 390 × 844: browser Back cancellation/discard, Forward cancellation/discard, refreshed plan guard, saved-plan reload, successful save exit, active-workout resume, pill/bar geometry, detail footer and source-scroll restoration to 200 px pass. +- Chrome and WebKit legacy-boundary CLI workflows: pre-subscription unindexed Back cancellation/discard, forward reopen, and unindexed future Forward discard pass. +- Wider eight-suite run: 66 tests passed and the first journeys unit test timed out under parallel load; the journeys suite passed all nine tests when run alone. No source changes were made to mask that timeout. + +Ignored reproducible browser helpers and screenshots are under `web/output/playwright/push-navigation-*`. Browser QA uses production assets and synthetic training records. WebKit reports its existing unsupported `interactive-widget` viewport-key warning; no app exception was observed in these checks. + +## Limits + +This slice did not run native builds, a native emulator, physical-device QA or installed iOS/PWA navigation. Native ownership above is source evidence; native runtime evidence is coordinated separately. Native HistoryCard expands inline while the web retains its preexisting dedicated history detail route. Settings destinations and modal editors that remain inside a tab are unchanged. Scroll position is restored; retained list search/filter state is outside this shell slice. diff --git a/docs/reviews/2026-10-08-remaining-parity-execution.md b/docs/reviews/2026-10-08-remaining-parity-execution.md new file mode 100644 index 0000000..e8e3502 --- /dev/null +++ b/docs/reviews/2026-10-08-remaining-parity-execution.md @@ -0,0 +1,32 @@ +# Remaining parity execution ledger — 8 October 2026 + +Baseline: c5ace518, signed .10/code11. Whole-plan authorization includes implementation, validation, GitHub and deployment. Native source in this repository is authoritative; unrelated UnifiedPort work stays preserved. + +| Slice | Status | Acceptance | +|---|---|---| +| Root navigation | Implemented; 320px/desktop runtime checks pending | Route ownership, detail chrome, unsaved editor interception, browser Back/Forward and scroll restoration | +| Native exercise integrity | Implemented; focused tests and both reviews passed | Transactional duplicate/create/rename, metadata fidelity, real ObjectBox regressions | +| Home and interaction motion | Pending | Measured density/geometry, whole gradient cycle, native plan/day/active card controls; drag/swipe/sheets evidence | +| Plan controls and intelligence | Pending | Editor pointer reorder, deload action, today directive, progression rationale | +| History editing and sharing | Pending | Saved date/time/duration/rating, bulk delete/share, chronology and mutation safeguards | +| AI review and optional cloud | Pending | Active gym context, unresolved review lock/rematch, provider/model settings, explicit requests/cancel/retry, summaries/debrief | +| Analytics | Pending | ISO week/program ranges, display-unit chart values, radar/prior overlay, muscle drill, share | +| Portability | Pending | Complete calibration/provenance, native legacy formats, Strong/Hevy reviewed CSV import | +| Existing body/photos/onboarding | Verify | Current supported workflows, reload/retry, byte export/share fallback; no invented missing features | +| Release/workspace | Pending | Full checks, signed update compatibility, deployed verification, organized artifacts and handoff | + +Physical Safari/Home Screen and OEM device certification cannot be replaced by emulation. Record those environmental limits separately from implemented browser-capable features. Android-only integrations remain excluded. No all-parity claim from source inspection alone. + +## Explicit pause checkpoint + +Paused at the user's request on 8 October 2026. Branch: feat/remaining-parity-closure. Nothing from this batch has been pushed, released or deployed; public web and signed .10 APK remain the prior published version. + +Completed locally: full-screen detail shell ownership, active pill/footer spacing, plan dirty-navigation guards including browser/hash boundary recovery, scroll metadata/return restoration, and transactional native exercise identity/metadata fixes. Navigation has 45 focused tests, TypeScript/production build, separate spec/quality reviews and Chrome/WebKit 390px workflows. Native has 3 real-store regressions (all red before, all green after), separate spec/quality reviews and unchanged schema IDs. Full integrated web/native gates remain pending. + +Known navigation adaptation: unknown legacy/unindexed history boundaries preserve the draft/destination but replace the unknown forward tail. 320px and desktop runtime acceptance still needs completion after resumption. A wider parallel unit run had one journeys timeout; all9 journeys passed alone. Final gates should run serially to distinguish resource pressure. + +Motion evidence captured before changing code: signed .10/code11 emulator, 1080x2400, density420 (2.625), system font scale1, Athlete, Full Body Beginner3x/week, active FULL BODY A synthetic session. Ignored evidence output/android/parity-native-cycle.mp4 and parity-native-phase-01..12.png captures the full12-second cycle; parity-native-home.png and parity-native-resume.png show controls. SwiftShader emulator attempts exited; host graphics remained stable with no crash-buffer entries. Emulator stopped safely; its synthetic data was retained. Home controls and gradient density changes are NOT implemented yet. + +Audit confirmed additional real gaps (all pending above): editor exercise pointer drag/deload; saved-history timestamp/duration/rating and bulk/share; AI unresolved import lock/review plus active-gym equipment; optional cloud settings/transport/summaries/debrief; local directive and conservative target/effort-based progression evidence; analytics ISO WEEK/program window, lb chart labels, radar/prior/drill/share; full calibration/provenance portability and legacy SQLite-export JSON/AsyncStorage plus Strong/Hevy/OpenWeight import workflows. Existing body/photo/onboarding source and53 focused tests across9 suites were checked; actual runtime/export/failure checks still required. SQLite legacy means exported JSON schema, not a raw database engine requirement. + +Root workspace preservation: UnifiedPort was not edited or reset. Signing/configuration files were not read, moved or tracked. Existing ignored Vercel staging was retained after its earlier approval rejection. Added .playwright-cli ignore to keep diagnostic snapshots out of publication. Provider research evidence is documentation only; no provider requests/keys were used. diff --git a/web/src/App.tsx b/web/src/App.tsx index 36cd4db..4c6cd2c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,7 +24,7 @@ import { VolumeAnalytics } from "./features/stats/VolumeAnalytics"; import { BodyMeasurements, BodyWeight } from "./features/body/BodyComposition"; import { ProgressPhotos } from "./features/body/ProgressPhotos"; import { Research } from "./research"; -import { backFrom } from "./ui/navigation"; +import { backFrom, isTabRoute, subscribeNavigation } from "./ui/navigation"; const tabs = ["Home", "Plans", "Log", "Stats", "Settings"]; const tabRoutes = tabs.map((tab) => tab.toLowerCase()); const detailTitles: Record = { @@ -71,7 +71,7 @@ export function App() { useLayoutEffect(() => { // Focus the committed route before it can be interacted with. A deferred // animation frame can otherwise steal focus between Enter's key events. - window.scrollTo(0, 0); + window.scrollTo(0, Number(history.state?.ironlogScrollY) || 0); main.current?.focus({ preventScroll: true }); }, [route]); useEffect(() => { @@ -83,15 +83,14 @@ export function App() { ); }, []); useEffect(() => { - const hash = () => { - const nextRoute = location.hash.replace(/^#\//, "") || "home"; + const unsubscribeNavigation = subscribeNavigation((nextRoute, direction) => { const from = tabRoutes.indexOf(routeRef.current); const to = tabRoutes.indexOf(nextRoute); if (from >= 0 && to >= 0 && from !== to) { routeDirection.current = to > from ? "next" : "previous"; - } + } else routeDirection.current = direction; setRoute(nextRoute); - }; + }); const visible = () => { if (document.visibilityState === "visible") { setNow(Date.now()); @@ -104,14 +103,13 @@ export function App() { const theme = (e: StorageEvent) => { if (e.key === "ironlog-theme") applyTheme(currentTheme()); }; - window.addEventListener("hashchange", hash); document.addEventListener("visibilitychange", visible); window.addEventListener("pageshow", visible); window.addEventListener("online", network); window.addEventListener("offline", network); window.addEventListener("storage", theme); return () => { - window.removeEventListener("hashchange", hash); + unsubscribeNavigation(); document.removeEventListener("visibilitychange", visible); window.removeEventListener("pageshow", visible); window.removeEventListener("online", network); @@ -229,7 +227,7 @@ export function App() { ); const active = data.workouts.find((w) => w.status === "active"); - const detail = !["home", "plans", "log", "stats", "settings"].includes(route); + const detail = !isTabRoute(route); const selectedTabRoute = route === "library" ? "settings" : route.startsWith("plan/") ? "plans" : route.startsWith("history/") || route === "calendar" @@ -320,7 +318,7 @@ export function App() { ) : ( <> -
+
{detail && route !== "library" && (
)} - {active && route !== "workout" && ( + {active && !detail && (
- {route !== "library" &&