Skip to content

Add toggleable stall detection feature - #7

Merged
9thLevelSoftware merged 1 commit into
mainfrom
claude/toggleable-stall-detection-8g8c2
Dec 16, 2025
Merged

Add toggleable stall detection feature#7
9thLevelSoftware merged 1 commit into
mainfrom
claude/toggleable-stall-detection-8g8c2

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

Add stallDetectionEnabled flag to allow users to toggle stall detection auto-stop behavior on Just Lift and AMRAP workout configurations:

  • Add stallDetectionEnabled to WorkoutParameters (default: true)
  • Add stallDetectionEnabled to RoutineExercise model for per-exercise config
  • Update checkAutoStop logic to check stallDetectionEnabled flag
  • Add stall detection toggle card to JustLiftScreen
  • Add stall detection toggle to ExerciseEditBottomSheet (shows for AMRAP)
  • Update ExerciseConfigViewModel to handle stallDetectionEnabled state
  • Update routine workflow to pass stallDetectionEnabled to WorkoutParameters
  • Update JustLiftDefaults to persist stallDetectionEnabled setting

Stall detection auto-stops a set when movement pauses for 5 seconds. Users can now choose whether they want this behavior enabled per mode.

Add stallDetectionEnabled flag to allow users to toggle stall detection
auto-stop behavior on Just Lift and AMRAP workout configurations:

- Add stallDetectionEnabled to WorkoutParameters (default: true)
- Add stallDetectionEnabled to RoutineExercise model for per-exercise config
- Update checkAutoStop logic to check stallDetectionEnabled flag
- Add stall detection toggle card to JustLiftScreen
- Add stall detection toggle to ExerciseEditBottomSheet (shows for AMRAP)
- Update ExerciseConfigViewModel to handle stallDetectionEnabled state
- Update routine workflow to pass stallDetectionEnabled to WorkoutParameters
- Update JustLiftDefaults to persist stallDetectionEnabled setting

Stall detection auto-stops a set when movement pauses for 5 seconds.
Users can now choose whether they want this behavior enabled per mode.
Copilot AI review requested due to automatic review settings December 16, 2025 02:40
@9thLevelSoftware
9thLevelSoftware merged commit 8f4666c into main Dec 16, 2025
3 checks passed
@9thLevelSoftware
9thLevelSoftware deleted the claude/toggleable-stall-detection-8g8c2 branch December 16, 2025 02:40

Copilot AI 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.

Pull request overview

This PR adds a toggleable stall detection feature for Just Lift and AMRAP workout modes. The stall detection auto-stops a set when movement pauses for 5 seconds, and users can now control whether this behavior is enabled.

