Skip to content

fix(#620): rebuild cycle templates E2E — root-cause fix, live 1RM/PR wiring, customization#633

Merged
9thLevelSoftware merged 9 commits into
mainfrom
fix/620-cycle-templates-rebuild
Jul 7, 2026
Merged

fix(#620): rebuild cycle templates E2E — root-cause fix, live 1RM/PR wiring, customization#633
9thLevelSoftware merged 9 commits into
mainfrom
fix/620-cycle-templates-rebuild

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

Closes #620.

Root cause (why "Start Workout" did nothing)

A prefix-filter paradox: TemplateConverter tags routines cycle_routine_ to hide them from Daily Routines → RoutineFlowManager filters that prefix out of the shared routines StateFlow → ActiveSessionEngine.loadRoutineFromCycle[Async] looked up routines ONLY in that StateFlow → lookup always missed → return false → UI silently ignored it. Template cycles could never start.

Commit 1 — root-cause fix + all silent failures (shippable alone)

  • DB fallback (getRoutineById) in both cycle lookups
  • Snackbars on load/connection failure; cycle cards render exercise lists via DB-merged routines; explicit "routine data missing" state
  • [HOLD: device validation required] BleConnectionManager.ensureConnection: tapping Start while Connecting/Scanning dropped both callbacks (second independent "nothing happens" path); now restarts with the caller's callbacks, teardown sequenced to avoid racing the new scan

Commit 2 — template normalization + live %-of-1RM/PR resolution

  • 5/3/1 was entirely non-functional (percentageSets = null → 1 set, no reps, 0kg, no AMRAP). Now carries real week-1 prescriptions folded through the 90% training max (setWeightsPercentOfPR = [59, 68, 77]), AMRAP flagged, weekNumber param ready for auto-advancement (5/3/1 week auto-advancement for template cycles #632)
  • ALL template exercises opt into ResolveRoutineWeightsUseCase (usePercentOfPR + ESTIMATED_1RM basis): weights resolve fresh at every workout start from VBT 1RM → stored 1RM → PRs, and grow with the user
  • Never 0kg: 10kg/cable conservative fallback when no data exists
  • Silent day-drop fixed: unresolvable days are kept (empty routine + user-visible warning) instead of vanishing
  • 1RM input step (skippable, prefilled) for all templates; ID-keyed storage
  • Rest days normalized (PPL +1, 5/3/1 +3); templates relocated data.migrationdomain.model

Commit 3 — customization

  • New editable preview between template pick and 1RM step (add/remove/reorder exercises, sets/reps)
  • "Edit Exercises" from cycle-day sheet → RoutineEditor (fixed a third instance of the prefix-filter bug in MainViewModel.getRoutineById)

Verification

  • 2,331 shared tests green, incl. 13 new (cycle-load fallback regression + production-template converter suite)
  • assembleDebug green
  • Device validation checklist (before merge, per HOLD convention):
    • [Bug]: Default Cycle Workout won’t start #620 repro: create 3-Day Full Body → connect → activate → Start Workout → workout begins
    • 5/3/1 cycle: main lifts show 3 sets (5/5/AMRAP) with non-zero resolved weights
    • Double-tap Start while connecting → connection completes, no dead-end
    • Vee_ hardware; Trainer+ if available

Follow-up filed: #632 (5/3/1 week auto-advancement — needs schema + portal DTO parity).

https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz

…ced failures

Root cause: TemplateConverter tags routines "cycle_routine_" to hide them
from Daily Routines; RoutineFlowManager filters that prefix out of the
shared routines StateFlow; ActiveSessionEngine.loadRoutineFromCycle[Async]
looked up routines ONLY in that filtered StateFlow, so Start Workout on any
template-created cycle silently no-opped.

- ActiveSessionEngine: fall back to workoutRepository.getRoutineById() in
  both cycle lookups (sync + async)
- TrainingCyclesScreen: snackbar on load failure and connection failure
  (fresh-start and restart paths); merge DB-fetched cycle routines into
  card display so exercise lists render; explicit "routine data missing"
  state instead of blank fall-through
- BleConnectionManager.ensureConnection: tapping while Connecting/Scanning
  previously dropped both callbacks (second silent no-op path); now cancels
  the stale attempt and restarts with the caller's callbacks, teardown
  sequenced inside the new connection coroutine
  [HOLD: device validation required]
- Tests: CycleRoutineLoadFallbackTest (3 tests) covering DB fallback,
  StateFlow regression, and missing-routine false return

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
…resolution

Template data:
- Relocate CycleTemplates from data.migration (misnomer) to domain.model
- Fix 5/3/1: main lifts now carry FiveThreeOneWeeks.WEEK_1 percentageSets
  (previously null → 1 set, no reps, 0kg, no AMRAP — entirely non-functional)
- Every exercise declares an explicit percentOfOneRm prescription by rep range
- Rest-day normalization: PPL gains day 7, 5/3/1 gains days 5-7

TemplateConverter:
- All exercises opt into ResolveRoutineWeightsUseCase (usePercentOfPR=true,
  scalingBasis=ESTIMATED_1RM): weights resolve fresh each workout start via
  VBT 1RM → stored 1RM → mode PR → cross-mode PR and grow with the user
- 5/3/1 per-set percentages folded through the 90% training max as
  setWeightsPercentOfPR (week 1: 59/68/77; integer+double math — Float
  arithmetic truncated 81% to 80%)
- weekNumber param selects the active 5/3/1 week (infra for auto-advancement)
- DEFAULT_FALLBACK_WEIGHT_KG=10/cable when no 1RM exists anywhere — never 0kg
- Explicit user-configured weight pins absolute and disables live scaling
- BUG 2 fix: days with unresolvable exercises are kept (empty routine +
  warning) instead of silently vanishing and shifting day numbers

Creation flow:
- 1RM input step (skippable) now shown for ALL templates, prefilled from
  PR/stored-1RM; storage keyed by stable exercise ID instead of findByName

Tests: TemplateConverterTest rewritten (10 tests) incl. production-template
regression suite; FakeExerciseRepository.findByIdOrName now mirrors the
production 3-strategy resolution.

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
…y routine editing

Creation-time customization:
- New TemplatePreviewEditSheet between template pick and 1RM input:
  add/remove/reorder exercises, sets/reps steppers, 5/3/1 lifts badged
  (structure driven by the weekly % scheme). Reuses MiniExercisePickerDialog.
- New CycleCreationState.Previewing; flow is now
  template pick -> preview/edit -> 1RM (skippable) -> mode confirm -> create

Post-creation customization:
- AddDaySheet gains optional "Edit Exercises" action; CycleEditorScreen wires
  it to RoutineEditor for the day's routine
- MainViewModel.getRoutineById is now suspend with a DB fallback — the sync
  path searched only the filtered routines StateFlow, so cycle_routine_
  template routines could never be opened in the editor (third instance of
  the #620 prefix-filter bug)

Full shared suite green (2331 tests), incl. DesignSystemRatchetTest
(preview sheet uses MaterialTheme.shapes tokens).

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces template customization and live weight resolution for training cycles, allowing users to preview and edit cycle templates before creation. It also addresses several data-integrity and connection issues, such as implementing a database fallback for loading template-created routines and improving BLE connection stability. The review feedback highlights a potential bug in FakeExerciseRepository where blank names could trigger incorrect fuzzy matches, a rounding issue in TemplateConverter that could result in a 0kg fallback weight, and opportunities to extract hardcoded strings to localized resources for proper internationalization.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 125 to 134
override suspend fun findByIdOrName(id: String?, name: String): Exercise? {
// Mirrors SqlDelightExerciseRepository's 3-strategy resolution:
// 1) direct ID lookup, 2) exact name (trim-tolerant), 3) fuzzy contains-search.
if (id != null) {
exercises[id]?.let { return it }
}
return exercises.values.find { it.name == name }
val trimmed = name.trim()
exercises.values.find { it.name.trim() == trimmed }?.let { return it }
return exercises.values.find { it.name.contains(trimmed, ignoreCase = true) }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If name is blank or empty, trimmed will be "". The fuzzy search it.name.contains(trimmed, ignoreCase = true) will then match any exercise name (since every string contains ""), returning the first available exercise in the repository. To prevent accidental matches on blank names, ensure that the fuzzy search is only performed when trimmed is not empty.

    override suspend fun findByIdOrName(id: String?, name: String): Exercise? {
        // Mirrors SqlDelightExerciseRepository's 3-strategy resolution:
        // 1) direct ID lookup, 2) exact name (trim-tolerant), 3) fuzzy contains-search.
        if (id != null) {
            exercises[id]?.let { return it }
        }
        val trimmed = name.trim()
        if (trimmed.isEmpty()) return null
        exercises.values.find { it.name.trim() == trimmed }?.let { return it }
        return exercises.values.find { it.name.contains(trimmed, ignoreCase = true) }
    }

Comment on lines 181 to 187
val fallbackWeight = if (oneRepMax > 0f) {
kotlin.math.round(
(oneRepMax * (templateExercise.percentOfOneRm / 100f)) * 2,
).toInt() / 2f
} else {
// Use configured weight directly if available, otherwise calculate from 1RM
config?.weightPerCableKg?.takeIf { it > 0f }
?: run {
val oneRepMax = exercise.oneRepMaxKg ?: 0f
if (oneRepMax > 0f) {
// Round to nearest 0.5kg for cleaner weights.
// F381: use round(), not toInt() — toInt() truncates
// (70.9 → 70.5 instead of 71.0).
kotlin.math.round((oneRepMax * DEFAULT_STARTING_WEIGHT_PERCENT) * 2).toInt() / 2f
} else {
0f
}
}
DEFAULT_FALLBACK_WEIGHT_KG
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If oneRepMax is very small (e.g., less than 0.36kg with a 70% ratio), the calculated fallbackWeight can round down to 0.0f. A 0kg command can confuse the machine and read as a broken workout to the user. To prevent this, ensure the calculated fallback weight is coerced to be at least a safe non-zero minimum (such as 0.5f or DEFAULT_FALLBACK_WEIGHT_KG).

Suggested change
val fallbackWeight = if (oneRepMax > 0f) {
kotlin.math.round(
(oneRepMax * (templateExercise.percentOfOneRm / 100f)) * 2,
).toInt() / 2f
} else {
// Use configured weight directly if available, otherwise calculate from 1RM
config?.weightPerCableKg?.takeIf { it > 0f }
?: run {
val oneRepMax = exercise.oneRepMaxKg ?: 0f
if (oneRepMax > 0f) {
// Round to nearest 0.5kg for cleaner weights.
// F381: use round(), not toInt() — toInt() truncates
// (70.9 → 70.5 instead of 71.0).
kotlin.math.round((oneRepMax * DEFAULT_STARTING_WEIGHT_PERCENT) * 2).toInt() / 2f
} else {
0f
}
}
DEFAULT_FALLBACK_WEIGHT_KG
}
val fallbackWeight = if (oneRepMax > 0f) {
val calculated = kotlin.math.round(
(oneRepMax * (templateExercise.percentOfOneRm / 100f)) * 2,
).toInt() / 2f
calculated.coerceAtLeast(0.5f)
} else {
DEFAULT_FALLBACK_WEIGHT_KG
}

Comment on lines +104 to +109
Text(
text = "Customize the exercises below, or continue with the defaults. " +
"Weights are set automatically from your 1RM and PR data.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The user-facing strings in this new component are hardcoded. To support internationalization (i18n) and maintain consistency with the rest of the project, please extract these strings to the project's established locale resource files (e.g., using stringResource(Res.string...)).

Comment on lines +359 to +363
// Issue #620: never fail silently — tell the user why
// the workout didn't start.
snackbarHostState.showSnackbar(
"Couldn't load this workout — routine data is missing. Try editing the cycle.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This user-facing snackbar message is hardcoded. Please extract it to the project's localized string resources (using stringResource(Res.string...)) to ensure proper internationalization (i18n) support.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dae18e84e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

?: EccentricLoad.LOAD_100,
// User-pinned absolute weight disables live scaling; otherwise
// resolve from the template's explicit %-of-1RM prescription.
usePercentOfPR = configuredWeight == null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not treat generated 1RM defaults as pinned weights

When a user enters or already has 1RM/PR data, ModeConfirmationScreen builds ExerciseConfig.fromTemplate(...), which auto-fills weightPerCableKg from that 1RM without the user explicitly pinning a weight. This new check then disables usePercentOfPR for every non-5/3/1 exercise with such an auto-generated config, so created templates store a fixed snapshot and no longer resolve live from future PR/1RM changes; distinguish explicit user edits from default config weights before turning off live scaling.

Useful? React with 👍 / 👎.

updateDay(day.dayNumber) { exercises ->
exercises.mapIndexed { i, ex ->
if (i == exerciseIndex && ex.reps != null) {
ex.copy(reps = (ex.reps + delta).coerceIn(1, 50))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute percent-of-1RM when preview reps change

When a user changes reps in the new template preview, the hidden percentOfOneRm prescription is left unchanged, so the converter still applies the original load to live 1RM/PR data. For example, changing a 10-rep lift to 5 reps keeps the old 65% prescription instead of the 75% used by the template normalization rules; update percentOfOneRm alongside rep edits (and seed it consistently for newly added exercises) so the generated workout matches the displayed reps.

Useful? React with 👍 / 👎.

// THIS caller's callbacks. Previously this path called cancelConnection() and returned,
// dropping both onConnected and onFailed — a silent "nothing happens" dead end
// (issue #620 secondary path). Teardown is sequenced inside the new connection
// coroutine so the fresh scan cannot race the async stopScanning().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: "Teardown is sequenced inside the new connection coroutine so the fresh scan cannot race the async stopScanning()" — that line is doing marketing, not engineering. You cancel connectionJob at L247, which fires its teardown, AND the new launch at L257 immediately calls bleRepository.stopScanning() + cancelConnection() on the same repo (L261-262). Two teardowns racing one fresh scan is still a race — you've just moved it from one inbox to another. Both teardown paths can interleave with the scan they claim to guard.

🩹 The Fix: Pick one side. Either (a) trust connectionJob?.cancel() + a finally { stopScanning(); cancelConnection() } on the old job and delete the explicit teardown block in the new connection coroutine, or (b) drop the eager connectionJob?.cancel() and own the teardown entirely in the new job. Don't do both — that's how you ship a deterministic scanAndConnect followed by a BluetoothGatt characteristic not ready crash.

📏 Severity: warning


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

// Issue #620: never fail silently — tell the user why
// the workout didn't start.
snackbarHostState.showSnackbar(
"Couldn't load this workout — routine data is missing. Try editing the cycle.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: You wrote the exact same snackbar message in two places — L362 here and L804 below for CycleListItem"Couldn't load this workout — routine data is missing. Try editing the cycle." — with the only difference being an em-dash–flavored comment above it. This is the copy-paste equivalent of bringing two identical suitcases to a trip and being surprised when TSA flags you.

🩹 The Fix: Hoist the literal into a private const val WORKOUT_LOAD_FAILED_MSG = "..." at file-top and reference it from both call sites. While you're at it, both branches share the same if (loaded) navigate(...) else showSnackbar(...) shape — extract a small launchWorkoutOrSnackbar(...) helper. One string, one helper, zero justifications for the copy.

📏 Severity: nitpick


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


// Template exercises carry stable library IDs — prefer ID-based lookup over the
// fragile name-only findByName (names can be edited; IDs are stable).
val templateExerciseIds = state.template.days

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: val templateExerciseIds = state.template.days.flatMap { it.routine?.exercises ?: emptyList() }.associate { it.exerciseName to it.exerciseId } (L543-545) — and then ~75 lines later at L618-620 you write the exact same expression under a different name (modeTemplateExerciseIds). Two variables, one brain cell. The IDE's "Find Duplicates" plugin is laughing at this from the dock.

🩹 The Fix: Compute it once at the top of the OneRepMaxInput branch (val exerciseIdByName = state.template.days.flatMap { it.routine?.exercises ?: emptyList() }.associate { it.exerciseName to it.exerciseId }) and reuse it in both the PR-prefill launch and the save loop. One map, two callers, zero justifications for the copy.

📏 Severity: suggestion


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Code Review Roast 🔥

Verdict: No Issues Found | Recommendation: Merge

Incremental scope (7cf4bc4b47e7e7)

Two files (TemplateConverter.kt, TemplateConverterTest.kt), +41 / −23 — the fix for the P2 finding that was already on the PR (chatgpt-codex connector comment: "Avoid saving empty routines as startable cycle days"). The previous round saved an empty Routine and pointed the cycle day at it, which made the cycle card render a startable day whose RoutineFlowManager.loadRoutineAsync would dead-end. The fix is the textbook resolution: don't create the routine at all, set routineId = null, let the existing "No routine assigned → Assign Routine" affordance surface. The cycleDays.add(CycleDay.create(...)) shape is duplicated across the if/else branches, but only two callers and the routineId arg differs — extracting a helper would be ceremony for ceremony's sake. The 7-line IMPORTANT: comment is the load-bearing bit: without the "this is a non-null-routineId-is-startable invariant" explanation, a future Codex round will happily point the day at the empty routine again.

The test is correctly updated to assert routineId == null && !isRestDay and routines.isEmpty() instead of the now-incorrect routines.size == 2 && exercises.isEmpty().

Correctness / Safety Findings

No correctness or safety findings.

Ponytail Review

Ponytail: Lean already. Ship.

One micro-nit that doesn't justify a comment: val routineId = "cycle_routine_${generateUUID()}" at L134 is generated eagerly before the routineExercises.isEmpty() check, so for unresolvable days the UUID string is built and discarded. Moving it inside the else branch saves one generateUUID() call per ghost day. Not worth flagging as a finding — it's one UUID, it ran for free, and the alternative (moving a single-line assignment into the else) trades clarity for nothing measurable. If you're touching this block again, fold it in; otherwise leave it.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge as-is. This incremental cleanly closes the P2 from the previous round (the empty-routine dead-end), and the test now pins the invariant. Remaining external gates: the PR description's [HOLD: device validation required] checklist (still valid for the BLE sequencing path in BleConnectionManager).


🏆 Best part: The fix chose the visible repair path (unassigned day with the existing Assign Routine affordance) over the silent one (non-startable empty routine). When two options both fix the symptom, picking the one the UI already knows how to handle is the senior-engineer move — and the comment explains why, so the next round doesn't "simplify" it back.

💀 Worst part: This fix landed in round 6 to address a P2 that was already on the PR in the previous review. The P2 sat across multiple rounds doing nothing — not because it was wrong, but because each Codex round chased a different symptom instead of reading the existing comment thread. There's a chatgpt-codex-connector comment from rounds ago saying exactly this; treating the bot's prior findings as inputs instead of background is a process bug, not a code bug.

📊 Overall: The "quiet closer" round. The actual change is ~20 lines net, the comment is the real fix (it pins the invariant), and the test was rewritten to assert the right thing. Not flashy, but exactly the round this PR needed to be mergeable.

Files Reviewed (2 files in incremental)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/TemplateConverter.kt — Empty-routine path: drop routine, set routineId = null, keep day for Assign Routine repair flow
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/TemplateConverterTest.kt — BUG 2 regression test updated to assert unassigned days and no empty routines
Previous Review Summaries (6 snapshots, latest commit 7cf4bc4)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 7cf4bc4)

Verdict: No Issues Found | Recommendation: Merge

Incremental scope (4295fe47cf4bc4)

One file (TrainingCyclesScreen.kt), +55 / −31 — Codex round-5: the actual 1RM prefill fix. The pre-existing existingOneRepMaxValues = remember { mutableStateMapOf<String, Float>() } had stable map identity, so the remember(existingOneRepMaxValues, weightUnit) block inside OneRepMaxInputScreen.kt:52 keyed on an identity that never changed — in-place mutation of the map's contents was never observed, and the prefill silently stayed empty for returning users. The Continue button's gate made this a hard dead end: you'd see a blank 1RM form, type a value, hit Continue, and only then would the editor's internal state catch up to what should have been there all along.

The new pattern is the right shape:

  • Build two plain mutableMapOf inside LaunchedEffect(mainLiftNames).
  • Publish one snapshot via existingOneRepMaxValues = oneRepMaxValues.toMap() (and same for PRs) — different identity, so the downstream remember re-keys.
  • Gate the OneRepMaxInputScreen composition on the snapshot being non-null, rendering a CircularProgressIndicator for the local-DB lookup frame.

The two-axis design (new identity and composed-once gating) is the textbook Compose remedy for this exact identity-trap — leaving either out would be wrong. The double snapshot is symmetric between 1RM and PR maps, the existingPrWeightValues stays as a non-null empty-map so the onConfirm site doesn't need a ?:, and the val loadedOneRepMaxValues = existingOneRepMaxValues alias earns its keep by enabling smart-cast on the null check.

Correctness / Safety Findings

No correctness or safety findings.

Ponytail Review

Ponytail: Lean already. Ship.

The 11-line loading gate (the if (loadedOneRepMaxValues == null) { Box + CircularProgressIndicator } else { OneRepMaxInputScreen(...) }) is the minimum surface that satisfies the snapshot invariant. The 6-line IMPORTANT: comment is dense but load-bearing — without it, a future Codex (or sleepy human) will "simplify" the mutableStateOf<Map?>(null) back to mutableStateMapOf<String, Float>() and reintroduce the regression. The mutableStateMapOf import was correctly dropped (no other call site), and mutableStateOf is already imported from earlier commits. Net of what could be cut: nothing worth cutting.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge as-is. The previous round-4 P2 (#635, catalog equipment=[] data quality) is still the load-bearing external ticket — it's now downstream of a prefill that actually works, so a user can finally edit a cycle, see their real 1RMs pre-populated, accept the defaults, and trigger whatever the isBodyweight flag misclassification is supposed to surface. The remaining external gate is the PR description's [HOLD: device validation required] checklist (still valid for the BLE sequencing path in BleConnectionManager).


🏆 Best part: The fix identifies the real root cause — Compose remember keys on map identity, not contents — and applies the textbook remedy (new identity + composition gate) instead of papering over with a LaunchedEffect that mutates the map and prays. Whoever wrote this understood the framework.

💀 Worst part: This bug was always there. Every Codex round before round-5 added a 1RM step in some form, and not one of them triggered the prefill path because unit tests don't render Compose. The fact that it took five rounds and a real-device repro to surface a Compose-state-management 101 issue is exactly why snapshot integration tests exist. There don't appear to be any.

📊 Overall: The slow-but-eventually-correct fix. Five rounds to land a mutableStateOf<Map?>(null) and a spinner, but the result is small, the comment prevents the next regression, and the path is finally unblocked. If round-6 wants to be useful, it's a @Preview for OneRepMaxInputScreen with a non-empty prefill — that would have caught this on day one.

Files Reviewed (1 file in incremental)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/TrainingCyclesScreen.kt — Codex round-5: 1RM/PR prefill as immutable snapshot with composition gate

Previous review (commit 4295fe4)

Verdict: No Issues Found | Recommendation: Merge

Overview

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 0

Incremental scope (04f77744295fe4)

One file (TemplatePreviewEditSheet.kt), +8 / −4 — the Codex round-4 reversal of the round-2 isBodyweight sentinel. The previous if (exercise.isBodyweight) null else ProgramMode.OldSchool was an evidence-free default on round-2; this push replaces it with an evidence-backed one, citing the bundled catalog: Squat and Good Morning ship with equipment=[] (so Exercise.isBodyweight returns true), while Crunch ships with HANDLES (so it returns false). Trusting isBodyweight in the picker silently disabled live %-of-1RM scaling for two of the program's main lifts — a far worse failure than the inverse.

The new fallback (suggestedMode = ProgramMode.OldSchool always) chooses the less catastrophic direction: a wrongly-cable addition gets a visible, skippable 1RM prompt and an editable weight in ModeConfirmationScreen; a wrongly-bodyweight addition gets silent no-scaling on a major lift. The catalog data-quality problem is filed in #635 for a real fix instead of papering over it here. Net +4 lines — all of it the explanatory comment that prevents the next Codex round from re-flipping this and re-introducing the regression.

Correctness / Safety Findings

No correctness or safety findings.

Ponytail Review

Ponytail: Lean already. Ship.

The comment is dense (six lines) but every line earns its keep: it names the specific catalog offenders, the failure-direction trade-off, and points at the tracking issue (#635). Without it, someone with the exact same intent as round-2 would "fix" this back. The dedupe guard at TemplatePreviewEditSheet.kt:209 is preserved unchanged from round-2 and still works correctly — two same-name picks on one day remain a no-op rather than a key collision.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge as-is. The previous round-2 P2 (#3538193146, preserve bodyweight status) is now explicitly rebalanced against the catalog unreliability finding — the underlying Exercise.isBodyweight consumer list is tracked in #635. The code is sound, the unit-test gate is green, and the only remaining external dependency is the PR description's [HOLD: device validation required] checklist (still valid for the BLE sequencing path).


🏆 Best part: The author checked the actual catalog before deciding which way round-2 was wrong. equipment=[] shipping on Squat and Good Morning is the kind of detail that makes prompt-driven reviews look like they were written by people who have never opened a JsonObject in their life. This push has the receipts.

💀 Worst part: The data-quality problem (Exercise.isBodyweight is wired to a field the catalog can't be trusted to populate) is load-bearing across multiple consumers — picker, portal sync flag, and (presumably) whatever else decided isBodyweight was a usable signal. Every consumer of that flag is one copy-paste of a bad catalog row away from silently misclassifying lifts. Filed in #635, but the filing doesn't make the blast radius smaller.

📊 Overall: Four round-trips in, this thread is finally boring in the right way — one surgical behavioral default backed by catalog evidence, one third-party ticket for the underlying data, no new abstractions, no new flags. If round-5 is just "someone merged #635 and we deleted the comment," I'll take it.

Files Reviewed (1 file in incremental)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/cycle/TemplatePreviewEditSheet.kt — Codex round-4: always-cable stamp for picker-added exercises, with catalog-evidence rationale

Previous review (commit 04f7774)

Verdict: No Issues Found | Recommendation: Merge

Overview

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 0

Incremental scope (e57129604f7774)

One file (ModeConfirmationScreen.kt), +13 / −8 — the Codex round-3 fix for first-weight-edit detection. The display fallback (exerciseConfigs[name] ?: ExerciseConfig.fromTemplate(...)) was previously recomputed twice: once inline for the card, once inside onConfigUpdated via previous. The previous lookup crashed when an exercise was never seeded, silently storing userEditedWeight = false on the user's first explicit weight override — so TemplateConverter ignored the chosen weight and live %-of-1RM stayed on.

The fix hoists that expression into a single displayedConfig local. Now there's one source of truth for both the card's display and the edit baseline. The new condition reads displayedConfig.userEditedWeight || newConfig.weightPerCableKg != displayedConfig.weightPerCableKg — which trivially fires on first edit (the fromTemplate default has userEditedWeight=false, the user's new weight differs) and stays true on subsequent edits (the saved config carries the flag forward). No-op modal closes correctly preserve the existing flag. Non-weight field edits (echo level, eccentric load) correctly leave userEditedWeight untouched.

Correctness / Safety Findings

No correctness or safety findings.

Ponytail Review

Ponytail: Lean already. Ship.

The hoist is the right kind of refactor — same expression, two call sites, one variable. The previous code did the ?: fromTemplate(...) dance twice. The new code does it once. That's a net line reduction and a DRY win. No new helpers, no new flags, no speculative future-proofing.

Ponytail net: −2 lines (and one fewer mental model).

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge as-is. The remaining Codex P2 thread (#3538236481) about first-weight-edit tracking is now resolved by this incremental — the comment's anchor line has changed, the bug it flagged is fixed, the closure no longer captures a null previous. Still subject to the [HOLD: device validation required] checklist on the PR description for the BLE sequencing path.


🏆 Best part: The fix isn't a new flag or a new helper — it's the absence of duplicated logic. The display path and the edit-detection path were doing the same ?: fromTemplate(...) dance. Folding them into one displayedConfig is the kind of cleanup that makes a future reader say "oh, this can't drift" instead of "I hope these stay in sync."

💀 Worst part: I can't roast it. The displayedConfig.userEditedWeight || short-circuit on the left correctly handles the "previously pinned, no change now" case, and the != on the right correctly handles the "first edit, weight changed" case. Even the Float inequality is safe here because both sides flow through the same kgToDisplay round-trip the modal uses.

📊 Overall: Three commits in, this thread is converging. The previous review's P1 (pin gate) and P2 (preview rep consistency) were already landed; this incremental closes the last open Codex P2 (first-edit detection). One surgical hoist, zero new concepts.

Files Reviewed (1 file in incremental)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ModeConfirmationScreen.ktdisplayedConfig hoist + edit-baseline fix

Previous review (commit e571296)

Verdict: No Issues Found | Recommendation: Merge

This incremental (e571296 over 604e319) tightens exactly the two seams the previous review left dangling. Two files, +23/−11 — the kind of diff a senior reviewer wishes for.

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 0

What landed

TemplatePreviewEditSheet.kt (L207–L223) — Two surgical hardenings:

  1. Same-day dedupe guardif (exercises.any { it.exerciseName == exercise.name }) exercises else …. The cheapest possible fix for the ModeConfirmationScreen name-key collision the previous review hinted at. New exercise addition is now a no-op on a duplicate, which is exactly what the subsequent LazyColumn keys need.
  2. Bodyweight sentinelsuggestedMode = if (exercise.isBodyweight) null else ProgramMode.OldSchool. Now the TemplateConverter isBodyweight branch (suggestedMode == null) actually fires for Plank / Crunch / Sit-up picked from the catalog, instead of silently slamming them with 0.7 × oneRepMax through the cable path.

ModeConfirmationScreen.kt (L173–L178)itemsIndexed with an index-bearing key. Pure belt-and-suspenders for any pre-existing cycles that already had duplicates (the picker now prevents the creation path; this guard handles the read path). The key shape "${dayNumber}_${index}_${exerciseName}" is the minimum that keeps the legacy data from crashing the list.

No new correctness findings on changed lines. The two external P2 file-level threads (chatgpt-codex #633 review: "Keep duplicate additions from re-adding" and "Preserve bodyweight status") are exactly the two patches this push contains — both resolved.

Correctness / Safety Findings

No correctness or safety findings.

Ponytail Review

Ponytail: Lean already. Ship.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge as-is once the [HOLD: device validation required] checklist on the PR description is signed off (3-Day Full Body Start, 5/3/1 week-1 weights, double-tap Start, hardware). The code in this push is sound — the only thing left is the physical-machine proof.


🏆 Best part: The bodyweight sentinel uses the existing Exercise.isBodyweight flag from the domain model — no new "isBodyweight-ish" property, no second source of truth, no hand-rolled name.contains("Plank") heuristic. Just one boolean the picker already has access to.

💀 Worst part: I can't roast anything in this diff. The dedupe uses any { it.exerciseName == exercise.name } instead of a HashSet, but n ≤ ~10 exercises on a day so the linear scan is invisible. Even my strict-mode nit rack came up empty.

📊 Overall: Two surgical P2 fixes — one structural (no-op on duplicates), one semantic (preserve the null sentinel). The kind of incremental that shows the previous review actually landed.

Files Reviewed (2 changed in incremental)
  • shared/.../components/cycle/TemplatePreviewEditSheet.kt — same-day dedupe + bodyweight sentinel
  • shared/.../screen/ModeConfirmationScreen.ktitemsIndexed key guard for legacy duplicates

Previous review (commit 604e319)

Verdict: Approve | Recommendation: Ship — incremental fixes cleanly resolve every prior finding

Overview

Severity Count
🚨 critical 0
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 0

All findings from the previous review pass are resolved. Three additional hardening fixes landed alongside them:

Resolved in this push:

  • BLE teardown racestaleJob?.cancelAndJoin() now runs before stopScanning()/cancelConnection(), so the old job's finally block fully unwinds before the fresh scan starts. Single, deterministic cancellation path. The misleading “Teardown is sequenced …” comment is replaced with one that actually describes what the code does.
  • Bodyweight scalingisBodyweight = templateExercise.suggestedMode == null short-circuits both the fallbackWeight and usePercentOfPR paths, so Plank/Crunch get the light fixed default instead of 0.7 × oneRepMax.
  • Auto-filled configs silently pinning live scaling (P1) → new ExerciseConfig.userEditedWeight flag, propagated correctly by ModeConfirmationScreen (set true only when the user actually changed the value or it was already true), gates configuredWeight in the converter.
  • Snackbar duplicationWORKOUT_LOAD_FAILED_MESSAGE / CONNECTION_FAILED_MESSAGE constants hoist the strings.
  • templateExerciseIds / modeTemplateExerciseIds duplicationCycleTemplate.exerciseIdsByName() helper.

Extra hardening the PR picked up along the way:

  • coerceAtLeast(0.5f) on the fallback weight — tiny 1RMs no longer round to 0 kg.
  • defaultPercentOfOneRmForReps(reps) in TemplateModels.kt keeps the %-of-1RM prescription in sync with the displayed rep range, both for rep-stepper edits in the preview sheet and for newly added exercises.
  • FakeExerciseRepository.findByIdOrName returns null for blank input instead of fuzzy-matching every exercise.
  • Two new tests cover the bodyweight path and the 0.5 kg floor; the production-template sweep now also asserts declared percentOfOneRm matches the normalization rule for every non-percentage, non-bodyweight exercise.

Correctness / Safety Findings

No correctness or safety findings.

Ponytail Review

Ponytail: Lean already. Ship.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge as-is. The [HOLD: device validation required] gate on the PR description is the only remaining external dependency — the code itself is sound, the race is closed, the unit-level tests cover the new branches, and the previous-review P1 (auto-fill silently pinning) is explicitly defended against in both TemplateConverter and ModeConfirmationScreen.


🏆 Best part: The userEditedWeight flag. It is the smallest possible addition that fixes the P1 — no new abstraction, no second config map, no rewrite of the resolution chain — just one boolean that correctly distinguishes "the user typed a number" from "we filled this in for them."

💀 Worst part: Honestly nothing in this diff. The previous worst part (the lying comment + double teardown) is now a single cancelAndJoin and a comment that finally matches reality.

📊 Overall: Like the second pancake of the batch — same ingredients, better cook. Previous review's loudest complaint is now the simplest line in the file (staleJob?.cancelAndJoin()). I'd eat this PR for breakfast.

Files Reviewed (9 changed in incremental)
  • shared/.../domain/model/ExerciseConfig.ktuserEditedWeight flag
  • shared/.../domain/model/TemplateModels.ktdefaultPercentOfOneRmForReps
  • shared/.../domain/usecase/TemplateConverter.kt — bodyweight path + 0.5 kg floor + pin-via-flag
  • shared/.../components/cycle/TemplatePreviewEditSheet.kt — rep/percent sync on edit
  • shared/.../manager/BleConnectionManager.ktcancelAndJoin deterministic sequencing
  • shared/.../screen/ModeConfirmationScreen.kt — propagate userEditedWeight only on real change
  • shared/.../screen/TrainingCyclesScreen.kt — hoist snackbar consts + exerciseIdsByName() helper
  • shared/.../test/.../TemplateConverterTest.kt — bodyweight + floor + auto-fill pin tests + normalization sweep
  • shared/.../test/.../FakeExerciseRepository.kt — blank-name guard

Fix these issues in Kilo Cloud

Previous review (commit dae18e8)

Verdict: Request changes | Recommendation: Address the BLE teardown race before merge; Ponytail cleanups optional

Overview

Severity Count
🚨 critical 0
⚠️ warning 1
💡 suggestion 1
🤏 nitpick 1
Issue Details (click to expand)
File Line Roast
shared/.../manager/BleConnectionManager.kt 242 "Teardown is sequenced inside the new connection coroutine so the fresh scan cannot race the async stopScanning()" — that line is doing marketing, not engineering. Two teardowns racing one fresh scan is still a race.
shared/.../screen/TrainingCyclesScreen.kt 362 / 804 Same snackbar string duplicated across both ActiveCycleCard and CycleListItem failure paths.
shared/.../screen/TrainingCyclesScreen.kt 543 / 618 templateExerciseIds and modeTemplateExerciseIds are the same .associate { name to id } expression written twice.

Verdict

Request changes — author acknowledgment of the BLE race window is the right call (the PR description tags it [HOLD: device validation required]), but the comment at L242 overpromises sequencing the code can't deliver. Tighten that, ship it.

Correctness / Safety Findings

  • warning: BleConnectionManager.kt:242 — Claims "teardown is sequenced … so the fresh scan cannot race the async stopScanning()" but both the cancelled old job and the new job's body call stopScanning() + cancelConnection() against the same bleRepository. Two teardowns racing one scan is still a race window. Pick one cancellation path. Device validation (the marked [HOLD]) is the right risk gate here.
  • minor: MainViewModel.kt:475getRoutineById flipped from non-suspending to suspend. The RoutineEditor screen presumably calls this; if anywhere was calling it synchronously outside a coroutine context, that file will now fail to compile. Worth a quick grep of callers before merge.
  • minor: TemplateConverter.kt:200-201 — Comment in CycleTemplates.kt says "Bodyweight/core exercises (Plank, Crunch) use suggestedMode = null; the converter maps them to OldSchool at a light fallback weight" — but Plank/Crunch carry no explicit percentOfOneRm, so they default to 70 and the converter still applies 70% of any resolved 1RM. Comment is aspirational; the code will hand a cable machine a 70%-of-1RM plank command if the user has 1RM data. Add percentOfOneRm = 0 (and skip the percent assignment) for true body-weight exercises.

Ponytail Review

  • shared/.../screen/TrainingCyclesScreen.kt:362 / 804: reuse — same "Couldn't load this workout — routine data is missing. Try editing the cycle." literal duplicated; hoist to a top-level private const val WORKOUT_LOAD_FAILED_MSG. While there, both call sites are structurally identical if (loaded) navigate else showSnackbar — extract launchWorkoutOrSnackbar(scope, …).
  • shared/.../screen/TrainingCyclesScreen.kt:543 / 618: reuse — state.template.days.flatMap { it.routine?.exercises ?: emptyList() }.associate { it.exerciseName to it.exerciseId } written twice under different names (templateExerciseIds, modeTemplateExerciseIds). Compute once at top of the OneRepMaxInput branch and reuse.
  • shared/.../screen/TemplatePreviewEditSheet.kt:411: shrink — the ExerciseEditRow / StepperControl / RestDayRow / TrainingDayCard ladder is a lot of machinery to "customize a template" before creation. If the preview sheet ends up rarely-used once users trust the defaults, it's a candidate for c

[Snapshot truncated.]

Additional previous summary content was truncated to keep this comment within platform limits.


Reviewed by minimax-m3 · Input: 32.4K · Output: 4.4K · Cached: 152.4K

…t sentinel, BLE sequencing

Codex P1 (confirmed regression): ModeConfirmationScreen auto-fills config
weights from 1RM (ExerciseConfig.fromTemplate), which the converter treated
as user-pinned — entering 1RMs silently disabled live %-of-1RM scaling for
every non-5/3/1 exercise. ExerciseConfig gains userEditedWeight; only an
explicit user edit pins an absolute weight now.

Codex P2: rep edits in the template preview now recompute percentOfOneRm via
the shared defaultPercentOfOneRmForReps() rule (also seeds added exercises),
so resolved loads match displayed reps.

Gemini: fallback weight floors at the 0.5kg machine increment (tiny 1RM
could round to 0kg); FakeExerciseRepository returns null for blank names
instead of fuzzy-matching everything.

Kilo (warning): ensureConnection now has a single cancellation path — the
new connection coroutine cancelAndJoin()s the previous job before radio
teardown and the fresh scan, making the sequencing deterministic rather
than claimed. [HOLD: device validation still required]
Kilo (minor): bodyweight sentinel (suggestedMode == null) honored — Plank/
Crunch get the light fixed default, never 70% of a resolved 1RM.
Kilo (cleanups): snackbar copy hoisted to consts; duplicate template
ID-map builds extracted to CycleTemplate.exerciseIdsByName().

Tests: +4 (auto-config non-pin, 0.5kg floor, bodyweight sentinel,
normalization-rule consistency across production templates).

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
@9thLevelSoftware

Copy link
Copy Markdown
Owner Author

Review feedback addressed in 604e319:

Fixed

  • Codex P1 (confirmed, most important find): ModeConfirmationScreen auto-fills config weights from 1RM via ExerciseConfig.fromTemplate, so the converter's "configured weight pins absolute" check disabled live scaling for anyone who entered their 1RMs — the recommended path. ExerciseConfig now carries userEditedWeight; only an explicit edit in the config modal pins an absolute weight. Regression test added.
  • Codex P2: rep edits in the template preview recompute percentOfOneRm through a shared defaultPercentOfOneRmForReps() rule (also seeds newly added exercises). A new test locks all production template prescriptions to that rule.
  • Gemini: fallback weight now floors at the 0.5kg machine increment (tiny 1RM could round to 0kg); FakeExerciseRepository returns null for blank names instead of fuzzy-matching everything.
  • Kilo warning (BLE): fair hit on the comment overpromising. Now a single cancellation path: the new connection coroutine cancelAndJoin()s the previous job — waiting for it to fully unwind — before radio teardown and the fresh scan. Deterministic by construction, not by hope. [HOLD: device validation required] stands.
  • Kilo minor (Plank/Crunch): suggestedMode == null is now honored as the bodyweight sentinel — light fixed default, never 70% of a resolved 1RM. Test added.
  • Kilo cleanups: snackbar copy hoisted to consts; duplicate ID-map builds extracted to CycleTemplate.exerciseIdsByName().

Not fixed, with rationale

  • Gemini i18n (×2): the surrounding code in these exact files is largely hardcoded English ("UP NEXT", "Rest Day - Take it easy!", "Add to Cycle", "Create Training Cycle"). Extracting only the new strings buys nothing; a repo-wide i18n sweep should be its own issue.
  • Kilo getRoutineById suspend concern: verified — the only caller is RoutineEditorScreen's LaunchedEffect (suspend context), and CI compiled green on the previous push.
  • Kilo "collapse the preview sheet": creation-time editing was an explicit product decision for this rebuild; revisit with usage data.

Shared suite: 2335 tests green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 604e31907f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

onDismiss = { pickingForDay = null },
onExerciseSelected = { exercise ->
updateDay(dayNumber) { exercises ->
exercises + TemplateExercise(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep duplicate additions from reusing lazy-list keys

When a user adds an exercise that is already present on the same day, this unconditional append creates two TemplateExercise entries with the same exerciseName. The next ModeConfirmationScreen renders those rows with the key ${day.dayNumber}_${exercise.exerciseName}, so same-day duplicates reuse a LazyColumn key and can crash or mix row state before the cycle is created; either prevent duplicate additions here or include a per-row unique value in the downstream key.

Useful? React with 👍 / 👎.

exerciseName = exercise.name,
sets = 3,
reps = 10,
suggestedMode = ProgramMode.OldSchool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve bodyweight status for picked exercises

When the picker returns a no-cable/bodyweight exercise such as Plank or Crunch, this always stamps it with a non-null suggestedMode. The creation flow uses suggestedMode != null as the cable predicate in the 1RM/mode screens, and TemplateConverter only disables live PR scaling when suggestedMode == null, so added bodyweight exercises get cable-style 1RM prompts and %1RM loading instead of the template bodyweight handling.

Useful? React with 👍 / 👎.

…ght semantics

Codex round-2 (both confirmed):
- Same-day duplicate additions created two TemplateExercise rows with one
  name; ModeConfirmationScreen keyed rows by day+name → LazyColumn key
  collision (crash) and shared name-keyed config. Preview now skips adding
  an exercise already on the day; ModeConfirmation key includes the index
  as a defense-in-depth guard.
- Picked bodyweight exercises (Exercise.isBodyweight) were stamped with
  suggestedMode=OldSchool, opting them into cable 1RM prompts and %-of-1RM
  loading. The picker now preserves the suggestedMode=null bodyweight
  sentinel.

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
@9thLevelSoftware

Copy link
Copy Markdown
Owner Author

Codex round-2 addressed in e571296: same-day duplicate additions are now skipped in the preview (plus index-hardened LazyColumn keys in ModeConfirmationScreen as defense-in-depth), and picked bodyweight exercises keep the suggestedMode = null sentinel via Exercise.isBodyweight instead of being stamped OldSchool. Full shared suite green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e57129682a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +196 to +197
userEditedWeight = (previous?.userEditedWeight == true) ||
(previous != null && newConfig.weightPerCableKg != previous.weightPerCableKg),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track first weight edits as user-pinned

When the user opens a default exercise card and changes the weight for the first time, previous is null because the auto-generated ExerciseConfig.fromTemplate(...) was never inserted into exerciseConfigs; this condition therefore stores userEditedWeight = false even though the modal weight changed. TemplateConverter only honors weightPerCableKg when userEditedWeight is true, so the user's first explicit weight override is silently ignored and the created cycle keeps live %-of-1RM loading instead of the chosen fixed weight.

Useful? React with 👍 / 👎.

Codex round-3: userEditedWeight was computed against exerciseConfigs[name],
which can be null if an exercise was never seeded — a first explicit edit
would then store userEditedWeight=false and the converter would ignore the
user's chosen weight. Edit detection now compares against the exact config
the card displays (map entry or the fromTemplate fallback), so it cannot
drift from the seeding logic.

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
@9thLevelSoftware

Copy link
Copy Markdown
Owner Author

Codex round-3 addressed in 04f7774: edit detection now baselines against the exact config the card displays (map entry or fromTemplate fallback) instead of the raw map lookup — a first explicit weight edit is always tracked as user-pinned, regardless of seeding. Suite green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04f7774519

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Preserve bodyweight semantics: suggestedMode == null is the
// sentinel the 1RM/mode screens and TemplateConverter use to
// exclude an exercise from cable %-of-1RM handling.
suggestedMode = if (exercise.isBodyweight) null else ProgramMode.OldSchool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use template bodyweight semantics for added exercises

When the user adds exercises whose catalog equipment metadata disagrees with the template sentinel, this stamps the wrong suggestedMode: fresh evidence is that the built-in templates mark Crunch as bodyweight with suggestedMode = null while the bundled catalog gives it a cable accessory, and conversely templates treat Squat/Good Morning as cable even though their catalog entries have no cable accessory. Because the next 1RM/mode screens and TemplateConverter key entirely off suggestedMode, adding one of these exercises through the preview either enables cable PR loading for intended bodyweight work or skips 1RM/live scaling for intended cable work; use the same template/bodyweight allowlist or a reliable capability flag instead of Exercise.isBodyweight here.

Useful? React with 👍 / 👎.

…ght is unreliable

Codex round-4 (verified against exercise_dump.json): Squat and Good Morning
ship with equipment=[] so Exercise.isBodyweight returns TRUE for them, while
Crunch ships with HANDLES (false). Trusting isBodyweight in the picker
silently disabled live %-of-1RM scaling for added major lifts. Added
exercises now always get suggestedMode=OldSchool: the wrong-direction cost
is a visible, skippable 1RM prompt instead of silent no-scaling.

Catalog data quality tracked separately (empty equipment on cable lifts
affects every isBodyweight consumer).

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
@9thLevelSoftware

9thLevelSoftware commented Jul 7, 2026

Copy link
Copy Markdown
Owner Author

Codex round-4 addressed in 4295fe4 — and it surfaced something real: verified against the bundled catalog, Squat and Good Morning ship with equipment=[] (so Exercise.isBodyweight returns true for them) while Crunch ships with HANDLES. Trusting isBodyweight in the picker silently disabled live scaling for added major lifts — the worst failure direction. Preview-added exercises are now always stamped cable (OldSchool): if that's wrong for a genuinely-bodyweight addition, the cost is a visible, skippable 1RM prompt rather than silent no-scaling. The underlying catalog data-quality problem affects every isBodyweight consumer (incl. the portal sync flag) and is now tracked in #635. Suite green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4295fe4462

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

template = state.template,
exerciseRepository = exerciseRepository,
onContinue = { editedTemplate ->
creationState = CycleCreationState.OneRepMaxInput(editedTemplate)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t route prefilled templates into a blank 1RM form

Now that every template continues into OneRepMaxInputScreen, users with existing PR/1RM data hit a form whose fields stay blank: existingOneRepMaxValues is a mutableStateMapOf populated later in LaunchedEffect, while OneRepMaxInputScreen seeds its inputValues with remember(existingOneRepMaxValues, weightUnit), so mutating the same map object does not rebuild the text-field state. In this path the Continue button remains disabled unless the user re-enters values, or they must press Skip, losing the existing values for the confirmation step; pass an immutable snapshot/loading state or key the inputs by the map contents before making this step mandatory for all templates.

Useful? React with 👍 / 👎.

…ally prefills

Codex round-5 (confirmed, pre-existing but amplified by routing all templates
through the 1RM step): existingOneRepMaxValues was a mutableStateMapOf
populated in a LaunchedEffect, but OneRepMaxInputScreen seeds its text
fields in remember(existingOneRepMaxValues, weightUnit) — keyed on map
IDENTITY, so in-place mutation never re-seeded. Returning users saw blank
fields and a disabled Continue.

The 1RM/PR lookups now build plain maps and publish one immutable snapshot;
a brief loading gate composes the form only after the snapshot exists, so
remember sees the final map on first composition. PR weights snapshot the
same way.

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
@9thLevelSoftware

Copy link
Copy Markdown
Owner Author

Codex round-5 addressed in 7cf4bc4 — confirmed real, and pre-existing (the 5/3/1 path had the same defect; routing all templates through the 1RM step made it universal). The prefill map was mutated in place after composition while OneRepMaxInputScreen seeds its fields via identity-keyed remember, so values never appeared. Lookups now publish a single immutable snapshot behind a brief loading gate, so the form composes with the final prefill on first composition. Suite green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7cf4bc462f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +265 to +269
val routine = Routine(
id = routineId,
name = routineTemplate.name,
exercises = routineExercises,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid saving empty routines as startable cycle days

When every exercise on a template day fails to resolve, this still saves a Routine with exercises = emptyList() and points the cycle day at it. The active cycle card treats any non-null routineId as startable, but RoutineFlowManager.loadRoutineAsync returns false for empty routines after loadRoutineFromCycleAsync has already set the active cycle context, so a user who starts the warned day gets a failed start path instead of a clearly repairable/unassigned day; save these days without a routineId or make empty routines non-startable.

Useful? React with 👍 / 👎.

…y-routine startable

Codex round-6: a day whose exercises all failed to resolve was saved with an
empty routine, which the cycle card treats as startable (non-null routineId)
— starting it dead-ends after the cycle context is already set. Such days
are now saved with routineId = null, landing in the existing "No routine
assigned" state with the "Assign Routine" repair affordance. Day numbering
is still preserved (BUG 2 regression guard updated).

Claude-Session: https://claude.ai/code/session_012QHYF2m7nPwancEeQ7CQoz
@9thLevelSoftware

Copy link
Copy Markdown
Owner Author

Codex round-6 addressed in b47e7e7: days whose exercises all fail to resolve are now saved unassigned (routineId = null) instead of pointing at an empty startable routine — they land in the existing "No routine assigned" state with the "Assign Routine" repair button, and day numbering is still preserved. Regression test updated. Suite green.

@9thLevelSoftware 9thLevelSoftware merged commit 4e6ef66 into main Jul 7, 2026
10 checks passed
@9thLevelSoftware 9thLevelSoftware deleted the fix/620-cycle-templates-rebuild branch July 7, 2026 18:05
9thLevelSoftware added a commit that referenced this pull request Jul 7, 2026
… equipment metadata (#636)

* fix(#635): explicit isBodyweight flag — stop deriving from unreliable equipment metadata

Exercise.isBodyweight was derived as !hasCableAccessory from the equipment
string, misclassifying 6 empty-equipment catalog cable lifts (Squat, Good
Morning, Medial Delt Twist x2, Kneeling 45 Degree Kickback, Just Lift) as
bodyweight app-wide: warmup sets silently skipped, BLE start replaced by a
countdown timer, DURATION mode forced, cable controls hidden, and the wrong
flag shipped to the portal on every sync.

Root fixes:
- New nullable isBodyweight column on Exercise and RoutineExercise
  (migration 39; NULL = derive from equipment, 0 = cable, 1 = bodyweight)
  with catalog backfill for the 6 misclassified IDs.
- Exercise gains isBodyweightOverride; isBodyweight now honors the stored
  flag and falls back to equipment derivation for custom exercises and
  pre-migration rows. hasCableAccessory semantics unchanged.
- Kill the pull-sync corruption loop: the portal DTO flag is stored in the
  new RoutineExercise.isBodyweight column instead of writing a "Bodyweight"
  sentinel string into exerciseEquipment (which made snapshot Exercises
  re-derive bodyweight forever). Migration converts existing sentinel rows;
  backup restore converts legacy sentinel backups the same way.
- Push sends ex.exercise.isBodyweight (stored flag) instead of re-deriving.
  Wire format unchanged (per-routine-exercise boolean) — no portal changes.
- Catalog JSON tags the 6 exceptions with "isBodyweight": false; importer
  carries the flag and a KNOWN_NON_BODYWEIGHT_IDS override set protects
  against updateFromGitHub() re-imports from the upstream Vitruvian repo,
  which will never carry the field.
- All direct !hasCableAccessory bodyweight derivations (10 call sites)
  now route through Exercise.isBodyweight.
- Revert the #633 TemplatePreviewEditSheet force-cable workaround: added
  template exercises derive suggestedMode from the now-reliable flag.

Tests: migration-39 backfill/sentinel test (SchemaParityTest), override
precedence tests (ExerciseTest), pull-sync equipment-corruption regression
(SqlDelightSyncRepositoryTest), source guard updated to the new canonical
check. :shared:testAndroidHostTest (2339 tests) and
:androidApp:testDebugUnitTest green; assembleDebug builds.

Closes #635

Claude-Session: https://claude.ai/code/session_015hLWDq5QLjBpRwUYAec23o

* fix(#635): mirror migration 39 in resilient fallback + fix fallback version off-by-one

Codex review (P2) on #636: applyMigrationResilient had no case for
migration 39, so a drifted DB falling back from a failed
Schema.migrate(39, 40) would skip the isBodyweight backfill and
sentinel conversion — reconciliation heals columns but never re-runs
data fixes.

Root-cause found while mirroring: every fallback caller (Android
DriverFactory, iOS DriverFactory, SchemaParityTest) passed version + 1,
but getMigrationStatements is keyed by the .sqm file number (the
version migrated FROM — verified against the generated migrate guards:
37.sqm runs under `oldVersion <= 37`). The resilient path has always
replayed the NEXT migration's statements instead of the failed one's.
Fixed all three callers and documented the key convention.

Also keyed CreateExerciseDialog's usesCables remember on the whole
exercise (classification now depends on isBodyweightOverride, not just
the equipment string) per Gemini review.

New test: migration-39 backfill via the resilient fallback path
(duplicate-column drift). :shared:testAndroidHostTest green.

Claude-Session: https://claude.ai/code/session_015hLWDq5QLjBpRwUYAec23o

* fix(#635): keep derived classification when portal pull omits isBodyweight

Codex review (P2) on #636: PullRoutineExerciseDto.isBodyweight defaulted
to false, so a payload from an older Edge Function that omits the field
was persisted as an explicit 0 (cable) — flipping genuinely-bodyweight
catalog exercises (e.g. Plank) to cable and losing the bodyweight
flow/volume behavior. The DTO field is now nullable: absent -> NULL
stored -> load derives from catalog equipment (pre-#635 fallback).
Current Edge Function always sends the field, so behavior is unchanged
for up-to-date deployments.

Regression covered in the mergePortalRoutines isBodyweight test
(omitted-field case asserts stored NULL).

Claude-Session: https://claude.ai/code/session_015hLWDq5QLjBpRwUYAec23o

* fix(#635): backfill name-only legacy routine rows in migration 39

Codex review (P2) on #636: rows with a NULL or stale exerciseId (which
the loader heals by name at load time) were missed by the id-keyed
force-correct, so a legacy name-only Squat/Good Morning row with empty
equipment would still push as bodyweight until the routine was resaved.
Migration 39 (and its resilient mirror) now also matches the known
cable lifts by trimmed snapshot name, guarded on isBodyweight IS NULL
so explicit flags (e.g. sentinel-converted portal toggles) are
preserved.

Migration test extended: name-only Squat row corrected to 0, name-only
Plank row stays NULL (derived).

Claude-Session: https://claude.ai/code/session_015hLWDq5QLjBpRwUYAec23o

* fix(#635): inherit catalog flag when portal pull omits isBodyweight

Codex review (P2) on #636: storing NULL for an omitted pull field left
catalog cable lifts (Squat: equipment='', catalog flag=0) with no row
override — and the push snapshot builder reconstructs the Exercise from
the RoutineExercise row alone, so the next push re-derived bodyweight
from the empty equipment string and re-poisoned the portal.

Pull now resolves: explicit DTO flag > catalog stored flag > NULL
(derived). Omitted fields on non-catalog / no-explicit-flag exercises
keep the previous NULL behavior.

Regression test extended: omitted field + catalog Squat (explicit 0)
inherits 0; omitted field without catalog match stays NULL.

Claude-Session: https://claude.ai/code/session_015hLWDq5QLjBpRwUYAec23o
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Default Cycle Workout won’t start

1 participant