Skip to content

Codex/profile readiness fixtures#651

Open
9thLevelSoftware wants to merge 100 commits into
mainfrom
codex/profile-readiness-fixtures
Open

Codex/profile readiness fixtures#651
9thLevelSoftware wants to merge 100 commits into
mainfrom
codex/profile-readiness-fixtures

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

No description provided.

@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 implements a comprehensive profile-based training settings system, including a new Profile tab, migration of training preferences to a profile-scoped SQLDelight aggregate, and a robust QA seeding mechanism for local testing. My feedback highlights that the rackSnapshotJson function currently uses brittle hardcoded JSON strings, which should be replaced with the project's JSON serializer to ensure consistency and maintainability.

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 +506 to +512
private fun rackSnapshotJson(profileKey: String, isProfileA: Boolean): String {
val id = rackItemId(profileKey)
return if (isProfileA) {
"[{\"id\":\"$id\",\"name\":\"QA Weighted Vest A\",\"category\":\"WEIGHTED_VEST\",\"weightKg\":10.0,\"behavior\":\"ADDED_RESISTANCE\",\"enabled\":true,\"sortOrder\":1,\"createdAt\":1700000001000,\"updatedAt\":1700000001100}]"
} else {
"[{\"id\":\"$id\",\"name\":\"QA Assistance B\",\"category\":\"ASSISTANCE\",\"weightKg\":7.5,\"behavior\":\"COUNTERWEIGHT\",\"enabled\":true,\"sortOrder\":2,\"createdAt\":1700000002000,\"updatedAt\":1700000002100}]"
}

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 rackSnapshotJson function uses hardcoded JSON strings to create the rack snapshot. This is brittle and can easily break if the RackItem serialization changes. To improve maintainability and ensure consistency with the rest of the app, consider using the project's JSON serializer to generate this string from RackItem data classes.

This would involve creating the RackItem instances and then using a Json instance to encode them into a string, which ensures that the generated JSON is always in sync with the RackItem data class definition and its serialization rules.

private fun rackSnapshotJson(profileKey: String, isProfileA: Boolean): String {
    val json = kotlinx.serialization.json.Json { encodeDefaults = true }
    val rackItem = if (isProfileA) {
        RackItem(
            id = rackItemId(profileKey),
            name = "QA Weighted Vest A",
            category = RackItemCategory.WEIGHTED_VEST,
            weightKg = 10.0f,
            behavior = RackItemBehavior.ADDED_RESISTANCE,
            enabled = true,
            sortOrder = 1,
            createdAt = 1_700_000_001_000L,
            updatedAt = 1_700_000_001_100L,
        )
    } else {
        RackItem(
            id = rackItemId(profileKey),
            name = "QA Assistance B",
            category = RackItemCategory.ASSISTANCE,
            weightKg = 7.5f,
            behavior = RackItemBehavior.COUNTERWEIGHT,
            enabled = true,
            sortOrder = 2,
            createdAt = 1_700_000_002_000L,
            updatedAt = 1_700_000_002_100L,
        )
    }
    return json.encodeToString(listOf(rackItem))
}

@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: eff8470a88

ℹ️ 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".

),
selected = core.weightUnit,
enabled = enabled,
onSelected = { onCoreChange(core.copy(weightUnit = it)) },

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 Reset increments when changing weight units

When a profile switches between KG and LB here, the existing weightIncrement is carried over even though UserPreferences.effectiveWeightIncrement interprets the stored value in the newly selected display unit. For example, a user with a 5 kg step who switches to LB now silently gets a 5 lb step (and a 1 lb step becomes 1 kg when switching back), which changes workout weight adjustments/recommendations; reset to the unit default (-1f) or convert the increment when changing weightUnit.

Useful? React with 👍 / 👎.

@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: efac83b241