Key changes:

  • Added stallDetectionEnabled boolean flag (default: true) to WorkoutParameters, RoutineExercise, and JustLiftDefaults data classes
  • Updated auto-stop logic to check both mode compatibility AND the stallDetectionEnabled flag
  • Added stall detection toggle UI controls in JustLiftScreen and ExerciseEditBottomSheet

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Models.kt Added stallDetectionEnabled field to WorkoutParameters with default value true
Routine.kt Added stallDetectionEnabled field to RoutineExercise model for per-exercise configuration
MainViewModel.kt Updated stall detection logic to respect the toggleable flag; added stallDetectionEnabled to JustLiftDefaults; updated routine workflow to pass stallDetectionEnabled through WorkoutParameters
ExerciseConfigViewModel.kt Added state management for stallDetectionEnabled with getter/setter methods
JustLiftScreen.kt Added stall detection toggle card UI and state handling; included setting in LaunchedEffect dependencies
ExerciseEditBottomSheet.kt Added stall detection toggle UI that displays when exercise has AMRAP sets
PreferencesManager.kt Added stallDetectionEnabled field to JustLiftDefaults data class for persistence support

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -224,7 +224,8 @@ data class WorkoutParameters(
val selectedExerciseId: String? = null,
val isAMRAP: Boolean = false, // AMRAP (As Many Reps As Possible) - disables auto-stop

Copilot AI Dec 16, 2025

Copy link

Choose a reason for hiding this comment

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

The comment "AMRAP (As Many Reps As Possible) - disables auto-stop" is now outdated since AMRAP mode can have stall detection enabled when stallDetectionEnabled is true. The comment should be updated to reflect that stall detection is now configurable.

Suggested change
val isAMRAP: Boolean = false, // AMRAP (As Many Reps As Possible) - disables auto-stop
val isAMRAP: Boolean = false, // AMRAP (As Many Reps As Possible) - auto-stop (stall detection) is configurable via stallDetectionEnabled

Copilot uses AI. Check for mistakes.
9thLevelSoftware added a commit that referenced this pull request Jan 5, 2026
Migration 6 was already used on main branch for Training Cycle tables.
Sync tracking fields migration needs to be #7 to avoid conflict.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9thLevelSoftware added a commit that referenced this pull request Feb 16, 2026
… issue

**Issues Fixed:**
- #2: Race condition - SingleExerciseScreen now awaits routine load via
  loadRoutineAsync() before ensureConnection(), preventing PR weight fetch
  from racing with onConnected callback
- #4: Keypad input - CompactNumberPicker.ios.kt sanitizes input and
  handles parse failure by keeping current value instead of max (242 lbs)
- #6: Unilateral weight - ActiveSessionEngine detects single-cable exercises
  and uses maxOf(loadA, loadB) instead of totalLoad/2
- #7: Mode persistence - SqlDelightWorkoutRepository.parseProgramMode()
  handles unprefixed legacy mode strings ("Pump" vs "Program:Pump")

**Diagnostic Logging for Issue #5:**
Added WEIGHT_DEBUG logging at 5 points to trace weight value flow:
- iOS CompactNumberPicker scroll handler
- JustLiftScreen picker onValueChange
- JustLiftScreen params update
- ActiveSessionEngine set completion
- SetSummaryCard display calculation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9thLevelSoftware added a commit that referenced this pull request Apr 19, 2026
…lback

Resolves audit item #7 (.planning/audit/06-dto-drift-matrix.md).

Server-side mobile-sync-pull now enforces HTTP 413 when any parity list
exceeds 500 entries (replacing the prior silent-empty behavior). This commit
adds matching client-side capping so legitimate power-user accounts do not
get rejected.

- `SyncConfig.MAX_PARITY_IDS = 500` new constant matching the server cap.
- `capParity()` helper in `runPullLoop` truncates each parity list to the
  last MAX_PARITY_IDS entries when oversized; the pull then falls back to
  server-side lastSync delta semantics for the older tail, and local dedupe
  handles overlap. Logs a warning at WARN level when truncation occurs so
  power users' accounts are observable in telemetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9thLevelSoftware added a commit that referenced this pull request Apr 19, 2026
Test was authored with the pre-Phase-4.1 behavior (mobile sends uncapped
knownEntityIds; server silently returns empty for >500). Phase 4.1
(audit item #7) inverted that contract: server now enforces HTTP 413 at
500, mobile capParity() truncates to the most recent 500 entries via
takeLast(). Test renamed + assertion flipped to match new contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9thLevelSoftware added a commit that referenced this pull request Apr 19, 2026
…370)

* cursor: iOS CI, Android FGS, BLE/sync, and Koin iOS @throws

Made-with: Cursor

* fix(ios,ci,android): repair Koin iOS symbol, Supabase xcconfig, and release guard

- iOS: Swift was calling `KoinInitKt.doInitKoin()`, but the new
  `@Throws` entrypoint lives in `shared/iosMain/.../KoinInitIos.kt`, so
  Kotlin/Native exports it as `KoinInitIosKt.doInitKoin()`. Without this
  fix the iOS app fails to compile. Switched the Swift call (with `try`)
  and updated the KoinInit.kt doc comment + iosApp/README accordingly.

- CI: The three iOS workflows wrote `Supabase.xcconfig` without
  escaping `//`, which xcconfig treats as an inline comment. A secret
  like `https://xxx.supabase.co` was being truncated to `https:` at
  build time, breaking Supabase in TestFlight/Release. Apply the same
  `/$()/` escape already used by the committed `.example` template via
  `sed 's|//|/$()/|g'` to both URL and anon-key fields.

- Android: Replaced `findByName("assembleRelease").forEach { doFirst }`
  with `tasks.matching { ... }.configureEach { doFirst }` so the
  explicit-versionCode guard attaches regardless of when AGP registers
  the release tasks (findByName at configuration time silently no-ops
  if the task isn't registered yet).

Made-with: Cursor

* fix(mobile): beta audit hardening + DTO drift Phase 1

Bundles the in-progress beta audit remediation work with the first phase of
the DTO drift remediation plan (.planning/audit/06-dto-drift-matrix.md, resolved
items #2 wire-contract portion, #3, #4).

Beta audit hardening:
- .gitignore (C1): cover Supabase.xcconfig in any VitruvianPhoenix subdir so a
  stray sibling file cannot leak the anon key
- PortalAuthRepository (C6): explicit failure messages for social sign-in
  stubs; consumers can surface a clear reason instead of string-sniffing
- GamificationViewModel (H): surface load errors via StateFlow instead of
  swallowing silently; log via kermit
- RepCounterFromMachine (Issue #163): remove repCountersNeedBaseline baseline
  suppression; first post-reset rep now counts immediately, matching expected
  UX after BLE reconnect
- SafeWordListener (android/ios): hardening of listener lifecycle
- FakePortalApiClient: test double alignment
- New common-test suites covering asymmetry threshold, error classification,
  portal mapping weight, pull pagination, push limits, token refresh,
  sync backoff, sync failure cap, velocity zone boundaries, workout phase
  round trip
- Supabase.xcconfig + xcodeproj: secret wiring adjustment
- androidApp/build.gradle.kts: build config cleanup

DTO drift Phase 1:
- PullWorkoutSessionDto (#2): add nullable `notes` field so session-level
  notes round-trip from portal. Mobile persistence is tracked separately
  (Phase 3.5) because WorkoutSession is per-exercise while portal sessions
  are per-workout; notes need a SessionNotes side-table keyed on
  routineSessionId rather than a straight column addition.
- PortalSetDto (#3): documentation clarifying that prType/prPhase/prVolume
  are SEND-ONLY PR derivation hints consumed by the portal into
  personal_records; they are intentionally not persisted on the sets table.
- PortalRepTelemetryDto (#4): update `cable` doc comment to reflect the
  canonical "A"|"B" wire format (A = left actuator, B = right actuator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mobile): extend ExternalActivityAckDto with localId/serverId/updatedAt

Resolves audit items #5 and #10 (.planning/audit/06-dto-drift-matrix.md).

- ExternalActivityAckDto now carries `localId`, `serverId`, and `updatedAt`
  (all nullable defaults so older response payloads still decode) so mobile
  can reconcile server-canonical metadata (notably the `updated_at` timestamp
  used to seed LWW convergence in Phase 3) onto local rows after push.
- PortalSyncPushResponse.externalActivityIds marked @deprecated; prefer
  externalActivityKeys which now carries full ack metadata. Retained for one
  release for older adapters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): cap parityIds at MAX_PARITY_IDS=500 with tail-window fallback

Resolves audit item #7 (.planning/audit/06-dto-drift-matrix.md).

Server-side mobile-sync-pull now enforces HTTP 413 when any parity list
exceeds 500 entries (replacing the prior silent-empty behavior). This commit
adds matching client-side capping so legitimate power-user accounts do not
get rejected.

- `SyncConfig.MAX_PARITY_IDS = 500` new constant matching the server cap.
- `capParity()` helper in `runPullLoop` truncates each parity list to the
  last MAX_PARITY_IDS entries when oversized; the pull then falls back to
  server-side lastSync delta semantics for the older tail, and local dedupe
  handles overlap. Logs a warning at WARN level when truncation occurs so
  power users' accounts are observable in telemetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mobile): populate updatedAt on push DTOs + decode LWW rejections

Phase 3.2 client-side companion to the portal LWW push handler swap.

Adds optional `updatedAt: String?` (ISO 8601) to the three shared-edit
push DTOs that will flow through LWW RPCs on the server:

  - PortalWorkoutSessionDto
  - PortalRoutineSyncDto
  - PortalTrainingCycleSyncDto

PortalSyncAdapter populates each with `epochToIso8601(currentTimeMillis())`
on every build so the server's LWW gate receives a real monotonic
timestamp. When mobile domain models start tracking per-row updated_at
end-to-end (Phase 3.3), swap the adapter to emit domain values instead.

PortalSyncPushResponse now decodes a nullable-default `rejections:
SyncRejectionsDto` field (with nested SyncRejectionDto entries), so the
mobile SyncManager can log LWW rejections and trigger a repair pull. Older
server responses without the field still decode because of the empty
default.

The server side uses NOW() when `updatedAt` is absent (pre-Phase-3.2
clients), so existing mobile builds continue to work unmodified while
updated builds participate in LWW immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mobile): self-cap + sliding-window client rate limit for sync

Phase 4.2 of the DTO drift remediation plan. Resolves audit item #9.

A misbehaving retry loop or a user with a huge local history should fail
fast locally instead of wasting Edge Function invocations for payloads the
server will reject with HTTP 413/429.

SyncConfig additions (mirror server-side caps at mobile-sync-push):
  - MAX_SESSIONS_PER_BATCH    = 10000
  - MAX_ROUTINES_PER_BATCH    = 10000
  - MAX_CYCLES_PER_BATCH      = 10000 (aligned with audit #6)
  - MAX_TELEMETRY_PER_BATCH   = 10000
  - MAX_PAYLOAD_BYTES         = 9_500_000 (500 KiB below 10 MiB server cap)
  - PUSH_RATE_LIMIT_PER_MIN   = 10
  - PULL_RATE_LIMIT_PER_MIN   = 20
  - RATE_LIMIT_WINDOW_MS      = 60_000

PortalApiClient.pushPortalPayload now validates the array caps and the
serialized payload size BEFORE opening the HTTP connection. On violation
it returns `Result.failure(PortalApiException(...))` with a clear message.
The serialized bytes are reused (not re-encoded) for the request body so
there is no double-JSON cost.

New ClientRateLimiter is an in-memory sliding-window limiter keyed by
operation ("push" / "pull"). Internal state is a Mutex-guarded deque of
recent attempt timestamps. SyncManager's push/pull entry points call
`tryAcquire()` at the very top and surface a 429-coded
PortalApiException when denied. In-memory scope only for this phase — a
persistent SQLDelight-backed variant is noted as a follow-up for when
process restarts during a retry storm would defeat the window.

SyncManager gains an optional `rateLimiter: ClientRateLimiter =
ClientRateLimiter()` constructor parameter so existing DI wiring keeps
working unchanged; tests can supply a `ClientRateLimiter(windowMillis =
...)` with a reset hook for deterministic assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mobile): persist portal session notes via SessionNotes side-table

Phase 3.5 of the DTO drift remediation plan. Closes the mobile persistence
gap left from Phase 1.1 of audit item #2 (portal session-level notes
round-trip on the wire but were not stored locally).

The mobile WorkoutSession model is per-exercise (one portal session
expands into N mobile rows keyed by routineSessionId), so adding a `notes`
column to WorkoutSession would duplicate state across rows. Instead this
phase introduces a single-row-per-portal-session side-table.

Schema (migration 26.sqm + main VitruvianDatabase.sq):
  - CREATE TABLE SessionNotes(
      routineSessionId TEXT PRIMARY KEY,
      notes TEXT,
      updatedAt INTEGER
    )
  - INDEX idx_session_notes_updated_at on (updatedAt) for delta queries.
  - upsertSessionNotesLww query gates writes on `excluded.updatedAt >=
    SessionNotes.updatedAt` (NULL stored treated as older). Uses the same
    LWW semantics shipping in Phase 3.1 RPCs server-side so portal edits
    from a newer device win-by-timestamp.
  - getSessionNotes / selectSessionNotesForIds for read paths.

Repository surface:
  - SyncRepository gains `mergeSessionNotes(notes: Map<String,
    SessionNotesEntry>)` with a default no-op so existing test fakes keep
    compiling.
  - SqlDelightSyncRepository wraps the upsert in a single transaction.

SyncManager pull merge:
  - Extracts non-blank session notes from the pull response into a Map
    keyed on `routineSessionId` (== portal session id).
  - Persists outside the main atomic merge so a notes-table failure is
    non-fatal — sessions remain consistent even if the side-table write
    fails. Logged at WARN.
  - updatedAt sourced from `PullWorkoutSessionDto.startedAt` parsed via
    kotlin.time.Instant. When the wire DTO eventually carries a real
    `updatedAt` field (Phase 3.3 mobile-side LWW), swap to that.

Build:
  - SQLDelight schema version bumped 26 → 27 to match the new migration.

UI surface (session detail screen rendering of notes) is intentionally
out of scope for this commit and tracked as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): SQLDelight 3.18-compatible SessionNotes upsert + manifest entry

Two CI failures from PR #370:

1. SQLDelight gen failed: the dialect (sqlite_3_18) does not support
   INSERT ... ON CONFLICT DO UPDATE. Replaced the single
   `upsertSessionNotesLww` query with two SQLite-3.18-compatible queries
   (`selectSessionNotesUpdatedAt` + `upsertSessionNotes` using
   `INSERT OR REPLACE`) and moved the LWW gate into Kotlin
   (`SqlDelightSyncRepository.mergeSessionNotes`) where it runs inside
   the same transaction as the read.

2. iOS Schema Sync Check: SessionNotes missing from SchemaManifest.kt.
   Added the SchemaTableOperation entry plus a SchemaIndexOperation for
   `idx_session_notes_updated_at` so the iOS-side schema bootstrap stays
   parity-aligned with the SQLDelight schema.

Also fixed `SELECT ... WHERE routineSessionId IN ?;` to the SQLDelight
named-list form `WHERE routineSessionId IN :ids` (the bare `?` is not a
valid bind for IN in SQLDelight).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mobile): import currentTimeMillis in PortalSyncAdapter

Phase 3.2 commit (3494794) added `currentTimeMillis()` calls for the
new `updatedAt = epochToIso8601(currentTimeMillis())` LWW timestamps
at lines 196 / 510 / 600 but missed the import. CI compile failed with
'Unresolved reference currentTimeMillis' on linux because the function
lives in `com.devil.phoenixproject.util.KmpUtils`. Add the import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(mobile): assert parity ids capped at MAX_PARITY_IDS, not uncapped

Test was authored with the pre-Phase-4.1 behavior (mobile sends uncapped
knownEntityIds; server silently returns empty for >500). Phase 4.1
(audit item #7) inverted that contract: server now enforces HTTP 413 at
500, mobile capParity() truncates to the most recent 500 entries via
takeLast(). Test renamed + assertion flipped to match new contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9thLevelSoftware added a commit that referenced this pull request Jun 27, 2026
…asis (#517)

Finding #7: generalize per-set weight helpers to resolve against the
selected-basis baseline (baselineKgForCurrentBasis) instead of the
max-weight PR. Rename resolvedSetWeightsKgFromCurrentPR ->
resolvedSetWeightsKgFromCurrentBasis; per-set percent<->weight helpers
and updateWeight now take/use baselineKg. onScalingBasisChange and the
mode-independent baseline load re-sync visible set weights. Behavior is
unchanged when basis == MAX_WEIGHT_PR.

Finding #8: make the "Scale from" basis selector always available while
% scaling is enabled (moved out of the baselineWeightKg != null guard),
and gate the % toggle on hasAnyBaseline (any basis has data) rather than
only the currently-selected basis — so a user with only an Est. 1RM can
enable scaling, pick ESTIMATED_1RM, and proceed. Preview/slider/presets
remain gated on the selected basis having a baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm
9thLevelSoftware added a commit that referenced this pull request Jun 27, 2026
…3-5) (#517) (#598)

* fix(1rm): bump SQLDelight schema version to 38 for migrations 36/37 (#517)

Version was stuck at 36; 36.sqm (36→37) and 37.sqm (37→38) would never
run on upgrades. Updated version and comment to match SchemaParityTest's
CURRENT_VERSION = 38L.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): reject NaN session MCV in personalized-MVT capture (#517)

`NaN <= 0f` is false in IEEE 754, so a NaN value silently passed the
old guard and could corrupt the rolling mean. Changed to the negated-
positive form `!(x > 0f)` which correctly rejects NaN, 0, and negatives.
Added a test asserting invoke(..., Float.NaN) returns false and makes no
repo write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): persist MCV on manual-stop sets so they feed velocity-1RM (#517)

Manual-stop path in stopWorkout() built WorkoutSession without avgMcvMmS,
so those sets were excluded from selectVelocityPointsByExercise (IS NOT NULL
filter) and from personalized-MVT capture. Safety verified: biomechanicsEngine
.reset() is not called anywhere in stopWorkout() before the session is built.
Mirrors the bioSummary capture already present in saveWorkoutSession().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* docs: add Phase 3 (% of 1RM scaling) implementation plan (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): add ScalingBasis enum + RoutineExercise.scalingBasis (#517)

* feat(1rm): persist RoutineExercise.scalingBasis, migration 38 (#517)

- Add nullable scalingBasis TEXT column to RoutineExercise table
- Create migration 38.sqm with ALTER TABLE ADD COLUMN
- Add migration 38 to MigrationStatements.kt
- Add SchemaHealOperation + SchemaTableOperation entry in SchemaManifest.kt
- Bump SQLDelight schema version 38 → 39 in shared/build.gradle.kts
- Bump CURRENT_VERSION 38L → 39L in SchemaParityTest
- Update SqlDelightWorkoutRepository: mapper reads scalingBasis via
  ScalingBasis.valueOf(), insertRoutineExercise passes scalingBasis?.name
- Add scalingBasis = null to all other insertRoutineExercise/Ignore call
  sites (SqlDelightSyncRepository, DataBackupManager, test helpers)
- Add round-trip androidHostTest SqlDelightWorkoutRepositoryRoutineScalingTest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): preserve scalingBasis across backup/restore (#517)

Backup export/import dropped RoutineExercise.scalingBasis, so a user who
set scalingBasis (e.g. ESTIMATED_1RM) silently lost it on restore.

- BackupModels: add scalingBasis: String? = null to RoutineExerciseBackup
  (default null keeps pre-existing backup files loadable)
- DataBackupManager.mapRoutineExerciseToBackup: capture exercise.scalingBasis
  (already the enum-name TEXT on the DB row) verbatim
- DataBackupManager: both restore/import call sites now parse-and-validate
  the backup string via ScalingBasis.valueOf (invalid -> null) instead of
  hardcoding null
- Add cross-database export->import round-trip test asserting ESTIMATED_1RM
  survives

Sync-path nulls left as-is: scalingBasis stays mobile-local until the
portal phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): resolve % of estimated-1RM in routine weights (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* test(1rm): make velocity-1RM fake id-aware + import cleanup (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): add system-wide defaultScalingBasis preference (#517)

Adds `UserPreferences.defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR`
persisted via SettingsPreferencesManager (putString/getStringOrNull with runCatching
fallback), exposed as `SettingsManager.defaultScalingBasis: StateFlow<ScalingBasis>` +
`setDefaultScalingBasis(basis)`. FakePreferencesManager updated for tests.
Round-trip test in SettingsManagerTest confirms RED→GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): UI for % of 1RM scaling basis + global default (#517)

- ExerciseConfigViewModel: expose scalingBasis StateFlow + onScalingBasisChange setter;
  load from RoutineExercise.scalingBasis in initialize(); persist via onSave() copy.
- ExerciseEditBottomSheet/WeightConfigurationCard: add 3-way SingleChoiceSegmentedButtonRow
  (Max-weight PR / Max-volume PR / Est. 1RM) shown when usePercentOfPR is on; bound to VM.
- SettingsTab: add defaultScalingBasis param + 3-way segmented control near weight-programming
  settings; passes through NavGraph -> MainViewModel -> SettingsManager.setDefaultScalingBasis.
- MainViewModel: add setDefaultScalingBasis delegation to SettingsManager.
- ExerciseConfigViewModelTest: two new tests for scalingBasis round-trip and null back-compat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): seed new routine exercises from default scaling basis (#517)

New exercises added in the routine editor now adopt the system-wide
defaultScalingBasis from UserPreferences, making the Settings control
actually take effect. Editing an existing exercise is unaffected — the
VM still loads the stored scalingBasis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): fall back to PR for % of estimated-1RM when no estimate/stored 1RM (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* docs: add Phase 4 (velocity-1RM badges) implementation plan (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): add tiered velocity-1RM improvement badges (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): add getAllPassing velocity-1RM query (#517)

Add selectAllPassingVelocityOneRepMaxByProfile SQL query and
getAllPassing(profileId) method to VelocityOneRepMaxRepository interface
and SqlDelightVelocityOneRepMaxRepository impl. Update Fake and two
inline anonymous impls in androidHostTest to satisfy the new contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): count velocity-1RM improvements (#517)

* feat(1rm): award velocity-1RM badges from post-save hook (#517)

- Add GamificationManager.checkVelocityOneRepMaxBadges: filters VelocityOneRepMaxImprovements badges, awards unearned ones when improvementCount >= req.count, emits haptic + badgeEarnedEvents
- Wire CountVelocityOneRepMaxImprovementsUseCase into DomainModule (single {}) and MainViewModel constructor
- Extend onPostSaveComputed lambda: after computeVelocityOneRepMaxUseCase, count all passing estimates via countVelocityOneRepMaxImprovementsUseCase then call checkVelocityOneRepMaxBadges
- Add explicit : GamificationManager type annotation to resolve Kotlin recursive type-checking error from the self-referential lambda
- Update all 4 MainViewModel construction sites (PlatformModule.android, PlatformModule.ios, MainViewModelTest, WorkoutFlowE2ETest)
- Add 2 GamificationManagerTest cases: bronze award at count=1, idempotent re-award guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* docs(1rm): fix badge-stub comments + add velocity-badge log line (#517)

* docs: add Phase 5 (velocity-1RM historical backfill) plan (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): enumerate MCV-bearing exercises + hasEstimates (#517)

Add two SQL queries (selectExerciseIdsWithVelocityData,
countVelocityOneRepMaxByExercise), regen SQLDelight, and wire both into
VelocityOneRepMaxRepository.hasEstimates + WorkoutRepository.getExerciseIdsWithVelocityData.
Update FakeWorkoutRepository and FakeVelocityOneRepMaxRepository with
sensible defaults; add hasEstimates override to the two inline anonymous
VelocityOneRepMaxRepository stubs in WorkoutFlowE2ETest and
MainViewModelTest. Extend SqlDelightVelocityOneRepMaxRepositoryTest and
SqlDelightWorkoutRepositoryVelocityPointsTest with the new behaviour
tests; 5+3 tests pass, 0 failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): parameterize compute window for backfill (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): add BackfillVelocityOneRepMaxUseCase + run-once flag (#517)

- BackfillVelocityOneRepMaxUseCase: function-typed collaborators pattern,
  iterates exercise IDs for a profile, skips those with existing estimates,
  calls computeAllTime for the rest, returns count of newly created estimates
- velocityOneRepMaxBackfillDone: Boolean = false wired across UserPreferences,
  PreferencesManager (KEY + load + interface + impl), FakePreferencesManager,
  and SettingsManager (StateFlow + setter), mirroring gamificationEnabled exactly
- commonTest: BackfillVelocityOneRepMaxUseCaseTest proves skip-if-hasEstimates
  logic and correct non-null count
- androidHostTest: SettingsManagerTest round-trip for the new backfill-done flag

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* feat(1rm): run velocity-1RM backfill once on startup (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

* fix(1rm): backfill the loaded active profile, not the init-time default (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

* fix(1rm): rethrow CancellationException in backfill trigger (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): use explicit isNaN guard in personalized-MVT capture (#517)

Replaces the non-idiomatic !(x > 0f) NaN filter (which a linter could
"simplify" back to <= 0f, reintroducing the NaN bypass) with an explicit
isNaN() || <= 0f check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): persist full biomechanics + reset engine on manual-stop sets (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

* fix(1rm): preserve local scalingBasis across portal pulls (#517)

Both delete+reinsert paths in mergePortalRoutines and the atomic merge
path now capture localScalingBasisByExerciseId before the delete and
re-apply it on insert, mirroring the existing rack-field preservation
pattern. Adds a targeted test asserting ESTIMATED_1RM survives a portal
pull that overwrites an existing routine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

* fix(1rm): backfill every profile before setting run-once flag (#517)

The velocityOneRepMaxBackfillDone flag is global, so backfilling only the
active profile permanently skipped every other profile's historical MCV
data. Iterate all profiles (awaiting the loaded list) and backfill each
before marking the run-once flag done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): editor basis gating + preview from selected scaling basis (#517)

- ExerciseConfigViewModel: add optional VelocityOneRepMaxRepository and
  ExerciseRepository constructor params; load max-volume PR alongside
  max-weight PR in loadPRForExercise (mode-dependent); load velocity
  estimate + stored Exercise.oneRepMaxKg once per initialize via new
  loadModeIndependentBaselines(); expose currentMaxVolumePR,
  velocityEstimateKg, storedOneRepMaxKg StateFlows; add
  baselineKgForCurrentBasis() mirroring ResolveRoutineWeightsUseCase's
  fallback chain; fix calculateResolvedWeight() to use it.

- ExerciseEditBottomSheet: inject VelocityOneRepMaxRepository via
  koinInject() and pass with exerciseRepository to VM; collect new
  baseline state flows; compute baselineWeightKg from the effective
  scaling basis; add baselineWeightKg param to WeightConfigurationCard;
  fix switch gating (enabled = baselineWeightKg != null) so ESTIMATED_1RM
  and MAX_VOLUME_PR are accessible without a max-weight PR; fix preview
  to use baselineWeightKg instead of always max-weight PR; update label
  text per basis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

* docs: add Phase 6 (portal velocity-1RM display) design spec (#517)

Cross-repo spec: sync velocity_estimated_1rm_kg to the portal and display
it distinctly from the rep-based estimate. Display only; scaling-basis
parity deferred to a separate spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* docs: add Phase 6 (portal velocity-1RM display) cross-repo plan (#517)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): isolate per-profile backfill failures (#517)

One profile's backfill failure no longer aborts the remaining profiles or
leaves the run-once flag unset (which re-failed on the same profile every
launch). Each profile is wrapped in its own try/catch; the flag is set after
the loop regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z5vuLimeDX3GXvY1tGC6o

* fix(1rm): per-set weights + basis selector use the selected scaling basis (#517)

Finding #7: generalize per-set weight helpers to resolve against the
selected-basis baseline (baselineKgForCurrentBasis) instead of the
max-weight PR. Rename resolvedSetWeightsKgFromCurrentPR ->
resolvedSetWeightsKgFromCurrentBasis; per-set percent<->weight helpers
and updateWeight now take/use baselineKg. onScalingBasisChange and the
mode-independent baseline load re-sync visible set weights. Behavior is
unchanged when basis == MAX_WEIGHT_PR.

Finding #8: make the "Scale from" basis selector always available while
% scaling is enabled (moved out of the baselineWeightKg != null guard),
and gate the % toggle on hasAnyBaseline (any basis has data) rather than
only the currently-selected basis — so a user with only an Est. 1RM can
enable scaling, pick ESTIMATED_1RM, and proceed. Preview/slider/presets
remain gated on the selected basis having a baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

* fix(1rm): editor uses effectiveScalingBasis + re-syncs set weights after baseline load (#517)

Finding A: the editor derived the current basis as scalingBasis ?:
MAX_WEIGHT_PR, which is wrong for legacy rows where scalingBasis is null
but prTypeForScaling = MAX_VOLUME. Add ExerciseConfigViewModel.
effectiveScalingBasis() (= scalingBasis ?: fromPrType(prTypeForScaling))
and use it in baselineKgForCurrentBasis(); the screen now derives the
same effective basis (collecting prTypeForScaling) and passes it to
WeightConfigurationCard, which switches its scalingBasis param for
effectiveScalingBasis. Legacy MAX_VOLUME rows now select MAX_VOLUME_PR
and preview/per-set weights off the volume PR.

Finding B: a MAX_VOLUME_PR exercise re-synced visible set weights after
the max-weight PR loaded but before the volume PR was populated, leaving
stale rows. Add resyncSetWeightsIfBaselineReady() (guarded: only when
scaling on and the current-basis baseline is available) and call it
after each async baseline load (volume PR, velocity estimate, stored
1RM). The sync only rewrites set weights from stored percentages, so it
cannot loop.

Test: legacy null scalingBasis + MAX_VOLUME prType resolves editor basis
to MAX_VOLUME_PR and baseline/per-set weights to the volume PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3r4QJcxvNCB1evniqwxJm

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

3 participants