ℹ️ 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 +1363 to +1365
if (verbalEvent != null) {
coordinator._hapticEvents.emit(verbalEvent)
Logger.i { "VBT: VERBAL_ENCOURAGEMENT emitted (tier=${verbalEvent.vulgarTier}, dominatrix=${verbalEvent.dominatrixMode}, vulgar=${verbalEvent.vulgarMode})" }

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 Remove the legacy verbal encouragement emission

When a velocity-loss alert fires with verbal encouragement enabled, this new if (verbalEvent != null) block is not closed before the legacy val prefs = ... emission below, so the code emits a second VERBAL_ENCOURAGEMENT using raw vulgarModeEnabled/dominatrixModeActive values. In the inspected workout path, a profile with persisted vulgar/dominatrix intent but adultsOnlyConfirmed == false first emits the neutralized event and then immediately emits an adult-routed event, bypassing the local adult confirmation gate; close this block before the legacy code or remove the legacy emission.

Useful? React with 👍 / 👎.

@kilo-code-bot

kilo-code-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Code Review Roast 🔥

Verdict: No Issues Found | Recommendation: Merge

Someone deleted a feature AND updated the tests AND updated the spec. I'm supposed to be cynical about PRs but this one is just… competent. I don't have a joke for this. Let me look at my notes — no, still nothing. Moving on.

Overview

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

No new inline comments this pass. Re-verified the open review threads against HEAD (655075d9).

Existing thread status

  • 🟡 ProfileQaSeeder.kt (medium, hardcoded rackSnapshotJson JSON literals) — unchanged by this PR.
  • 🟡 DataBackupManager.kt (P2, adopt session backups whose profile is absent) — unchanged by this PR.
  • 🟡 ActiveSessionEngine.kt:1379 (P1, dual-emit verbal encouragement) — unchanged by this PR.
  • 🟡 ActiveSessionEngine.kt:1860 (P2, normalize non-Echo Just Lift defaults) — unchanged by this PR.
  • 🟡 ActiveSessionEngine.kt:1920 (P2, normalize non-Echo single-exercise defaults) — unchanged by this PR.
  • ProfilePreferenceComponents.kt (P2, reset weightIncrement on unit change) — unchanged by this PR; still resolved.

Incremental scope (524f968655075d)

9 files, +12/-74, one commit (655075d9 Remove routine PR default from profile):

  • ProfilePreferenceComponents.kt:148 — removed profileId = profileId, from the WorkoutPreferenceCard call site.
  • ProfilePreferenceComponents.kt:334-339WorkoutPreferenceCard lost the profileId parameter (it was only used as a rememberSaveable key for the deleted draft state).
  • ProfilePreferenceComponents.kt:340-345 (deleted) — removed the authoritativePercentOfPr / percentOfPrDraft / LaunchedEffect triple that backed the deleted slider.
  • ProfilePreferenceComponents.kt:414-451 (deleted) — removed the PreferenceSwitchRow + conditional Slider block (the profile_routine_starting_weights toggle and its 50–120% Slider).
  • strings.xml (×5 locales: en, de, es, fr, nl) — removed profile_routine_starting_weights and profile_routine_starting_weights_description. values-it/strings.xml was never modified (it never had these keys).
  • docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md:545 — bullet rewritten: "Routine % of PR behavior is configured on each routine exercise, so the Profile screen does not expose defaultRoutineExerciseUsePercentOfPR or its 50–120% seeding control. The stored fields remain decodable for compatibility."
  • ProfileResourceContractTest.kt:75, 125 — removed key from the newKeys inventory; assertion assertEquals(55, newKeys.size) (was 56).
  • ProfileScreenContractTest.kt:194-197 — removed profile_routine_starting_weights and profile_routine_starting_weights_description from the English map.
  • ProfileScreenContractTest.kt:251-252 — removed the same two keys from proseThatMustBeTranslated.
  • ProfileScreenContractTest.kt:271 — assertion threshold dropped from >= 19 to >= 17 to match the 2-key removal.
  • ProfileScreenContractTest.kt:457-474 (continuousSlidersDraftLocallyAndCommitOnlyWhenFinished) — Slider count assertion dropped from 2 to 1; removed the assertSliderContract(... authoritativeField = "defaultRoutineExerciseWeightPercentOfPR" ...) block. The remaining velocity-loss slider contract assertion is unchanged.
  • ProfileScreenContractTest.kt:477-500 (renamed from exerciseLevelDefaultsAreAbsentAndRoutinePrSeedingIsExplainedAndConditionalexerciseLevelDefaultsAndRoutinePrSeedingAreAbsentFromProfile) — flipped the assertion direction. Previously asserted the PR seeding strings were present with conditional explanation; now asserts they're absent from workoutCard. Test name now matches the new contract.

Correctness / Safety Findings

No correctness or safety findings on changed lines.

The WorkoutPreferences data class still exposes defaultRoutineExerciseUsePercentOfPR and defaultRoutineExerciseWeightPercentOfPR; ProfilePreferencesCodec still encodes/decodes them; LegacyProfilePreferencesReader still reads them from the legacy STALL_DETECTION-era key map; SettingsManager still exposes StateFlows and setters; PreferencesManager still persists them. This matches the spec's "stored fields remain decodable for compatibility" mandate. Removing the UI control without keeping the persistence path would have broken backup restore for any profile that already had the field set — the engineer did not fall into that trap.

Ponytail Review

  • ProfilePreferenceComponents.kt:340-345 (deleted): delete: authoritativePercentOfPr / percentOfPrDraft / LaunchedEffect(...) { percentOfPrDraft = ... } were scaffolding for the deleted slider. Removed. ✓
  • ProfilePreferenceComponents.kt:414-451 (deleted): delete: the entire PreferenceSwitchRow + if (workout.defaultRoutineExerciseUsePercentOfPR) { Slider(...) } block. Removed. ✓
  • strings.xml ×5 locales: delete: profile_routine_starting_weights and profile_routine_starting_weights_description. Removed from every locale that had them. ✓
  • WorkoutPreferences.defaultRoutineExerciseUsePercentOfPR / defaultRoutineExerciseWeightPercentOfPR: yagni-considered but retained per spec ("stored fields remain decodable for compatibility"). The codec, legacy reader, SettingsManager StateFlows, and setters are all live wires feeding backup-restore and sync paths. Removing them would break compatibility.
  • SettingsManager.kt:269-272 (setDefaultRoutineExerciseUsePercentOfPR, setDefaultRoutineExerciseWeightPercentOfPR): yagni-considered but retained. With the Profile UI gone, the UI doesn't call these setters, but they are the canonical write side of the codec round-trip. Backup-restore and sync code paths need them. Per spec.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed.

Final Merge Guidance

Can merge. This commit is the cleanest possible execution of a UI removal: the unused code is gone, the strings are gone from every locale that had them, the spec doc was updated to match the new contract, and the contract tests now actively assert the absence so re-regression is caught. No new dependencies, no new abstractions, no new tests (the existing ones were tightened), no behavior change for any path that still legitimately needs the stored fields.

🏆 Best part: exerciseLevelDefaultsAndRoutinePrSeedingAreAbsentFromProfile — a test name that literally documents what the feature does NOT do anymore. Whoever renamed this from the old "are explained and conditional" name understood that a removal needs a new test name, not just a tweaked assertion. The list of "must be absent" strings (profile_routine_starting_weights, profile_routine_starting_weights_description, defaultRoutineExerciseUsePercentOfPR, defaultRoutineExerciseWeightPercentOfPR, 50f..120f) is a fingerprint of the deleted feature, and that fingerprint is now codified as a regression test. That's how you delete safely.

💀 Worst part: The spec doc still describes the workout-preferences JSON shape at docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md:351 with "defaultRoutineExerciseUsePercentOfPR":false,"defaultRoutineExerciseWeightPercentOfPR":80 baked in. The fields are correctly retained in the data model per spec, so this is faithful — but if you ever do delete the fields, that line will need to be updated too. Tiny papercut, not in this PR's scope.

📊 Overall: A 62-line net-deletion commit that retires a UI control cleanly. No issues, no new abstractions, no new tests, no behavior change for any caller that still needs the underlying field. The data-model residue is intentional and spec-mandated. This is what "remove a feature" looks like when the engineer actually thought about it for ten minutes instead of just hiding the view.

Files Reviewed (9 files)
  • docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md - 0 issues
  • shared/src/commonMain/composeResources/values-de/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-es/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-fr/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-nl/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values/strings.xml - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt - 0 issues
Previous Review Summaries (3 snapshots, latest commit 524f968)

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

Previous review (commit 524f968)

Verdict: No New Issues Found | Recommendation: Merge

Oh look — the engineer actually read the previous review and fixed it. I had to put down my coffee for this.

Overview

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

Existing thread status

  • ProfileQaSeeder.kt (medium, hardcoded rackSnapshotJson JSON literals) — RESOLVED by 524f9682. rackSnapshotJson now derives from rackPreferences(...).items.single() and routes through the new encodeRackItemsJson helper in shared/src/commonMain/kotlin/com/devil/phoenixproject/util/RackItemJson.kt:9, which uses kotlinx-serialization with encodeDefaults = true. The new QaReleaseBoundaryTest.profile QA rack snapshots use typed serialization instead of JSON literals enforcement test prevents regression. Mark this thread resolved.
  • DataBackupManager.kt (P2, adopt session backups whose profile is absent) — RESOLVED by 524f9682. New private helper resolveImportedProfileId(requestedProfileId, activeProfileId, availableProfileIds) at DataBackupManager.kt:2705-2709 centralizes the policy: requestedProfileId?.takeIf { it in availableProfileIds } ?: activeProfileId. Applied to the buffered path's insert (profile_id = resolveImportedProfileId(...) at :563-567) and adoption (if (resolvedProfileId == activeProfileId) at :580-588), and to the streaming path's adoption (resolveImportedProfileId(...) == normalizedActiveProfileId at :1970-1981 and :2068-2080). New tests streaming session import adopts explicit owner when its profile is absent and buffered session import adopts explicit owner when its profile is absent directly cover the fix. The pre-existing streamed absent explicit owner adopts normalized target instead of provisional default and buffered legacy and absent explicit owners use represented fallback when active is absent tests were tightened — both flip from expecting "original-owner" to "first-represented", which is the correct behavior now that explicit owners are validated against the available set. Mark this thread resolved.
  • 🟡 ActiveSessionEngine.kt:1379 (P1, dual-emit verbal encouragement) — unchanged by this PR (no edits to ActiveSessionEngine.kt in this incremental). Still open.
  • 🟡 ProfilePreferenceComponents.kt (P2, reset weightIncrement on unit change) — resolved in 2a01e2f1 (the prior SHA), re-verified against 524f9682. Still resolved.

Incremental scope (2a01e2f524f968)

7 files, +172/-19, one commit (524f9682 Address backup and QA review feedback):

  • DataBackupManager.kt:2705-2709resolveImportedProfileId is a clean, single-purpose helper. Three-line body. Pure. Testable. The kind of extraction that makes future review comments evaporate.
  • DataBackupManager.kt:563-567, :580-588, :1970-1981, :2068-2080 — every session insert/adopt now flows through the same resolver. The buffered path uses existingUserProfileIds + representedProfileIds (pre-computed at :491), the streaming path re-queries selectAllUserProfileIds() at :1963 (functionally equivalent because the backup profiles were committed in their own transactions at :1668-1693 before the final reconciliation transaction starts at :1958).
  • DataBackupManager.kt:1286-1290 — populates explicitSessionAdoptions for non-null profileId sessions. Previously these were silently dropped on the floor; now they participate in the same fallback resolution.
  • ProfileQaSeeder.kt:508-509 — single-line change that replaced two parallel hardcoded JSON literals with encodeRackItemsJson(listOf(rackPreferences(profileKey, isProfileA).items.single())). Drift-proof.
  • RackItemJson.kt — 9-line helper module. private val rackItemsJson = Json { encodeDefaults = true } and one encodeToString extension. Ponytail would say "use the json in DataBackupManager" but the QA seeder lives in androidApp/src/debug and can't reach that private member. Helper is justified.
  • RackItemJsonTest.kt — round-trip + asserts enabled and sortOrder are encoded (the encodeDefaults = true contract).
  • BackupJsonNavigatorTest.kt:765-826, :828-863 — test rename + value flip from "original-owner" to "first-represented" (fixing the previous wrong expected value) + new test for explicit-owner-absent adoption.
  • DataBackupManagerRoutineNameTest.kt:1373-1450, :1452-1486 — same pattern: rename, value flip, new explicit-owner-absent test.
  • QaReleaseBoundaryTest.kt:45-55 — new boundary test that asserts the QA seeder file contains the typed-serializer call AND does NOT contain raw [{ JSON literals. Catches re-regressions of the original finding.

Correctness / Safety Findings

No correctness or safety findings on changed lines.

Ponytail Review

  • DataBackupManager.kt:1963: reuse: streaming path computes availableProfileIds via a fresh selectAllUserProfileIds() while the non-streaming path at :491 uses existingUserProfileIds + representedProfileIds. Functionally equivalent in practice (backup profiles are committed before the final transaction), but the asymmetry will confuse the next reader. Optional — leave for a future cleanup, not a blocker.
  • RackItemJson.kt:1-9: shrink: the helper is 9 lines including package/imports for one function. Could be a top-level expression in ProfileQaSeeder.kt, but the named helper documents the encodeDefaults = true policy at the call site, so it earns its keep.

Ponytail net: 0 lines.

Suggested Minimal Patch

No patch needed. Both prior findings are properly addressed.

Final Merge Guidance

Can merge. The P2 backup-adoption finding and the medium hardcoded-JSON finding are both resolved with tests that prevent regression. The remaining P1 finding on ActiveSessionEngine.kt:1379 is unchanged by this PR and should be tracked separately.

🏆 Best part: resolveImportedProfileId is exactly the right shape — single-purpose, no flags, no behavior knob, no second parameter to remember. The three call sites are mechanically equivalent. Whoever wrote this avoided the "but should we adopt or skip?" boolean ladder that would have produced a four-branch mess.

💀 Worst part: The test rename + value flip for the "absent explicit owner" tests is a quiet behavior change that may bite downstream snapshot tests or other callers that depended on the old "original-owner" fallback. Worth a release note: "session/routine import now adopts orphaned rows to the active profile instead of preserving the source profile id when that profile is missing."

📊 Overall: Two review findings, two clean fixes, two regression tests. The kind of incremental that makes a code reviewer suspicious that something is hiding — but I've checked, and it isn't. Ship it.

Files Reviewed (7 files)
  • androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeeder.kt - 0 issues
  • androidApp/src/test/kotlin/com/devil/phoenixproject/qa/QaReleaseBoundaryTest.kt - 0 issues
  • shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt - 0 issues
  • shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/util/RackItemJson.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/util/RackItemJsonTest.kt - 0 issues

Previous review (commit 2a01e2f)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, this incremental is actually clean. I had my flamethrower warmed up and everything.

Overview

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

No new inline comments this pass. Re-verified all four existing threads against HEAD (2a01e2f1) and the on-disk code.

Existing thread status

  • ProfilePreferenceComponents.kt (P2, reset weightIncrement on unit change, line null) — RESOLVED in this PR. coreAfterWeightUnitSelection() now writes Constants.DEFAULT_WEIGHT_INCREMENT_KG (0.5f) for KG and Constants.DEFAULT_WEIGHT_INCREMENT_LB (1f) for LB. displayedWeightIncrement(core) continues to render a legacy -1f storage as the unit default without an automatic write. The new helpers (weightIncrementOptionsFor, displayedWeightIncrement) live in the same file and back the new chip set. Mark this thread resolved.
  • ActiveSessionEngine.kt:1379 (P1, dual-emit verbal encouragement) — unchanged by this PR; still resolved.
  • 🟡 ProfileQaSeeder.kt:512 (medium, hardcoded rackSnapshotJson) — unchanged by this PR.
  • 🟡 DataBackupManager.kt:562 (P2, session backups to deleted profiles) — unchanged by this PR.

Incremental scope

The PR is a UI/UX cleanup of the Profile preferences screen: removes global per-profile knobs that don't belong there (defaultRestSeconds, repCountTiming, stopAtTop, stallDetectionEnabled), moves them to per-exercise defaults; adds an explicit weight-increment selector with unit-specific options and explicit unit defaults on switch; rebuilds the LED scheme picker as a horizontally scrolling radio group of 48 dp gradient swatches with a crossed-out "None" tile; collapses the 7-tap easter egg onto the card title for both Dominatrix (VBT) and Disco (LED) and hides the dominatrix switch row until the unlock flag is set; replaces the standalone Adults Only action with the Vulgar toggle as the only age-confirmation entry point; and wires the new Scaffolds to leave window insets to the outer app.

The behavior shifts are codified in the design doc (docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md, "Profile preferences correction: 2026-07-15 (PR #651)"). Concrete verified changes:

  • ProfilePreferenceComponents.kt:946-994 — three new internal helpers (weightIncrementOptionsFor, displayedWeightIncrement, vbtAfterEnabledSelection, vbtAfterVerbalEncouragementSelection, vbtAfterVulgarModeSelection). coreAfterWeightUnitSelection now writes the explicit unit default instead of -1f. The chip set derives from Constants.WEIGHT_INCREMENT_OPTIONS_KG/LB and no longer offers "Automatic" as a user-selectable option. All three VBT reset paths clear dominatrixModeActive without touching dominatrixModeUnlocked. The new unit tests cover the explicit-default write, the unit-specific options, the legacy -1f display fallback, and the three-prerequisite dominatrix deactivation.
  • ProfilePreferenceComponents.kt:496, 623-638 — both 7-tap counters now key off firstTapAt == 0L || now - firstTapAt > 2000L instead of lastTapTime = 0L, which previously made the very first tap of a fresh counter increment rather than reset. The DominatrixUnlockGateTest was retargeted to the new unlockEligible predicate and now asserts that all four prerequisites (adult + VBT + verbal + vulgar) are required, and that seven taps spread beyond the 2 s window do not unlock. The reset state is keyed by dominatrixUnlockEligible, so toggling any prerequisite clears the counter — slightly destructive of progress if a user is mid-tap and toggles verbal off, but acceptable.
  • ProfilePreferenceComponents.kt:515-573LazyRow with selectableGroup() replaces the old Column + RadioButton. Each swatch is a 48 dp Box.selectable(role = Role.RadioButton) with a 40 dp gradient Box inside, a thicker primary border + check icon when selected, and a diagonal Canvas.drawLine overlay for the "None" index (ColorSchemes.ALL.lastIndex). TalkBack will announce role + selection per swatch.
  • ProfileViewModel.kt:295-325declineAdultsOnly() now writes VBT (vulgarModeEnabled = false, dominatrixModeActive = false) and localSafety (adults only declined + prompted) in a single mutation with both sections in the initial busy set. The dominantActive-off write happens BEFORE the local-safety commit, so a failing VBT write leaves local safety unchanged — the right ordering for the under-18 path. isDominatrixUnlockEligible now also requires vbt.enabled, so you cannot start the unlock counter with VBT disabled.
  • ProfileScreen.kt:313contentWindowInsets = WindowInsets(0, 0, 0, 0) plus padding.calculateTopPadding() + 12.dp so the inner scaffold keeps a fixed 12 dp from the outer top bar regardless of system bars.
  • AdultModePresentation.kt:32-37, AdultModePresentationTest.kt:32-56shouldShowDominatrixHint(...) and its single test were removed; the unlock discoverability now lives on the VBT title click alone. The settings_dominatrix_unlock_hint string is removed; the settings_dominatrix_unlock_toast text was renamed from "Boss Mode unlocked" to "Dominatrix Mode unlocked" in all six locales.

No correctness, security, data-loss, race, or regression issues found in the changed lines.

🏆 Best part: The five extracted internal fun helpers (coreAfterWeightUnitSelection, weightIncrementOptionsFor, displayedWeightIncrement, vbtAfterEnabledSelection, vbtAfterVerbalEncouragementSelection, vbtAfterVulgarModeSelection) — each is testable in isolation, each is one screenful of code, and the new test cases actually pin the contract. Whoever decided that displayedWeightIncrement should NOT auto-write on legacy -1f deserves a slow clap; that's the difference between a migration that silently fixes users and one that silently mis-bills them.

💀 Worst part: The 7-tap-on-title unlock is now genuinely undiscoverable — both VBT (Dominatrix) and LED (Disco) titles render as plain bold text with no ripple, no "Tap header 7× to unlock" supporting line, and no role hint. The unlock popup fires correctly when triggered, and the design doc explicitly documents the design, but a first-time user has a snowball's chance of finding either easter egg without external documentation. Minor discoverability regression.

📊 Overall: 17 files, +630/-198, one commit, two stale threads finally closed, no new fires. The architecture is doing what architecture does — moving policy out of the view layer and into helpers the tests can name. Ship it.

Files Reviewed (17 files)
  • docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md - 0 issues
  • shared/src/commonMain/composeResources/values-de/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-es/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-fr/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-it/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values-nl/strings.xml - 0 issues
  • shared/src/commonMain/composeResources/values/strings.xml - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentation.kt - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt - 0 issues
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceInputPolicyTest.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/WeightIncrementWiringTest.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/DominatrixUnlockGateTest.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt - 0 issues
  • shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt - 0 issues

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

Previous review (commit 23f4ede)

Verdict: Comment Only | Recommendation: Can merge; previously-blocking P1/P2 are now resolved, leaving only the two pre-existing review threads.

Overview

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

No new inline comments this pass. I re-verified all four existing threads against HEAD (23f4edea) and the on-disk code.

Existing thread status

  • ProfilePreferenceComponents.kt (P2, reset weightIncrement on unit change) — RESOLVED in this PR by the new coreAfterWeightUnitSelection(core, selectedUnit) helper (line 957–965). It returns the same core when the unit is unchanged and otherwise returns core.copy(weightUnit = selectedUnit, weightIncrement = -1f). Wired at the unit-chip's onSelected (line 221). One-line, no callers left with the silent-unit-bug. Mark this thread resolved.
  • ActiveSessionEngine.kt:1379 (P1, dual-emit verbal encouragement) — confirmed still resolved at HEAD; verbalEncouragementEventOrNull() gates vulgar/dominatrix on adultsOnlyConfirmed, the block closes cleanly at 1389, no legacy emission path. No action.
  • 🟡 ProfileQaSeeder.kt:512 (medium, hardcoded rackSnapshotJson) — still present at lines 509 and 511, unchanged by this PR. Active thread, no duplicate added.
  • 🟡 DataBackupManager.kt:562 (P2, session backups to deleted profiles) — unchanged by this PR. Active thread, no duplicate added.

Stall-detection / Echo / Issue-#656 fixes (incremental scope)

The PR carries out the full F1–F9 stall-detection audit and the #656 Summary-nav fix. Highlights:

  • F1 — Echo-mode deload suppressed (ActiveSessionEngine.kt:433): DELOAD_OCCURRED is now ignored entirely when params.isEchoMode because Echo levels are deload windows. Velocity-stall path still runs in Echo. Regression test F1 - Echo mode deload event does not arm stall timer passes.
  • F4 — stallArmedByDeload flag (WorkoutCoordinator.kt:432, used at ActiveSessionEngine.kt:459, 465, 519, 529): velocity-armed countdowns re-check position per sample and self-cancel when handles return to rest; deload-armed countdowns survive retraction because a real cable release pulls cables to ~0mm. Upgrade branch at 461–465 makes a firmware deload the stronger signal. Eight new tests in DWSMWorkoutLifecycleTest cover the matrix (velocity-arm / deload-arm / rest / upgrade).
  • F7 — single reset source (WorkoutCoordinator.resetAutoStopState(), delegates from ActiveSessionEngine and RoutineFlowManager): deferAutoStopDeadlineMs moved onto the coordinator, both managers call the same reset; the previous RFM copy couldn't clear the (then engine-private) defer deadline. WorkoutCoordinatorAutoStopResetTest confirms all seven fields reset.
  • F8 — handle release clears verbal-cue defer (ActiveSessionEngine.kt:383–394): when HandleState.Released arrives during the defer window, the deadline is zeroed immediately. Stall/position countdowns still need their own 2.5–5s, so transient blips can't insta-end a set. Test F8 - handle release clears verbal cue defer deadline verifies.
  • F3 — biomech/VBT reset hoisted (ActiveSessionEngine.kt:2568–2576): Phase 35C variable-warm-up fast path advances via startWorkout(skipCountdown = true) and bypassed the handleSetCompletion reset block; the new pre-skipCountdown resets close the leak (biomechanicsEngine, velocityThresholdAlertEmitted, consecutiveThresholdReps, repBoundaryTimestamps). The handleSetCompletion block remains (must run before summary display) and the comment correctly notes both are idempotent. Test F3 - startWorkout for next set clears rep boundary timestamps and biomech state covers it.
  • Issue Echo VBT state accumulates across progressive sets and stops later sets unexpectedly (related to #649) #652 — completed rep cancels stall (ActiveSessionEngine.kt:1126): resetStallTimer() now runs at the rep-boundary line so a stale countdown can't auto-complete a subsequent valid rep. PR fix(#652): reset shared stall timer on completed working rep #653 absorbed.
  • F6 — global UserPreferences.stallDetectionEnabled removed (UserPreferences.kt, PreferencesManager.kt, SettingsManager.kt, DWSMTestHarness.kt, FakePreferencesManager.kt, LegacyProfilePreferencesReader.kt): the field was orphaned — no workout path consumed it and the only "Auto-End on Velocity Loss" row in Settings is per-exercise now. The persisted boolean lives in the legacy reader's STALL_DETECTION key so existing installs still migrate cleanly. LegacyProfilePreferencesReaderTest.legacyStallDetectionKeyStillMigratesAfterGlobalPreferenceRemoval confirms the round-trip.
  • Issue Set Summary (unlimited) + per-set rest times: "Next Set" stuck on first set, then jumps to End Routine #656proceedFromSummary autoplay-OFF nav fix (DefaultWorkoutSessionManager.kt:957): explicit coordinator._workoutState.value = WorkoutState.Idle before enterSetReady(...) so the ActiveWorkoutScreen !isWorkoutActive gate fires. Issue656ReproTest.proceedFromSummary_unlimitedSummaryDoesNotNavigateOutOfSetSummary documents the bug fingerprint.
  • Health-body-weight sync atomicity (HealthBodyWeightSyncManager.kt:130–153): re-reads activeProfileContext, then writes the measurement, then mutates core — so a failing upsertMeasurements no longer mutates the active profile's body weight. HealthBodyWeightSyncManagerTest.measurementPersistenceFailureLeavesActiveBodyWeightUntouched proves the new ordering.
  • SyncManager active-context republish (SyncManager.kt:1390–1396, 1690–1699): userProfileRepository.refreshProfiles() after a successful applyPushOutcomes / applyPulledSections so UI subscribers see the canonical core/workout/LED/VBT sections immediately. Three new tests cover the pushed, pulled, and no-op branches.

Ponytail Review

  • shared/src/commonTest/kotlin/com/devil/phoenixproject/Issue656ReproTest.kt:L1-22: delete: file header documents "(from /tmp/phoenix-mp-656-recreation)" — a recreation sandbox path that does not exist in the repo. The test now lives in shared/src/commonTest/kotlin/... and runs as part of the normal :shared:commonTest target. Replace the header with a one-line class kdoc pointing at proceedFromSummary (the function under test) and the Set Summary (unlimited) + per-set rest times: "Next Set" stuck on first set, then jumps to End Routine #656 issue number. ~22 lines deletable.
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:L2568-2576: shrink: the F3 reset block (biomechanicsEngine.reset() + 3 flag/timestamp resets) is a copy of the handleSetCompletion reset block at L4089–4093. Extract private fun resetBiomechAndVbtForNewSet() once and call it from both sites; the existing comment confirms they're idempotent. ~8 lines deletable.
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt:L383-394: native: F8 handle-release defer-clear could move into the existing when (activityState) arm at L372 (HandleState.Released already logs and cancels auto-startTimer) so the defer-clear lives next to the related handle-state handling. ~10 lines deletable and the lifetime of "Released means set over" becomes single-sourced.

Ponytail net: ~40 lines removable (all optional cleanup; none gate correctness).

Suggested Minimal Patch

No patch from this review. Existing threads cover correctne

[Snapshot truncated.]

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


Reviewed by minimax-m3 · Input: 51.4K · Output: 7.9K · Cached: 556.4K

@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: e15c7953a4

ℹ️ 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".

@@ -466,7 +559,7 @@ abstract class BaseDataBackupManager(
dominantSide = session.dominantSide,
strengthProfile = session.strengthProfile,
formScore = session.formScore,
profile_id = session.profileId ?: "default",
profile_id = session.profileId ?: activeProfileId,

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 Adopt session backups whose profile is absent

When importing a per-session/per-routine backup from another install or from a deleted profile, the backup has no userProfiles entry (see exportSession/exportRoutine) but mapSessionToBackup still serializes the source profileId. This assignment preserves that non-existent profile id instead of falling back to the active profile, so the imported workout history is written under an orphan profile and disappears from the user's active profile; resolve session.profileId against the imported/existing profile set, and mirror the fix in the streaming import path.

Useful? React with 👍 / 👎.

@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: 524f968291

ℹ️ 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".

@@ -1842,20 +1857,21 @@ class ActiveSessionEngine(
repCountTimingName = params.repCountTiming.name,
restSeconds = params.justLiftRestSeconds,
)
preferencesManager.saveJustLiftDefaults(defaults)
settingsManager.saveJustLiftDefaultsDocument(defaults)

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 Normalize non-Echo Just Lift defaults before saving

When a Just Lift workout is saved in a non-Echo mode, echoLevelVal is 0 (EchoLevel.HARD). This new direct profile-preference write bypasses the old SettingsPreferencesManager.saveJustLiftDefaults normalization that converted non-Echo HARD placeholders to HARDER, so after saving any OldSchool/Pump Just Lift workout the next Echo Just Lift default reopens at the stricter pre-issue-553 HARD level. Normalize the document before persisting, or preserve the prior Echo difficulty when the saved workout is not Echo.

Useful? React with 👍 / 👎.

@@ -1901,7 +1917,7 @@ class ActiveSessionEngine(
perSetRestTime = currentExercise.perSetRestTime,
defaultRackItemIds = currentExercise.defaultRackItemIds.filter { it.isNotBlank() }.distinct(),
)
preferencesManager.saveSingleExerciseDefaults(defaults)
settingsManager.saveSingleExerciseDefaultsDocument(defaults.toDocument())

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 Normalize non-Echo single-exercise defaults before saving

For temporary single-exercise routines in non-Echo modes, echoLevelVal is also 0, and this new direct document save bypasses the old saveSingleExerciseDefaults normalization for non-Echo HARD placeholders. Completing a non-Echo single-exercise workout now overwrites that exercise's saved Echo difficulty to HARD, so the next Echo session for that exercise starts with the pre-issue-553 strict default; apply the same normalization before saving.

Useful? React with 👍 / 👎.

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.

1 participant