From 69c8abf49e995db6d731c3433fceeacd0e095ab3 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:53:26 +0200 Subject: [PATCH 01/24] lift log: one Save, zeros for discarded sets, and add or remove sets when editing From a second gym session on 15 Sep 2026, run on #2099 shortly before it merged: Finishing had two buttons, Skip and Save session, that saved the same way. Skip is gone; session RPE stays optional. Discarding unfinished sets now keeps them as 0 kg x 0 reps instead of dropping them, so a discard made by mistake can be filled back in under Edit sets. A set with zero reps was not performed: `LiftMetrics.isPerformed` leaves it out of every figure, and `WhoopStore.liftSetCounts` and `lastLiftSets` apply the same rule in SQL, so it never counts toward a muscle or becomes the next session's grey numbers. This also stops a set typed as 0 reps from counting as a set, which it did before. The two set counts stay pinned to agree on it. A set with no rep count still counts. The guard from #2099 that files nothing when a discard leaves no set now reads "no set counts", since discarded sets are no longer dropped. A session run face-down with nothing typed and then discarded still files no session, sets or workout, and the finish sheet now says so before Save. Edit sets can add a set to an exercise and remove its last one. Set numbers are renumbered on save, an added set takes the exercise's muscles and no timing, and a removed one is deleted through the new `deleteLiftSets`. Only that session changes, never the program. A field holding 0 empties when focused, so typing replaces the 0 instead of appending to it. The session summary shows only performed sets, and says so when there are none. Co-Authored-By: Claude Opus 5 --- .../Sources/StrandAnalytics/LiftMetrics.swift | 28 +- .../LiftMetricsStoreAgreementTests.swift | 24 +- .../LiftMetricsTests.swift | 22 ++ .../Sources/WhoopStore/LiftLogStore.swift | 26 +- .../WhoopStoreTests/LiftLogStoreTests.swift | 49 ++++ Strand/Data/LiftSessionController.swift | 45 ++-- Strand/Resources/Localizable.xcstrings | 19 +- Strand/Screens/LiftSessionDetailSheet.swift | 34 ++- Strand/Screens/LiftSessionEditSheet.swift | 247 ++++++++++++++---- Strand/Screens/LiftSessionView.swift | 51 ++-- StrandTests/LiftSessionEditTests.swift | 92 ++++++- StrandTests/LiftSessionFinishTests.swift | 33 ++- 12 files changed, 527 insertions(+), 143 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift index dca9444343..347f7c0347 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -28,6 +28,20 @@ import WhoopStore public enum LiftMetrics { + // MARK: - Performed or not + + /// Whether a set was performed, from its rep count. A set with ZERO reps was not: that is how a + /// finished session keeps the sets the user discards, so Edit sets can still fill them in, and it is + /// what a user types for a planned set they skipped. Every figure leaves such a set out, since + /// counting it would add a set nobody did. A set with no rep count (nil) still counts: it was done, + /// the number just was not typed. `WhoopStore.liftSetCounts` and `lastLiftSets` apply the same rule + /// in SQL. One function per platform, so the parity ledger pairs the twins unambiguously. + /// + /// The Kotlin twin is `LiftMetrics.isPerformed`. + public static func isPerformed(reps: Int?) -> Bool { + reps != 0 + } + // MARK: - Volume load (tonnage) /// Σ (weight × reps) over WORKING sets, in kilograms. Nil when nothing countable was logged. @@ -113,13 +127,14 @@ public enum LiftMetrics { } /// One summary per exercise, in the order the exercises were first performed — which is the - /// order they were done in, not alphabetical, because that is how a session reads back. + /// order they were done in, not alphabetical, because that is how a session reads back. A set that + /// was not performed (`isPerformed`) appears nowhere, and neither does an exercise with only such sets. /// /// The Kotlin twin is `LiftMetrics.perExercise`. public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] { var order: [String] = [] var grouped: [String: [LiftSetRow]] = [:] - for s in sets.sorted(by: { $0.ord < $1.ord }) { + for s in sets.sorted(by: { $0.ord < $1.ord }) where isPerformed(reps: s.reps) { if grouped[s.exercise] == nil { order.append(s.exercise) } grouped[s.exercise, default: []].append(s) } @@ -182,7 +197,7 @@ public enum LiftMetrics { /// The Kotlin twin is `LiftMetrics.rpeProfile`. public static func rpeProfile(_ sets: [LiftSetRow], threshold: Double = hardSetRpeThreshold) -> RpeProfile { - let working = sets.filter { !$0.isWarmup } + let working = sets.filter { !$0.isWarmup && isPerformed(reps: $0.reps) } let rated = working.compactMap(\.rpe) let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) return RpeProfile(mean: mean, @@ -217,15 +232,16 @@ public enum LiftMetrics { /// operationalisation, so the credit and the doses have to move together or the comparison /// silently stops meaning anything. /// - /// Warm-ups are excluded; nothing else is. An unclassified exercise (nil primary) contributes to - /// volume and session load but claims no muscle it was never assigned. + /// Warm-ups and sets never performed (`isPerformed`) are excluded; nothing else is. An unclassified + /// exercise (nil primary) contributes to volume and session load but claims no muscle it was never + /// assigned. /// /// The Kotlin twin is `LiftMetrics.muscleCounts`. public static func muscleCounts(_ sets: [LiftSetRow]) -> MuscleCounts { var fractional: [LiftMuscle: Double] = [:] var direct: [LiftMuscle: Int] = [:] var indirect: [LiftMuscle: Int] = [:] - for s in sets where !s.isWarmup { + for s in sets where !s.isWarmup && isPerformed(reps: s.reps) { if let p = s.primaryMuscle { direct[p, default: 0] += 1 fractional[p, default: 0] += LiftMuscle.directSetCredit diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift index ad53c331a5..a96c7861ea 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift @@ -26,7 +26,8 @@ final class LiftMetricsStoreAgreementTests: XCTestCase { /// Insert sets through raw SQL so the row shapes are exactly what is asked for, including the /// one the public API would clean up. - private func store(_ rows: [(primary: String?, secondary: String, warmup: Int)]) async throws -> WhoopStore { + private func store(_ rows: [(primary: String?, secondary: String, warmup: Int)], + reps: [Int] = []) async throws -> WhoopStore { let store = try await WhoopStore.inMemory() let writer = store.registryWriter try await writer.write { db in @@ -40,8 +41,9 @@ final class LiftMetricsStoreAgreementTests: XCTestCase { INSERT INTO liftSet (id, deviceId, sessionId, ord, exercise, primaryMuscle, secondaryMuscles, setIndex, weightKg, reps, rpe, isWarmup, startTs, endTs, restSec, note) - VALUES (?, 'dev', 's1', ?, 'Exercise', ?, ?, 1, 60, 10, NULL, ?, NULL, NULL, NULL, NULL) - """, arguments: ["set-\(i)", i, r.primary, r.secondary, r.warmup]) + VALUES (?, 'dev', 's1', ?, 'Exercise', ?, ?, 1, 60, ?, NULL, ?, NULL, NULL, NULL, NULL) + """, arguments: ["set-\(i)", i, r.primary, r.secondary, + reps.indices.contains(i) ? reps[i] : 10, r.warmup]) } } return store @@ -115,4 +117,20 @@ final class LiftMetricsStoreAgreementTests: XCTestCase { XCTAssertEqual(counts.indirect[.triceps], 1, "the recognisable half still counts") XCTAssertTrue(counts.direct.isEmpty) } + + /// A set saved at zero reps was never performed, and neither implementation counts it. The rule is + /// exactly `reps != 0` on both sides, so a nonsensical negative count still counts in both rather + /// than in one: an SQL `reps > 0` would drop it where `LiftMetrics.isPerformed` keeps it. + func testBothImplementationsAgreeThatASetWithZeroRepsDoesNotCount() async throws { + let store = try await store([ + (primary: "chest", secondary: "triceps", warmup: 0), + (primary: "chest", secondary: "triceps", warmup: 0), + (primary: "chest", secondary: "triceps", warmup: 0), + ], reps: [0, 10, -1]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.chest], 2, "the zero-rep set is the only one left out") + XCTAssertEqual(counts.indirect[.triceps], 2) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift index c4278551d5..e2ea19c28a 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift @@ -193,4 +193,26 @@ final class LiftMetricsTests: XCTestCase { XCTAssertEqual(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek, 1.0) XCTAssertEqual(LiftMetrics.ReferenceDose.strengthPlateauSetsPerWeek, 4.0) } + + // MARK: - Sets never performed + + /// A set saved at zero reps was discarded or skipped, and counts nowhere: not as a working set, not + /// in the RPE profile, not toward a muscle. An exercise with only such sets does not appear. + func testASetWithZeroRepsCountsNowhere() { + let sets = [set(weight: 100, reps: 5, rpe: 8, primary: .chest), + set(weight: 0, reps: 0, primary: .chest), + set("Row", weight: 0, reps: 0, primary: .lats)] + XCTAssertEqual(LiftMetrics.perExercise(sets).map(\.exercise), ["Bench press"]) + XCTAssertEqual(LiftMetrics.perExercise(sets).first?.workingSets, 1) + XCTAssertEqual(LiftMetrics.rpeProfile(sets).unratedSets, 0, "a discarded set is not an unrated one") + XCTAssertEqual(LiftMetrics.muscleCounts(sets).direct[.chest], 1) + XCTAssertNil(LiftMetrics.muscleCounts(sets).direct[.lats]) + } + + /// No rep count at all is different: the set was done, the number just was not typed. + func testASetWithNoRepCountStillCounts() { + let sets = [set(weight: nil, reps: nil, primary: .chest)] + XCTAssertTrue(LiftMetrics.isPerformed(reps: sets[0].reps)) + XCTAssertEqual(LiftMetrics.muscleCounts(sets).direct[.chest], 1) + } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 18e381be5d..5a4d3570be 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -586,6 +586,21 @@ extension WhoopStore { } } + /// Delete individual sets, as editing a finished session does when a set is removed. The session + /// itself stays. Returns how many rows went. + @discardableResult + public func deleteLiftSets(ids: [String]) async throws -> Int { + guard !ids.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for id in ids { + try db.execute(sql: "DELETE FROM liftSet WHERE id = ?", arguments: [id]) + n += db.changesCount + } + return n + } + } + // MARK: Sets /// Upsert sets by `id`. Called as each set is logged, so a session in progress is durable set by @@ -644,7 +659,9 @@ extension WhoopStore { /// This is the read the whole feature exists for: it pre-fills the next session with what you /// actually did last time, which the user then confirms or overrides. Empty when the exercise /// has never been logged. `before` excludes the session currently in progress (pass its - /// `startTs`) so a running session never pre-fills from itself. + /// `startTs`) so a running session never pre-fills from itself. Sets with zero reps were not + /// performed (`LiftMetrics.isPerformed`) and are skipped, so a discarded set never becomes the next + /// session's grey numbers, and a session holding only such sets is not "last time" for the exercise. public func lastLiftSets(deviceId: String, exercise: String, before: Int? = nil) async throws -> [LiftSetRow] { try syncRead { db in // Two steps rather than a correlated subquery: find the latest qualifying session, then @@ -654,12 +671,13 @@ extension WhoopStore { SELECT s.sessionId FROM liftSet s JOIN liftSession sess ON sess.id = s.sessionId WHERE s.deviceId = ? AND s.exercise = ? AND sess.startTs < ? + AND (s.reps IS NULL OR s.reps <> 0) ORDER BY sess.startTs DESC LIMIT 1 """, arguments: [deviceId, exercise, cutoff]) else { return [] } return try Row.fetchAll(db, sql: """ SELECT * FROM liftSet - WHERE sessionId = ? AND exercise = ? + WHERE sessionId = ? AND exercise = ? AND (reps IS NULL OR reps <> 0) ORDER BY ord ASC """, arguments: [sessionId, exercise]).map(LiftSetRow.decode) } @@ -677,7 +695,8 @@ extension WhoopStore { /// `direct` and `indirect` are returned alongside so the arithmetic is inspectable rather than /// asserted. /// - /// **Warm-ups are excluded; nothing else is.** In particular this does NOT filter by RPE, even + /// **Warm-ups and sets with zero reps (never performed, `LiftMetrics.isPerformed`) are excluded; + /// nothing else is.** In particular this does NOT filter by RPE, even /// though a hard set is the thing that drives adaptation — because the reference doses were /// derived from unfiltered working-set counts, and filtering here would quietly compare a /// smaller number against a scale built from a larger one. Proximity to failure is reported @@ -695,6 +714,7 @@ extension WhoopStore { WHERE s.deviceId = ? AND sess.startTs >= ? AND sess.startTs <= ? AND s.isWarmup = 0 + AND (s.reps IS NULL OR s.reps <> 0) """, arguments: [deviceId, fromTs, toTs]) var direct: [LiftMuscle: Int] = [:] diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index f85dec111b..7d701cc052 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -541,6 +541,55 @@ final class LiftLogStoreTests: XCTestCase { } } + // MARK: - Sets never performed, and removing sets + + /// A set saved at zero reps was discarded at finish or skipped: the weekly counts leave it out. + func testSetCountsLeaveOutSetsWithZeroReps() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "done", sessionId: "s1", ord: 0, setIndex: 1, primary: .chest, secondary: []), + mkSet(id: "discarded", sessionId: "s1", ord: 1, setIndex: 2, weightKg: 0, reps: 0, + primary: .chest, secondary: []), + mkSet(id: "untyped", sessionId: "s1", ord: 2, setIndex: 3, weightKg: nil, reps: nil, + primary: .chest, secondary: []), + ]) + let counts = try await store.liftSetCounts(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(counts.direct[.chest], 2, "the zero-rep set is out; a set with no rep count is still in") + } + + /// A discarded set must never become the next session's grey numbers, and a session holding only + /// discarded sets for an exercise is not "last time" for it. + func testLastLiftSetsSkipsSetsWithZeroReps() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([ + mkSession(id: "real", startTs: 1_000), + mkSession(id: "discarded", startTs: 5_000), + ]) + _ = try await store.upsertLiftSets([ + mkSet(id: "a", sessionId: "real", ord: 0, setIndex: 1, weightKg: 100, reps: 8), + mkSet(id: "b", sessionId: "discarded", ord: 0, setIndex: 1, weightKg: 0, reps: 0), + ]) + let last = try await store.lastLiftSets(deviceId: dev, exercise: "Leg Press") + XCTAssertEqual(last.map(\.id), ["a"]) + } + + func testDeletingSetsRemovesOnlyThoseSets() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "x1", sessionId: "s1", ord: 0, setIndex: 1), + mkSet(id: "x2", sessionId: "s1", ord: 1, setIndex: 2), + mkSet(id: "x3", sessionId: "s1", ord: 2, setIndex: 3), + ]) + let removed = try await store.deleteLiftSets(ids: ["x2", "missing"]) + XCTAssertEqual(removed, 1) + let left = try await store.liftSets(sessionId: "s1") + XCTAssertEqual(left.map(\.id), ["x1", "x3"]) + let sessions = try await store.liftSessions(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(sessions.count, 1, "the session itself stays") + } + // MARK: - Helpers private let dev = "my-whoop" diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 922116bf77..25a6f29dd5 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -1,6 +1,7 @@ import Foundation import Combine import WhoopStore +import StrandAnalytics // The live session, owned ABOVE any screen. // @@ -410,7 +411,7 @@ final class LiftSessionController: ObservableObject { // MARK: - Finishing /// Slots with no number typed in — never started, or finished without typing. Finishing asks once - /// whether to complete all of them with their grey numbers or leave them out. + /// whether to complete all of them with their grey numbers or discard them. var unfinishedSlots: [LiftSlot] { engine?.unenteredSlots ?? [] } /// One set as the finished session saves it. Timing is nil for a set completed at finish without @@ -427,35 +428,45 @@ final class LiftSessionController: ObservableObject { var restSec: Int? } - /// The sets the session saves. + /// The sets the session saves — every slot on the sheet. /// - /// A set with anything typed always saves, and a number left blank takes its grey value, so a set - /// that was rated but never weighed does not save empty. Unfinished sets are saved with their grey - /// numbers (and anything typed in advance) when `completingUnfinished`, and left out otherwise. - /// Performed sets keep the order they happened in; sets completed at finish follow in plan order. + /// A set with anything typed saves its numbers, and a number left blank takes its grey value, so a + /// set that was rated but never weighed does not save empty. Unfinished sets save with their grey + /// numbers (and anything typed in advance) when `completingUnfinished`; otherwise they save as + /// 0 kg × 0 reps, which every figure leaves out (`LiftMetrics.isPerformed`) and Edit sets still + /// shows, so a discard made by mistake can be filled back in. Performed sets keep the order they + /// happened in and their timing; sets never started follow in plan order, with no timing. func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { guard let engine else { return [] } let unfinished = Set(engine.unenteredSlots) - var out = engine.sets - .filter { completingUnfinished || !unfinished.contains($0.slot) } - .map { set -> FinishedSet in - let shown = values(of: set.slot) - return FinishedSet(slot: set.slot, weightKg: shown.weightKg, reps: shown.reps, - rpe: set.rpe, isWarmup: set.isWarmup, startTs: set.startTs, - endTs: set.endTs, restSec: set.restSec) - } - guard completingUnfinished else { return out } + var out = engine.sets.map { set -> FinishedSet in + let discarded = unfinished.contains(set.slot) && !completingUnfinished + let shown = values(of: set.slot) + return FinishedSet(slot: set.slot, + weightKg: discarded ? 0 : shown.weightKg, reps: discarded ? 0 : shown.reps, + rpe: discarded ? nil : set.rpe, isWarmup: set.isWarmup, + startTs: set.startTs, endTs: set.endTs, restSec: set.restSec) + } for slot in engine.allSlots where !engine.isCompleted(slot) { let typed = pendingValues[slot] let grey = carry(for: slot) - out.append(FinishedSet(slot: slot, weightKg: typed?.weightKg ?? grey.weightKg, - reps: typed?.reps ?? grey.reps, rpe: typed?.rpe, + out.append(FinishedSet(slot: slot, + weightKg: completingUnfinished ? typed?.weightKg ?? grey.weightKg : 0, + reps: completingUnfinished ? typed?.reps ?? grey.reps : 0, + rpe: completingUnfinished ? typed?.rpe : nil, isWarmup: pendingWarmups.contains(slot), startTs: nil, endTs: nil, restSec: nil)) } return out } + /// Whether any of `sets` was performed. When none was (a session run face-down with nothing typed, + /// then discarded), there is nothing to file: `LiftSessionView.save` writes no session, no sets and + /// no workout, and the finish sheet says so before Save. + static func anyPerformed(_ sets: [FinishedSet]) -> Bool { + sets.contains { LiftMetrics.isPerformed(reps: $0.reps) } + } + /// A program line whose set count this session changed. struct SetCountChange: Equatable { var itemId: String diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 420de042a8..44a1d69024 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1893,8 +1893,8 @@ "Discard them": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard them"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartarlas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Les abandonner"}}, "it": {"stringUnit": {"state": "translated", "value": "Scartale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartá-las"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отбросить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄"}} } }, - "Completing saves them with the grey numbers shown. Discarding leaves them out of the session.": { "localizations": { - "de": {"stringUnit": {"state": "translated", "value": "Abschließen speichert sie mit den grau angezeigten Werten. Verwerfen lässt sie aus der Einheit weg."}}, "en": {"stringUnit": {"state": "translated", "value": "Completing saves them with the grey numbers shown. Discarding leaves them out of the session."}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas las guarda con los números en gris. Descartarlas las deja fuera de la sesión."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter les enregistre avec les chiffres affichés en gris. Les abandonner les retire de la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "Completarle le salva con i numeri in grigio. Scartarle le lascia fuori dalla sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnienie zapisze je z liczbami pokazanymi na szaro. Odrzucenie pominie je w sesji."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las guarda-as com os números a cinzento. Descartá-las deixa-as fora da sessão."}}, "ru": {"stringUnit": {"state": "translated", "value": "Если завершить, они сохранятся с серыми числами. Если отбросить, в сессию они не попадут."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全会用灰色显示的数字保存它们;放弃则不计入本次训练。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全會用灰色顯示的數字儲存它們;放棄則不計入本次訓練。"}} + "Every set would be a zero, so discarding saves no session and no workout.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Jeder Satz wäre eine Null, daher speichert Verwerfen keine Einheit und kein Workout."}}, "en": {"stringUnit": {"state": "translated", "value": "Every set would be a zero, so discarding saves no session and no workout."}}, "es": {"stringUnit": {"state": "translated", "value": "Todas las series quedarían en cero, así que descartarlas no guarda ninguna sesión ni entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "Toutes les séries seraient à zéro : les abandonner n'enregistre ni séance ni entraînement."}}, "it": {"stringUnit": {"state": "translated", "value": "Ogni serie sarebbe a zero, quindi scartarle non salva né la sessione né l'allenamento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Każda seria byłaby zerem, więc odrzucenie nie zapisze ani sesji, ani treningu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Todas as séries ficariam a zero, por isso descartá-las não guarda sessão nem treino."}}, "ru": {"stringUnit": {"state": "translated", "value": "Все подходы стали бы нулями, поэтому если отбросить, не сохранится ни сессия, ни тренировка."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每一组都会是 0,因此放弃后既不会保存本次训练,也不会创建训练记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每一組都會是 0,因此放棄後既不會儲存本次訓練,也不會建立訓練記錄。"}} } }, "You changed the number of sets. Keep the new counts in the program for next time?": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Du hast die Anzahl der Sätze geändert. Die neuen Zahlen fürs nächste Mal ins Programm übernehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You changed the number of sets. Keep the new counts in the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has cambiado el número de series. ¿Guardar las nuevas cifras en el programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as modifié le nombre de séries. Garder les nouveaux nombres dans le programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai cambiato il numero di serie. Tenere i nuovi numeri nel programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zmieniono liczbę serii. Zachować nowe liczby w programie na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alteraste o número de séries. Guardar os novos números no programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Количество подходов изменено. Сохранить новые значения в программе на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你更改了组数。要把新的组数保存到计划里,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你更改了組數。要把新的組數儲存到計畫裡,下次使用嗎?"}} @@ -1914,12 +1914,18 @@ "Edit sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Sätze bearbeiten"}}, "en": {"stringUnit": {"state": "translated", "value": "Edit sets"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar series"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier les séries"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj serie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑各组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯各組"}} } }, - "Fix a number you missed or mistyped. The session's figures follow when you save.": { "localizations": { - "de": {"stringUnit": {"state": "translated", "value": "Korrigiere einen vergessenen oder falsch eingegebenen Wert. Die Kennzahlen der Einheit folgen beim Speichern."}}, "en": {"stringUnit": {"state": "translated", "value": "Fix a number you missed or mistyped. The session's figures follow when you save."}}, "es": {"stringUnit": {"state": "translated", "value": "Corrige un número que olvidaste o escribiste mal. Las cifras de la sesión se actualizan al guardar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Corrige un chiffre oublié ou mal saisi. Les chiffres de la séance suivent à l'enregistrement."}}, "it": {"stringUnit": {"state": "translated", "value": "Correggi un numero dimenticato o sbagliato. I dati della sessione si aggiornano quando salvi."}}, "pl": {"stringUnit": {"state": "translated", "value": "Popraw pominiętą lub błędnie wpisaną liczbę. Wskaźniki sesji zaktualizują się po zapisaniu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Corrige um número que esqueceste ou escreveste mal. Os valores da sessão atualizam-se ao guardar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Исправь пропущенное или неверно введённое число. Показатели сессии обновятся при сохранении."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "修正漏填或填错的数字。保存后,本次训练的数据会随之更新。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "修正漏填或填錯的數字。儲存後,本次訓練的數據會隨之更新。"}} - } }, "Save changes": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Änderungen speichern"}}, "en": {"stringUnit": {"state": "translated", "value": "Save changes"}}, "es": {"stringUnit": {"state": "translated", "value": "Guardar cambios"}}, "fr": {"stringUnit": {"state": "translated", "value": "Enregistrer"}}, "it": {"stringUnit": {"state": "translated", "value": "Salva modifiche"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisz zmiany"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Guardar alterações"}}, "ru": {"stringUnit": {"state": "translated", "value": "Сохранить изменения"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "保存更改"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "儲存變更"}} } }, + "Completing saves them with the grey numbers shown. Discarding keeps them out of every figure; they stay under Edit sets as zeros you can fill in later.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abschließen speichert sie mit den grau angezeigten Werten. Verwerfen hält sie aus allen Kennzahlen heraus; unter „Sätze bearbeiten“ bleiben sie als Nullen, die du später ausfüllen kannst."}}, "en": {"stringUnit": {"state": "translated", "value": "Completing saves them with the grey numbers shown. Discarding keeps them out of every figure; they stay under Edit sets as zeros you can fill in later."}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas las guarda con los números en gris. Descartarlas las deja fuera de todas las cifras; quedan en Editar series como ceros que puedes rellenar después."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter les enregistre avec les chiffres affichés en gris. Les abandonner les exclut de tous les chiffres ; elles restent dans Modifier les séries sous forme de zéros à remplir plus tard."}}, "it": {"stringUnit": {"state": "translated", "value": "Completarle le salva con i numeri in grigio. Scartarle le esclude da tutti i dati; restano in Modifica serie come zeri da compilare più tardi."}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnienie zapisze je z liczbami pokazanymi na szaro. Odrzucenie wyłączy je ze wszystkich wskaźników; zostaną w Edytuj serie jako zera do uzupełnienia później."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las guarda-as com os números a cinzento. Descartá-las deixa-as fora de todos os valores; ficam em Editar séries como zeros que podes preencher depois."}}, "ru": {"stringUnit": {"state": "translated", "value": "Если завершить, они сохранятся с серыми числами. Если отбросить, они не войдут ни в какие показатели и останутся в «Изменить подходы» нулями, которые можно заполнить позже."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全会用灰色显示的数字保存它们。放弃则不计入任何数据;它们会以 0 保留在“编辑各组”中,之后可以补填。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全會用灰色顯示的數字儲存它們。放棄則不計入任何數據;它們會以 0 保留在「編輯各組」中,之後可以補填。"}} + } }, + "Fix numbers, or add and remove sets. Sets left at 0 reps stay out of the figures, and only this session changes — not the program.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Korrigiere Werte oder füge Sätze hinzu und entferne sie. Sätze mit 0 Wiederholungen zählen in keiner Kennzahl, und nur diese Einheit ändert sich – nicht das Programm."}}, "en": {"stringUnit": {"state": "translated", "value": "Fix numbers, or add and remove sets. Sets left at 0 reps stay out of the figures, and only this session changes — not the program."}}, "es": {"stringUnit": {"state": "translated", "value": "Corrige números o añade y quita series. Las series con 0 repeticiones no cuentan en las cifras, y solo cambia esta sesión, no el programa."}}, "fr": {"stringUnit": {"state": "translated", "value": "Corrige les chiffres, ou ajoute et retire des séries. Les séries à 0 répétition ne comptent dans aucun chiffre, et seule cette séance change — pas le programme."}}, "it": {"stringUnit": {"state": "translated", "value": "Correggi i numeri, oppure aggiungi e togli serie. Le serie a 0 ripetizioni restano fuori dai dati, e cambia solo questa sessione, non il programma."}}, "pl": {"stringUnit": {"state": "translated", "value": "Popraw liczby albo dodaj i usuń serie. Serie z 0 powtórzeń nie wchodzą do wskaźników, a zmienia się tylko ta sesja — nie program."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Corrige números ou adiciona e remove séries. As séries com 0 repetições ficam fora dos valores, e só esta sessão muda — não o programa."}}, "ru": {"stringUnit": {"state": "translated", "value": "Исправь числа или добавь и удали подходы. Подходы с 0 повторений не входят в показатели, и меняется только эта сессия — не программа."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "修正数字,或添加和删除组。0 次的组不计入数据,且只修改本次训练,不会改动计划。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "修正數字,或新增和刪除組。0 次的組不計入數據,且只修改本次訓練,不會改動計畫。"}} + } }, + "No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Keine Sätze absolviert. Verworfene Sätze bleiben unter „Sätze bearbeiten“ als Nullen, die du ausfüllen kannst."}}, "en": {"stringUnit": {"state": "translated", "value": "No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in."}}, "es": {"stringUnit": {"state": "translated", "value": "No se hizo ninguna serie. Las series descartadas quedan en Editar series como ceros que puedes rellenar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucune série effectuée. Les séries abandonnées restent dans Modifier les séries sous forme de zéros à remplir."}}, "it": {"stringUnit": {"state": "translated", "value": "Nessuna serie eseguita. Le serie scartate restano in Modifica serie come zeri da compilare."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie wykonano żadnej serii. Odrzucone serie zostają w Edytuj serie jako zera do uzupełnienia."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nenhuma série feita. As séries descartadas ficam em Editar séries como zeros que podes preencher."}}, "ru": {"stringUnit": {"state": "translated", "value": "Ни один подход не выполнен. Отброшенные подходы остаются в «Изменить подходы» нулями, которые можно заполнить."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有完成任何一组。放弃的组会以 0 保留在“编辑各组”中,可以补填。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有完成任何一組。放棄的組會以 0 保留在「編輯各組」中,可以補填。"}} + } }, "Add a set to %@": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Einen Satz zu %@ hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add a set to %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir una serie a %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série à %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi una serie a %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię do %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar uma série a %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход к %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为 %@ 添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為 %@ 新增一組"}} } }, @@ -2205,9 +2211,6 @@ "Next": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Weiter" } }, "en": { "stringUnit": { "state": "translated", "value": "Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Siguiente" } }, "fr": { "stringUnit": { "state": "translated", "value": "Suivant" } }, "it": { "stringUnit": { "state": "translated", "value": "Avanti" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dalej" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Seguinte" } }, "ru": { "stringUnit": { "state": "translated", "value": "Далее" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下一步" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下一步" } } } }, - "Skip": { "localizations": { - "de": { "stringUnit": { "state": "translated", "value": "Überspringen" } }, "en": { "stringUnit": { "state": "translated", "value": "Skip" } }, "es": { "stringUnit": { "state": "translated", "value": "Omitir" } }, "fr": { "stringUnit": { "state": "translated", "value": "Passer" } }, "it": { "stringUnit": { "state": "translated", "value": "Salta" } }, "pl": { "stringUnit": { "state": "translated", "value": "Pomiń" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ignorar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пропустить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "跳过" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "跳過" } } - } }, "Rest period": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Pause" } }, "en": { "stringUnit": { "state": "translated", "value": "Rest period" } }, "es": { "stringUnit": { "state": "translated", "value": "Descanso entre series" } }, "fr": { "stringUnit": { "state": "translated", "value": "Temps de repos" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Descanso entre séries" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых между подходами" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组间休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組間休息" } } } }, diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index d7a9ef26ee..4871ef0b6e 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -52,8 +52,10 @@ struct LiftSessionDetailSheet: View { } else { figuresSection exercisesSection - muscleSection - rpeSection + if !performed.isEmpty { + muscleSection + rpeSection + } footnote NoopButton("Edit sets", systemImage: "pencil", kind: .secondary) { editing = true } deleteSection @@ -106,7 +108,7 @@ struct LiftSessionDetailSheet: View { Button("Delete", role: .destructive) { Task { await deleteSession() } } Button("Cancel", role: .cancel) { } } message: { - Text("\(sets.count) recorded sets will be removed, and so will the workout this session created. This cannot be undone.") + Text("\(performed.count) recorded sets will be removed, and so will the workout this session created. This cannot be undone.") } } @@ -143,7 +145,7 @@ struct LiftSessionDetailSheet: View { // exercises compares nothing. The per-exercise figure below, with its delta against // last time, is the form that answers "am I progressing" — this one is the tally. tile(String(localized: "Volume"), - LiftFormat.weight(LiftMetrics.volumeLoadKg(sets), system: unitSystem), + LiftFormat.weight(LiftMetrics.volumeLoadKg(performed), system: unitSystem), String(localized: "\(workingSetCount) working sets · compare when you repeat this program")) tile(String(localized: "Session load"), @@ -157,7 +159,11 @@ struct LiftSessionDetailSheet: View { } } - private var workingSetCount: Int { sets.filter { !$0.isWarmup }.count } + /// The sets that were performed. A set at zero reps was discarded or skipped: it stays in the store + /// so Edit sets can fill it in, and out of everything this screen shows. + private var performed: [LiftSetRow] { sets.filter { LiftMetrics.isPerformed(reps: $0.reps) } } + + private var workingSetCount: Int { performed.filter { !$0.isWarmup }.count } private var sessionLoadText: String { guard let load = LiftMetrics.sessionLoad(sessionRpe: sessionRpe, @@ -202,14 +208,22 @@ struct LiftSessionDetailSheet: View { private var exercisesSection: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Exercises", overline: "As performed") - ForEach(LiftMetrics.perExercise(sets), id: \.exercise) { summary in + if performed.isEmpty { + NoopCard { + Text("No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + ForEach(LiftMetrics.perExercise(performed), id: \.exercise) { summary in exerciseCard(summary) } } } private func exerciseCard(_ summary: LiftMetrics.ExerciseSummary) -> some View { - let rows = sets.filter { $0.exercise == summary.exercise }.sorted { $0.ord < $1.ord } + let rows = performed.filter { $0.exercise == summary.exercise }.sorted { $0.ord < $1.ord } return NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { Text(summary.exercise) @@ -320,7 +334,7 @@ struct LiftSessionDetailSheet: View { // MARK: - Sets per muscle private var muscleSection: some View { - let counts = LiftMetrics.muscleCounts(sets) + let counts = LiftMetrics.muscleCounts(performed) let ordered = LiftMuscle.ordered.filter { (counts.fractional[$0] ?? 0) > 0 } return VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Sets per muscle", overline: "This session · estimated") @@ -371,7 +385,7 @@ struct LiftSessionDetailSheet: View { // MARK: - RPE profile private var rpeSection: some View { - let p = LiftMetrics.rpeProfile(sets) + let p = LiftMetrics.rpeProfile(performed) return VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("How hard it felt", overline: "RPE") NoopCard { @@ -434,7 +448,7 @@ struct LiftSessionDetailSheet: View { // Previous volume per exercise, for the "vs last time" line. var previous: [String: Double] = [:] - for name in Set(sets.map(\.exercise)) { + for name in Set(performed.map(\.exercise)) { let before = (try? await store.lastLiftSets(deviceId: repo.deviceId, exercise: name, before: session.startTs)) ?? [] diff --git a/Strand/Screens/LiftSessionEditSheet.swift b/Strand/Screens/LiftSessionEditSheet.swift index b70576ed1c..b3f2a6ab9a 100644 --- a/Strand/Screens/LiftSessionEditSheet.swift +++ b/Strand/Screens/LiftSessionEditSheet.swift @@ -2,9 +2,12 @@ import SwiftUI import StrandDesign import WhoopStore -// Correct a finished session: a number missed or mistyped at the gym, a warm-up not marked, or the -// session's RPE. Only the numbers move — which sets were done, when and in what order stays as it -// happened — and every figure on the session screen is recomputed from these rows. +// Correct a finished session: a number missed or mistyped at the gym, a set added or removed, a warm-up +// not marked, or the session's RPE. Only this session changes — never the program it ran from — and +// every figure on the session screen is recomputed from these rows. +// +// Sets saved at 0 reps (discarded at finish, or skipped) appear here and nowhere else, so a discard +// made by mistake can be filled back in: give such a set its reps and it counts again. // // Fields hold plain text and are parsed once, on Save, so a field is never rewritten while it is being // typed into (the bug that stored 45.5 kg as 455). Only a field whose text changed is written back: @@ -22,12 +25,15 @@ struct LiftSessionEditSheet: View { @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } - /// The text of every field, by set id, and what it held when the sheet opened. - @State private var form: [String: SetForm] = [:] - @State private var original: [String: SetForm] = [:] + /// Exercises in the order they were first performed, each with its rows in set order — and the same + /// as the sheet opened, to tell whether anything changed. + @State private var exercises: [ExerciseRows] = [] + @State private var original: [ExerciseRows] = [] @State private var sessionRpeText = "" @State private var originalSessionRpe = "" @State private var saving = false + /// A weight or reps field whose 0 was emptied when it was focused. + @State private var clearedZero: Field? @FocusState private var focused: Field? private enum Field: Hashable { case weight(String), reps(String), rpe(String), sessionRpe } @@ -40,24 +46,30 @@ struct LiftSessionEditSheet: View { var isWarmup: Bool } - private var hasChanges: Bool { form != original || sessionRpeText != originalSessionRpe } + /// One row on the sheet: a saved set (its row id) or one added here (a new id). + struct Entry: Equatable { + let id: String + var form: SetForm + } - /// Exercises in the order they were first performed. - private var exercises: [String] { - var seen = Set() - return sets.sorted { $0.ord < $1.ord }.map(\.exercise).filter { seen.insert($0).inserted } + /// An exercise and its rows, in set order. + struct ExerciseRows: Equatable { + let name: String + var entries: [Entry] } + private var hasChanges: Bool { exercises != original || sessionRpeText != originalSessionRpe } + private var weightHeading: LocalizedStringKey { unitSystem == .imperial ? "Lb" : "Kg" } var body: some View { ScreenScaffold(title: "Edit sets", - subtitle: "Fix a number you missed or mistyped. The session's figures follow when you save.") { + subtitle: "Fix numbers, or add and remove sets. Sets left at 0 reps stay out of the figures, and only this session changes — not the program.") { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { sessionRpeCard - ForEach(exercises, id: \.self) { exerciseCard($0) } + ForEach(exercises.indices, id: \.self) { exerciseCard($0) } footer } } @@ -69,6 +81,13 @@ struct LiftSessionEditSheet: View { .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) .dismissesKeyboardOnTap($focused) + // A field holding 0 (a discarded set) empties when focused, so typing replaces the 0 instead of + // appending to it ("0" then "60" read "600" in the simulator); left empty, it goes back to 0. The + // single-argument form on purpose: the two-argument `onChange` is macOS 14+. + .onChange(of: focused) { now in + restoreClearedZero() + if swapText(now, "0", "") { clearedZero = now } + } .onAppear(perform: fill) } @@ -83,10 +102,11 @@ struct LiftSessionEditSheet: View { } } - private func exerciseCard(_ exercise: String) -> some View { - NoopCard { + private func exerciseCard(_ index: Int) -> some View { + let group = exercises[index] + return NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { - Text(exercise) + Text(group.name) .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) HStack(spacing: 8) { @@ -98,19 +118,21 @@ struct LiftSessionEditSheet: View { } .lineLimit(1) .minimumScaleFactor(0.8) - ForEach(sets.filter { $0.exercise == exercise }.sorted { $0.ord < $1.ord }, id: \.id) { - setRow($0) + ForEach(Array(group.entries.enumerated()), id: \.element.id) { position, entry in + setRow(exercise: index, position: position, entry: entry) } + setCountRow(index) } } } - /// The set number toggles a warm-up, as on the session sheet. - private func setRow(_ row: LiftSetRow) -> some View { - let warmup = form[row.id]?.isWarmup ?? row.isWarmup + /// The set number toggles a warm-up, as on the session sheet. Numbers follow the rows, so removing a + /// set renumbers the ones after it. + private func setRow(exercise: Int, position: Int, entry: Entry) -> some View { + let warmup = entry.form.isWarmup return HStack(spacing: 8) { - Button { form[row.id]?.isWarmup.toggle() } label: { - Text(warmup ? String(localized: "W") : "\(row.setIndex)") + Button { update(exercise, entry.id) { $0.form.isWarmup.toggle() } } label: { + Text(warmup ? String(localized: "W") : "\(position + 1)") .font(StrandFont.captionNumber) .foregroundStyle(warmup ? StrandPalette.metricAmber : StrandPalette.textSecondary) .frame(width: LiftSessionView.setColumnWidth, alignment: .center) @@ -119,14 +141,49 @@ struct LiftSessionEditSheet: View { .buttonStyle(.plain) .accessibilityLabel(warmup ? String(localized: "Warm-up set — tap to make it a working set") - : String(localized: "Set \(row.setIndex) — tap to mark it a warm-up")) - field(.weight(row.id), text: binding(row.id, \.weight)) - field(.reps(row.id), text: binding(row.id, \.reps)) - field(.rpe(row.id), text: binding(row.id, \.rpe)) + : String(localized: "Set \(position + 1) — tap to mark it a warm-up")) + field(.weight(entry.id), text: binding(exercise, entry.id, \.weight)) + field(.reps(entry.id), text: binding(exercise, entry.id, \.reps)) + field(.rpe(entry.id), text: binding(exercise, entry.id, \.rpe)) } .padding(.vertical, 6) } + /// Add a set at the end of an exercise, or drop its last one — the same control as the session + /// sheet. An exercise keeps at least one row: set it to 0 reps to take it out of the figures. + private func setCountRow(_ index: Int) -> some View { + let group = exercises[index] + let canAdd = group.entries.count < LiftSessionEngine.maxSetsPerExercise + let canRemove = group.entries.count > 1 + return HStack(spacing: 8) { + Button { addSet(index) } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle") + .font(.system(size: 17, weight: .semibold)) + Text("Add set").font(StrandFont.caption) + } + .foregroundStyle(canAdd ? StrandPalette.effortColor : StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .accessibilityLabel(String(localized: "Add a set to \(group.name)")) + + Button { removeSet(index) } label: { + Image(systemName: "minus.circle") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(canRemove ? StrandPalette.textSecondary + : StrandPalette.textTertiary.opacity(0.4)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canRemove) + .accessibilityLabel(String(localized: "Remove the last set from \(group.name)")) + } + .padding(.top, 2) + } + private func field(_ target: Field, text: Binding) -> some View { TextField(Self.empty, text: text) .textFieldStyle(.plain) @@ -139,9 +196,60 @@ struct LiftSessionEditSheet: View { /// A typed comma becomes a point, as on the session sheet: iOS labels the decimal key from the /// device's region, and the field reads back in the notation the app displays. - private func binding(_ id: String, _ key: WritableKeyPath) -> Binding { - Binding(get: { form[id]?[keyPath: key] ?? "" }, - set: { form[id]?[keyPath: key] = $0.replacingOccurrences(of: ",", with: ".") }) + private func binding(_ exercise: Int, _ id: String, + _ key: WritableKeyPath) -> Binding { + Binding(get: { entry(exercise, id)?.form[keyPath: key] ?? "" }, + set: { text in + update(exercise, id) { $0.form[keyPath: key] = text.replacingOccurrences(of: ",", with: ".") } + }) + } + + private func entry(_ exercise: Int, _ id: String) -> Entry? { + guard exercises.indices.contains(exercise) else { return nil } + return exercises[exercise].entries.first { $0.id == id } + } + + private func update(_ exercise: Int, _ id: String, _ change: (inout Entry) -> Void) { + guard exercises.indices.contains(exercise), + let i = exercises[exercise].entries.firstIndex(where: { $0.id == id }) else { return } + change(&exercises[exercise].entries[i]) + } + + /// Replace a weight or reps field's text with `new` if it is `old`. RPE never holds 0. + @discardableResult + private func swapText(_ field: Field?, _ old: String, _ new: String) -> Bool { + let target: (id: String, key: WritableKeyPath) + switch field { + case .weight(let id)?: target = (id, \.weight) + case .reps(let id)?: target = (id, \.reps) + default: return false + } + guard let exercise = exercises.firstIndex(where: { $0.entries.contains { $0.id == target.id } }), + entry(exercise, target.id)?.form[keyPath: target.key] == old else { return false } + update(exercise, target.id) { $0.form[keyPath: target.key] = new } + return true + } + + /// Put back a 0 that was emptied on focus and left empty, before anything reads the fields: an empty + /// reps field saves as nil, which counts as a performed set. + private func restoreClearedZero() { + swapText(clearedZero, "", "0") + clearedZero = nil + } + + /// A new set starts from the exercise's last one — usually what the extra set was — without its RPE. + private func addSet(_ exercise: Int) { + restoreClearedZero() + guard let last = exercises[exercise].entries.last, + exercises[exercise].entries.count < LiftSessionEngine.maxSetsPerExercise else { return } + exercises[exercise].entries.append(Entry( + id: UUID().uuidString, + form: SetForm(weight: last.form.weight, reps: last.form.reps, rpe: "", isWarmup: false))) + } + + private func removeSet(_ exercise: Int) { + guard exercises[exercise].entries.count > 1 else { return } + exercises[exercise].entries.removeLast() } private var footer: some View { @@ -160,9 +268,18 @@ struct LiftSessionEditSheet: View { } private func fill() { - guard form.isEmpty else { return } - for row in sets { form[row.id] = Self.form(for: row, system: unitSystem) } - original = form + guard exercises.isEmpty else { return } + var order: [String] = [] + for row in sets.sorted(by: { $0.ord < $1.ord }) where !order.contains(row.exercise) { + order.append(row.exercise) + } + exercises = order.map { name in + ExerciseRows(name: name, entries: sets + .filter { $0.exercise == name } + .sorted { ($0.setIndex, $0.ord) < ($1.setIndex, $1.ord) } + .map { Entry(id: $0.id, form: Self.form(for: $0, system: unitSystem)) }) + } + original = exercises sessionRpeText = session.sessionRpe.map { LiftFormat.trim($0) } ?? "" originalSessionRpe = sessionRpeText } @@ -172,11 +289,10 @@ struct LiftSessionEditSheet: View { saving = true defer { saving = false } - let edited = sets.compactMap { row -> LiftSetRow? in - guard let after = form[row.id], let before = original[row.id], after != before else { return nil } - return Self.applying(after, over: before, to: row, system: unitSystem) - } - if !edited.isEmpty { _ = try? await store.upsertLiftSets(edited) } + restoreClearedZero() + let change = Self.changes(from: sets, to: exercises, system: unitSystem) + if !change.upserts.isEmpty { _ = try? await store.upsertLiftSets(change.upserts) } + if !change.deletedIds.isEmpty { _ = try? await store.deleteLiftSets(ids: change.deletedIds) } if sessionRpeText != originalSessionRpe { var row = session row.sessionRpe = LiftFormat.number(sessionRpeText) @@ -199,20 +315,53 @@ struct LiftSessionEditSheet: View { static func applying(_ after: SetForm, over before: SetForm, to row: LiftSetRow, system: UnitSystem) -> LiftSetRow { var edited = row - if after.weight != before.weight { - edited.weightKg = LiftFormat.number(after.weight).map { - LiftFormat.kilograms(fromDisplay: $0, system: system) - } - } - if after.reps != before.reps { - edited.reps = Int(after.reps.trimmingCharacters(in: .whitespaces)) - } - if after.rpe != before.rpe { - edited.rpe = LiftFormat.number(after.rpe) - } + if after.weight != before.weight { edited.weightKg = kilograms(after.weight, system) } + if after.reps != before.reps { edited.reps = repCount(after.reps) } + if after.rpe != before.rpe { edited.rpe = LiftFormat.number(after.rpe) } edited.isWarmup = after.isWarmup return edited } + /// What saving writes: every row that changed or was added, and the ids of rows no longer on the + /// sheet. Rows are numbered 1… per exercise in sheet order. A saved row has only its edited fields + /// parsed back in; an added row takes its muscles from the exercise's other sets, has no timing, and + /// is ordered after every set the session already had. + static func changes(from rows: [LiftSetRow], to exercises: [ExerciseRows], + system: UnitSystem) -> (upserts: [LiftSetRow], deletedIds: [String]) { + let byId = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) + var nextOrd = (rows.map(\.ord).max() ?? -1) + 1 + var upserts: [LiftSetRow] = [] + var kept = Set() + for group in exercises { + guard let template = rows.first(where: { $0.exercise == group.name }) else { continue } + for (position, entry) in group.entries.enumerated() { + if let row = byId[entry.id] { + kept.insert(row.id) + var edited = applying(entry.form, over: form(for: row, system: system), to: row, system: system) + edited.setIndex = position + 1 + if edited != row { upserts.append(edited) } + } else { + upserts.append(LiftSetRow( + id: entry.id, deviceId: template.deviceId, sessionId: template.sessionId, + ord: nextOrd, exercise: group.name, primaryMuscle: template.primaryMuscle, + secondaryMuscles: template.secondaryMuscles, setIndex: position + 1, + weightKg: kilograms(entry.form.weight, system), reps: repCount(entry.form.reps), + rpe: LiftFormat.number(entry.form.rpe), isWarmup: entry.form.isWarmup, + startTs: nil, endTs: nil, restSec: nil, note: nil)) + nextOrd += 1 + } + } + } + return (upserts, rows.map(\.id).filter { !kept.contains($0) }) + } + + private static func kilograms(_ text: String, _ system: UnitSystem) -> Double? { + LiftFormat.number(text).map { LiftFormat.kilograms(fromDisplay: $0, system: system) } + } + + private static func repCount(_ text: String) -> Int? { + Int(text.trimmingCharacters(in: .whitespaces)) + } + private static let empty = "—" } diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 28b9bc44b0..b941d500d8 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -621,29 +621,21 @@ struct LiftSessionView: View { if unfinished > 0 { unfinishedCard(count: unfinished) } if !setCountChanges.isEmpty { programCard } - HStack { - Button("Skip") { Task { await save() } } - .buttonStyle(.plain) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textSecondary) - .disabled(saving || !answered) - Spacer() - Button("Save session") { Task { await save() } } - .buttonStyle(.noopPrimary) - .frame(maxWidth: 180) - .disabled(saving || !answered) - .opacity(saving || !answered ? NoopButtonMetrics.disabledOpacity : 1) - } + // One way to save. Session RPE above is optional, so an empty field is simply no rating; + // a separate "Skip" saved exactly the same way and read as a second choice. + Button("Save session") { Task { await save() } } + .buttonStyle(.noopPrimary) + .disabled(saving || !answered) + .opacity(saving || !answered ? NoopButtonMetrics.disabledOpacity : 1) if !answered { Text("Choose an option above to save.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) } - // A way OUT that records nothing. Until this existed, every route off this screen - // saved: "Skip" skips the RPE question, not the session. A session started by a - // mis-tap, or to try something out, had to be saved and then lived in the history - // and in that day's Effort for good. + // A way OUT that records nothing. Until this existed, the only route off this screen + // saved. A session started by a mis-tap, or to try something out, had to be saved and + // then lived in the history and in that day's Effort for good. Button(role: .destructive) { confirmingDiscard = true } label: { @@ -679,7 +671,7 @@ struct LiftSessionView: View { /// Sets nobody typed a number into. One choice covers all of them, because what matters at the end /// of a session is simply whether they happened: complete them with the numbers the sheet showed, - /// or leave them out. + /// or discard them to zeros that every figure leaves out and Edit sets can still fill in. private func unfinishedCard(count: Int) -> some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.gap) { @@ -694,10 +686,18 @@ struct LiftSessionView: View { } .pickerStyle(.segmented) .labelsHidden() - Text("Completing saves them with the grey numbers shown. Discarding leaves them out of the session.") + Text("Completing saves them with the grey numbers shown. Discarding keeps them out of every figure; they stay under Edit sets as zeros you can fill in later.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + // Said before Save rather than after: `save` files nothing when no set counts. + if unfinishedChoice == .discard, + !LiftSessionController.anyPerformed(session.setsToSave(completingUnfinished: false)) { + Text("Every set would be a zero, so discarding saves no session and no workout.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusWarning) + .fixedSize(horizontal: false, vertical: true) + } } } } @@ -761,13 +761,14 @@ struct LiftSessionView: View { // After `finish`, which closes out the running rest: that set's measured rest belongs to it. let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) - // Nothing to file, so file nothing. Discarding can empty a session completely: a session run + // Nothing to file, so file nothing. Discarding can leave no set that counts: a session run // face-down and advanced entirely on the strap has nothing typed, so every slot is unentered - // and "Discard them" leaves no set behind. Filing it anyway wrote a session row with no sets - // AND a manual workout, and the engine fills that workout's strain from the heart rate the - // strap measured — so an hour that recorded nothing still read back as a workout. The - // program's set counts are a separate thing the user chose explicitly, so those still apply. - guard !finished.isEmpty else { + // and "Discard them" turns every set into a zero. Filing it anyway wrote a session with nothing + // in it AND a manual workout, and the engine fills that workout's strain from the heart rate the + // strap measured — so an hour that recorded nothing still read back as a workout. The finish + // sheet says so before Save. The program's set counts are a separate thing the user chose + // explicitly, so those still apply. + guard LiftSessionController.anyPerformed(finished) else { if programChoice == .update { await writeSetCountsToProgram(store: store, plan: engine.plan) } diff --git a/StrandTests/LiftSessionEditTests.swift b/StrandTests/LiftSessionEditTests.swift index deab70005a..4bd65aaaaf 100644 --- a/StrandTests/LiftSessionEditTests.swift +++ b/StrandTests/LiftSessionEditTests.swift @@ -1,50 +1,118 @@ import XCTest @testable import Strand +import StrandAnalytics import WhoopStore -/// Correcting a finished session. The parsing is the part that can quietly store the wrong number. +/// Correcting a finished session: the parsing, and adding and removing sets. Both can quietly store the +/// wrong thing, which the screen alone would not show. @MainActor final class LiftSessionEditTests: XCTestCase { - private func row(weightKg: Double? = 60, reps: Int? = 8, rpe: Double? = nil) -> LiftSetRow { - LiftSetRow(id: "s1", deviceId: "d", sessionId: "x", ord: 0, exercise: "Squat", - primaryMuscle: .quads, setIndex: 1, weightKg: weightKg, reps: reps, rpe: rpe, + private typealias Sheet = LiftSessionEditSheet + + private func row(_ id: String = "s1", ord: Int = 0, setIndex: Int = 1, + weightKg: Double? = 60, reps: Int? = 8, rpe: Double? = nil) -> LiftSetRow { + LiftSetRow(id: id, deviceId: "d", sessionId: "x", ord: ord, exercise: "Squat", + primaryMuscle: .quads, secondaryMuscles: [.glutes], setIndex: setIndex, + weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: false, startTs: 100, endTs: 140, restSec: 90, note: nil) } + private func entries(_ rows: [LiftSetRow]) -> [Sheet.Entry] { + rows.map { Sheet.Entry(id: $0.id, form: Sheet.form(for: $0, system: .metric)) } + } + + // MARK: - Editing a set's fields + /// Opening and saving an untouched pound value must not nudge the stored kilograms. func testAnUntouchedFieldIsNotWrittenBack() { - let before = LiftSessionEditSheet.form(for: row(), system: .imperial) + let before = Sheet.form(for: row(), system: .imperial) var after = before after.reps = "10" - let edited = LiftSessionEditSheet.applying(after, over: before, to: row(), system: .imperial) + let edited = Sheet.applying(after, over: before, to: row(), system: .imperial) XCTAssertEqual(edited.weightKg, 60, "exactly the stored kilograms, not a round trip through pounds") XCTAssertEqual(edited.reps, 10) } func testAChangedWeightIsReadInTheDisplayUnit() { - let before = LiftSessionEditSheet.form(for: row(), system: .imperial) + let before = Sheet.form(for: row(), system: .imperial) var after = before after.weight = "135" - let edited = LiftSessionEditSheet.applying(after, over: before, to: row(), system: .imperial) + let edited = Sheet.applying(after, over: before, to: row(), system: .imperial) XCTAssertEqual(edited.weightKg ?? 0, LiftFormat.kilograms(fromDisplay: 135, system: .imperial), accuracy: 1e-9) } func testADecimalCommaAndAClearedFieldAreHonoured() { - let before = LiftSessionEditSheet.form(for: row(rpe: 8), system: .metric) + let before = Sheet.form(for: row(rpe: 8), system: .metric) var after = before after.weight = "62,5" after.rpe = "" - let edited = LiftSessionEditSheet.applying(after, over: before, to: row(rpe: 8), system: .metric) + let edited = Sheet.applying(after, over: before, to: row(rpe: 8), system: .metric) XCTAssertEqual(edited.weightKg, 62.5) XCTAssertNil(edited.rpe, "a cleared field clears the value rather than keeping the old one") } func testMarkingAWarmUpAfterTheFactApplies() { - let before = LiftSessionEditSheet.form(for: row(), system: .metric) + let before = Sheet.form(for: row(), system: .metric) var after = before after.isWarmup = true - XCTAssertTrue(LiftSessionEditSheet.applying(after, over: before, to: row(), system: .metric).isWarmup) + XCTAssertTrue(Sheet.applying(after, over: before, to: row(), system: .metric).isWarmup) + } + + // MARK: - Adding and removing sets + + func testAnUntouchedSessionWritesNothing() { + let rows = [row("a", ord: 0, setIndex: 1), row("b", ord: 1, setIndex: 2)] + let change = Sheet.changes(from: rows, to: [.init(name: "Squat", entries: entries(rows))], system: .metric) + XCTAssertTrue(change.upserts.isEmpty) + XCTAssertTrue(change.deletedIds.isEmpty) + } + + /// An added set is a new row, numbered after the exercise's others and ordered after every set the + /// session had, with the exercise's muscles and no timing to invent. + func testAnAddedSetBecomesANewRow() { + let rows = [row("a", ord: 0, setIndex: 1), row("b", ord: 1, setIndex: 2)] + var list = entries(rows) + list.append(.init(id: "new", form: .init(weight: "62.5", reps: "6", rpe: "", isWarmup: false))) + let change = Sheet.changes(from: rows, to: [.init(name: "Squat", entries: list)], system: .metric) + + XCTAssertEqual(change.upserts.count, 1) + let added = change.upserts[0] + XCTAssertEqual(added.id, "new") + XCTAssertEqual(added.setIndex, 3) + XCTAssertEqual(added.ord, 2) + XCTAssertEqual(added.weightKg, 62.5) + XCTAssertEqual(added.reps, 6) + XCTAssertEqual(added.primaryMuscle, .quads) + XCTAssertEqual(added.secondaryMuscles, [.glutes]) + XCTAssertNil(added.startTs) + XCTAssertTrue(change.deletedIds.isEmpty) + } + + /// Removing a set deletes its row and closes the gap, so the session still reads 1, 2. + func testARemovedSetIsDeletedAndTheRestRenumbered() { + let rows = [row("a", ord: 0, setIndex: 1), row("b", ord: 1, setIndex: 2), row("c", ord: 2, setIndex: 3)] + let list = entries(rows).filter { $0.id != "b" } + let change = Sheet.changes(from: rows, to: [.init(name: "Squat", entries: list)], system: .metric) + + XCTAssertEqual(change.deletedIds, ["b"]) + XCTAssertEqual(change.upserts.map(\.id), ["c"], "only the set whose number moved is rewritten") + XCTAssertEqual(change.upserts.first?.setIndex, 2) + } + + /// A set discarded at finish is saved at 0 × 0 and shows only here; typing its numbers makes it a + /// performed set again, which every figure then counts. + func testFillingInADiscardedSetMakesItCountAgain() { + let discarded = row("z", weightKg: 0, reps: 0) + XCTAssertFalse(LiftMetrics.isPerformed(reps: discarded.reps)) + let before = Sheet.form(for: discarded, system: .metric) + XCTAssertEqual(before.reps, "0") + var after = before + after.weight = "70" + after.reps = "8" + let edited = Sheet.applying(after, over: before, to: discarded, system: .metric) + XCTAssertEqual(edited.weightKg, 70) + XCTAssertTrue(LiftMetrics.isPerformed(reps: edited.reps)) } } diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift index 65fa330ba8..02f3b828be 100644 --- a/StrandTests/LiftSessionFinishTests.swift +++ b/StrandTests/LiftSessionFinishTests.swift @@ -5,7 +5,7 @@ import WhoopStore /// Finishing a session: what saves, and whether the program keeps a changed set count. /// /// Asked for after a real session, 15 Sep 2026: grey numbers stay grey during the session, finishing -/// asks once whether the sets without typed numbers are completed with them or left out ("the user +/// asks once whether the sets without typed numbers are completed with them or discarded ("the user /// might not complete all the workout, just a couple of exercises"), and a set count changed with ⊕/⊖ /// reaches the program only if the user says so. @MainActor @@ -43,18 +43,20 @@ final class LiftSessionFinishTests: XCTestCase { } /// A session run face-down, every set advanced on the strap and nothing typed. Discarding then - /// leaves NOTHING: this is the precondition `LiftSessionView.save` guards on, because filing it - /// wrote a session with no sets and a manual workout the engine would fill strain into, so an - /// hour that recorded nothing read back as a workout. Completing still saves all five. + /// leaves no set that counts, only zeros: this is the precondition `LiftSessionView.save` guards + /// on, because filing it wrote a session with nothing in it and a manual workout the engine would + /// fill strain into, so an hour that recorded nothing read back as a workout. Completing still + /// files all five. func testAFaceDownSessionDiscardingSavesNothingAtAll() { let c = controller() c.start(plan: plan(), programId: "p", programName: "Upper A") for _ in 0..<10 { c.advance() } // every set worked, none typed XCTAssertEqual(c.unfinishedSlots.count, 5, "nothing typed, so every slot is unentered") - XCTAssertTrue(c.setsToSave(completingUnfinished: false).isEmpty, - "discarding an all-untyped session must leave no set to file") - XCTAssertEqual(c.setsToSave(completingUnfinished: true).count, 5, - "completing still files every set with its grey numbers") + XCTAssertFalse(LiftSessionController.anyPerformed(c.setsToSave(completingUnfinished: false)), + "discarding an all-untyped session must leave no set to file") + let completed = c.setsToSave(completingUnfinished: true) + XCTAssertEqual(completed.count, 5, "completing still files every set with its grey numbers") + XCTAssertTrue(LiftSessionController.anyPerformed(completed)) } func testUnfinishedSetsAreTheUntypedAndTheNeverStarted() { @@ -74,9 +76,20 @@ final class LiftSessionFinishTests: XCTestCase { } } - func testDiscardingLeavesEveryUnfinishedSetOut() { + /// Discarding keeps every unfinished set as 0 kg × 0 reps: out of every figure, but still there to fill + /// in under Edit sets if the discard was a mistake. A performed one keeps its timing. + func testDiscardingSavesUnfinishedSetsAsZeros() { let saved = halfDoneSession().setsToSave(completingUnfinished: false) - XCTAssertEqual(saved.map(\.slot), [slot(0, 1)]) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1), slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + XCTAssertEqual(saved[0].weightKg, 55, "the typed set is untouched") + for set in saved.dropFirst() { + XCTAssertEqual(set.weightKg, 0) + XCTAssertEqual(set.reps, 0) + XCTAssertNil(set.rpe) + } + XCTAssertNotNil(saved[1].endTs, "bench 2 was performed, so its timing is kept") + XCTAssertNil(saved[2].startTs, "bench 3 was never started") + XCTAssertTrue(LiftSessionController.anyPerformed(saved), "one typed set is enough to file the session") } /// Completing saves the untyped and the never-started sets with the grey numbers the sheet showed: From 7e5beedbc1a40a32ccbcd9825417415c9d4f4f24 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:53:28 +0200 Subject: [PATCH 02/24] analytics(android): a set with zero reps counts nowhere in the Kotlin LiftMetrics twin The Swift side of this change leaves a set with zero reps (how a discarded set is saved) out of every figure. #2232 made LiftMetrics.kt its twin, so it has to follow: `isPerformed` is added with the same rule, `reps != 0`, and `perExercise`, `rpeProfile` and `muscleCounts` skip such a set exactly as Swift does. A set with no rep count still counts on both. The oracle fixture gains a bench set at zero reps that carries muscles and an RPE, so a port that skips the rule in `muscleCounts` or `rpeProfile` shows up as an extra chest set or rating rather than only a missing row, and an `isPerformed` section. The expected block is the stdout of the Swift build, regenerated from the real StrandAnalytics and WhoopStore packages rather than a stub; every section this change does not touch came back byte-identical to the previous oracle. Co-Authored-By: Claude Opus 5 --- .../java/com/noop/analytics/LiftMetrics.kt | 25 +++++++++++++++---- .../analytics/LiftMetricsParityOracleTest.kt | 21 +++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt index 7a9b58d187..7865efdd84 100644 --- a/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt +++ b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt @@ -58,6 +58,18 @@ object LiftMetrics { LiftMuscle.decodeList(LiftMuscle.encodeList(secondaryMuscles, primaryMuscle)) } + // MARK: - Performed or not + + /** + * Whether a set was performed, from its rep count. A set with ZERO reps was not: that is how a + * finished session keeps the sets the user discards, so they can still be filled in later, and it + * is what a user types for a planned set they skipped. Every figure leaves such a set out, since + * counting it would add a set nobody did. A set with no rep count (null) still counts: it was + * done, the number just was not typed. + * The Swift twin is `LiftMetrics.isPerformed`. + */ + fun isPerformed(reps: Int?): Boolean = reps != 0 + // MARK: - Volume load (tonnage) /** @@ -141,7 +153,8 @@ object LiftMetrics { /** * One summary per exercise, in the order the exercises were first performed, because that is how - * a session reads back. + * a session reads back. A set that was not performed ([isPerformed]) appears nowhere, and neither + * does an exercise with only such sets. * * Assumes `ord` is unique across the rows handed in, which holds because it is assigned 0-based * within one session and the only caller passes one session's sets. If that ever stops being @@ -154,6 +167,7 @@ object LiftMetrics { val order = ArrayList() val grouped = LinkedHashMap>() for (s in sets.sortedBy { it.ord }) { + if (!isPerformed(s.reps)) continue if (grouped[s.exercise] == null) { order.add(s.exercise) grouped[s.exercise] = ArrayList() @@ -213,7 +227,7 @@ object LiftMetrics { * The Swift twin is `LiftMetrics.rpeProfile`. */ fun rpeProfile(sets: List, threshold: Double = hardSetRpeThreshold): RpeProfile { - val working = sets.filter { !it.isWarmup } + val working = sets.filter { !it.isWarmup && isPerformed(it.reps) } val rated = working.mapNotNull { it.rpe } val mean = if (rated.isEmpty()) null else rated.sum() / rated.size.toDouble() return RpeProfile( @@ -243,8 +257,9 @@ object LiftMetrics { * models. The reference doses in [ReferenceDose] were derived under that same operationalisation, * so the credit and the doses have to move together or the comparison stops meaning anything. * - * Warm-ups are excluded; nothing else is. An unclassified exercise (null primary) contributes to - * volume and session load but claims no muscle it was never assigned. + * Warm-ups and sets never performed ([isPerformed]) are excluded; nothing else is. An unclassified + * exercise (null primary) contributes to volume and session load but claims no muscle it was never + * assigned. * The Swift twin is `LiftMetrics.muscleCounts`. */ fun muscleCounts(sets: List): MuscleCounts { @@ -252,7 +267,7 @@ object LiftMetrics { val direct = LinkedHashMap() val indirect = LinkedHashMap() for (s in sets) { - if (s.isWarmup) continue + if (s.isWarmup || !isPerformed(s.reps)) continue s.primaryMuscle?.let { p -> direct[p] = (direct[p] ?: 0) + 1 fractional[p] = (fractional[p] ?: 0.0) + LiftMuscle.directSetCredit diff --git a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt index fbb2a5beff..c08b7f9ced 100644 --- a/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt +++ b/android/app/src/test/java/com/noop/analytics/LiftMetricsParityOracleTest.kt @@ -22,6 +22,9 @@ import org.junit.Test * - Curl has one set with no weight and one with no reps, so the fallback compares 0.0 against 20. * - Set 2 lists `chest` as both primary and secondary; it must be counted once. * - Set 4 is 13 reps, one past the ceiling, so it estimates nil rather than a number. + * - Sets 8 and 10 have ZERO reps, which is how a discarded set is saved: not performed, so they + * count nowhere. Set 10 carries muscles and an RPE, so a port that skips `isPerformed` in + * `muscleCounts` or `rpeProfile` shows up as an extra chest set or rating, not just a missing row. * - Dip lists `triceps` three times and the primary `chest` once. Swift's `LiftSetRow.init` * normalises a row's secondaries at construction, so `LiftMetrics` never sees a repeat; * `LiftMetrics.Row` has to carry the same invariant or the muscle is counted once per @@ -55,6 +58,7 @@ class LiftMetricsParityOracleTest { row(9, "Dip", 50.0, 6, 8.5, false, LiftMuscle.chest, listOf(LiftMuscle.triceps, LiftMuscle.triceps, LiftMuscle.chest, LiftMuscle.frontDelts, LiftMuscle.triceps)), + row(10, "Bench", 100.0, 0, 9.0, false, LiftMuscle.chest, listOf(LiftMuscle.triceps)), ) private fun f(d: Double?) = if (d == null) "nil" else String.format(Locale.ROOT, "%.6f", d) @@ -82,6 +86,11 @@ class LiftMetricsParityOracleTest { nil nil nil + == isPerformed == + false + true + true + 9 == normalisedSecondaries == 0|triceps,frontDelts 1|triceps,frontDelts @@ -93,15 +102,15 @@ class LiftMetricsParityOracleTest { 7|[] 8|[] 9|triceps,frontDelts + 10|triceps == perExercise == Bench|3|1|1520.000000|90.000000|10|120.000000 Row|2|0|1600.000000|70.000000|8|88.666667 Curl|2|0|nil|20.000000|nil|nil - Plank|1|0|nil|0.000000|0|nil Dip|1|0|300.000000|50.000000|6|60.000000 == rpeProfile == - 8.000000|6|3|5|8.000000 - 8.000000|6|3|5|7.000000 + 8.000000|6|2|5|8.000000 + 8.000000|6|2|5|7.000000 nil|0|0|0|8.000000 == muscleCounts == chest|4.000000|4|nil @@ -139,6 +148,12 @@ class LiftMetricsParityOracleTest { out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(null, 5))) out.appendLine(f(LiftMetrics.estimatedOneRepMaxKg(100.0, null))) + out.appendLine("== isPerformed ==") + for (r in listOf(0, null, 5)) { + out.appendLine(LiftMetrics.isPerformed(r).toString()) + } + out.appendLine(sets.count { LiftMetrics.isPerformed(it.reps) }.toString()) + out.appendLine("== normalisedSecondaries ==") for (s in sets) { val sec = s.secondaryMuscles From fcba4cc7e9352e1df08aaacac3d521ed8df89e7e Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:04:56 +0200 Subject: [PATCH 03/24] lift log: the Kotlin twin of deleting individual sets `WhoopStore.deleteLiftSets`, which editing a finished session calls when a set is removed, was Swift-only. The parity ratchet names that as a new one-sided declaration, and `PARITY_GOVERNANCE.md` asks for the twin rather than debt: a disposition cannot settle `add-unpaired-function` anyway (#2163). `DeviceRegistryDao` already holds the lift tables' delete queries, so the by-id delete sits with them. It is ported ahead of its consumer, as #2232 did for `LiftMetrics.kt`: Android has no Lift Log screen yet, so nothing calls it. Both sides now declare the pair in their doc comments. Measured, not assumed: with this the ratchet reports no error and the ledger no new finding, so the branch needs no authority refresh and touches neither `parity_twin_map.json` nor `parity_ledger_baseline.json`. Co-Authored-By: Claude Opus 5 --- Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift | 2 ++ .../app/src/main/java/com/noop/data/DeviceRegistryDao.kt | 7 +++++++ .../java/com/noop/analytics/RegistryDayOwnerSourceTest.kt | 1 + .../java/com/noop/ble/SourceCoordinatorAdoptionTest.kt | 1 + .../app/src/test/java/com/noop/data/DeviceRegistryTest.kt | 2 ++ 5 files changed, 13 insertions(+) diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 5a4d3570be..96dad3fc0e 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -588,6 +588,8 @@ extension WhoopStore { /// Delete individual sets, as editing a finished session does when a set is removed. The session /// itself stays. Returns how many rows went. + /// + /// The Kotlin twin is `DeviceRegistryDao.deleteLiftSets`. @discardableResult public func deleteLiftSets(ids: [String]) async throws -> Int { guard !ids.isEmpty else { return 0 } diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 78382d913d..84d272e850 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -123,6 +123,13 @@ interface DeviceRegistryDao { @Query("DELETE FROM liftSession WHERE deviceId = :deviceId") suspend fun deleteLiftSessionsFor(deviceId: String) @Query("DELETE FROM liftSet WHERE deviceId = :deviceId") suspend fun deleteLiftSetsFor(deviceId: String) + /** + * Delete individual sets by id, as editing a finished session does when a set is removed. The + * session itself stays. Ported ahead of its consumer: Android has no Lift Log screen yet. + * The Swift twin is `WhoopStore.deleteLiftSets`. + */ + @Query("DELETE FROM liftSet WHERE id IN (:ids)") suspend fun deleteLiftSets(ids: List) + // #771 adopt-serial: re-key one device's rows onto the serial id across every device-scoped table. // `UPDATE OR IGNORE` so the canonical (serial) row wins any (deviceId, ts…) primary-key clash; the // leftover clashing rows are then cleared by the matching delete*For(from) above. One per table (Room diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index bcd86b6990..bc8d99b125 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -88,6 +88,7 @@ class RegistryDayOwnerSourceTest { override suspend fun deleteLiftProgramItemsFor(deviceId: String) {} override suspend fun deleteLiftSessionsFor(deviceId: String) {} override suspend fun deleteLiftSetsFor(deviceId: String) {} + override suspend fun deleteLiftSets(ids: List) {} // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above. dayOwnership IS modelled ([owners]), so its diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index f252803495..db16b6ab32 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -101,6 +101,7 @@ class SourceCoordinatorAdoptionTest { override suspend fun deleteLiftProgramItemsFor(deviceId: String) {} override suspend fun deleteLiftSessionsFor(deviceId: String) {} override suspend fun deleteLiftSetsFor(deviceId: String) {} + override suspend fun deleteLiftSets(ids: List) {} override suspend fun deleteDayOwnershipFor(deviceId: String) { owners.entries.removeIf { it.value.deviceId == deviceId } } diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index 7af6ae2af9..f508a0bc21 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -124,6 +124,8 @@ class DeviceRegistryTest { override suspend fun deleteLiftProgramItemsFor(deviceId: String) { deletedTables += "liftProgramItem" to deviceId } override suspend fun deleteLiftSessionsFor(deviceId: String) { deletedTables += "liftSession" to deviceId } override suspend fun deleteLiftSetsFor(deviceId: String) { deletedTables += "liftSet" to deviceId } + // Editing a finished session, not a device wipe: this fake models per-device deletes only. + override suspend fun deleteLiftSets(ids: List) {} // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above for those tables. dayOwnership IS modelled From a706f32b37d8d8ea1ebd92cc6ee18c4301e4f0c8 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:17:09 +0200 Subject: [PATCH 04/24] lift log: a max RPE per exercise, shown grey in the session and read from the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for by Utku after his gym sessions: a lifter wants to set how hard a set may feel at most, so they know where to hold back and avoid injury. A program line's max RPE (1-10) is typed in the line editor or imported from the template's new `Target max RPE` column, and stored in `liftProgramItem.targetRpe`, which the schema already carried and nothing filled. It is a ceiling, not effort planned in advance: the session's RPE field shows it grey as "≤8" and the program list as "max RPE 8", and only a typed rating is ever saved. A set done without a rating, or completed at finish, saves no RPE, so the RPE coverage card still counts only real ratings. Outside 1-10 is refused: the editor will not save it, and the importer leaves the line without one and names the row in a warning. The template's column only accepts 1 to 10, the importer also reads `Max RPE` and `RPE` headers, and the import guide and preview show the column. No schema change; the column's description on both platforms now says what it holds. Co-Authored-By: Claude Opus 5 --- .../LiftProgramSheetImporter.swift | 24 +++++++++- .../LiftProgramSheetImporterTests.swift | 35 ++++++++++++++- .../Sources/WhoopStore/LiftLogStore.swift | 2 +- Strand/Resources/Localizable.xcstrings | 12 +++++ Strand/Screens/LiftProgramEditorSheet.swift | 3 ++ Strand/Screens/LiftProgramImportSheet.swift | 5 ++- Strand/Screens/LiftProgramItemSheet.swift | 41 ++++++++++++++---- Strand/Screens/LiftSessionView.swift | 8 ++-- StrandTests/LiftSessionFinishTests.swift | 15 +++++++ Tools/make_lift_program_template.py | 25 +++++++---- .../main/java/com/noop/data/LiftEntities.kt | 2 +- docs/LIFT_LOG_PROGRAM_IMPORT.md | 5 ++- docs/lift-log-program-template.xlsx | Bin 8825 -> 9470 bytes 13 files changed, 148 insertions(+), 29 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift index 1f4a801699..7d26314d09 100644 --- a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -5,7 +5,7 @@ import ZIPFoundation // Build a Lift Log PROGRAM from a spreadsheet filled in on a computer. // // WHY THIS EXISTS. A program is a name plus an ordered list of exercise lines, and every line carries -// an exercise, a muscle classification, sets, reps, a weight, a rest period and a technique note. +// an exercise, a muscle classification, sets, reps, a weight, a max RPE, a rest period and a technique note. // Typing all of that on a phone, for a dozen exercises, is the single most tedious thing in the // feature — and it is exactly the kind of work a spreadsheet on a real keyboard is good at. // @@ -27,11 +27,13 @@ public struct ImportedProgramLine: Sendable, Equatable { public var targetSets: Int? public var targetReps: Int? public var targetWeightKg: Double? + /// The line's max RPE, 1-10: a ceiling to stay under, stored as `liftProgramItem.targetRpe`. + public var targetMaxRpe: Double? public var restSec: Int? public var note: String? public init(exercise: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle], - targetSets: Int?, targetReps: Int?, targetWeightKg: Double?, + targetSets: Int?, targetReps: Int?, targetWeightKg: Double?, targetMaxRpe: Double?, restSec: Int?, note: String?) { self.exercise = exercise self.primaryMuscle = primaryMuscle @@ -39,6 +41,7 @@ public struct ImportedProgramLine: Sendable, Equatable { self.targetSets = targetSets self.targetReps = targetReps self.targetWeightKg = targetWeightKg + self.targetMaxRpe = targetMaxRpe self.restSec = restSec self.note = note } @@ -120,6 +123,7 @@ public enum LiftProgramSheetImporter { private static let setsKeys = ["sets", "working_sets", "target_sets"] private static let repsKeys = ["reps", "rep", "target_reps", "repetitions"] private static let weightKeys = ["weight_kg", "weight", "kg", "load", "load_kg"] + private static let maxRpeKeys = ["target_max_rpe", "max_rpe", "rpe_max", "target_rpe", "rpe"] private static let restKeys = ["rest_sec", "rest_seconds", "rest", "rest_s"] private static let noteKeys = ["note", "technique_note", "notes", "cue"] @@ -196,6 +200,16 @@ public enum LiftProgramSheetImporter { } } + // A max RPE outside the scale is refused with a warning rather than clamped: 12 is a typo, + // and guessing whether it meant 10 or 1.2 would put a ceiling in the plan nobody chose. + var maxRpe = doubleValue(row, maxRpeKeys) + if let rpe = maxRpe, !(1...10).contains(rpe) { + maxRpe = nil + if warnings.count < maxWarnings { + warnings.append(rowMessage(i, "max RPE \(trimmed(rpe)) is not between 1 and 10, so \"\(exercise)\" has none")) + } + } + // Notes are capped to the same lengths the in-app editors enforce. A spreadsheet cell // holds far more than a phone can show, and a note that arrives longer than the editor // would ever let you type is a note you can never fully see or edit afterwards. @@ -206,6 +220,7 @@ public enum LiftProgramSheetImporter { targetSets: intValue(row, setsKeys), targetReps: intValue(row, repsKeys), targetWeightKg: doubleValue(row, weightKeys), + targetMaxRpe: maxRpe, restSec: intValue(row, restKeys), note: value(row, noteKeys)?.trimmed.nilIfEmpty .map { String($0.prefix(WhoopStore.maxExerciseNoteLength)) }) @@ -249,6 +264,11 @@ public enum LiftProgramSheetImporter { "Row \(i + 2): \(text)" } + /// "8", "8.5" — a number as a person typed it, for a warning. + private static func trimmed(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(value) + } + private static func programNote(_ row: [String: String]) -> String? { value(row, programNoteKeys)?.trimmed.nilIfEmpty .map { String($0.prefix(WhoopStore.maxProgramNoteLength)) } diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift index a71024a5e2..9cd8387378 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -54,6 +54,31 @@ final class LiftProgramSheetImporterTests: XCTestCase { XCTAssertEqual(r.programs[0].lines[2].targetWeightKg, 40.5) } + /// Max RPE is a ceiling on the 1-10 scale. The template's header, a decimal comma and the shorter + /// spellings a hand-built sheet uses all read; a blank stays nil. + func testMaxRpeIsReadFromTheTemplateHeaderAndItsShorterSpellings() throws { + let csv = "Exercise,Target max RPE\nBack squat,\"8,5\"\nBench press,\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines[0].targetMaxRpe, 8.5) + XCTAssertNil(r.programs[0].lines[1].targetMaxRpe, "a blank cell means no ceiling") + for header in ["Max RPE", "RPE"] { + let short = try LiftProgramSheetImporter.parse(data: Data("Exercise,\(header)\nRow,7\n".utf8)) + XCTAssertEqual(short.programs[0].lines[0].targetMaxRpe, 7, "failed on \(header)") + } + } + + /// Outside 1-10 is a typo, not a ceiling: the line imports without one and the row is named. + func testAMaxRpeOutsideTheScaleWarnsAndIsLeftBlank() throws { + let csv = "Exercise,Target max RPE\nBack squat,12\nBench press,0\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat", "Bench press"]) + XCTAssertNil(r.programs[0].lines[0].targetMaxRpe) + XCTAssertNil(r.programs[0].lines[1].targetMaxRpe) + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 2:") && $0.contains("12") && $0.contains("Back squat") }, + "the warning names the row, the value and the exercise: \(r.warnings)") + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 3:") }, "0 is outside the scale too") + } + func testABlankTargetStaysNilRatherThanBecomingZero() throws { let r = try parse("lift_program_filled.xlsx") let fly = r.programs[1].lines[1] @@ -117,7 +142,7 @@ final class LiftProgramSheetImporterTests: XCTestCase { let grid = try XlsxSheet.grids(from: data).first ?? [] let headers = grid.first.map { $0.map { HeaderNorm.normalize($0) } } ?? [] for expected in ["program", "program_note", "exercise", "primary_muscle", - "secondary_muscles", "sets", "reps", "weight_kg", "rest_sec", "note"] { + "secondary_muscles", "sets", "reps", "weight_kg", "target_max_rpe", "rest_sec", "note"] { XCTAssertTrue(headers.contains(expected), "the shipped template lost the \"\(expected)\" column: \(headers)") } @@ -151,6 +176,14 @@ final class LiftProgramSheetImporterTests: XCTestCase { XCTAssertTrue(xml.contains("selectUnlockedCells=\"0\""), "the data cells must be typable") } + /// The max RPE column only accepts the scale, so a typo is caught while filling the sheet rather + /// than surfacing as an import warning later. + func testTheShippedTemplateLimitsMaxRpeToTheScale() throws { + let xml = try templateSheetXml() + XCTAssertTrue(xml.contains("type=\"decimal\" operator=\"between\""), "max RPE must be validated") + XCTAssertTrue(xml.contains("110"), "and bounded to 1-10") + } + // MARK: - Refusals func testAFileWithNoExerciseColumnIsRefusedWithThatReason() { diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 96dad3fc0e..828db42428 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -127,7 +127,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { /// Rep range low end — the 8 of "8-10". Nil when the line has no rep target. public var targetRepsLow: Int? public var targetRepsHigh: Int? - /// Target RPE on the user's own 1-10 scale. + /// Max RPE on the user's own 1-10 scale: a ceiling the session shows, never a set's recorded rating. public var targetRpe: Double? /// Planned working weight in kilograms (v41). A program line plans a weight, not only reps. public var targetWeightKg: Double? diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 44a1d69024..596b1849e6 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1926,6 +1926,18 @@ "No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in.": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Keine Sätze absolviert. Verworfene Sätze bleiben unter „Sätze bearbeiten“ als Nullen, die du ausfüllen kannst."}}, "en": {"stringUnit": {"state": "translated", "value": "No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in."}}, "es": {"stringUnit": {"state": "translated", "value": "No se hizo ninguna serie. Las series descartadas quedan en Editar series como ceros que puedes rellenar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucune série effectuée. Les séries abandonnées restent dans Modifier les séries sous forme de zéros à remplir."}}, "it": {"stringUnit": {"state": "translated", "value": "Nessuna serie eseguita. Le serie scartate restano in Modifica serie come zeri da compilare."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie wykonano żadnej serii. Odrzucone serie zostają w Edytuj serie jako zera do uzupełnienia."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nenhuma série feita. As séries descartadas ficam em Editar séries como zeros que podes preencher."}}, "ru": {"stringUnit": {"state": "translated", "value": "Ни один подход не выполнен. Отброшенные подходы остаются в «Изменить подходы» нулями, которые можно заполнить."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有完成任何一组。放弃的组会以 0 保留在“编辑各组”中,可以补填。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有完成任何一組。放棄的組會以 0 保留在「編輯各組」中,可以補填。"}} } }, + "Max RPE (1–10)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Max. RPE (1–10)"}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE (1–10)"}}, "es": {"stringUnit": {"state": "translated", "value": "RPE máx. (1–10)"}}, "fr": {"stringUnit": {"state": "translated", "value": "RPE max (1–10)"}}, "it": {"stringUnit": {"state": "translated", "value": "RPE max (1–10)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE (1–10)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "RPE máx. (1–10)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE (1–10)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE(1–10)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE(1–10)"}} + } }, + "Max RPE must be between 1 and 10.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE muss zwischen 1 und 10 liegen."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE must be between 1 and 10."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo debe estar entre 1 y 10."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max doit être compris entre 1 et 10."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max deve essere tra 1 e 10."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE musi mieścić się w zakresie od 1 do 10."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo tem de estar entre 1 e 10."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE должен быть от 1 до 10."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 必须在 1 到 10 之间。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 必須介於 1 到 10 之間。"}} + } }, + "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session as a reminder and is never saved as how a set felt.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE ist eine Obergrenze: So hart darf sich ein Satz höchstens anfühlen, 10 heißt, nichts mehr übrig. Sie erscheint während der Einheit grau als Erinnerung und wird nie als Empfinden eines Satzes gespeichert."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session as a reminder and is never saved as how a set felt."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo es un techo: lo más duro que debería sentirse una serie, donde 10 significa no poder más. Se muestra en gris durante la sesión como recordatorio y nunca se guarda como lo que sentiste en la serie."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max est un plafond : le plus dur qu'une série devrait sembler, 10 signifiant qu'il ne reste plus rien. Il s'affiche en gris pendant la séance comme rappel et n'est jamais enregistré comme ressenti d'une série."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max è un tetto: la fatica massima che una serie dovrebbe dare, dove 10 significa non averne più. Appare in grigio durante la sessione come promemoria e non viene mai salvato come percezione della serie."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE to górna granica: najcięższe odczucie, jakie powinna dać seria, gdzie 10 oznacza brak zapasu. Podczas sesji wyświetla się na szaro jako przypomnienie i nigdy nie jest zapisywane jako odczucie serii."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo é um teto: o mais difícil que uma série deve parecer, em que 10 significa não ter mais nada. Aparece a cinzento durante a sessão como lembrete e nunca é guardado como o esforço sentido na série."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE — это потолок: насколько тяжёлым может ощущаться подход, где 10 — сил больше нет. Во время сессии он показан серым как напоминание и никогда не сохраняется как ощущение подхода."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一组最多应感觉多吃力,10 表示已毫无余力。训练中以灰色显示作为提醒,绝不会被记录为某组的实际感受。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一組最多應感覺多吃力,10 表示已毫無餘力。訓練中以灰色顯示作為提醒,絕不會被記錄為某組的實際感受。"}} + } }, + "max RPE %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "max. RPE %@"}}, "en": {"stringUnit": {"state": "translated", "value": "max RPE %@"}}, "es": {"stringUnit": {"state": "translated", "value": "RPE máx. %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "RPE max %@"}}, "it": {"stringUnit": {"state": "translated", "value": "RPE max %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "maks. RPE %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "RPE máx. %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "макс. RPE %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE %@"}} + } }, "Add a set to %@": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Einen Satz zu %@ hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add a set to %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir una serie a %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série à %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi una serie a %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię do %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar uma série a %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход к %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为 %@ 添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為 %@ 新增一組"}} } }, diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index 4b9bf5fe1c..82f1bfc7f2 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -202,6 +202,9 @@ struct LiftProgramEditorSheet: View { if let kg = item.targetWeightKg { parts.append(LiftFormat.weight(kg, system: unitSystem)) } + if let rpe = item.targetRpe { + parts.append(String(localized: "max RPE \(LiftFormat.trim(rpe))")) + } if let rest = item.restSec { parts.append(String(localized: "\(LiftFormat.duration(rest)) rest")) } diff --git a/Strand/Screens/LiftProgramImportSheet.swift b/Strand/Screens/LiftProgramImportSheet.swift index 1ed61a097e..b69138102e 100644 --- a/Strand/Screens/LiftProgramImportSheet.swift +++ b/Strand/Screens/LiftProgramImportSheet.swift @@ -167,12 +167,13 @@ struct LiftProgramImportSheet: View { } } - /// "3 x 10 · 50 kg · 90s", skipping whatever the sheet left blank. + /// "3 x 10 · 50 kg · RPE ≤8 · 90s", skipping whatever the sheet left blank. private func summary(_ line: ImportedProgramLine) -> String { var parts: [String] = [] if let sets = line.targetSets, let reps = line.targetReps { parts.append("\(sets) x \(reps)") } else if let sets = line.targetSets { parts.append("\(sets) x") } if let kg = line.targetWeightKg { parts.append(LiftFormat.trim(kg) + " kg") } + if let rpe = line.targetMaxRpe { parts.append("RPE ≤" + LiftFormat.trim(rpe)) } if let rest = line.restSec { parts.append("\(rest)s") } return parts.joined(separator: " · ") } @@ -242,7 +243,7 @@ struct LiftProgramImportSheet: View { targetSets: line.targetSets, // The sheet plans ONE rep count, which is what the editor plans too; the range's // high end stays nil rather than inventing a spread nobody typed. - targetRepsLow: line.targetReps, targetRepsHigh: nil, targetRpe: nil, + targetRepsLow: line.targetReps, targetRepsHigh: nil, targetRpe: line.targetMaxRpe, targetWeightKg: line.targetWeightKg, restSec: line.restSec, note: line.note) } diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index d30ae1cbe9..dab0291f1e 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -31,6 +31,7 @@ struct LiftProgramItemSheet: View { @State private var repsText: String = "" @State private var weightText: String = "" @State private var restText: String = "" + @State private var maxRpeText: String = "" @State private var note: String = "" /// The user's own exercise vocabulary, for suggestions and for adopting a known classification. @@ -50,12 +51,19 @@ struct LiftProgramItemSheet: View { } @FocusState private var focused: Field? - private enum Field: Hashable { case exercise, sets, reps, weight, rest, note } + private enum Field: Hashable { case exercise, sets, reps, weight, rest, maxRpe, note } private var trimmedExercise: String { exercise.trimmingCharacters(in: .whitespacesAndNewlines) } - private var canSave: Bool { !trimmedExercise.isEmpty } + /// The ceiling as typed, when it is one: 1 to 10. A blank field is no ceiling. + private var maxRpe: Double? { + LiftFormat.number(maxRpeText).flatMap { (1...10).contains($0) ? $0 : nil } + } + private var maxRpeInvalid: Bool { + !maxRpeText.trimmingCharacters(in: .whitespaces).isEmpty && maxRpe == nil + } + private var canSave: Bool { !trimmedExercise.isEmpty && !maxRpeInvalid } /// Vocabulary entries matching what has been typed so far, minus an exact match (no point /// suggesting the thing already in the box). Capped — this is a hint, not a browser. @@ -280,10 +288,25 @@ struct LiftProgramItemSheet: View { numberInput("120", text: $restText, field: .rest) } } - // No target RPE here on purpose. RPE is how hard a set FELT, which you can only - // know once you have done it — planning one means guessing at your own effort in - // advance and then reading the guess back as if it were data. It is recorded per - // set during the session instead. + // Max RPE is a CEILING, not effort planned in advance (Utku, 15 Sep 2026): the + // hardest a set should feel, so a lifter knows where to hold back. How hard a set + // actually FELT is only known afterwards and is still recorded per set; this is + // shown grey in the session and never saved as a rating (RULES 34). + HStack(spacing: NoopMetrics.gap) { + field("Max RPE (1–10)") { + numberInput("8", text: $maxRpeText, field: .maxRpe) + } + Color.clear.frame(maxWidth: .infinity, maxHeight: 0) + } + if maxRpeInvalid { + Text("Max RPE must be between 1 and 10.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusWarning) + } + Text("Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session as a reminder and is never saved as how a set felt.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) Text("Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) @@ -383,6 +406,7 @@ struct LiftProgramItemSheet: View { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) } ?? "" restText = item.restSec.map(String.init) ?? "" + maxRpeText = item.targetRpe.map { LiftFormat.trim($0) } ?? "" note = item.note ?? "" } guard let store = await repo.storeHandle() else { return } @@ -436,11 +460,10 @@ struct LiftProgramItemSheet: View { ord: item?.ord ?? 0, exercise: name, targetSets: Int(setsText.trimmingCharacters(in: .whitespaces)), - // ONE rep count. `targetRepsHigh`/`targetRpe` stay nil: they are schema columns the - // editor no longer fills, not part of the plan any more. + // ONE rep count: `targetRepsHigh` stays nil, a schema column the editor no longer fills. targetRepsLow: Int(repsText.trimmingCharacters(in: .whitespaces)), targetRepsHigh: nil, - targetRpe: nil, + targetRpe: maxRpe, targetWeightKg: LiftFormat.number(weightText).map { LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) }, diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index b941d500d8..44c997cae8 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -404,10 +404,12 @@ struct LiftSessionView: View { session.carry(for: slot).reps.map(String.init) ?? "—" } - /// RPE is never carried, so its ghost is only the previous set's own rating — a reminder, never a - /// value any set will save. + /// RPE is never carried. Its ghost is the line's max RPE when the program sets one — "≤8", a ceiling + /// to stay under — else the previous set's own rating as a reminder. Neither is a value any set + /// saves: only a typed rating is recorded (RULES 5, 34). private func ghostRpe(_ engine: LiftSessionEngine, slot: LiftSlot) -> String { - engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" + if let ceiling = engine.planItem(for: slot)?.targetRpe { return "≤" + LiftFormat.trim(ceiling) } + return engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" } private func display(_ kg: Double) -> String { diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift index 02f3b828be..a9cb06438c 100644 --- a/StrandTests/LiftSessionFinishTests.swift +++ b/StrandTests/LiftSessionFinishTests.swift @@ -126,6 +126,21 @@ final class LiftSessionFinishTests: XCTestCase { XCTAssertEqual(saved.first { $0.slot == slot(1, 2) }?.isWarmup, true) } + /// A program's max RPE is a ceiling the session SHOWS, never a rating it records: a set done without + /// a typed rating, and a set completed at finish, both save no RPE (RULES 34). + func testAMaxRpeIsNeverSavedAsASetsRating() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 2, targetRepsLow: 5, targetRpe: 8)], + programId: nil, programName: nil) + c.advance() + c.advance() // set 1 done, nothing typed + for completing in [false, true] { + let saved = c.setsToSave(completingUnfinished: completing) + XCTAssertEqual(saved.count, 2) + XCTAssertTrue(saved.allSatisfy { $0.rpe == nil }, "the ceiling must never become a rating") + } + } + /// Nothing unfinished means nothing to ask, and every set saves. func testAFullyTypedSessionHasNothingUnfinished() { let c = controller() diff --git a/Tools/make_lift_program_template.py b/Tools/make_lift_program_template.py index de2983a2f0..c289c1e462 100644 --- a/Tools/make_lift_program_template.py +++ b/Tools/make_lift_program_template.py @@ -2,7 +2,7 @@ """Generate the Lift Log program template (.xlsx). The template is the thing a user actually touches: they fill it in on a computer, where typing a -dozen exercises with sets, reps, weights, rests and technique notes is a two-minute job instead of +dozen exercises with sets, reps, weights, max RPEs, rests and technique notes is a two-minute job instead of the most tedious screen in the app. It is written by hand rather than with a library so the repository needs no Python dependency to @@ -14,6 +14,7 @@ reordered or deleted, which is what keeps the importer's column mapping true; * the two muscle columns are dropdowns over the app's closed 20-token vocabulary, so a muscle cannot be misspelled into something the importer has to reject; + * the max RPE column only accepts a number from 1 to 10; * a second sheet carries the instructions and a worked example, and is NOT read by the importer, which always reads the first sheet. @@ -32,7 +33,7 @@ HEADERS = [ "Program", "Program note", "Exercise", "Primary muscle", "Secondary muscles", - "Sets", "Reps", "Weight kg", "Rest sec", "Note", + "Sets", "Reps", "Weight kg", "Target max RPE", "Rest sec", "Note", ] # Generous, so a user can paste a long routine in without running out of validated rows. @@ -53,15 +54,17 @@ ("separated by commas: Front delts, Triceps", False), ("", False), ("Weight is in KILOGRAMS. The app shows it in your chosen unit; it is stored in kg.", False), + ("Target max RPE is a CEILING from 1 to 10: the hardest a set should feel, 10 meaning nothing", False), + ("left. The app shows it grey during the session as a reminder; it is never saved as how a set felt.", False), ("Rest is in SECONDS. 120 means two minutes.", False), ("", False), ("Do not rename, reorder or delete the header row - the import matches on those names.", False), ("", False), ("Worked example", True), - ("Program Exercise Primary Secondary Sets Reps Weight Rest", False), - ("Lower A Leg Press midfoot Quads Glutes 3 10 50 90", False), - ("Lower A Lying Leg Curl Hamstrings Calves 3 8 30 90", False), - ("Lower A Leg Extension Quads 3 12 40 60", False), + ("Program Exercise Primary Secondary Sets Reps Weight MaxRPE Rest", False), + ("Lower A Leg Press midfoot Quads Glutes 3 10 50 8 90", False), + ("Lower A Lying Leg Curl Hamstrings Calves 3 8 30 8 90", False), + ("Lower A Leg Extension Quads 3 12 40 60", False), ("", False), ("Muscle groups you can choose from", True), ] + [(m, False) for m in MUSCLES] @@ -99,18 +102,24 @@ def program_sheet(): cells = "".join(f'' for i in range(len(HEADERS))) rows.append(f'{cells}') - widths = [16, 26, 30, 16, 26, 7, 7, 11, 10, 34] + widths = [16, 26, 30, 16, 26, 7, 7, 11, 15, 10, 34] cols = "".join( f'' for i, w in enumerate(widths)) listing = ",".join(MUSCLES) + rpe_col = col_letter(HEADERS.index("Target max RPE")) validations = ( - f'' + f'' f'"{esc(listing)}"' f'"{esc(listing)}"' + f'110' + f'' f'') # Sheet protection, and the attribute semantics are the opposite of what they look like. diff --git a/android/app/src/main/java/com/noop/data/LiftEntities.kt b/android/app/src/main/java/com/noop/data/LiftEntities.kt index a2578e24a2..ed0f18b579 100644 --- a/android/app/src/main/java/com/noop/data/LiftEntities.kt +++ b/android/app/src/main/java/com/noop/data/LiftEntities.kt @@ -108,7 +108,7 @@ data class LiftProgramItemRow( /** Rep-range low end — the 8 of "8-10". */ val targetRepsLow: Int? = null, val targetRepsHigh: Int? = null, - /** Target RPE on the user's own 1-10 scale. */ + /** Max RPE on the user's own 1-10 scale: a ceiling the session shows, never a set's recorded rating. */ val targetRpe: Double? = null, val targetWeightKg: Double? = null, /** Intended rest after each set, seconds. */ diff --git a/docs/LIFT_LOG_PROGRAM_IMPORT.md b/docs/LIFT_LOG_PROGRAM_IMPORT.md index 0e3f93c93b..f1bacf36df 100644 --- a/docs/LIFT_LOG_PROGRAM_IMPORT.md +++ b/docs/LIFT_LOG_PROGRAM_IMPORT.md @@ -1,7 +1,7 @@ # Importing a Lift Log program from a spreadsheet Filling in a program on a phone — a dozen exercises, each with a muscle group, sets, reps, a weight, -a rest period and a technique note — is the most tedious part of the Lift Log. This lets you do it +a max RPE, a rest period and a technique note — is the most tedious part of the Lift Log. This lets you do it on a computer instead, in a couple of minutes, and bring the file across. ## The template @@ -12,7 +12,7 @@ Excel, Numbers, Google Sheets or LibreOffice, and fill in one row per exercise. The sheet is protected: the header row cannot be renamed, reordered or deleted, because the import matches on those names. Only the data cells accept typing. The two muscle columns are dropdowns over the app's closed muscle vocabulary, so a muscle group cannot be misspelled into something the import -has to reject. A second sheet carries the instructions and a worked example; it is ignored on import. +has to reject, and the max RPE column only accepts a number from 1 to 10. A second sheet carries the instructions and a worked example; it is ignored on import. ## The columns @@ -26,6 +26,7 @@ has to reject. A second sheet carries the instructions and a worked example; it | `Sets` | no | Working sets. | | `Reps` | no | One rep count, not a range. | | `Weight kg` | no | **Always kilograms.** The app displays it in your chosen unit; it is stored in kg. | +| `Target max RPE` | no | **A ceiling from 1 to 10**: the hardest a set should feel, 10 meaning nothing left. The session shows it grey (`≤8`) as a reminder; it is never saved as how a set felt. Also read from a column named `Max RPE` or `RPE`. Outside 1–10 imports without it, with a warning. | | `Rest sec` | no | **Seconds.** `120` is two minutes. | | `Note` | no | Your technique cue, verbatim. | diff --git a/docs/lift-log-program-template.xlsx b/docs/lift-log-program-template.xlsx index ba1875f188c7b136d5a9b1d2249019abc786a344..aabde1c5f7bf849e050b2867de7a2e167b7d4459 100644 GIT binary patch literal 9470 zcmdU#cTiK?_Qyf#T@+B@(j+1zQiKb5rK<=*iim+A5J6CSXi`IwUZjO8RRp9%=+dPd zssc(ALPrHc?*xAF-rvl^ zuYvNp&=E#b5)zTKBqZmFmPfacwoYcYP6!=0J2OW^VOJaLc66^st0bYjPc z2|93A{_d0(*JFFh5SA0{QvBSC{a_0z3zIfTBfdd=Gu%2M`4f9pr^;AeXh|Dd+^I`y z>^Y0~sN99&l>29febyPka=h#6hPF9AZDqsv+Ye(~!eg`CD->ENo!kZv%z5W+mQ6{1 zRS0ruFfdY`H&jXpb^0*%+%4$Z@;#H?CvtK-!REo`xQ;r7cD=jscE4d%p8B4xjv4EYV1MhO$x9lue+`?wpuD78-I zFL7)steBWL0b&kmiC2V!nYE*c@Xx>D{ml%%qI48fx^VY|7nD!v{KCYhE!RjFU=*ZZ z%#vx4s}UwLuGR;;hl6EC3n8+ff?gE*@O8h)c$h_LMfP>98&3tfZBd!>;f)W=*`bGS z?W!G4%m>Zn@pK$yTVl267ZtaqyQ89SB}@LY5ATq0Z%x(7l=!gg<~4OZ=u?DczxH0L zmgGw@!%1o05=Fo>?Z`_uNB7s`RLZs}GJVWuL^ftq&3l+>>V@_fnt#xrmc{07)!Rdq zqd}C#NW8jQi?|>itW1zdtDjRMT7QR!$J1uZIYoMJw(1jmU70uq zQnMpb!ZzjTkT9}>7r%!08+v0$Ij&9~;Tpx{_G4;g`#YXA#ca+eO!WEM9g4a}*$}SD zI}F9|M}JFnG~!sbNfgYmF$D?7+?OyG&eoWOf#@X9&sXf#=HoqEKG+$XZ^?6BkFbAz z_fkZNG(showWqzl*Z$XUh)5PU1^5pMh0jT#8v z+{rH&$+UfGx&(TB@bZ}BwD9xEw?lslmpDU0!v2TwKT7&n`M;O9A-X@bMS(5|VfMi@ zxr&x=W+aTth|SZ-IO(|A`Dvo5?%@tYR~HQ*m#OA-LX-%%DLl19k)Sm# zj~qBUxmlYz{w#gA&bV!gC=-FDdo|oRM)^MZoH0vQy-qY5>iMfE2Id*F63&Cbi9Ln$ z{o0i+;`(&ivCqIdbo{>R23;R*rdkrhG}gyfU0_RV0HL{E%>hMC)y@u>U;R#&HVF%0 zjPdV0tH7i3+EbO0cZB=p{tij#A?*{zSq)^$45^`%DDs!JzPnLq>jbmmd-fR?54V&l zHT4~bGP3GGK71l`uhe>katehcbfb=B6W-YKQNVwD)kuFx_tZ$nBp|k652XGgA0tC% zSF0QZ*%>q~J>IdA2_KO&FZc<8t_ATXU%Xu#41l=jup_-ZEY% z%F9lmhQ_2(sh0&`>K})P+yBzHr(>p}nUNp$k<$OB80QT8%W(E9WSJkok#WCVxK5M8 z6!xOxn#%q~WXtL?%kIc|Pn}Ad-9)a*+>!4wA=KBNzERbf!0NvDd1L_F`_72o zDjdD14?Pf>E z@gF7*zae*|k|&IDO*MNB@`gwID@*-BvpIvEjq;j-gELpC1+kMcqRMJndGYot*X|qF^+fBzik8hs6*B4Z`dbs=S^Qtpa_;|qZ zp5*Z2N(q56IY@*sF^!V@Du+5+kL^3#AkTGNheH@NdV203g=}`WvN?H4YUfca5>BmPKh@;Jd9Qh{peuYMNzwJT#FSGOuh=vyi zgmuR{cI3u3vOwk}w=;d)308-lPryZY;@BH*ASWB;xa2e=5MFmmxP_0*q}#rCBflkw zt$0;W?8>J`w%)C7UEx~2g-gXt4!0ub>;!IXcOnkMs^vKuxg(OthZ7d3#bu6ax|eb? z3ESh5lB3*xp7T*ZI2bIvmLiA8AFS9NBsaFKtSsyu&g~pKH5B|Zglbjv{!zl%iZkpF zl6@Fq)%5+{;#kI4Db63A$HmT9Pw0aV?luwD)@Am$G92pH=sOSH9FIhgSmeLbUXee3 zEJPWOb85`loEt*uG9)xk+j+N&Y|Y!PdNk5H_q#OX*GDF1J&)FnbbN&r6COh`pm)qD z>I{E9rLL*=ntSQ}X8LJr)cItFzJks1Q_wY#KeGw7#h7%$=w?k%m!qI!+-o&a&o{5n-YFqg$uWD(X}aYw%t0zuY|7H@q2kz z3B$uf^M;qggqLB4mz0@TRsWGvhZwKkc_#8tOlM1(DA%J%LZbMK40&fUOc2^ryy!0- zQTC=(FpArX5^V~QHbohJ1wMXNE2Pux?#K}ke*U+_^;kN z+#|Df@>H&}iYujR?U7C+9H$a6RJo%pg%4-&ID4uHwO=L^BRrmj0yqZ$W%E-_ueDJ| zIYRTI{2m!qI_dNS9u$MWrtcb@NsfMCjAPIqd9U0WDlUW7GK6V{ypa9 z05;S-Il{ep*l4Yt4Yf^<@M<1LtdUH?@?YMgV+Zt7aN0BVsM=T3Ys{w+}5M8Uuc6Lb83$h_QJjH(9X@Ljug8}ucpPB3nx zYqx$wy#zH3#_bRnf`5&*z#dr$tnlYCj{o~+NJaAi#RCp&8_}xfF$zbHWgF3V%`+4+ z97PSK?7JN_faMuRG;{5Cumj9r{vVtB_z;CCvCZ~Awp$2@r`UG;7q%yeLQk>X&v?)I z<`U?70s1IkqDkme33R&vO~`j@5+eSeyI+7lwybI@F54SsSwisK8Q{NF#(#GsOCdCC z%Qw`qN9nOH80^2cbI^1F}W_n&A0C=t0GV zT<>g7+<^tiTmO4H>`ZRl8B5R$eT&yHzT7w(OOW3f{`CUy5R|$StyEN@1r{5E(pREy z6>VvO6^EebD$%!Xr0#ECwagCH$G(Bp=EjLyX1~-|&4i8R#z|XdN9bcSs}6JBcq+A3 ziukp`;X_dVN^R95Q*ChS5cF!LHnb>9d%S2!CSE@;8|IJ~XJiRV)E9gUL+8akwFD*U z=e>oM<*nFSa%mLBJ^+6og5IoT)+`!&0Nx*h%2zUL6*EF^wgvrSgwS!TC=b|QVq>g4K!7am{TRrH`SyS7EHHhcYdj5vc8{|-jvTdH6y1gXl*I^hx^1q z&VUQw12lSI(B#Geck}=nIbdbdg%}1kfW|f`YbZ;+2|#179`;RXizNi0ksAzGfayg6 zG*+2{!mu<}WdM!Eg{H=EV|YBEFo|)H>)^s3Aawcl?h{gZJm-~Xs;BCh76*|77xn{& z->g6fmvPJ(IK;t7Y{2ffM7@$$o8Wc7{ z1FiySOe-=~d}RCysIe*zVhb)D2SQh)18f7b9|52@VGH~6`aP+w%h&$0RH7vgtI_0k z0ptdN;k3GC7mG20K#em{)()0Npe!$p;ty0BAg{MUy)R1Q-IKt#r#qiZN9{Xgrj4k|o{+08MX%<*RR@ z5dlDVg5goRdUHT%H&ECn%~Cx8TBF1?!^k)f2)!H!83PyY0HGVuj&wX}qa0x~TCfotjM zUAO{Rw*x?7w6set0O&r9X@;?}1rVAu9`YNckO2tYiY5;ZAU6R(hv}7FEX61Tp_$cK zudv340HDK7uzc+;P%!|sE(DIz(}Mt^!$4tdv`g&(=!jBN-Y3RbAhbk0#1c}-4utMN zlSc#uJOMy2>6ML?Vw{1{TxzUxtnrZmXnIpD--i~O>j3DWuV%7y5wfPILjOpTofBT- zby?;;1)|-_wK4zw2`+tmq2BHAV10(Mv$dv44#{|ko!y?^-7|4;-9`ANa*3oD9}*Vc z$;ucmxwPJGlsEAHfz)Xlr+42DIxfSp93R`&Po&rrFm+86iyO1kn+XNuggp5OU1Z>F z+TmhBPV4%StFfcE9Px
rvBgMf>8%14}3-OAd@^gWH3rB>pOX(r>X#rdEsi@4PT zDYm+m?=qZEt8b!?mu$;Vw&uSQ_X~pv6NG)-;h@E4hPSe#3|EB6T6*U(p;dGLCSksM zep&+W!r|tTA-9vW3HFd#?q@_C?{EFcSy4Z6TJQ97MAhzX+IvjkQjI*DE)^d+xD0a( ztV#~2Q{d>d>TzFFjP`q1zN5b4QcUJFPZBm;SA{z3%v^W_9-@3|C(_JH3NH+T5w7QPjjJv=byEkqdviZD*rHRG;0L7*>+k{#DH!?ysJK zYM!}2jXCZ6d+7O9lpO4KvcL*UuHeA>nuu}>&>;E~oKjyg5Sqw8I@>N7@mZMgePt|KS)hioZd0Zp(~bj6j!A6O2{dLFqBECkku#IOF+k9 zXpESwLhruUBYjQ@vth2EB5x5!6{yGeE8AU;U+@X^(xO*FmkYJBR>4bX@kWd;NRM~x z7t=vQAfeg_-N z7@?`vrQ}1A$Y<4>Ph9b#q&mX!T};s>A&GNJ=T4GB&yTx$2y6?D#(h&;C}b=!&j`wr z6@PV~@vSp;BxAxAQNPXZrR1eL5lqw@|BqL#rDZLv<)01l#@;`3N9xn{B;ODIdN02* zUyXkF_C45y$Fol1ks+vmpLek1=buaLP!XZf`L97#LZ>iB| zUmENyy8^rEeP<4P^l zQ*J? zBzJq`N=sHZ>DF*@(s#`-G3TD-9uEY(E~4Da;jL@vAY_Z)8U3Ym!Hs!(`h#7 z0h@L~UUbk~hNxn?X8OrZWmdzvlDWnTUO8TwXYETODvzAjSyhU%l)2CcRvIEW30M)` z^Acl?pc3JnbDlkKTi0a;v5S}tf-Gj6m!IFb%zsTyf*lI4rx?!>%d^#~s8Y!u=; zy0d+sZy^x*`SOldB)()M1td~@F@Co+G7jC7%A@vWq;>0PdzwP;O($2!TxB5`|XOB0k;Lk#bVmpg=(ENH?4h$da{2?leoXxMvou%+2+Z) z76-S5=K12Ybqm}w5*v>)74&J+c0}b}i-^*F8L43#ExfxpkTwW}m@Z z{?K#{Q!dM2oiFyO<8?*TBM+VhdGo?@8lj9P5;Se4oB5c0jVystOO5o*g|q*>M}~NI z`uk57@r?X$J7$0ZfK4KQxRa1%ktz^F{H0|C=m>1X_|uV%*q;Ds$^be8TN(azW+FEG z5S{-?V*@Y_upQvfI2VWw0>n6fHU|J*fk)1Nx-Jo4h5yU{4+qe|D8N_epHVD+`P(SJ Yzeu&z$SHn~q$GYih-v1#{PWxY0Bc5=fB*mh literal 8825 zcmbuFc{r5)yTGUHyDZ6)HIY|Y2iZfilr6GGWZ#A`$TG5I9c0g%C>d*nGM4NiitN+a zm(Yx?W0~Ph?|ZIOPv`ypajwI~bD3wpx6kLk@9#X1$&g=;h#Z_ey|=dsoVUUWYhiq)pdWT^xEZj8SKpMCHb; z0rx#`nON47-t~+{jQ5xJQ>=_ef_fRvQrmF%%$y;P`W~$*WL#Beij;4k?35oXY*LwJ zJlF8_c-SWWU&?%&x9)nDgmu=88+IL~wZqejAHGp-r|=CNK6K_=^7w8K0;@(pXtuJ} zUb?H68Rz?9)-N#n^7or|xckb=doj*2wWy*W?-6p5_uho3tvuMs7E7DgV4S?__Go6(*5fOQ zc@rY$kcN1)dEnsgBX;HIZ}_J+`fzbt@>x^!hnbHk?$buZOU$|cIJJC-{M4934mD&W z(M~bI{SbHbrN(+WR%s~uQF$1D|D!^aVhT6XuT%Y4N>L4$y4(-X!&pg1Oai;KyM37s z+sHC#IZ1aU8ktwF?aK8hKT*$-Ie%c@E&Z?~&$vkX11>Oh_V`O!MK8zY_p*(ku}b=r z*QM*^QBFol>>hZ-&2+7r9rB_uhk3EBg*@kvOw>&x2g_}1bU-b(hi(HOi8^{jEe7H- zz+KEA^1#gw0&)A<5^#f`EkPTa{=q+^k|GjOkL2z2_=B6sTKdi2=!sforpv-Bs+VT!+Q5cyYfU*U6DBO0nG2*Xb+YPs(Tc zSaWW8WL+xsuot|NYA9`c<%Qn#9YI%9tI|f>ID81nsi)zepNOZl zW(x_k%|32}I%e6M9_`Wh^-=S4+w0F|CX4ado9A_3BN!}BE6`ofh~Re$K_l;Ugf<*x zMc7GH%0&1V+wwnlt;0WW)3TVWWGH9qc>P%Kf+*u8RGMQjm^bqxJgMjj%l-Jai3k^} ze#HV?e1BNb%c(Jr`T8rWn3t+m)gfPYGRCGulNJ}-s1y$aW)roTMmjSzIx4yXE?v)f z6_(`C2g|rr&>P%x`>_MZ@O{X|aeNT>MoY6i2`~qd^Q^(;#KPYq1%c@P$$^h=pu2<5 z&(gmzMtSCnGZI+)H{iCZnucVHwyecX#!pgoLcrqHcS2Gza9*1k2}g5&aNi5D0LSk> zgI4ZwC`0Wn+CiEk-E15Cv@p+GLOTY-Hu}2_oI1g?jSIuh!mFhD(|4j6QX_jvRC%?Y zg=jPIP4FZf?1AErXzpKI(1Ya8pSmk64mtm0@J4cqd#1zqP47Y%lO0V8eG8wF!eXRg z7{A!!Q{9guCFLU0rpb7v%;(1#%w`5Ps zCX|ge-Gjru&kyb!JLu^bB9cE-L@G#d&2uEcIWCeGef~zuldvpFoy!>i=*?xVgL9Df zjbm2a1apXS9W^eCd-~PHYHBRiWyj~*dW$nPf>=7tQl@k7L=_%VxGnV%N1pBFwq%@R zJTuR?{9Q?o`5E2n8CwK&H&z?J&_RVu>ZASjRf_ zMx$(ItA9^_YfA{h1-2i6|Lj*1zm4(lkI)1mRe7>xojbURR9eT1kr_&PX6R2GK zWeu&5uD2{`-1fUVv*lU0ekJr|)f0JD>3x?A3>Sjy@AX4ra{3`RGTF*@UAEnD9cyzV zGZ_660;U7SvoXASvz2S_fQWsiRY zJH)dc?_ZiVEg40PM-xioJ61k5>~=&!KCM9U_|?7T2el|qC|bXyl#djTl#7&(RE(6A(F@rMUQ3-T z{d3L5?o#~%{SbM@UB0(``+VOR+ZpE>hZ(mR`$pt;C$~#KJuXoul_ganJ+F37jZ2L~ zja%(0dxXE zPYVc_{bI(kO->yU&SllfqWTLU9J%FT*V&sgfN&V@U6fKg^!K%zW6-2S47V-vcRK+$ z~FCh>^*R=X0T`KIp=w~ zpC^N2lSzA%C2Q(~Mj$v!6y0wE?B*z?wT7Tb%wxHjXyFU0{1>iC7AKVdU(g89q-ykU zvT0B1&g$8PLU8AR$+AQ# ztu_RO1Cn*K9FxC;QfdR#@!}X%{}7XZ4}i(+h7=+|^?}KXtwX1-jgR6(z%bi^8MAHE zF?=2v<}xs2u{}P9Z>}Sr4n+z9bJ;nC)ow*I19NHTfh(TDQUP=Mx;|(af+Gc3JT?0d z7JDeAE`Y^LqQwy=-wt4L+YMPlv;Zc%v=03x6j=hq9GSwpw4!eVFgt20AUGDFg#}6p(-0H~tVIPT|KBV= z5o-b5Q&Mh7Cj#^nuoh42(6ymRC!od0DXe)bS_)`E#4Mk|vI8+|>x0H1I9ebkaetNN zD-Jp(ZLcl_AIlY}4mu@oUs?>NzgnO%==6lxQR@vsj{z1Ri5*3JW#|CZ!f_1xuNEsr z3t%nY)uFdTkt;xpuT$8-Rp2_p?(s*PQTKuc&)HCSkB4t|-6NhRb`)^?`nVzg)gnF=$p*A& zpTb^mMbiK+Y&~$#XRu@di<;{CpdkqEG{C~#4AuOtA?OjnLbM!Xy@P6g53o2p1{FKR zSOd2&jT@4U02KqaOjI2@e(lBA;LEZ$9|uf*wwJyJE6Lh?9x(OY7DNZ%Y9OBeHw%YW zv@X!X#RFF}gB1i?yr~bGgy5Kf78a=H?+rn*K#K~D^&YCZ6JU`+v^c_81GlfP8#0q< z0c@EUb?E+3WF|1#!&BIQ#}ug%?`^+GQzTkmBi`SBg{DJPv{rn;uEBgwPPb@q_(QGq zp(tEcx9Ib*)*ES#%Wy5-qG3Bni^?*+ez5vw-WyvN1j&pp2pU~XXOn)bZc?+9J#aQz zATTB6^raN0OR0S{9A66&(+>uMX+Atr)Yg8)q8*#2y+lTbHDG=4&aszlQ7gZ=ST(L# zqpx@?oZSDFYonFa+UW-u^o@*ouN(0@7zJL7P%vJ)TRG`}>S~J6rAx{Dm!3$LHa%-Q zkI)etzu6qd)tj2eqV*(A>#4Ez=UIA_p-i_sHmZq>+B($5S~|ryMZ}!Fq7ldaU=nd4 z?zaMynM>00p73PLqQx<-cVG)oc$Q_+^4N!W;D??zl#Qd;8#IS2hJ4*L&>SWWnqw71 zer}Oy4u=NKsR6uRVImT|<_T}IEZP{OYywk5;GLF5n`1FeU=av(xdPh+R)fHYEd_SR zoSVUR5crg(0B-DCGdLUq-K@}S0cS(t-zqGE3b5cjlAJ^tOU-Ezv==%@P=Dk37i`j?gpoLJ4)#xoO7!=Zv0l7Gg3T4 zCMtU8k_CRNOpJL-s$t$E58r#7d=r{f^-sb*-rVDqn9!uFe-h!bb8ldS1%b#F{|gYP zR!qp`o}O72Akfq7R0LQkVc^Mc749q_)K1T1hOTJ+R$x2wZ^a^?shE8){Ax#52}@t2 zu5XcG`IKMX7?))(U)t!~E`GlES2JG!sFr6OD{otr_**aXJC^GqEb_!nftZ#;CU5o3 zDuI~Qsov;BNaq6Q?!KmS%TN-8bdi(nKvS({C>f%$_|rExui6O^NXlKvWKu7y9uR2q zZK}uvq4KW)-;ZeRQ6w|@2EaFCqgZE#3gr**6$mgg`4P#k2=G->EDaB6=jR6aO6MDU zDB5x*0el7Z5jXRzQ-PRSLM9UWSs#IzgQ?mChQS{SWXs#XHZQ-UramGuU%VI)p!SuJ ziIM)q2p|Cacq*b&=;J!T^ZR#g)+y$UJ%Hy$T(K*bswfEHxgKP+go`970X+GXOLcO!a**;F8d9{ujws*eaOsGbD` zkXjNl>DNEE4G3Vqn<}y{^!*UvNsHGu*=N3Z4DhT!E{-EmxxoOQwJ;;UlgK$0fG3%1 zDN|TGIWNHT+%sdasx5sopq^|72=Qmtv|51udPdkpz~CI!1;Bo#Ni!$tqqY>t$Vs&S z=f#-cQO*GI;Fb26Ti1vM;Cv~AQyP9n@&@a%`-0${M5BE0L-Hp zBQmC_C=CE+sYa=QuOs2U=|pNkg)F<%Iq+%z=14a77_LzD^A z{sqJo)k!yFkvR*%>^}PfahcjD9)O9AH;Q75TDlIvq|z+qNNA@N0AO+y8DG`3O#)(O z86qT$#0P+wLvjcXxPT1D@Bdw(_Fo^B;iyXFX~Zu6eNQNf4JP+pAAR^{a^nhjRe{QR zF|oak<0JQ;p8g)c2Fvp@M;tSt-jwzgE~Wg$(v6`F%1?I_g;6Y+pNm!;ph1RZz52nt7MAeg1I86?ydp7p$Mi zwoV{mcf5L{A&qi`(DCMXUp*5`wr%TCgp!T=GIY-;9H!&=_B0w@a6EQCsSJmfTAQm@ zX?hRiX|&-!)@j`=Pyusey9rQ1h!{E=8}N{o{s_G~x1--=%g z$71)_jz$mqKKc8j=jPF_gw5||wV}8JxGhX>W`1vftrcr3+hgkD7o>Pz28ru2^$voq zC04aGzk6p>uou1k!XIY!pbS&R0D0&iyj!qQp97mIs0wa9+38W(4{A6LI5=E8NU*VK z##0h_f;%eJN3-+= z`+Y{OqwlT-H)u*WY!FQOh9XziYK6HI1>VauU#((u8XAvY_rGyQcwj9z{dsq-bCk$K zSZE{5mNsh)hjVbn;B&S0WqWBO^GB?=)raog^5>2!&w8o*?p&!Re?R6Kf=h5=v+b2# zQ{te$l%YsaWE>~s8RPF=$6|q%@wMOfdBe+1ER1Iw6XiJa3KAQ=q1_BOYqxJ-%HSQi zn>~l4X~&{17(idLBi01P_{aaGi&&jx2NGIwyf@TTksL+oJ`9Sjldb<&U!Zj)G`!2Kz#A9sHPFu#K=+39-b;6b(=E?FzHNW> zu8@Y5&u7g#_H0J^Sa+;d7U$^ah>&4{obQRE?Cp2UD!jWyBB!`02d48_XnGM!&qzVV zI~KcMt1d5X6Vvo)98~v;SVdB!l;0dFO%c#Tr{A}-$9=p{A^6VVWH5m8ryY_+lj@ajRnd8G*?j0`%Xmk#}ZFjzcI{7e&BMv+sIV4&W z`I7ey39n~(A?H`U68|4PY9~~(cthsz*Zp_jxkiq>mq|Qi#_gXN zRNaG&xcemzJP`JlEbuRF7NjpmGX1Q&?ZLNrCq<}*bg1C-%?#@9Xs7hSp@WK}sw{WT@5 zH)}!7RK}%SLH>jH>LWvCNt`$*QL&(2KJ zPgAj(FdCZ(7X?jeO!ZaH2nZQ%4!Ln(^@vIG7h`TQJf%UCAePWa0`Z<@;bo7mM!CwA zqcr7hksd6ddNXtbJ5$3(ta0_ATdI_DlO8+;1fFuwScgW?gx`3e9h;>>-FM3MYrR3v>WJ?0TxMe30K7Ii#RiB0b@R`hTf zV@`9)Lg({nqr89;SQ?i0e5=i;CY?0RG_#9S(JL;KRCs!Jkp0#z&Qj)g(yEKo(|fl|bX;OYBvgV+_+7%{d?D068A=E4?#(3RUMJTiVPRb6 zU&69is$|*TpHZR{Hm)q?va8v$@Cw!=*>{inB-^~;!|~p!fo9)bPQK)nFBYmkgHP;p z0i}y=H4VHH-+h`TcTxXzR9s< y@7L0G;)UtI`Ty7K@6RZIUdVn$xx@LdQU1QH8R(Lc|9mC|@w=Ot<~-h?fBhFKE|moU From 0c5a614124f17e0ca3b6f2dbbe6f594fdb3ea433 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:44:06 +0200 Subject: [PATCH 05/24] lift log: a set left unrated saves the line's max RPE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 16 Sep 2026: grey should mean the same thing in every column. Weight and reps already save their grey number when the field is left empty, so RPE now does too — the line's max RPE, which is what the session showed grey. A rating typed for the set still wins, a discarded set still saves zeros and no rating, and a previous set's own rating is never copied onto another set: only the plan's own number fills a blank. The grey RPE is drawn as a plain number rather than "≤8", because grey now means "this is what saves". The cost is stated rather than hidden: a stored rating no longer proves the lifter rated that set, so the session's RPE card can report the plan. Co-Authored-By: Claude Opus 5 --- .../LiftProgramSheetImporter.swift | 11 ++--- .../Sources/WhoopStore/LiftLogStore.swift | 3 +- Strand/Data/LiftSessionController.swift | 11 +++-- Strand/Resources/Localizable.xcstrings | 4 +- Strand/Screens/LiftProgramItemSheet.swift | 8 ++-- Strand/Screens/LiftSessionView.swift | 8 ++-- StrandTests/LiftSessionFinishTests.swift | 40 +++++++++++++----- Tools/make_lift_program_template.py | 2 +- .../main/java/com/noop/data/LiftEntities.kt | 2 +- docs/LIFT_LOG_PROGRAM_IMPORT.md | 2 +- docs/lift-log-program-template.xlsx | Bin 9470 -> 9468 bytes 11 files changed, 56 insertions(+), 35 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift index 7d26314d09..af57e6d02f 100644 --- a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -123,7 +123,6 @@ public enum LiftProgramSheetImporter { private static let setsKeys = ["sets", "working_sets", "target_sets"] private static let repsKeys = ["reps", "rep", "target_reps", "repetitions"] private static let weightKeys = ["weight_kg", "weight", "kg", "load", "load_kg"] - private static let maxRpeKeys = ["target_max_rpe", "max_rpe", "rpe_max", "target_rpe", "rpe"] private static let restKeys = ["rest_sec", "rest_seconds", "rest", "rest_s"] private static let noteKeys = ["note", "technique_note", "notes", "cue"] @@ -202,11 +201,12 @@ public enum LiftProgramSheetImporter { // A max RPE outside the scale is refused with a warning rather than clamped: 12 is a typo, // and guessing whether it meant 10 or 1.2 would put a ceiling in the plan nobody chose. - var maxRpe = doubleValue(row, maxRpeKeys) + var maxRpe = doubleValue(row, ["target_max_rpe", "max_rpe", "rpe_max", "target_rpe", "rpe"]) if let rpe = maxRpe, !(1...10).contains(rpe) { maxRpe = nil if warnings.count < maxWarnings { - warnings.append(rowMessage(i, "max RPE \(trimmed(rpe)) is not between 1 and 10, so \"\(exercise)\" has none")) + let shown = rpe == rpe.rounded() ? String(Int(rpe)) : String(rpe) + warnings.append(rowMessage(i, "max RPE \(shown) is not between 1 and 10, so \"\(exercise)\" has none")) } } @@ -264,11 +264,6 @@ public enum LiftProgramSheetImporter { "Row \(i + 2): \(text)" } - /// "8", "8.5" — a number as a person typed it, for a warning. - private static func trimmed(_ value: Double) -> String { - value == value.rounded() ? String(Int(value)) : String(value) - } - private static func programNote(_ row: [String: String]) -> String? { value(row, programNoteKeys)?.trimmed.nilIfEmpty .map { String($0.prefix(WhoopStore.maxProgramNoteLength)) } diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 828db42428..268b5e33de 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -127,7 +127,8 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { /// Rep range low end — the 8 of "8-10". Nil when the line has no rep target. public var targetRepsLow: Int? public var targetRepsHigh: Int? - /// Max RPE on the user's own 1-10 scale: a ceiling the session shows, never a set's recorded rating. + /// Max RPE on the user's own 1-10 scale: the ceiling the session shows grey, and what a set left + /// unrated records as its rating. public var targetRpe: Double? /// Planned working weight in kilograms (v41). A program line plans a weight, not only reps. public var targetWeightKg: Double? diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 25a6f29dd5..3177616eb2 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -431,7 +431,10 @@ final class LiftSessionController: ObservableObject { /// The sets the session saves — every slot on the sheet. /// /// A set with anything typed saves its numbers, and a number left blank takes its grey value, so a - /// set that was rated but never weighed does not save empty. Unfinished sets save with their grey + /// set that was rated but never weighed does not save empty. That includes RPE: a set left unrated + /// saves the program line's max RPE (Utku, 16 Sep 2026), which is the number the session showed grey. + /// A rating typed for the set always wins, and a previous set's rating is never copied onto another — + /// only the plan's own number fills a blank. Unfinished sets save with their grey /// numbers (and anything typed in advance) when `completingUnfinished`; otherwise they save as /// 0 kg × 0 reps, which every figure leaves out (`LiftMetrics.isPerformed`) and Edit sets still /// shows, so a discard made by mistake can be filled back in. Performed sets keep the order they @@ -439,12 +442,14 @@ final class LiftSessionController: ObservableObject { func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { guard let engine else { return [] } let unfinished = Set(engine.unenteredSlots) + // The plan's max RPE, which the session shows grey in the RPE field. + func planned(_ slot: LiftSlot) -> Double? { engine.planItem(for: slot)?.targetRpe } var out = engine.sets.map { set -> FinishedSet in let discarded = unfinished.contains(set.slot) && !completingUnfinished let shown = values(of: set.slot) return FinishedSet(slot: set.slot, weightKg: discarded ? 0 : shown.weightKg, reps: discarded ? 0 : shown.reps, - rpe: discarded ? nil : set.rpe, isWarmup: set.isWarmup, + rpe: discarded ? nil : (set.rpe ?? planned(set.slot)), isWarmup: set.isWarmup, startTs: set.startTs, endTs: set.endTs, restSec: set.restSec) } for slot in engine.allSlots where !engine.isCompleted(slot) { @@ -453,7 +458,7 @@ final class LiftSessionController: ObservableObject { out.append(FinishedSet(slot: slot, weightKg: completingUnfinished ? typed?.weightKg ?? grey.weightKg : 0, reps: completingUnfinished ? typed?.reps ?? grey.reps : 0, - rpe: completingUnfinished ? typed?.rpe : nil, + rpe: completingUnfinished ? typed?.rpe ?? planned(slot) : nil, isWarmup: pendingWarmups.contains(slot), startTs: nil, endTs: nil, restSec: nil)) } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 596b1849e6..0d54d94de0 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1932,8 +1932,8 @@ "Max RPE must be between 1 and 10.": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE muss zwischen 1 und 10 liegen."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE must be between 1 and 10."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo debe estar entre 1 y 10."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max doit être compris entre 1 et 10."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max deve essere tra 1 e 10."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE musi mieścić się w zakresie od 1 do 10."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo tem de estar entre 1 e 10."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE должен быть от 1 до 10."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 必须在 1 到 10 之间。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 必須介於 1 到 10 之間。"}} } }, - "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session as a reminder and is never saved as how a set felt.": { "localizations": { - "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE ist eine Obergrenze: So hart darf sich ein Satz höchstens anfühlen, 10 heißt, nichts mehr übrig. Sie erscheint während der Einheit grau als Erinnerung und wird nie als Empfinden eines Satzes gespeichert."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session as a reminder and is never saved as how a set felt."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo es un techo: lo más duro que debería sentirse una serie, donde 10 significa no poder más. Se muestra en gris durante la sesión como recordatorio y nunca se guarda como lo que sentiste en la serie."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max est un plafond : le plus dur qu'une série devrait sembler, 10 signifiant qu'il ne reste plus rien. Il s'affiche en gris pendant la séance comme rappel et n'est jamais enregistré comme ressenti d'une série."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max è un tetto: la fatica massima che una serie dovrebbe dare, dove 10 significa non averne più. Appare in grigio durante la sessione come promemoria e non viene mai salvato come percezione della serie."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE to górna granica: najcięższe odczucie, jakie powinna dać seria, gdzie 10 oznacza brak zapasu. Podczas sesji wyświetla się na szaro jako przypomnienie i nigdy nie jest zapisywane jako odczucie serii."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo é um teto: o mais difícil que uma série deve parecer, em que 10 significa não ter mais nada. Aparece a cinzento durante a sessão como lembrete e nunca é guardado como o esforço sentido na série."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE — это потолок: насколько тяжёлым может ощущаться подход, где 10 — сил больше нет. Во время сессии он показан серым как напоминание и никогда не сохраняется как ощущение подхода."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一组最多应感觉多吃力,10 表示已毫无余力。训练中以灰色显示作为提醒,绝不会被记录为某组的实际感受。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一組最多應感覺多吃力,10 表示已毫無餘力。訓練中以灰色顯示作為提醒,絕不會被記錄為某組的實際感受。"}} + "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session, and a set you leave unrated saves it as its rating.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE ist eine Obergrenze: So hart darf sich ein Satz höchstens anfühlen, 10 heißt, nichts mehr übrig. Sie erscheint während der Einheit grau, und ein Satz ohne eigene Bewertung speichert sie als seine Bewertung."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session, and a set you leave unrated saves it as its rating."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo es un techo: lo más duro que debería sentirse una serie, donde 10 significa no poder más. Se muestra en gris durante la sesión, y una serie que dejes sin valorar lo guarda como su valoración."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max est un plafond : le plus dur qu'une série devrait sembler, 10 signifiant qu'il ne reste plus rien. Il s'affiche en gris pendant la séance, et une série laissée sans note l'enregistre comme la sienne."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max è un tetto: la fatica massima che una serie dovrebbe dare, dove 10 significa non averne più. Appare in grigio durante la sessione e una serie lasciata senza valutazione lo salva come propria."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE to górna granica: najcięższe odczucie, jakie powinna dać seria, gdzie 10 oznacza brak zapasu. Podczas sesji wyświetla się na szaro, a seria bez własnej oceny zapisze je jako swoją ocenę."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo é um teto: o mais difícil que uma série deve parecer, em que 10 significa não ter mais nada. Aparece a cinzento durante a sessão, e uma série que deixes sem classificação guarda-o como a sua."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE — это потолок: насколько тяжёлым может ощущаться подход, где 10 — сил больше нет. Во время сессии он показан серым, и подход без своей оценки сохранит его как свою оценку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一组最多应感觉多吃力,10 表示已毫无余力。训练中以灰色显示;若某组未自行评分,就会以它作为该组的评分保存。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一組最多應感覺多吃力,10 表示已毫無餘力。訓練中以灰色顯示;若某組未自行評分,就會以它作為該組的評分儲存。"}} } }, "max RPE %@": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "max. RPE %@"}}, "en": {"stringUnit": {"state": "translated", "value": "max RPE %@"}}, "es": {"stringUnit": {"state": "translated", "value": "RPE máx. %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "RPE max %@"}}, "it": {"stringUnit": {"state": "translated", "value": "RPE max %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "maks. RPE %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "RPE máx. %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "макс. RPE %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE %@"}} diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index dab0291f1e..a350a92c61 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -289,9 +289,9 @@ struct LiftProgramItemSheet: View { } } // Max RPE is a CEILING, not effort planned in advance (Utku, 15 Sep 2026): the - // hardest a set should feel, so a lifter knows where to hold back. How hard a set - // actually FELT is only known afterwards and is still recorded per set; this is - // shown grey in the session and never saved as a rating (RULES 34). + // hardest a set should feel, so a lifter knows where to hold back. It is shown grey in + // the session and, like every other grey number, a set left unrated saves it + // (Utku, 16 Sep 2026; RULES 34) — typing a rating always wins. HStack(spacing: NoopMetrics.gap) { field("Max RPE (1–10)") { numberInput("8", text: $maxRpeText, field: .maxRpe) @@ -303,7 +303,7 @@ struct LiftProgramItemSheet: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.statusWarning) } - Text("Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session as a reminder and is never saved as how a set felt.") + Text("Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session, and a set you leave unrated saves it as its rating.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 44c997cae8..3a08f04cf3 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -404,11 +404,11 @@ struct LiftSessionView: View { session.carry(for: slot).reps.map(String.init) ?? "—" } - /// RPE is never carried. Its ghost is the line's max RPE when the program sets one — "≤8", a ceiling - /// to stay under — else the previous set's own rating as a reminder. Neither is a value any set - /// saves: only a typed rating is recorded (RULES 5, 34). + /// Grey RPE is the line's max RPE when the program sets one — and, like every other grey number, it is + /// what the set saves if nothing is typed over it (RULES 34). A previous set's own rating is shown as a + /// reminder when the plan sets no maximum, and that one is never saved: it belongs to another set. private func ghostRpe(_ engine: LiftSessionEngine, slot: LiftSlot) -> String { - if let ceiling = engine.planItem(for: slot)?.targetRpe { return "≤" + LiftFormat.trim(ceiling) } + if let planned = engine.planItem(for: slot)?.targetRpe { return LiftFormat.trim(planned) } return engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" } diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift index a9cb06438c..863d6fe169 100644 --- a/StrandTests/LiftSessionFinishTests.swift +++ b/StrandTests/LiftSessionFinishTests.swift @@ -109,7 +109,7 @@ final class LiftSessionFinishTests: XCTestCase { XCTAssertEqual(row1.reps, 12) XCTAssertNil(row1.startTs, "never started: no moment to record") XCTAssertNil(row1.restSec) - XCTAssertNil(row1.rpe, "RPE is never invented") + XCTAssertNil(row1.rpe, "this plan sets no max RPE, so there is nothing to fill") } /// Numbers typed in advance and a warm-up mark still count for a set completed at finish. @@ -126,19 +126,39 @@ final class LiftSessionFinishTests: XCTestCase { XCTAssertEqual(saved.first { $0.slot == slot(1, 2) }?.isWarmup, true) } - /// A program's max RPE is a ceiling the session SHOWS, never a rating it records: a set done without - /// a typed rating, and a set completed at finish, both save no RPE (RULES 34). - func testAMaxRpeIsNeverSavedAsASetsRating() { + /// A program's max RPE is grey in the session, and grey numbers are what a set saves when nothing is + /// typed over them — RPE included (Utku, 16 Sep 2026). A typed rating always wins. + func testAMaxRpeFillsAnEmptyRatingLikeEveryOtherGreyNumber() { let c = controller() c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 2, targetRepsLow: 5, targetRpe: 8)], programId: nil, programName: nil) + c.advance() // set 1 working + c.updateSet(slot(0, 1), weightKg: 60, reps: nil, rpe: nil, isWarmup: false) + c.advance() // set 1 done: a weight typed, no rating + + let kept = c.setsToSave(completingUnfinished: false)[0] + XCTAssertEqual(kept.weightKg, 60) + XCTAssertEqual(kept.rpe, 8, "an unrated set saves the plan's max RPE, as its grey number") + + let completed = c.setsToSave(completingUnfinished: true) + XCTAssertEqual(completed.count, 2) + XCTAssertTrue(completed.allSatisfy { $0.rpe == 8 }, "completing fills RPE the same way") + + c.updateSet(slot(0, 1), weightKg: 60, reps: nil, rpe: 6, isWarmup: false) + XCTAssertEqual(c.setsToSave(completingUnfinished: true)[0].rpe, 6, "a typed rating wins") + } + + /// Discarding still saves zeros and no rating: the plan's number fills a blank only on a set the + /// session keeps, so a discarded set cannot arrive carrying an effort nobody made. + func testADiscardedSetTakesNoMaxRpe() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 1, targetRepsLow: 5, targetRpe: 8)], + programId: nil, programName: nil) c.advance() - c.advance() // set 1 done, nothing typed - for completing in [false, true] { - let saved = c.setsToSave(completingUnfinished: completing) - XCTAssertEqual(saved.count, 2) - XCTAssertTrue(saved.allSatisfy { $0.rpe == nil }, "the ceiling must never become a rating") - } + c.advance() // performed, nothing typed at all + let discarded = c.setsToSave(completingUnfinished: false)[0] + XCTAssertNil(discarded.rpe) + XCTAssertEqual(discarded.reps, 0) } /// Nothing unfinished means nothing to ask, and every set saves. diff --git a/Tools/make_lift_program_template.py b/Tools/make_lift_program_template.py index c289c1e462..c14edcbc8f 100644 --- a/Tools/make_lift_program_template.py +++ b/Tools/make_lift_program_template.py @@ -55,7 +55,7 @@ ("", False), ("Weight is in KILOGRAMS. The app shows it in your chosen unit; it is stored in kg.", False), ("Target max RPE is a CEILING from 1 to 10: the hardest a set should feel, 10 meaning nothing", False), - ("left. The app shows it grey during the session as a reminder; it is never saved as how a set felt.", False), + ("left. The app shows it grey during the session, and a set you leave unrated saves it as its rating.", False), ("Rest is in SECONDS. 120 means two minutes.", False), ("", False), ("Do not rename, reorder or delete the header row - the import matches on those names.", False), diff --git a/android/app/src/main/java/com/noop/data/LiftEntities.kt b/android/app/src/main/java/com/noop/data/LiftEntities.kt index ed0f18b579..21f50b9211 100644 --- a/android/app/src/main/java/com/noop/data/LiftEntities.kt +++ b/android/app/src/main/java/com/noop/data/LiftEntities.kt @@ -108,7 +108,7 @@ data class LiftProgramItemRow( /** Rep-range low end — the 8 of "8-10". */ val targetRepsLow: Int? = null, val targetRepsHigh: Int? = null, - /** Max RPE on the user's own 1-10 scale: a ceiling the session shows, never a set's recorded rating. */ + /** Max RPE on the user's own 1-10 scale: the ceiling the session shows grey, and what a set left unrated records. */ val targetRpe: Double? = null, val targetWeightKg: Double? = null, /** Intended rest after each set, seconds. */ diff --git a/docs/LIFT_LOG_PROGRAM_IMPORT.md b/docs/LIFT_LOG_PROGRAM_IMPORT.md index f1bacf36df..0e37d74d29 100644 --- a/docs/LIFT_LOG_PROGRAM_IMPORT.md +++ b/docs/LIFT_LOG_PROGRAM_IMPORT.md @@ -26,7 +26,7 @@ has to reject, and the max RPE column only accepts a number from 1 to 10. A seco | `Sets` | no | Working sets. | | `Reps` | no | One rep count, not a range. | | `Weight kg` | no | **Always kilograms.** The app displays it in your chosen unit; it is stored in kg. | -| `Target max RPE` | no | **A ceiling from 1 to 10**: the hardest a set should feel, 10 meaning nothing left. The session shows it grey (`≤8`) as a reminder; it is never saved as how a set felt. Also read from a column named `Max RPE` or `RPE`. Outside 1–10 imports without it, with a warning. | +| `Target max RPE` | no | **A ceiling from 1 to 10**: the hardest a set should feel, 10 meaning nothing left. The session shows it grey in the RPE box, and a set you leave unrated saves it as its rating. Also read from a column named `Max RPE` or `RPE`. Outside 1–10 imports without it, with a warning. | | `Rest sec` | no | **Seconds.** `120` is two minutes. | | `Note` | no | Your technique cue, verbatim. | diff --git a/docs/lift-log-program-template.xlsx b/docs/lift-log-program-template.xlsx index aabde1c5f7bf849e050b2867de7a2e167b7d4459..071d6f60ed18b5f1ff3dd7d9e0bc46dab10cdd85 100644 GIT binary patch delta 1466 zcmV;r1x5P)N&HE$cO3~&5mRA-1pom3lYku|e_eB%I28RWcX-&mxV8Z|Uz?ti+nBv1qZO ze>+b?)=bk+Sxi(bk>!1-x|$@@>F%UvyopYZGEu4i3^g|(BB+OE2i;if1)X*={U*gL!mbDl1;p%k2men4WCFiPo%3S zwm=k*;euBc@CIlLzzo@<1bjuAafKsuLzf6b=G43t9V}R*%_MSRZq#EB_Spv6Y*6$*Kz}wo@N^6Na<8419H<_p)y(ML;G=qfA-_{ zXaV3~TAYkE@Qm0}JOailXf0T^Uf?HQA$)*uty$&M5nPFdxuB*Wl!K6!tSPXEZRYEP zySstAyFJ`=3=izZ1~QOF(9&9SO&HB4OK3|%I|AY+7`nmD?d=`>QRqM6ia%+BUD#ij zbt|L>ww|aD@!kgU-W#dBzjFBURWkNjsLqd`UZ8Q=vG73n@sfa;&r(!K35^M)@E(DUH*&7SxGa z9H467kr$;lxk(xD_vO{?m;2M}hcP_bWB^r)g)&6sDCU~~*C)K(8lGGx?fB7W!_xGfdr*~rH!Q)#rx?Y_`?XOuXd zOWxAC#OYiTV9dUg<)j_QS4M`+YsUtDEI_4J-i6%4x=z91SgWl$oQ9ff%D#KjE?CLO zHG4IbH2LDg>f|g5f3SvYYXWeE1z{t~Q2ymlg0>#upN{n?m`@1LSoQJ?(jHy91yZM}Nr0yQx~Au^ z8k@e@+he$V!1eeTy|tuadp(S#xb&e?cKdN4dFN`CqD7hSsJ; z_~1F2N&?K@S@}-xJB3RE6mDP~JV8-z3@2)7fT+E1 ztkS`M)DR_Ne)vv3(g5|a|HbAkd-la}yiY^Clw|$|a%4>dT+d(cZh#|t8emK=$Z#@Q zdm}hLrvZHKf2GeiyyJ5kz-Lzje6K4Uoo4}bwpGwKzN7LifXb(N-eqL{y&)WvX8}w; zo&S#zj?1$EF53$18-MWDD1gnUbMN4e#42ge@SjtW2TKk>3{e3K+Jzoo3#omqrU)>3JbMz>% delta 1468 zcmV;t1w;D$N&ZQ&cO41!-Rd=i1pom2lYku|f7^1SI1v4nE*>^7j%~n`+a{UH<}#Do z+&0P7)IQ4422B7ll8EEK-xk=2shTKjxym*cpgwB#IepCW=eM$eSCop2>Lduq(*Uqa zM9Qn|B>47xwR;eN(yU5ZAu2oxR;Yr{r^ibnUsR5$f$p!=Nswz@A5A7I$+2W=ENZOi zf6PM2l4<%Wvx%xDvb4RaEGE%(x;H5quY%L#L=>w1gpwN&0hH{m{a*4^=O@A8UI0m> zv?#xK17R>ZJ)U&;wKHBY%}$S{Sb{WXX*P3kMpLMEB7G9@s^AqqX-Q`}-Pb}tQZ-9x zS%giYe8u4OE6t$=GzvlIT!F^2E||vSe~G4$=Eg}vZwl4Ni~4v`8*kd88(tCjSH$Zp zwm=k);ffap@Cs-Pz)V@A1bjo8aD^jtLgxrVrZl_~4J=uu%_34^PLyK@_F0c?wmq_F z3=fTNgDX}dROW-w>^w-3NmlLv6#yWW*Q$q0S2GQBq;krX0omWJP?;)pq5agOfBm#Q zS^)T$l9R9sUJz@FN5EJCbq7{$61?CA!Y+KPO_t9_a3hxHfQEih4MLK$D#I4GS+5K3 zUJrL~d${o!p4e*_$UquFO}XZpFq%!4Q0Ihp1jJ1+bc4J5`v>?V(|^JZUuc4zb-%95 zT1X9Svrre}{T}iDJEeTkUvaSgf12*AVg);w>r&VO;+Mpq4VRdX;j*n0LF<)3)NabE z-K#l5fwZ1^!K#;bwUmtlQ?p%$n9{MF!yfG6_F&hvVs$+tZb8~e62-1uyGo_aP*^Nn z`3$F4i>B{nG#uzvxIN4V$xB9wD;t$i;ZEQQ6G3|252>}7A*52&saRHOe>}K|B?;DA zZRjv^t;Q-{7;TSKVa+7%SPJtM?Qltr;s~y!AhAlZ&}szFk`ox^f?%aIjNePB6}8Yq z)xIOoa&2mpD&X(yoBJ=1XSYvdcb_tIUf0rgOgY(Plo9nwTu#lpJ&?sy;J!-c@fBUMb0R z0nM>q)q)DinQ*Ic;r{m6A5H$c%6PgwzrVYnx(K5#dDP98!dRF_f1`3@uo@a3yjR0k zx`qrJZHu0FGyQ-@fJv}q(&Z+`fE-v z-!xXO40W$zPKU29rxAV3ro-_)CsM1U(cXKkv3eElZ_k?Bf6f$SQZ!VXtF`^5hGl4N zI)n?Jm8rz(Hx)V@`?$V1@ET+>@V#~ysPJ;3#}!qR!>LFcXuy5_f3o_SFDEKQq)oWC=KW%A5} z$!GKb5yEnL=E3Ez1H0xQJT&rP^V!@vxux;UgU0vsg8yvX@GX;P9!%~G!UtkBvuvJu zu=x&uKl^0xu(LfRx2iGG#-OynTYDhpKWMD83M!$$3;>b~wRvdTKe;{q53?X6kO2wx W-Rd=i1pom2ler{41|lN>0002JyWOY& From cad69021cbb35b6803cbf388c5963eb1a34c5a1a Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:58:42 +0200 Subject: [PATCH 06/24] lift log: one tap moves the cursor from one field to the next Tapping the reps field with the keyboard open in the weight field took two taps on the phone: the screen-wide tap gesture that puts the keyboard away also fired for a tap that landed in another field, and took the focus the field had just been given (gym session, 16 Sep 2026). The watcher is now a UIKit tap recognizer on the window that reads the touched view: it stands aside for any text input and for touches outside its own screen, and still never swallows a tap, so a button pressed with the keyboard open both dismisses it and acts. Simulator, both builds: with the old gesture one tap on REPS closed the keyboard and the typed digit went nowhere; with this one the cursor moved and the digit landed. A tap on empty space still dismisses, and a tap on "Start first set" dismisses and starts the set. Co-Authored-By: Claude Opus 5 --- Strand/Screens/KeyboardDismiss.swift | 97 ++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/Strand/Screens/KeyboardDismiss.swift b/Strand/Screens/KeyboardDismiss.swift index 7307fd15bc..9b5d6b8282 100644 --- a/Strand/Screens/KeyboardDismiss.swift +++ b/Strand/Screens/KeyboardDismiss.swift @@ -1,4 +1,7 @@ import SwiftUI +#if os(iOS) +import UIKit +#endif // Tap outside a field to put the keyboard away. // @@ -7,17 +10,22 @@ import SwiftUI // workout sheet until you found it. In a gym that is the difference between glancing at your next // set and fighting the phone. // -// A `simultaneousGesture` rather than `onTapGesture`, deliberately: a plain tap gesture on a -// container SWALLOWS taps meant for the buttons and rows inside it, which on this screen would eat -// the very controls that advance the session. A simultaneous gesture runs ALONGSIDE the child's own -// handling, so a tap both dismisses the keyboard and does whatever it was aimed at. +// A tap on ANOTHER FIELD is not a tap outside. The first version cleared focus from a SwiftUI +// `simultaneousGesture` on the whole screen, which also fired for a tap that landed in a field: the +// tapped field took focus and the gesture took it straight back, so moving from the weight to the +// reps took two taps — one to lose the keyboard, one to get it back (gym session, 16 Sep 2026). +// SwiftUI's tap gesture cannot say what it landed on, so the watcher is a UIKit recognizer that +// reads the touched view and stands aside for any text input. +// +// It never swallows a tap: the recognizer runs alongside everything else, so a tap on a button both +// dismisses the keyboard and does whatever it was aimed at — this screen's buttons advance the session. extension View { /// Clear `focus` when the user taps anywhere in this view that isn't a field. func dismissesKeyboardOnTap(_ focus: FocusState.Binding) -> some View { #if os(iOS) self - .simultaneousGesture(TapGesture().onEnded { focus.wrappedValue = nil }) + .background(TapOutsideFields { focus.wrappedValue = nil }) // Dragging the sheet also puts it away, which is the gesture most people reach for // first when a keyboard is covering what they want to read. .scrollDismissesKeyboard(.interactively) @@ -27,3 +35,82 @@ extension View { #endif } } + +#if os(iOS) +/// Reports taps that land in this screen but not in a text input. +/// +/// The recognizer is attached to the WINDOW, because SwiftUI gives a background view no reliable +/// place in the hierarchy the touches pass through. Two filters keep it to this screen: a touch in a +/// text input is ignored (the tap is moving focus, not ending it), and so is a touch outside the view +/// controller this view belongs to — a sheet presented on top has its own fields and its own watcher. +private struct TapOutsideFields: UIViewRepresentable { + let onTap: () -> Void + + func makeUIView(context: Context) -> Probe { + let probe = Probe() + probe.onTap = onTap + return probe + } + + func updateUIView(_ probe: Probe, context: Context) { + probe.onTap = onTap + } + + static func dismantleUIView(_ probe: Probe, coordinator: ()) { + probe.detach() + } + + final class Probe: UIView, UIGestureRecognizerDelegate { + var onTap: () -> Void = {} + private var recognizer: UITapGestureRecognizer? + + override init(frame: CGRect) { + super.init(frame: frame) + // Present only to find the window; it must not take part in hit-testing itself. + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func didMoveToWindow() { + super.didMoveToWindow() + detach() + guard let window else { return } + let tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) + tap.cancelsTouchesInView = false + tap.delaysTouchesBegan = false + tap.delaysTouchesEnded = false + tap.delegate = self + window.addGestureRecognizer(tap) + recognizer = tap + } + + func detach() { + if let recognizer { recognizer.view?.removeGestureRecognizer(recognizer) } + recognizer = nil + } + + @objc private func tapped() { onTap() } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch) -> Bool { + guard let touched = touch.view else { return false } + let ancestors = sequence(first: touched, next: { $0.superview }) + if ancestors.contains(where: { $0 is UITextInput }) { return false } + guard let screen = owningController?.view else { return false } + return touched.isDescendant(of: screen) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { + true + } + + /// The nearest view controller up the responder chain — the sheet or screen hosting this view. + private var owningController: UIViewController? { + sequence(first: self as UIResponder, next: { $0.next }) + .first { $0 is UIViewController } as? UIViewController + } + } +} +#endif From b68705189f80e9dff94758a852ac28151989ebb3 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:01:44 +0200 Subject: [PATCH 07/24] lift log: hold back a strap knock, and buzz ahead of the sync the tap kicks Two things the strap log of the 16 Sep 2026 gym session showed. A knock read as a tap. The strap's own sensor log recorded two double-taps 3 s and 4 s after one that had just started a set. Both were genuine detections with their own timestamps, so FrameRouter's de-duplication let them through, and each finished a set seconds old and started its rest ("it skipped two things"). The session now holds back a strap double-tap that comes less than 8 s after the last one it acted on, unless a rest that is already over is waiting to be ended (a line planned with no rest). The held-back tap gets no buzz, the lifter's cue to tap again, and leaves a strap-log line. The on-screen button is never held back. The confirming buzz queued behind a sync. A DOUBLE_TAP event also kicks a rate-limited sync, and FrameRouter did that before handing the tap on; the session then hopped through a Task before buzzing. When the sync was not rate-limited, the strap received "send historical data" first and started the transfer before playing the buzz: those four taps buzzed 1.0-2.8 s after the strap sensed them, where most others came in under one second. FrameRouter now hands the tap on first and the session buzzes synchronously, so the buzz is written ahead of the sync request. Wrist and other events keep their order. Android has no Lift Log and is unchanged. For the part of that session the exported log still holds (21:54-22:26; the earlier half hour had rolled out of the 5,000-line buffer), every double-tap the strap's sensor reported (22) was acted on and buzzed. A tap the sensor never reports cannot be recovered by the app. Tests seen to fail without each change: the knock test, the synchronous-buzz test, and the order test in FrameRouterDoubleTapDedupTests. Co-Authored-By: Claude Opus 5 --- Strand/App/AppModel.swift | 2 +- Strand/BLE/FrameRouter.swift | 18 ++- Strand/Data/LiftSessionController.swift | 66 +++++++++-- .../FrameRouterDoubleTapDedupTests.swift | 19 ++++ StrandTests/LiftSessionStrapTapTests.swift | 104 ++++++++++++++++++ StrandiOS/App/StrandiOSApp.swift | 3 + 6 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 StrandTests/LiftSessionStrapTapTests.swift diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 7f800cfb00..8b607e82f2 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1712,7 +1712,7 @@ final class AppModel: ObservableObject { /// /// A double tap rather than a single one because a strap takes knocks against bars and benches all /// session, and two deliberate taps are not something a rack does by accident. - var strapDoubleTapOverride: (() -> Void)? + var strapDoubleTapOverride: (@MainActor () -> Void)? private func handleDoubleTap() { let now = Date() diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 89712797c8..7d0bdc8689 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -441,6 +441,16 @@ public final class FrameRouter { if !ev.hasPrefix("BLE_REALTIME_HR") { state.lastEvent = ev } + // A double-tap is handed on BEFORE the sync kick below, because what it triggers is + // usually a buzz that says "that registered", and a write that follows the sync + // request reaches the strap after it. The strap then starts the history transfer + // first and plays the buzz behind it: in a 16 Sep 2026 gym log the four taps that + // kicked a sync buzzed 1.0–2.8 s after the strap sensed them, and most of the others + // in under one. Live only (this path never sees historical replay, which goes through + // the Backfiller). Event strings are "NAME(rawValue)". + if ev.hasPrefix("DOUBLE_TAP") { + dispatchDoubleTapOnce(eventTimestamp: parsed.parsed["event_timestamp"]?.intValue) + } // Strap-pushed event = "I may have new data" → kick a (rate-limited) sync. onSyncTrigger?() // Belt-and-suspenders: a BLE_BONDED event confirms the link is bonded. @@ -494,11 +504,9 @@ public final class FrameRouter { } else if ev.hasPrefix("BATTERY_PACK_REMOVED") { state.charging = false } - // Physical inputs the strap exposes — live only (this path never sees historical - // replay, which goes through the Backfiller). Event strings are "NAME(rawValue)". - if ev.hasPrefix("DOUBLE_TAP") { - dispatchDoubleTapOnce(eventTimestamp: parsed.parsed["event_timestamp"]?.intValue) - } else if ev.hasPrefix("WRIST_ON") { + // The other physical inputs the strap exposes — live only, as above. The double-tap + // was handled before the sync kick. + if ev.hasPrefix("WRIST_ON") { if !state.worn { state.worn = true; state.onWristChange?(true) } } else if ev.hasPrefix("WRIST_OFF") { if state.worn { state.worn = false; state.onWristChange?(false) } diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 3177616eb2..cefecfefc0 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -84,7 +84,13 @@ final class LiftSessionController: ObservableObject { /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. private let buzz: (UInt8) -> Void /// Claims/releases the strap's double-tap for the session's lifetime. - private let setStrapHandler: ((() -> Void)?) -> Void + private let setStrapHandler: ((@MainActor () -> Void)?) -> Void + /// Writes a line to the strap log — how a double-tap the session holds back is accounted for. + private let log: (String) -> Void + + /// When the session last acted on a strap double-tap (unix seconds). Nil until it has, and after a + /// relaunch: a knock is judged against a tap in the same sitting, never one from before it. + private var lastStrapStepAt: Int? /// One pulse confirms a strap double-tap registered — with the phone face-down there is /// otherwise no way to know. Three means the rest is nearly up. Two patterns that cannot be @@ -93,11 +99,16 @@ final class LiftSessionController: ObservableObject { static let restWarningBuzzes: UInt8 = 3 /// How long before the rest ends the warning fires. static let restWarningLeadSec = 5 + /// A strap double-tap this soon after the last one the session acted on is taken as a knock. See + /// `isKnock(secondsSinceLastStep:stage:now:)`. + static let strapKnockWindowSec = 8 init(buzz: @escaping (UInt8) -> Void, - setStrapHandler: @escaping ((() -> Void)?) -> Void) { + setStrapHandler: @escaping ((@MainActor () -> Void)?) -> Void, + log: @escaping (String) -> Void = { _ in }) { self.buzz = buzz self.setStrapHandler = setStrapHandler + self.log = log } // MARK: - Lifecycle @@ -159,6 +170,7 @@ final class LiftSessionController: ObservableObject { programId = nil programName = nil warnedFor = nil + lastStrapStepAt = nil pendingWarmups = [] pendingValues = [:] isPresented = false @@ -167,10 +179,12 @@ final class LiftSessionController: ObservableObject { setStrapHandler(nil) } + /// The handler runs SYNCHRONOUSLY, inside the frame handling that delivered the tap. It used to hop + /// through a `Task`, which let the sync request the same strap event triggers reach the strap + /// first; the strap then started a history transfer before playing the confirming buzz, and those + /// buzzes came 1–2.8 s after the tap, where most others came in under one (strap log, 16 Sep 2026). private func claimStrap() { - setStrapHandler({ [weak self] in - Task { @MainActor in self?.advance(fromStrap: true) } - }) + setStrapHandler({ [weak self] in self?.advance(fromStrap: true) }) } private func startTicking() { @@ -186,14 +200,24 @@ final class LiftSessionController: ObservableObject { // MARK: - Actions - /// The one action. `fromStrap` earns a single confirming buzz. + /// The one action. `fromStrap` earns a single confirming buzz, unless the tap reads as a knock. func advance(fromStrap: Bool = false) { - guard engine != nil else { return } - // BUZZ FIRST, before any state work. The confirmation is a latency signal — its whole job is - // to say "that registered" — so it must not queue behind a JSON encode and a defaults write. - if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } - + guard let current = engine else { return } let stamp = Int(Date().timeIntervalSince1970) + if fromStrap { + if let last = lastStrapStepAt, + Self.isKnock(secondsSinceLastStep: stamp - last, stage: current.stage, now: stamp) { + // No buzz: the missing confirmation is the lifter's cue to tap again. + log("Lift Log: that double-tap was not acted on — \(stamp - last) s after the last one it " + + "acted on (under \(Self.strapKnockWindowSec) s is taken as a knock)") + return + } + lastStrapStepAt = stamp + // BUZZ FIRST, before any state work. The confirmation is a latency signal — its whole job + // is to say "that registered" — so it must not queue behind a JSON encode and a defaults write. + buzz(LiftSessionController.advanceConfirmBuzzes) + } + engine?.advance(now: stamp) applyPendingInput() now = stamp @@ -201,6 +225,26 @@ final class LiftSessionController: ObservableObject { persist() } + /// Whether a strap double-tap `secondsSinceLastStep` after the last one the session acted on is a + /// knock rather than a tap. + /// + /// The strap's own sensor log for the 16 Sep 2026 session shows two double-taps it detected 3 s + /// and 4 s after one that had just started a set — the arm going onto the bar, not a second tap. + /// Each was a genuine detection with its own timestamp, so the de-duplication in `FrameRouter` + /// rightly let it through, and each finished a set seconds old and started its rest: "it skipped + /// two things when it should have done only one". Only the timing tells such a knock from a tap. + /// + /// Under `strapKnockWindowSec` counts as a knock, because no set a lifter means to finish, and no + /// rest a lifter means to end, is that short. The exception is a rest that is already over — a + /// line planned with no rest, or the one left once every set is done — where going straight on is + /// the plan. The on-screen button is never held back: a knock does not press it. A tap held back + /// gets no buzz, which tells the lifter to tap again. + static func isKnock(secondsSinceLastStep: Int, stage: LiftSessionEngine.Stage, now: Int) -> Bool { + guard (0.. Void)? + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { [unowned self] in buzzes.append($0) }, + setStrapHandler: { [unowned self] in strapHandler = $0 }, + log: { [unowned self] in logged.append($0) }) + } + + private func plan(restSec: Int = 90) -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Lat pulldown", primaryMuscle: .lats, targetSets: 3, restSec: restSec)] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + buzzes = []; logged = []; strapHandler = nil + super.tearDown() + } + + /// The reported case: a tap starts the set, and a knock straight after must not finish it. + func testADoubleTapRightAfterTheOneThatStartedASetDoesNotFinishIt() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Pull") + + strapHandler?() // the tap: set 1 starts + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + strapHandler?() // the knock, a moment later + + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1)), "the set is still running") + XCTAssertTrue(c.engine?.sets.isEmpty ?? false, "nothing was recorded") + XCTAssertEqual(buzzes, [LiftSessionController.advanceConfirmBuzzes], "one buzz, for the tap only") + XCTAssertEqual(logged.count, 1) + XCTAssertTrue(logged[0].contains("not acted on") && logged[0].contains("knock"), logged[0]) + } + + /// The handler the strap calls acts at once, so the buzz is written before anything that follows + /// the tap in the same frame handling (see `FrameRouterDoubleTapDedupTests`). + func testTheStrapHandlerBuzzesBeforeItReturns() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Pull") + strapHandler?() + XCTAssertEqual(buzzes, [LiftSessionController.advanceConfirmBuzzes]) + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + } + + /// The button on the screen is pressed on purpose; only strap taps are judged. + func testTheOnScreenButtonIsNeverHeldBack() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Pull") + strapHandler?() + c.advance() + guard case .resting(let s, _) = c.engine?.stage else { return XCTFail("expected a rest") } + XCTAssertEqual(s, slot(0, 1)) + XCTAssertTrue(logged.isEmpty) + } + + /// A line planned with no rest goes straight on: the rest is over the moment it starts. + func testWithNoRestPlannedTheNextSetStartsOnTheNextTap() { + let c = controller() + c.start(plan: plan(restSec: 0), programId: nil, programName: "Pull") + c.advance() // set 1 starts (on screen) + strapHandler?() // set 1 done: a rest of zero + strapHandler?() // straight into set 2 + XCTAssertEqual(c.engine?.stage, .working(slot(0, 2))) + XCTAssertTrue(logged.isEmpty) + } + + func testAKnockIsJudgedByTimeAndByWhetherTheRestIsOver() { + let now = 1_800_000_000 + let working = LiftSessionEngine.Stage.working(slot(0, 1)) + let resting = LiftSessionEngine.Stage.resting(slot(0, 1), endsAt: now + 60) + let restOver = LiftSessionEngine.Stage.resting(slot(0, 1), endsAt: now) + let window = LiftSessionController.strapKnockWindowSec + + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 0, stage: working, now: now)) + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 3, stage: working, now: now)) + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 4, stage: resting, now: now)) + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: window - 1, stage: working, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: window, stage: working, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 21, stage: working, now: now), + "the shortest real set in that session was 21 s") + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 2, stage: restOver, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: -5, stage: working, now: now), + "a clock that stepped back is not evidence of a knock") + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 9eafc39123..b8e198c788 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -96,6 +96,9 @@ struct StrandiOSApp: App { }, setStrapHandler: { [weak model] handler in model?.strapDoubleTapOverride = handler + }, + log: { [weak model] line in + model?.live.append(log: line) })) // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a // bluetooth-central to receive it — and the re-score it triggers took nearly eight minutes on the From 9382adb63cea4469c944c07526f8461efce6c1cb Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:02:10 +0200 Subject: [PATCH 08/24] lift log: the next set on the bar and Lock Screen, a rest clock that stops at 0:00, and a Lock Screen that lights on a strap step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the 16 Sep 2026 gym session. - The next set replaces "3 of 19 sets done" on the minimised bar and the Lock Screen: "Next: Set 2 · Lat pulldown", on one line, always a set and never the rest before it. It is where the taps actually go (`LiftSessionEngine.upcomingSlot` is `slotAfter` with the current set counted as done), so the last set of an exercise names the next exercise; "Last set" while the final set is worked, "All sets done" once it is. The set number comes before the name, so a narrow line cuts the name. The bar gains it as a third line; the Lock Screen's third line drops the count and the program name for it. The warm-up now reads "Warm-up". - The Lock Screen rest clock stays at 0:00 once the rest is over, as the in-app bar does. It used to count up again, because a widget re-rendered after the end (for the "Ready for the next set" push) switched to a count-up; the countdown's range now starts at the rest's start. - Numbers typed into the set being lifted show on the bar and the Lock Screen. Found in the simulator walkthrough: 70 kg x 9 typed into the running set, and the bar said the grey "8 x 60 kg". - A strap double-tap that moves the session lights the Lock Screen: the push it triggers carries an ActivityKit alert, skipped while the app is on screen. ActivityKit offers an alert only the default sound or a named file, so it names a bundled 0.2 s of silence; the strap has already buzzed. Whether the phone also vibrates is up to iOS, and is to be seen on the phone. Simulator: the bar and the Lock Screen show the next-set line; after a rest ended and the "Ready" push re-rendered the Lock Screen, the new clock read 0:00 where a build with the old clock read 2:-- and climbing. The light-up cannot be seen in the simulator. New strings in all ten locales. Tests seen to fail without the change: the two next-set engine tests. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 50 ++++++++++----- Strand/Data/LiftSessionEngine.swift | 17 ++++++ Strand/Resources/Localizable.xcstrings | 6 ++ Strand/Screens/LiftSessionBar.swift | 13 +++- StrandTests/LiftSessionEngineTests.swift | 37 ++++++++++++ .../LiftSessionPendingInputTests.swift | 15 +++++ StrandiOS/App/StrandiOSApp.swift | 12 ++-- StrandiOS/Resources/lift-step-silence.caf | Bin 0 -> 12916 bytes .../Widgets/LiftLiveActivityController.swift | 29 +++++++-- StrandiOSShared/LiftActivityAttributes.swift | 12 ++-- StrandiOSWidgets/LiftLiveActivity.swift | 57 ++++++++++-------- 11 files changed, 192 insertions(+), 56 deletions(-) create mode 100644 StrandiOS/Resources/lift-step-silence.caf diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index cefecfefc0..a8a24a3d58 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -36,6 +36,11 @@ final class LiftSessionController: ObservableObject { /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. @Published private(set) var savedSessions = 0 + /// Sends after a strap double-tap has moved the session on, once the new state is in place — unlike + /// `$engine`, which publishes before the change lands. The Lock Screen banner uses it to light the + /// screen for the step the lifter just took. + let strapStepTaken = PassthroughSubject() + var isActive: Bool { engine != nil && engine?.isFinished == false } /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so @@ -223,6 +228,7 @@ final class LiftSessionController: ObservableObject { now = stamp warnedFor = nil persist() + if fromStrap { strapStepTaken.send() } } /// Whether a strap double-tap `secondsSinceLastStep` after the last one the session acted on is a @@ -254,14 +260,14 @@ final class LiftSessionController: ObservableObject { struct Presentation: Equatable { var isResting: Bool - /// The exercise being worked or rested from; the program's name when neither applies. + /// The exercise being worked or rested from; the program's name during the warm-up. var exercise: String - /// "Set 2", "Resting after set 2", "Ready for the next set", "3 of 19 sets done". + /// "Set 2", "Resting after set 2", "Ready for the next set", "Warm-up". var status: String /// "8 x 30 kg", already unit-converted. Nil when neither reps nor weight is known. var detail: String? - var setsDone: Int - var setsPlanned: Int + /// "Next: Set 3 · Bench press" — see `nextLine(_:)`. + var next: String var stageStartedAt: Date /// When the running rest is due to end. Nil while working. var restEndsAt: Date? @@ -269,16 +275,13 @@ final class LiftSessionController: ObservableObject { func presentation(system: UnitSystem) -> Presentation? { guard let engine, !engine.isFinished else { return nil } - let done = engine.completedWorkingSets - let planned = engine.plannedWorkingSets let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt)) - let fallback = String(localized: "\(done) of \(planned) sets done") + let next = Self.nextLine(engine) guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { return Presentation(isResting: false, exercise: programName ?? String(localized: "Session"), - status: fallback, detail: nil, - setsDone: done, setsPlanned: planned, + status: String(localized: "Warm-up"), detail: nil, next: next, stageStartedAt: started, restEndsAt: nil) } @@ -290,23 +293,42 @@ final class LiftSessionController: ObservableObject { isResting: true, exercise: item.exercise, status: ready ? String(localized: "Ready for the next set") : String(localized: "Resting after set \(slot.setIndex)"), - detail: detail, setsDone: done, setsPlanned: planned, + detail: detail, next: next, stageStartedAt: started, restEndsAt: Date(timeIntervalSince1970: TimeInterval(endsAt))) default: return Presentation( isResting: false, exercise: item.exercise, status: String(localized: "Set \(slot.setIndex)"), - detail: detail, setsDone: done, setsPlanned: planned, + detail: detail, next: next, stageStartedAt: started, restEndsAt: nil) } } - /// Reps x weight for a slot, as "8 x 30 kg": what the set counts as — typed numbers, else the grey - /// ones the sheet shows. + /// The set after this one, as the bar and the Lock Screen show it on one line. + /// + /// It replaced "3 of 19 sets done", which answered nothing a lifter acts on mid-session, while the + /// set coming up says where to walk (Utku, 16 Sep 2026). It is always a SET, never the rest before + /// it. The set number comes before the exercise so that a narrow line cuts the name, not the + /// number. "Last set" while the final set is worked; "All sets done" once it is. + static func nextLine(_ engine: LiftSessionEngine) -> String { + if let upcoming = engine.upcomingSlot, let item = engine.planItem(for: upcoming) { + return String(localized: "Next: Set \(upcoming.setIndex) · \(item.exercise)") + } + return engine.allCompleted ? String(localized: "All sets done") : String(localized: "Last set") + } + + /// Reps x weight for a slot, as "8 x 30 kg": what the set's row on the sheet shows — typed numbers, + /// else the grey ones. + /// + /// That includes numbers typed into a set BEFORE it is recorded (`pendingValues`), which the row shows + /// black. Without them the bar and the Lock Screen showed the grey plan for the set being lifted while + /// its row showed what was typed (simulator, 16 Sep 2026: 70 kg × 9 typed, "8 x 60 kg" on the bar). func setNumbers(for slot: LiftSlot, system: UnitSystem) -> String? { guard engine != nil else { return nil } - let shown = values(of: slot) + let grey = values(of: slot) + let typed = pendingValues[slot] + let shown = LiftSetCarry(weightKg: typed?.weightKg ?? grey.weightKg, reps: typed?.reps ?? grey.reps) let weight = shown.weightKg.map { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 8134b7a54f..280b7ca685 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -219,6 +219,23 @@ struct LiftSessionEngine: Equatable { slots(forExercise: slot.exerciseIndex).first { !isCompleted($0) } ?? nextPendingSlot } + /// The set the session is heading to after the current one — what the minimised bar and the Lock + /// Screen name as "next". During the warm-up it is the set `advance` starts; while a set is worked + /// or rested from, it is where `advance` goes once that set is done: `slotAfter(_:)`, with the + /// current set counted as done. Never a rest, which sits between sets rather than being one of + /// them. Nil when nothing is left to do after the current set. + var upcomingSlot: LiftSlot? { + switch stage { + case .warmup: + return nextPendingSlot + case .working(let slot), .resting(let slot, _): + return slots(forExercise: slot.exerciseIndex).first { !isCompleted($0) && $0 != slot } + ?? allSlots.first { !isCompleted($0) && $0 != slot } + case .finished: + return nil + } + } + var allCompleted: Bool { nextPendingSlot == nil } var isFinished: Bool { stage == .finished } var canUndo: Bool { !history.isEmpty } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 0d54d94de0..7ce77b8228 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -2085,6 +2085,12 @@ "Ready for the next set": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Bereit für den nächsten Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Ready for the next set" } }, "es": { "stringUnit": { "state": "translated", "value": "Listo para la siguiente serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Prêt pour la série suivante" } }, "it": { "stringUnit": { "state": "translated", "value": "Pronto per la serie successiva" } }, "pl": { "stringUnit": { "state": "translated", "value": "Gotowe na następną serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Pronto para a próxima série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Готов к следующему подходу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "可以开始下一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "可以開始下一組" } } } }, + "Next: Set %lld · %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Als Nächstes: Satz %1$lld · %2$@" } }, "en": { "stringUnit": { "state": "translated", "value": "Next: Set %lld · %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Siguiente: serie %1$lld · %2$@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ensuite : série %1$lld · %2$@" } }, "it": { "stringUnit": { "state": "translated", "value": "Prossima: serie %1$lld · %2$@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Następna: seria %1$lld · %2$@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A seguir: série %1$lld · %2$@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Далее: подход %1$lld · %2$@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "接下来:第 %1$lld 组 · %2$@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "接下來:第 %1$lld 組 · %2$@" } } + } }, + "Last set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Letzter Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Last set" } }, "es": { "stringUnit": { "state": "translated", "value": "Última serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Dernière série" } }, "it": { "stringUnit": { "state": "translated", "value": "Ultima serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ostatnia seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Última série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Последний подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最后一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最後一組" } } + } }, "No session running": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Keine Sitzung aktiv" } }, "en": { "stringUnit": { "state": "translated", "value": "No session running" } }, "es": { "stringUnit": { "state": "translated", "value": "No hay sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucune séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Nessuna sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak trwającej sesji" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nenhuma sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нет активной сессии" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "没有进行中的训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "沒有進行中的訓練" } } } }, diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 9299882f3c..e5c69dc5fb 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -43,6 +43,15 @@ struct LiftSessionBar: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .lineLimit(1) + // The set coming up, on one line that cuts the exercise name before the + // set number (`LiftSessionController.nextLine`). + if let next = session.presentation(system: unitSystem)?.next { + Text(next) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } } Spacer(minLength: 0) @@ -108,9 +117,7 @@ struct LiftSessionBar: View { } private func subtitle(_ engine: LiftSessionEngine) -> String { - guard let p = session.presentation(system: unitSystem) else { - return String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") - } + guard let p = session.presentation(system: unitSystem) else { return "" } guard let detail = p.detail else { return p.status } return "\(p.status) — \(detail)" } diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index c1edd8837f..0b5aa0d2d8 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -98,6 +98,43 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertEqual(e.slotAfter(slot(2, 2)), slot(0, 1), "exhausted -> first pending in plan order") } + // MARK: - What comes next (the bar and the Lock Screen) + + /// "Next" names the set the taps will actually reach — never the rest in between, and never a + /// guess of its own. Walked through a whole session with an exercise skipped and come back to, + /// the set named during a set and during its rest is the one `advance` then lands on. + func testTheNextSetNamedIsTheOneTheTapsReach() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + XCTAssertEqual(e.upcomingSlot, slot(0, 1), "the warm-up names the set the first tap starts") + + e.start(slot(2, 1), now: t0 + 10) // exercise 0's machine is busy + var now = t0 + 10 + while !e.allCompleted { + guard case .working(let current) = e.stage else { return XCTFail("expected a set, got \(e.stage)") } + let namedWhileWorking = e.upcomingSlot + now += 30; e.advance(now: now) // set done -> rest + XCTAssertEqual(e.upcomingSlot, namedWhileWorking, "the rest names what the set named") + now += 60; e.advance(now: now) // rest done -> next set + if e.allCompleted { + XCTAssertNil(namedWhileWorking, "\(current) was the last set, so nothing was next") + } else { + XCTAssertEqual(e.stage, .working(namedWhileWorking!), "after \(current)") + } + } + XCTAssertNil(e.upcomingSlot, "a complete sheet has nothing next") + } + + /// The last set of an exercise names the next exercise, and the order is the one a gym forces: + /// the rest of the machine you are at first, then the skipped exercise. + func testTheLastSetOfAnExerciseNamesTheNextExercise() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(2, 1), now: t0) + XCTAssertEqual(e.upcomingSlot, slot(2, 2), "the same machine first") + e.advance(now: t0 + 30); e.advance(now: t0 + 90) + XCTAssertEqual(e.stage, .working(slot(2, 2))) + XCTAssertEqual(e.upcomingSlot, slot(0, 1), "exercise 2 is done after this set: the skipped one") + } + // MARK: - Grey numbers // // A finished set records its timing only. Its numbers stay grey until typed, and what a set without diff --git a/StrandTests/LiftSessionPendingInputTests.swift b/StrandTests/LiftSessionPendingInputTests.swift index 6db2ea7e2a..435e57e898 100644 --- a/StrandTests/LiftSessionPendingInputTests.swift +++ b/StrandTests/LiftSessionPendingInputTests.swift @@ -82,6 +82,21 @@ final class LiftSessionPendingInputTests: XCTestCase { "and still count as the carried value, not as nothing") } + /// The bar and the Lock Screen show the set being lifted with the numbers its row shows, typed + /// ones included — not the grey plan behind them (simulator, 16 Sep 2026: 70 kg × 9 typed into the + /// running set, "8 x 60 kg" on the bar). A field left alone still shows its grey value. + func testTheBarShowsNumbersTypedIntoTheSetBeingLifted() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() // set 1 active + XCTAssertEqual(c.setNumbers(for: slot(0, 1), system: .metric), "10 x 50 kg", "the plan, grey") + + c.updateSet(slot(0, 1), weightKg: 70, reps: nil, rpe: nil, isWarmup: false) + XCTAssertEqual(c.setNumbers(for: slot(0, 1), system: .metric), "10 x 70 kg") + c.updateSet(slot(0, 1), weightKg: 70, reps: 9, rpe: nil, isWarmup: false) + XCTAssertEqual(c.presentation(system: .metric)?.detail, "9 x 70 kg") + } + /// Clearing the field puts the row back to showing the plan's grey ghost, rather than pinning an /// empty entry that would record a blank set. func testClearingTheFieldDropsTheHeldEntry() { diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index b8e198c788..99bb6a1dfc 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -242,6 +242,8 @@ struct StrandiOSApp: App { // pushing, since the widget's clocks tick on their own. .onReceive(liftSession.$now) { _ in pushLiftActivity() } .onReceive(liftSession.$engine) { _ in pushLiftActivity() } + // A strap double-tap lights the Lock Screen on the step it took. + .onReceive(liftSession.strapStepTaken) { _ in pushLiftActivity(alert: true) } // #911/#759: republish the Home/Lock-Screen widget whenever the dashboard caches actually // change mid-session. The only other publish site is the scenePhase .active handler, so // during a long foreground session the widget froze at the last-foreground snapshot while @@ -397,9 +399,10 @@ struct StrandiOSApp: App { /// The wording and the numbers come from `LiftSessionController.presentation`, the same /// resolution the in-app minimised bar renders, so the two surfaces cannot disagree. The heart /// rate is the app's smoothed value, and only while the strap is actually connected — a frozen - /// last-known bpm on a Lock Screen reads as live and is not. + /// last-known bpm on a Lock Screen reads as live and is not. `alert` lights the Lock Screen for this + /// push — see `LiftLiveActivityController.update`. @MainActor - private func pushLiftActivity() { + private func pushLiftActivity(alert: Bool = false) { let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric guard let p = liftSession.presentation(system: system) else { liftActivity.update(programName: "", state: nil) @@ -413,9 +416,10 @@ struct StrandiOSApp: App { status: p.status, detail: p.detail, bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, - progress: String(localized: "\(p.setsDone) of \(p.setsPlanned) sets done"), + next: p.next, stageStartedAt: p.stageStartedAt, - restEndsAt: p.restEndsAt)) + restEndsAt: p.restEndsAt), + alert: alert) } } diff --git a/StrandiOS/Resources/lift-step-silence.caf b/StrandiOS/Resources/lift-step-silence.caf new file mode 100644 index 0000000000000000000000000000000000000000..7312e5e43195468aa0091846c9bd146ab159be68 GIT binary patch literal 12916 zcmeI#u?@m75QX6*3&02#10bNFL!xC2V>@7p*jD05DJfZyeXt1&AW{A&ou7--6<>Eo zx2-HhtQ(BXrB1KsqkiwnbS0W^m#I1_D<9fevevP5qf_eRr%eSd3TVuckZ)Bme)CX5kLR|1Q0*~0R#|0009ILK;RDoR%bo0 zSx+VFJx?Kk00IagfB*srAb some View { + private func lockScreen(_ state: LiftActivityAttributes.ContentState) -> some View { HStack(spacing: 12) { Image(systemName: "dumbbell.fill") .font(.system(size: 18, weight: .semibold)) @@ -81,16 +80,14 @@ struct LiftLiveActivity: Widget { .font(.caption) .foregroundStyle(StrandPalette.textSecondary) .lineLimit(1) - // Two variables in an HStack rather than one interpolated string: the extension has - // no catalog, so a literal separator here would be untranslatable copy shipped to - // ten locales. Everything user-facing arrives pre-localized from the app. - HStack(spacing: 6) { - Text(state.progress) - Text(program) - } - .font(.caption2) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(1) + // The set coming up, alone on the line, arriving pre-localized from the app (the + // extension has no catalog). It puts the set number before the exercise, so the tail + // truncation a long name needs cuts the name and keeps the number. + Text(state.next) + .font(.caption2) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .truncationMode(.tail) } Spacer(minLength: 8) @@ -128,19 +125,29 @@ struct LiftLiveActivity: Widget { /// the Lock Screen it rendered "25 minutes" — a rounded, prose duration — where a gym timer has /// to read 25:02. Verified in the simulator, which is the only reason it was caught. /// - /// An overrun rest (`restEndsAt` already past) counts UP from when it was due, which is the - /// honest reading: you are over, and by how much. A zero-length range would render nothing, so - /// the end is pushed a day out — well beyond any session. + /// A rest that is over reads 0:00 and stays there, as the in-app bar does + /// (`LiftSessionEngine.restRemaining` floors at zero). It used to count UP past the end, and a + /// clock climbing from zero on the Lock Screen read as a new timer rather than a finished rest + /// (gym session, 16 Sep 2026). The countdown's range therefore starts at the REST'S start, not at + /// `.now`: a widget re-rendered after the end — for a heart-rate push, say — still gets a range + /// that is entirely past, which `Text(timerInterval:)` shows as its end value instead of switching + /// to a count-up. A rest with no length (the sheet is complete) has no range to count and shows + /// the same 0:00. A working set counts up from its start; a zero-length range would render + /// nothing, so that end is pushed a day out — well beyond any session. private func clock(_ state: LiftActivityAttributes.ContentState, tint: Color) -> some View { - let counter: some View = { - if let ends = state.restEndsAt, ends > .now { - return Text(timerInterval: .now...ends, countsDown: true) + Group { + if let ends = state.restEndsAt { + if ends > state.stageStartedAt { + Text(timerInterval: state.stageStartedAt...ends, countsDown: true) + } else { + Text(verbatim: "0:00") + } + } else { + Text(timerInterval: state.stageStartedAt...state.stageStartedAt.addingTimeInterval(86_400), + countsDown: false) } - let from = state.restEndsAt ?? state.stageStartedAt - return Text(timerInterval: from...from.addingTimeInterval(86_400), countsDown: false) - }() - return counter - .monospacedDigit() - .foregroundStyle(tint) + } + .monospacedDigit() + .foregroundStyle(tint) } } From 98172833fb494f0acdeab4e950a857932cd192b4 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:59:24 +0200 Subject: [PATCH 09/24] lift log: light a locked screen only, in the one update the strap step sends at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 17 Sep 2026: the Lock Screen should just light up from black when a double-tap moves the session, then go dark on the phone's own timer — nothing else, and no delay. - Only a locked phone is lit: the alert is skipped when protected data is available (unlocked), so using another app never pops the Dynamic Island. Skipped in the app as before. - The signal is sent straight after the stage moves, before the pending-input and persistence bookkeeping, so the lit update is the first update the step causes and usually the only one; what follows cannot change what the banner shows. - ActivityKit has no vibration setting for an alert; the sound stays a bundled silence. Test seen to fail without the signal: the strap step lights once with the new stage in place; a held-back knock and the on-screen button light nothing. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 10 +++++++--- StrandTests/LiftSessionStrapTapTests.swift | 17 +++++++++++++++++ .../Widgets/LiftLiveActivityController.swift | 12 ++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index a8a24a3d58..4c4fc65a0f 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -36,9 +36,10 @@ final class LiftSessionController: ObservableObject { /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. @Published private(set) var savedSessions = 0 - /// Sends after a strap double-tap has moved the session on, once the new state is in place — unlike + /// Sends the moment a strap double-tap has moved the session on, with the new stage in place — unlike /// `$engine`, which publishes before the change lands. The Lock Screen banner uses it to light the - /// screen for the step the lifter just took. + /// screen on the step just taken, and because it comes before anything else about the step reaches + /// the banner, that one lit update is usually the only update the step causes. let strapStepTaken = PassthroughSubject() var isActive: Bool { engine != nil && engine?.isFinished == false } @@ -224,11 +225,14 @@ final class LiftSessionController: ObservableObject { } engine?.advance(now: stamp) + // Straight after the stage moves, so the screen lights with no wait behind the bookkeeping below. + // What follows cannot change what the banner shows: numbers typed into a set are already shown + // before they are applied (`setNumbers`). + if fromStrap { strapStepTaken.send() } applyPendingInput() now = stamp warnedFor = nil persist() - if fromStrap { strapStepTaken.send() } } /// Whether a strap double-tap `secondsSinceLastStep` after the last one the session acted on is a diff --git a/StrandTests/LiftSessionStrapTapTests.swift b/StrandTests/LiftSessionStrapTapTests.swift index d2978232e2..9e753e6279 100644 --- a/StrandTests/LiftSessionStrapTapTests.swift +++ b/StrandTests/LiftSessionStrapTapTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine @testable import Strand import WhoopStore @@ -83,6 +84,22 @@ final class LiftSessionStrapTapTests: XCTestCase { XCTAssertTrue(logged.isEmpty) } + /// The Lock Screen lights on a strap step, once, with the new stage already in place, so that one + /// update shows where the lifter is now. A held-back knock and the on-screen button light nothing. + func testAStrapStepSignalsTheLockScreenOnceWithTheNewStage() { + let c = controller() + var seen: [LiftSessionEngine.Stage?] = [] + let watch = c.strapStepTaken.sink { [unowned c] in seen.append(c.engine?.stage) } + defer { watch.cancel() } + c.start(plan: plan(), programId: nil, programName: "Pull") + + strapHandler?() // set 1 starts + XCTAssertEqual(seen, [.working(slot(0, 1))]) + strapHandler?() // a knock: held back + c.advance() // the on-screen button + XCTAssertEqual(seen.count, 1, "neither a knock nor the button lights the screen") + } + func testAKnockIsJudgedByTimeAndByWhetherTheRestIsOver() { let now = 1_800_000_000 let working = LiftSessionEngine.Stage.working(slot(0, 1)) diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index daed50c29b..668c9309d2 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -40,10 +40,13 @@ final class LiftLiveActivityController { /// Drive the activity from the session's current state. `state` nil means no session is running, /// which ends any activity that is showing. /// - /// `alert` is set for the push that follows a strap double-tap. It lights the Lock Screen on the - /// new step, so a lifter who glances at a dark phone sees what they are on (Utku, 16 Sep 2026) — - /// an ActivityKit alert, not a notification: it wakes the screen without unlocking anything. It is - /// skipped while the app is on screen, where there is nothing to light and iOS would only vibrate. + /// `alert` is set for the push a strap double-tap causes. On a LOCKED phone it lights the Lock Screen + /// on the new step, so a lifter sees what they are on, and the screen goes dark again on the phone's + /// own timer (Utku, 16–17 Sep 2026: "just light up", nothing else). It is an ActivityKit alert, the only + /// way iOS lets an app light the screen, carried on the update the step needs anyway. It is skipped + /// whenever the phone is not locked — in the app there is nothing to light, and in another app it + /// would pop the Dynamic Island. Locked is read as protected data being unavailable, which is how a + /// passcode-locked iPhone reports it. ActivityKit offers no setting for vibration; the sound is silence. func update(programName: String, state: LiftActivityAttributes.ContentState?, alert: Bool = false) { guard authInfo.areActivitiesEnabled else { return } @@ -75,6 +78,7 @@ final class LiftLiveActivityController { if let activity { let lightsScreen = alert && UIApplication.shared.applicationState != .active + && !UIApplication.shared.isProtectedDataAvailable guard contentChanged || heartRateDue || lightsScreen else { return } lastSignature = signature lastPush = Date() From d16000a9135b0b87c22f3c53c03dac93f9bd72a8 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:31:56 +0200 Subject: [PATCH 10/24] lift log: resolve the session bar once per render, and drop the program name the banner no longer shows The minimised bar asked the controller for the session's presentation three times on every one-second tick (title, status line, next set); it now reads it once. The Lock Screen banner stopped showing the program name when the next set took its line, but the name was still carried in the activity's attributes and threaded through the controller; both are gone. Simulator: the bar still shows exercise, status and next set; the Lock Screen banner is unchanged. Co-Authored-By: Claude Opus 5 --- Strand/Screens/LiftSessionBar.swift | 34 ++++++------------- StrandiOS/App/StrandiOSApp.swift | 3 +- .../Widgets/LiftLiveActivityController.swift | 4 +-- StrandiOSShared/LiftActivityAttributes.swift | 9 ++--- 4 files changed, 16 insertions(+), 34 deletions(-) diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index e5c69dc5fb..cb34844b0c 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -24,7 +24,9 @@ struct LiftSessionBar: View { private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } var body: some View { - if let engine = session.engine, !engine.isFinished { + // `LiftSessionController.presentation`, the same resolution the Lock Screen renders, so the two + // cannot word the session differently. Resolved once per render; all three lines read it. + if let engine = session.engine, let shown = session.presentation(system: unitSystem) { Button { session.isPresented = true } label: { @@ -35,23 +37,21 @@ struct LiftSessionBar: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { - Text(title(engine)) + Text(shown.exercise) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textPrimary) .lineLimit(1) - Text(subtitle(engine)) + Text(shown.detail.map { "\(shown.status) — \($0)" } ?? shown.status) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .lineLimit(1) // The set coming up, on one line that cuts the exercise name before the // set number (`LiftSessionController.nextLine`). - if let next = session.presentation(system: unitSystem)?.next { - Text(next) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(1) - .truncationMode(.tail) - } + Text(shown.next) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .truncationMode(.tail) } Spacer(minLength: 0) @@ -108,20 +108,6 @@ struct LiftSessionBar: View { } } - - /// Title and subtitle come from `LiftSessionController.presentation` — the SAME resolution the - /// Lock Screen Live Activity renders, so the two surfaces cannot word the session differently. - private func title(_ engine: LiftSessionEngine) -> String { - session.presentation(system: unitSystem)?.exercise - ?? session.programName ?? String(localized: "Session") - } - - private func subtitle(_ engine: LiftSessionEngine) -> String { - guard let p = session.presentation(system: unitSystem) else { return "" } - guard let detail = p.detail else { return p.status } - return "\(p.status) — \(detail)" - } - /// Rest counts DOWN (that is the number you act on); everything else counts up. private func bigClock(_ engine: LiftSessionEngine) -> String { if let remaining = engine.restRemaining(now: session.now) { diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 99bb6a1dfc..fdbebd1358 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -405,11 +405,10 @@ struct StrandiOSApp: App { private func pushLiftActivity(alert: Bool = false) { let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric guard let p = liftSession.presentation(system: system) else { - liftActivity.update(programName: "", state: nil) + liftActivity.update(state: nil) return } liftActivity.update( - programName: liftSession.programName ?? String(localized: "Session"), state: LiftActivityAttributes.ContentState( isResting: p.isResting, exercise: p.exercise, diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index 668c9309d2..f39f0eca64 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -47,7 +47,7 @@ final class LiftLiveActivityController { /// whenever the phone is not locked — in the app there is nothing to light, and in another app it /// would pop the Dynamic Island. Locked is read as protected data being unavailable, which is how a /// passcode-locked iPhone reports it. ActivityKit offers no setting for vibration; the sound is silence. - func update(programName: String, state: LiftActivityAttributes.ContentState?, alert: Bool = false) { + func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false) { guard authInfo.areActivitiesEnabled else { return } // Re-adopt an activity that outlived a previous app session — ActivityKit keeps them alive @@ -99,7 +99,7 @@ final class LiftLiveActivityController { isStarting = true do { activity = try Activity.request( - attributes: LiftActivityAttributes(programName: programName), + attributes: LiftActivityAttributes(), content: content, pushType: nil) lastSignature = signature diff --git a/StrandiOSShared/LiftActivityAttributes.swift b/StrandiOSShared/LiftActivityAttributes.swift index 7fdd0f56b7..8a64a5d8b3 100644 --- a/StrandiOSShared/LiftActivityAttributes.swift +++ b/StrandiOSShared/LiftActivityAttributes.swift @@ -50,11 +50,8 @@ public struct LiftActivityAttributes: ActivityAttributes { } } - /// The program's name, fixed for the life of the session. - public var programName: String - - public init(programName: String) { - self.programName = programName - } + /// Nothing is fixed for the life of a session: everything the banner shows can change mid-session and + /// travels in `ContentState`. + public init() {} } #endif From 22a89427f56ac8359b8f97e82e9f876d6d4526f3 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:56:17 +0200 Subject: [PATCH 11/24] lift log: a strap knock is a second tap under 5 s, not 8 Utku, after the 17 Sep gym session: 8 s was too long to wait for a deliberate second double-tap. In that session's log the window held back two taps at +3.5 s and +6.0 s after an acted-on one; both read to him as "unregistered". 5 s still covers the knocks measured on 16 Sep (+2.9 s and +4.1 s). A test pins that 6 s is a tap. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 6 ++++-- StrandTests/LiftSessionStrapTapTests.swift | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 4c4fc65a0f..c5335f3021 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -107,7 +107,7 @@ final class LiftSessionController: ObservableObject { static let restWarningLeadSec = 5 /// A strap double-tap this soon after the last one the session acted on is taken as a knock. See /// `isKnock(secondsSinceLastStep:stage:now:)`. - static let strapKnockWindowSec = 8 + static let strapKnockWindowSec = 5 init(buzz: @escaping (UInt8) -> Void, setStrapHandler: @escaping ((@MainActor () -> Void)?) -> Void, @@ -245,7 +245,9 @@ final class LiftSessionController: ObservableObject { /// two things when it should have done only one". Only the timing tells such a knock from a tap. /// /// Under `strapKnockWindowSec` counts as a knock, because no set a lifter means to finish, and no - /// rest a lifter means to end, is that short. The exception is a rest that is already over — a + /// rest a lifter means to end, is that short. It was 8 s at first; after the next session Utku found + /// that too long to wait for a deliberate second tap (21 Sep 2026), and 5 s still covers the knocks + /// measured at +2.9 s and +4.1 s. The exception is a rest that is already over — a /// line planned with no rest, or the one left once every set is done — where going straight on is /// the plan. The on-screen button is never held back: a knock does not press it. A tap held back /// gets no buzz, which tells the lifter to tap again. diff --git a/StrandTests/LiftSessionStrapTapTests.swift b/StrandTests/LiftSessionStrapTapTests.swift index 9e753e6279..fdfc8ddfdc 100644 --- a/StrandTests/LiftSessionStrapTapTests.swift +++ b/StrandTests/LiftSessionStrapTapTests.swift @@ -112,6 +112,8 @@ final class LiftSessionStrapTapTests: XCTestCase { XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 4, stage: resting, now: now)) XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: window - 1, stage: working, now: now)) XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: window, stage: working, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 6, stage: working, now: now), + "8 s was too long to wait for a deliberate tap (Utku, 21 Sep 2026)") XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 21, stage: working, now: now), "the shortest real set in that session was 21 s") XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 2, stage: restOver, now: now)) From 3e20db40d64af82326c2b1811bb07c6550970f24 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:56:17 +0200 Subject: [PATCH 12/24] lift log: a set that was done is complete, and the program takes each line's heaviest set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 21 Sep 2026, after the fourth gym session. - A set ticked by Set done or a strap double-tap is complete, with no question at finish: it saves what was typed into it, and a field left blank takes the grey value the sheet showed. Finishing asks "complete or discard" only about sets never started ("Sets not started: N"), and "Discard them" turns only those into 0 x 0. `LiftSessionEngine. unperformedSlots` replaces `unenteredSlots`; a session with no set done and the rest discarded still files nothing (the #2099 guard). - At Save each program line takes the weight and reps of its heaviest set done this session (more weight first, then more reps), without asking — his choice of the heaviest over the last or first set, so a lighter back-off set does not pull the working weight down. Warm-ups, discarded zeros and sets completed at finish without being started move nothing; a bodyweight set keeps the line's weight; a leftover rep-range top below the new count is dropped. Set counts changed with plus/minus are still asked about, and both go to the program in one write, only when a line differs. Tests seen to fail without the change: done sets complete without asking, only never-started sets unfinished or zeroed, a discarded set takes no max RPE, the heaviest-set rule and its exclusions. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 76 ++++++++++---- Strand/Data/LiftSessionEngine.swift | 11 +- Strand/Resources/Localizable.xcstrings | 4 +- Strand/Screens/LiftSessionView.swift | 52 +++++----- StrandTests/LiftSessionEngineTests.swift | 10 +- StrandTests/LiftSessionFinishTests.swift | 127 +++++++++++++++++------ 6 files changed, 186 insertions(+), 94 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index c5335f3021..a71cb08272 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -482,9 +482,9 @@ final class LiftSessionController: ObservableObject { // MARK: - Finishing - /// Slots with no number typed in — never started, or finished without typing. Finishing asks once - /// whether to complete all of them with their grey numbers or discard them. - var unfinishedSlots: [LiftSlot] { engine?.unenteredSlots ?? [] } + /// Sets never started. Finishing asks once whether to complete them with their grey numbers or + /// discard them; a set that WAS done is never in question (`setsToSave`). + var unfinishedSlots: [LiftSlot] { engine?.unperformedSlots ?? [] } /// One set as the finished session saves it. Timing is nil for a set completed at finish without /// ever being started: there is no moment to record, and inventing one would give it a rest and a @@ -502,26 +502,24 @@ final class LiftSessionController: ObservableObject { /// The sets the session saves — every slot on the sheet. /// - /// A set with anything typed saves its numbers, and a number left blank takes its grey value, so a - /// set that was rated but never weighed does not save empty. That includes RPE: a set left unrated - /// saves the program line's max RPE (Utku, 16 Sep 2026), which is the number the session showed grey. - /// A rating typed for the set always wins, and a previous set's rating is never copied onto another — - /// only the plan's own number fills a blank. Unfinished sets save with their grey - /// numbers (and anything typed in advance) when `completingUnfinished`; otherwise they save as - /// 0 kg × 0 reps, which every figure leaves out (`LiftMetrics.isPerformed`) and Edit sets still - /// shows, so a discard made by mistake can be filled back in. Performed sets keep the order they - /// happened in and their timing; sets never started follow in plan order, with no timing. + /// A set that was DONE — ticked by Set done or a strap double-tap — is complete, with no question at + /// finish (Utku, 21 Sep 2026): it saves what was typed into it, and a number left blank takes the grey + /// value the sheet showed. That includes RPE: a set left unrated saves the program line's max RPE + /// (16 Sep 2026). A rating typed for the set always wins, and a previous set's rating is never copied + /// onto another — only the plan's own number fills a blank. Sets never started are the only + /// unfinished ones: they save with their grey numbers (and anything typed in advance) when + /// `completingUnfinished`; otherwise as 0 kg × 0 reps, which every figure leaves out + /// (`LiftMetrics.isPerformed`) and Edit sets still shows, so a discard made by mistake can be filled + /// back in. Done sets keep the order they happened in and their timing; sets never started follow in + /// plan order, with no timing. func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { guard let engine else { return [] } - let unfinished = Set(engine.unenteredSlots) // The plan's max RPE, which the session shows grey in the RPE field. func planned(_ slot: LiftSlot) -> Double? { engine.planItem(for: slot)?.targetRpe } var out = engine.sets.map { set -> FinishedSet in - let discarded = unfinished.contains(set.slot) && !completingUnfinished let shown = values(of: set.slot) - return FinishedSet(slot: set.slot, - weightKg: discarded ? 0 : shown.weightKg, reps: discarded ? 0 : shown.reps, - rpe: discarded ? nil : (set.rpe ?? planned(set.slot)), isWarmup: set.isWarmup, + return FinishedSet(slot: set.slot, weightKg: shown.weightKg, reps: shown.reps, + rpe: set.rpe ?? planned(set.slot), isWarmup: set.isWarmup, startTs: set.startTs, endTs: set.endTs, restSec: set.restSec) } for slot in engine.allSlots where !engine.isCompleted(slot) { @@ -537,9 +535,9 @@ final class LiftSessionController: ObservableObject { return out } - /// Whether any of `sets` was performed. When none was (a session run face-down with nothing typed, - /// then discarded), there is nothing to file: `LiftSessionView.save` writes no session, no sets and - /// no workout, and the finish sheet says so before Save. + /// Whether any of `sets` was performed. When none was (no set done, and the rest discarded), there + /// is nothing to file: `LiftSessionView.save` writes no session, no sets and no workout, and the + /// finish sheet says so before Save. static func anyPerformed(_ sets: [FinishedSet]) -> Bool { sets.contains { LiftMetrics.isPerformed(reps: $0.reps) } } @@ -565,6 +563,44 @@ final class LiftSessionController: ObservableObject { } } + /// The program's lines with each one's HEAVIEST done set this session as its new weight and reps. + /// + /// Utku, 21 Sep 2026: numbers typed during a session update the program, without asking. A line holds + /// one weight and one rep count for all its sets, so it takes the heaviest set — the working weight, + /// which a lighter back-off set must not pull down; between sets of equal weight, the one with more + /// reps. Only sets actually done count: a warm-up, a set discarded to zeros and a set completed at + /// finish without being started (it carries grey numbers, not new ones) move nothing. A number the + /// heaviest set does not have (a bodyweight line's weight) is left as it was; a leftover rep range + /// top below the new count is dropped, since the editor keeps one rep count. Lines without a program + /// line behind them, or deleted since, are skipped. + static func applyingHeaviestSets(_ sets: [FinishedSet], plan: [LiftPlanItem], + to items: [LiftProgramItemRow]) -> [LiftProgramItemRow] { + var heaviest: [String: FinishedSet] = [:] + for set in sets where set.startTs != nil && !set.isWarmup && LiftMetrics.isPerformed(reps: set.reps) { + guard plan.indices.contains(set.slot.exerciseIndex), + let id = plan[set.slot.exerciseIndex].programItemId else { continue } + if let best = heaviest[id], !isHeavier(set, than: best) { continue } + heaviest[id] = set + } + return items.map { row in + guard let top = heaviest[row.id] else { return row } + var edited = row + if let weight = top.weightKg { edited.targetWeightKg = weight } + if let reps = top.reps { + edited.targetRepsLow = reps + if let high = edited.targetRepsHigh, high < reps { edited.targetRepsHigh = nil } + } + return edited + } + } + + /// More weight wins; at equal weight, more reps. A missing number ranks below any number. + private static func isHeavier(_ a: FinishedSet, than b: FinishedSet) -> Bool { + let (weightA, weightB) = (a.weightKg ?? -1, b.weightKg ?? -1) + if weightA != weightB { return weightA > weightB } + return (a.reps ?? -1) > (b.reps ?? -1) + } + /// The program's lines with `changes` applied. Only `targetSets` moves. static func applying(_ changes: [SetCountChange], to items: [LiftProgramItemRow]) -> [LiftProgramItemRow] { let counts = Dictionary(changes.map { ($0.itemId, $0.to) }, uniquingKeysWith: { first, _ in first }) diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 280b7ca685..c3c5df5330 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -300,13 +300,10 @@ struct LiftSessionEngine: Equatable { return LiftSetCarry(weightKg: set.weightKg ?? grey.weightKg, reps: set.reps ?? grey.reps) } - /// Slots with no number typed in: never performed, or performed without typing. Finishing the - /// session asks once what happens to all of them. - var unenteredSlots: [LiftSlot] { - allSlots.filter { slot in - guard let set = recordedSet(for: slot) else { return true } - return set.weightKg == nil && set.reps == nil && set.rpe == nil - } + /// Slots never performed. Finishing the session asks once what happens to all of them; a set that + /// was performed is complete whether or not anything was typed into it (Utku, 21 Sep 2026). + var unperformedSlots: [LiftSlot] { + allSlots.filter { !isCompleted($0) } } // MARK: - Actions diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7ce77b8228..50c891311a 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1884,8 +1884,8 @@ "Unfinished sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Unvollständige Sätze"}}, "en": {"stringUnit": {"state": "translated", "value": "Unfinished sets"}}, "es": {"stringUnit": {"state": "translated", "value": "Series sin terminar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries non terminées"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie incomplete"}}, "pl": {"stringUnit": {"state": "translated", "value": "Niedokończone serie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries por terminar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Незавершённые подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未完成的组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未完成的組"}} } }, - "%lld sets have no numbers typed in — sets you did not start, or finished without typing.": { "localizations": { - "de": {"stringUnit": {"state": "translated", "value": "%lld Sätze haben keine eingetragenen Werte – nicht begonnene oder ohne Eingabe beendete Sätze."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld sets have no numbers typed in — sets you did not start, or finished without typing."}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series no tienen números: series que no empezaste o que terminaste sin escribir nada."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries n'ont aucun chiffre saisi — des séries non commencées ou terminées sans saisie."}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie non hanno numeri inseriti: serie non iniziate o finite senza scrivere nulla."}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie bez wpisanych liczb: %lld – nierozpoczęte lub zakończone bez wpisywania."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries não têm números: séries que não começaste ou que terminaste sem escrever nada."}}, "ru": {"stringUnit": {"state": "translated", "value": "Подходов без введённых чисел: %lld — не начатые или завершённые без ввода."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "有 %lld 组没有输入数字:未开始的组,或完成时没有输入的组。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "有 %lld 組沒有輸入數字:未開始的組,或完成時沒有輸入的組。"}} + "Sets not started: %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nicht begonnene Sätze: %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Sets not started: %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Series sin empezar: %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries non commencées : %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie non iniziate: %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nierozpoczęte serie: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries não começadas: %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не начатых подходов: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未开始的组:%lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未開始的組:%lld"}} } }, "Complete them": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Abschließen"}}, "en": {"stringUnit": {"state": "translated", "value": "Complete them"}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter"}}, "it": {"stringUnit": {"state": "translated", "value": "Completale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnij"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全"}} diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 3a08f04cf3..f0ff64ccbd 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -671,14 +671,15 @@ struct LiftSessionView: View { .task { await loadSetCountChanges() } } - /// Sets nobody typed a number into. One choice covers all of them, because what matters at the end - /// of a session is simply whether they happened: complete them with the numbers the sheet showed, - /// or discard them to zeros that every figure leaves out and Edit sets can still fill in. + /// Sets never started. One choice covers all of them, because what matters at the end of a session + /// is simply whether they happened: complete them with the numbers the sheet showed, or discard them + /// to zeros that every figure leaves out and Edit sets can still fill in. A set that was done is never + /// asked about — it is complete (`LiftSessionController.setsToSave`). private func unfinishedCard(count: Int) -> some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.gap) { Text("Unfinished sets").strandOverline() - Text("\(count) sets have no numbers typed in — sets you did not start, or finished without typing.") + Text("Sets not started: \(count)") .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) .fixedSize(horizontal: false, vertical: true) @@ -763,17 +764,13 @@ struct LiftSessionView: View { // After `finish`, which closes out the running rest: that set's measured rest belongs to it. let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) - // Nothing to file, so file nothing. Discarding can leave no set that counts: a session run - // face-down and advanced entirely on the strap has nothing typed, so every slot is unentered - // and "Discard them" turns every set into a zero. Filing it anyway wrote a session with nothing - // in it AND a manual workout, and the engine fills that workout's strain from the heart rate the - // strap measured — so an hour that recorded nothing still read back as a workout. The finish - // sheet says so before Save. The program's set counts are a separate thing the user chose - // explicitly, so those still apply. + // Nothing to file, so file nothing. With no set done, "Discard them" turns every set into a zero. + // Filing that anyway wrote a session with nothing in it AND a manual workout, and the engine fills + // that workout's strain from the heart rate the strap measured — so an hour that recorded nothing + // still read back as a workout. The finish sheet says so before Save. The program's set counts + // are a separate thing the user chose explicitly, so those still apply. guard LiftSessionController.anyPerformed(finished) else { - if programChoice == .update { - await writeSetCountsToProgram(store: store, plan: engine.plan) - } + await writeProgram(store: store, plan: engine.plan, sets: finished) await finishAndDismiss() return } @@ -805,9 +802,7 @@ struct LiftSessionView: View { restSec: s.restSec, note: nil) } _ = try? await store.upsertLiftSets(rows) - if programChoice == .update { - await writeSetCountsToProgram(store: store, plan: engine.plan) - } + await writeProgram(store: store, plan: engine.plan, sets: finished) // Through the SAME path a manual workout takes, so it inherits overlap dedup, the engine's // HR-derived strain fill and delete/merge. `strain` stays nil deliberately: the engine fills @@ -840,18 +835,23 @@ struct LiftSessionView: View { setCountChanges = LiftSessionController.setCountChanges(plan: plan, program: rows) } - /// Save this session's set counts onto its program — only when the user chose to. + /// Carry this session onto its program: each line's heaviest done set becomes its weight and reps + /// (always, Utku 21 Sep 2026), and its set count changes only when the user chose to keep them. /// - /// Re-reads the lines and moves only `targetSets`, so a program edited elsewhere while the session - /// ran keeps every other change and a line deleted since is not resurrected. The store call - /// replaces the lines wholesale, so nothing is written when no count differs. - private func writeSetCountsToProgram(store: WhoopStore, plan: [LiftPlanItem]) async { + /// Re-reads the lines, so a program edited elsewhere while the session ran keeps every other change + /// and a line deleted since is not resurrected. The store call replaces the lines wholesale, so + /// nothing is written when no line differs. + private func writeProgram(store: WhoopStore, plan: [LiftPlanItem], + sets: [LiftSessionController.FinishedSet]) async { guard let programId = session.programId, let rows = try? await store.liftProgramItems(programId: programId) else { return } - let changes = LiftSessionController.setCountChanges(plan: plan, program: rows) - guard !changes.isEmpty else { return } - _ = try? await store.replaceLiftProgramItems( - programId: programId, items: LiftSessionController.applying(changes, to: rows)) + var edited = LiftSessionController.applyingHeaviestSets(sets, plan: plan, to: rows) + if programChoice == .update { + edited = LiftSessionController.applying( + LiftSessionController.setCountChanges(plan: plan, program: rows), to: edited) + } + guard edited != rows else { return } + _ = try? await store.replaceLiftProgramItems(programId: programId, items: edited) } /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index 0b5aa0d2d8..6776305b2c 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -151,7 +151,7 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertNil(row?.weightKg, "a grey number is not an entry") XCTAssertNil(row?.reps) XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry(weightKg: 50, reps: 10)) - XCTAssertEqual(e.unenteredSlots.first, slot(0, 1)) + XCTAssertFalse(e.unperformedSlots.contains(slot(0, 1)), "done, so complete even with nothing typed") } /// The second set follows what the FIRST set counts as — if you dropped to 45 kg, set 2 follows @@ -225,14 +225,14 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry(weightKg: 0, reps: 0)) } - /// Only a set with nothing typed at all is unentered; a rating alone counts as an entry. - func testUnenteredSlotsAreTheOnesNobodyTypedInto() { + /// Only a set never started is unperformed. A set that was done counts as done whether or not + /// anything was typed into it (Utku, 21 Sep 2026). + func testUnperformedSlotsAreTheOnesNeverStarted() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.start(slot(0, 1), now: t0); e.advance(now: t0 + 40) // done, untyped e.start(slot(0, 2), now: t0 + 100); e.advance(now: t0 + 140) e.updateSet(slot(0, 2), weightKg: nil, reps: nil, rpe: 8, isWarmup: false) - XCTAssertEqual(e.unenteredSlots, [slot(0, 1), slot(1, 1)], - "an untyped finished set and a never-started one; the rated set is entered") + XCTAssertEqual(e.unperformedSlots, [slot(1, 1)], "only the set nobody started") } // MARK: - The default in-order path diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift index 863d6fe169..d0ddb500bf 100644 --- a/StrandTests/LiftSessionFinishTests.swift +++ b/StrandTests/LiftSessionFinishTests.swift @@ -2,12 +2,13 @@ import XCTest @testable import Strand import WhoopStore -/// Finishing a session: what saves, and whether the program keeps a changed set count. +/// Finishing a session: what saves, and what reaches the program. /// -/// Asked for after a real session, 15 Sep 2026: grey numbers stay grey during the session, finishing -/// asks once whether the sets without typed numbers are completed with them or discarded ("the user -/// might not complete all the workout, just a couple of exercises"), and a set count changed with ⊕/⊖ -/// reaches the program only if the user says so. +/// From real sessions. 15 Sep 2026: grey numbers stay grey during the session, and a set count changed +/// with ⊕/⊖ reaches the program only if the user says so. 21 Sep 2026: a set that was done is complete — +/// typed numbers, else its grey ones — with no question at finish; only sets never started are asked +/// about ("the user might not complete all the workout"); and each program line takes its heaviest done +/// set's numbers, without asking. @MainActor final class LiftSessionFinishTests: XCTestCase { @@ -42,26 +43,33 @@ final class LiftSessionFinishTests: XCTestCase { return c } - /// A session run face-down, every set advanced on the strap and nothing typed. Discarding then - /// leaves no set that counts, only zeros: this is the precondition `LiftSessionView.save` guards - /// on, because filing it wrote a session with nothing in it and a manual workout the engine would - /// fill strain into, so an hour that recorded nothing read back as a workout. Completing still - /// files all five. - func testAFaceDownSessionDiscardingSavesNothingAtAll() { + /// A session run face-down, every set done on the strap and nothing typed: every set is complete + /// with the grey numbers the sheet showed, and there is nothing to ask. + func testSetsDoneOnTheStrapAreCompleteWithoutAsking() { let c = controller() c.start(plan: plan(), programId: "p", programName: "Upper A") for _ in 0..<10 { c.advance() } // every set worked, none typed - XCTAssertEqual(c.unfinishedSlots.count, 5, "nothing typed, so every slot is unentered") - XCTAssertFalse(LiftSessionController.anyPerformed(c.setsToSave(completingUnfinished: false)), - "discarding an all-untyped session must leave no set to file") - let completed = c.setsToSave(completingUnfinished: true) - XCTAssertEqual(completed.count, 5, "completing still files every set with its grey numbers") - XCTAssertTrue(LiftSessionController.anyPerformed(completed)) + XCTAssertTrue(c.unfinishedSlots.isEmpty, "every set was done, so nothing is unfinished") + let saved = c.setsToSave(completingUnfinished: false) + XCTAssertEqual(saved.map(\.weightKg), [50, 50, 50, 40, 40]) + XCTAssertEqual(saved.map(\.reps), [10, 10, 10, 12, 12]) + XCTAssertTrue(saved.allSatisfy { $0.endTs != nil }) + } + + /// With no set done and the rest discarded, no set counts: the precondition `LiftSessionView.save` + /// guards on, so an hour that recorded nothing never reads back as a workout with strain. + func testNothingDoneAndTheRestDiscardedLeavesNothingToFile() { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + XCTAssertEqual(c.unfinishedSlots.count, 5) + XCTAssertFalse(LiftSessionController.anyPerformed(c.setsToSave(completingUnfinished: false))) + XCTAssertTrue(LiftSessionController.anyPerformed(c.setsToSave(completingUnfinished: true)), + "completing them still files every set with its grey numbers") } - func testUnfinishedSetsAreTheUntypedAndTheNeverStarted() { - XCTAssertEqual(halfDoneSession().unfinishedSlots, - [slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + func testUnfinishedSetsAreOnlyTheOnesNeverStarted() { + XCTAssertEqual(halfDoneSession().unfinishedSlots, [slot(0, 3), slot(1, 1), slot(1, 2)], + "bench 2 was done untyped, so it is complete, not unfinished") } /// A set with anything typed always saves, and a blank field takes its grey number rather than @@ -76,20 +84,21 @@ final class LiftSessionFinishTests: XCTestCase { } } - /// Discarding keeps every unfinished set as 0 kg × 0 reps: out of every figure, but still there to fill - /// in under Edit sets if the discard was a mistake. A performed one keeps its timing. - func testDiscardingSavesUnfinishedSetsAsZeros() { + /// Discarding keeps every set never started as 0 kg × 0 reps: out of every figure, but still there to + /// fill in under Edit sets if the discard was a mistake. The sets that were done are not touched. + func testDiscardingSavesOnlyTheNeverStartedSetsAsZeros() { let saved = halfDoneSession().setsToSave(completingUnfinished: false) XCTAssertEqual(saved.map(\.slot), [slot(0, 1), slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) XCTAssertEqual(saved[0].weightKg, 55, "the typed set is untouched") - for set in saved.dropFirst() { + XCTAssertEqual(saved[1].weightKg, 55, "bench 2 was done untyped: it keeps its grey numbers") + XCTAssertEqual(saved[1].reps, 10) + XCTAssertNotNil(saved[1].endTs) + for set in saved.dropFirst(2) { XCTAssertEqual(set.weightKg, 0) XCTAssertEqual(set.reps, 0) XCTAssertNil(set.rpe) + XCTAssertNil(set.startTs, "never started") } - XCTAssertNotNil(saved[1].endTs, "bench 2 was performed, so its timing is kept") - XCTAssertNil(saved[2].startTs, "bench 3 was never started") - XCTAssertTrue(LiftSessionController.anyPerformed(saved), "one typed set is enough to file the session") } /// Completing saves the untyped and the never-started sets with the grey numbers the sheet showed: @@ -148,17 +157,19 @@ final class LiftSessionFinishTests: XCTestCase { XCTAssertEqual(c.setsToSave(completingUnfinished: true)[0].rpe, 6, "a typed rating wins") } - /// Discarding still saves zeros and no rating: the plan's number fills a blank only on a set the - /// session keeps, so a discarded set cannot arrive carrying an effort nobody made. + /// Discarding saves zeros and no rating: the plan's number fills a blank only on a set the session + /// keeps, so a discarded set cannot arrive carrying an effort nobody made. A set that was done keeps + /// the plan's rating either way. func testADiscardedSetTakesNoMaxRpe() { let c = controller() - c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 1, targetRepsLow: 5, targetRpe: 8)], + c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 2, targetRepsLow: 5, targetRpe: 8)], programId: nil, programName: nil) c.advance() - c.advance() // performed, nothing typed at all - let discarded = c.setsToSave(completingUnfinished: false)[0] - XCTAssertNil(discarded.rpe) - XCTAssertEqual(discarded.reps, 0) + c.advance() // set 1 done, nothing typed at all + let saved = c.setsToSave(completingUnfinished: false) + XCTAssertEqual(saved[0].rpe, 8, "done: complete, with the plan's max RPE") + XCTAssertNil(saved[1].rpe, "never started and discarded") + XCTAssertEqual(saved[1].reps, 0) } /// Nothing unfinished means nothing to ask, and every set saves. @@ -213,6 +224,54 @@ final class LiftSessionFinishTests: XCTestCase { XCTAssertEqual(changed[1].targetSets, 2, "an unchanged line stays as it was") } + // MARK: - The program's numbers + + private func done(_ e: Int, _ s: Int, _ kg: Double?, _ reps: Int?, warmup: Bool = false, + started: Bool = true) -> LiftSessionController.FinishedSet { + .init(slot: slot(e, s), weightKg: kg, reps: reps, rpe: nil, isWarmup: warmup, + startTs: started ? 100 : nil, endTs: started ? 160 : nil, restSec: nil) + } + + /// Each line takes its heaviest done set — more weight first, then more reps — and nothing else moves. + func testEachProgramLineTakesItsHeaviestDoneSet() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let sets = [done(0, 1, 60, 8), done(0, 2, 65, 6), done(0, 3, 65, 7), + done(1, 1, 45, 10), done(1, 2, 42.5, 12)] + let edited = LiftSessionController.applyingHeaviestSets(sets, plan: plan(), to: rows) + XCTAssertEqual(edited[0].targetWeightKg, 65) + XCTAssertEqual(edited[0].targetRepsLow, 7, "equal weight: the set with more reps") + XCTAssertEqual(edited[1].targetWeightKg, 45, "a lighter back-off set does not pull the program down") + XCTAssertEqual(edited[1].targetRepsLow, 10) + XCTAssertEqual(edited[0].targetSets, 3) + XCTAssertEqual(edited[0].note, "keep me") + } + + /// Only sets actually done carry new numbers: a warm-up, a discarded zero and a set completed at + /// finish without being started (grey numbers, not new ones) leave the program as it was. + func testSetsThatWereNotDoneLeaveTheProgramAlone() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let sets = [done(0, 1, 80, 5, warmup: true), done(0, 2, 0, 0), + done(1, 1, 90, 3, started: false)] + XCTAssertEqual(LiftSessionController.applyingHeaviestSets(sets, plan: plan(), to: rows), rows) + } + + /// A bodyweight set keeps the line's weight; a leftover rep-range top below the new count is dropped; + /// a line with no program behind it, or deleted since, is skipped. + func testMissingNumbersAndMissingLinesAreLeftAlone() { + var rows = [item("bench", "Bench press", sets: 3)] + rows[0].targetRepsHigh = 12 + let edited = LiftSessionController.applyingHeaviestSets([done(0, 1, nil, 15)], plan: plan(), to: rows) + XCTAssertEqual(edited[0].targetWeightKg, 50, "no weight on the set, so the line's weight stays") + XCTAssertEqual(edited[0].targetRepsLow, 15) + XCTAssertNil(edited[0].targetRepsHigh, "12 would sit below the new 15") + + let unowned = [LiftPlanItem(exercise: "Curl", targetSets: 1)] + XCTAssertEqual(LiftSessionController.applyingHeaviestSets([done(0, 1, 20, 10)], plan: unowned, to: rows), + rows) + XCTAssertEqual(LiftSessionController.applyingHeaviestSets([done(1, 1, 20, 10)], plan: plan(), to: rows), + rows, "the row line was deleted from the program: nothing to write") + } + /// The hub reloads on this: a saved session must announce itself, and a discarded one must not. func testSavingASessionIsAnnouncedButDiscardingIsNot() { let c = controller() From b00e4b56f2a58cae5121117d369f36f3a10ac0ba Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:57:16 +0200 Subject: [PATCH 13/24] lift log: light the Lock Screen whenever NOOP is not on screen, and log each strap step's light-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 17 Sep gym session: four strap steps buzzed and moved the session but did not light the Lock Screen — "sometimes it lights up and sometimes not". The alert waited for the phone to report itself locked (protected data unavailable), and iOS reports that only about 10 s after the screen goes dark, so a tap soon after it dimmed — right after checking the rest timer — lit nothing. The alert now goes whenever NOOP is not the app on screen; with another app open iOS shows the step in the Dynamic Island instead. The log could not say which steps were asked to light, so each strap step now leaves one line in NOOP's strap log: "sent to the Lock Screen with a light-up alert", or not lit because NOOP was on screen or no Lift Log banner was running. A step whose alert was sent but did not light was iOS's own choice (a face-down phone, a Focus, its limits) — which the next log can now tell apart. Co-Authored-By: Claude Opus 5 --- StrandiOS/App/StrandiOSApp.swift | 4 +- .../Widgets/LiftLiveActivityController.swift | 50 +++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index fdbebd1358..bb5f080a4f 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -408,7 +408,7 @@ struct StrandiOSApp: App { liftActivity.update(state: nil) return } - liftActivity.update( + let lightUp = liftActivity.update( state: LiftActivityAttributes.ContentState( isResting: p.isResting, exercise: p.exercise, @@ -419,6 +419,8 @@ struct StrandiOSApp: App { stageStartedAt: p.stageStartedAt, restEndsAt: p.restEndsAt), alert: alert) + // One line per strap step into NOOP's strap log: whether the Lock Screen was asked to light. + if let lightUp { model.live.append(log: lightUp.logLine) } } } diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index f39f0eca64..787620724a 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -37,18 +37,37 @@ final class LiftLiveActivityController { /// has already buzzed. static let silentAlertSound = "lift-step-silence.caf" + /// What the push for a strap step did about lighting the screen — one strap-log line per step, so a + /// step that did not light can be told apart: NOOP was on screen, no banner was running, or the alert + /// went to iOS and iOS chose (a face-down phone, a Focus, its own limits). + enum LightUp { + case askedIOS, appOnScreen, noBanner + + var logLine: String { + switch self { + case .askedIOS: return "Lift Log: strap step sent to the Lock Screen with a light-up alert" + case .appOnScreen: return "Lift Log: strap step not lighting the Lock Screen — NOOP is open on screen" + case .noBanner: return "Lift Log: strap step not lighting the Lock Screen — no Lift Log banner is running" + } + } + } + /// Drive the activity from the session's current state. `state` nil means no session is running, /// which ends any activity that is showing. /// - /// `alert` is set for the push a strap double-tap causes. On a LOCKED phone it lights the Lock Screen - /// on the new step, so a lifter sees what they are on, and the screen goes dark again on the phone's - /// own timer (Utku, 16–17 Sep 2026: "just light up", nothing else). It is an ActivityKit alert, the only - /// way iOS lets an app light the screen, carried on the update the step needs anyway. It is skipped - /// whenever the phone is not locked — in the app there is nothing to light, and in another app it - /// would pop the Dynamic Island. Locked is read as protected data being unavailable, which is how a - /// passcode-locked iPhone reports it. ActivityKit offers no setting for vibration; the sound is silence. - func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false) { - guard authInfo.areActivitiesEnabled else { return } + /// `alert` is set for the push a strap double-tap causes. It lights a dark Lock Screen on the new + /// step, so a lifter sees what they are on, and the screen goes dark again on the phone's own timer + /// (Utku, 16–17 Sep 2026: "just light up", nothing else). It is an ActivityKit alert, the only way iOS + /// lets an app light the screen, carried on the update the step needs anyway, and it is sent whenever + /// NOOP is not the app on screen. It used to wait for the phone to report itself LOCKED (protected + /// data unavailable), but iOS reports that only about 10 s after the screen goes dark, so a tap soon + /// after it dimmed — right after checking the rest timer, say — lit nothing (gym session, 17 Sep + /// 2026: "sometimes it lights up and sometimes not"). With another app open, iOS shows the step in the + /// Dynamic Island instead. ActivityKit offers no setting for vibration; the sound is silence. + /// Returns what happened about lighting when `alert` was asked for; nil otherwise. + @discardableResult + func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false) -> LightUp? { + guard authInfo.areActivitiesEnabled else { return alert ? .noBanner : nil } // Re-adopt an activity that outlived a previous app session — ActivityKit keeps them alive // across relaunches, and a fresh controller starts with `activity == nil`. Without this we @@ -59,7 +78,7 @@ final class LiftLiveActivityController { // turned Live Activities off meant all of them. guard UnitPrefs.liveActivityEnabled(), let state else { if activity != nil { Task { await end() } } - return + return alert ? .noBanner : nil } // Everything a person would notice, EXCLUDING the clocks (which tick client-side) and the @@ -77,9 +96,9 @@ final class LiftLiveActivityController { staleDate: Date().addingTimeInterval(Self.staleAfter)) if let activity { - let lightsScreen = alert && UIApplication.shared.applicationState != .active - && !UIApplication.shared.isProtectedDataAvailable - guard contentChanged || heartRateDue || lightsScreen else { return } + let appOnScreen = UIApplication.shared.applicationState == .active + let lightsScreen = alert && !appOnScreen + guard contentChanged || heartRateDue || lightsScreen else { return alert ? .appOnScreen : nil } lastSignature = signature lastPush = Date() if lightsScreen { @@ -92,10 +111,11 @@ final class LiftLiveActivityController { } else { Task { await activity.update(content) } } + return alert ? (lightsScreen ? .askedIOS : .appOnScreen) : nil } else { // Set synchronously before any await, so a second tick arriving while `Activity.request` // is still in flight bails here instead of creating a duplicate activity. - guard !isStarting else { return } + guard !isStarting else { return alert ? .noBanner : nil } isStarting = true do { activity = try Activity.request( @@ -108,6 +128,8 @@ final class LiftLiveActivityController { activity = nil } isStarting = false + // A banner requested just now carries no alert: there was nothing on the Lock Screen to light. + return alert ? .noBanner : nil } } From 2d5f542a260c326a42bfe462526e12336525597d Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:57:16 +0200 Subject: [PATCH 14/24] =?UTF-8?q?lift=20log:=20one=20banner=20during=20a?= =?UTF-8?q?=20gym=20session=20=E2=80=94=20a=20foreground=20sync=20starts?= =?UTF-8?q?=20no=20sync=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's strap-sync Live Activity (#2272) starts whenever a sync begins with NOOP on screen. During a Lift Log session that is often: opening NOOP mid-session, or a strap double-tap while it is open, kicks a sync, and a second banner would sit beside the session's own. NOOP already stands its live-HR banner aside for the session; the sync banner now does the same for starting (`SyncLiveActivityController.holdsBackNewBanner`, set by the app for the length of a session). A banner the Sync Strap shortcut started still updates and ends as before. Co-Authored-By: Claude Opus 5 --- StrandiOS/App/StrandiOSApp.swift | 10 ++++++++-- StrandiOS/Widgets/SyncLiveActivityController.swift | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index bb5f080a4f..50a6375094 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -90,7 +90,7 @@ struct StrandiOSApp: App { SyncLiveActivityController.shared.attach(to: model.live) // The buzz and the strap-gesture claim are injected, so the controller itself knows nothing // about BLE and stays testable. - _liftSession = StateObject(wrappedValue: LiftSessionController( + let liftSession = LiftSessionController( buzz: { [weak model] loops in model?.buzz(loops: loops, gate: HapticPrefs.liftRest) }, @@ -99,7 +99,13 @@ struct StrandiOSApp: App { }, log: { [weak model] line in model?.live.append(log: line) - })) + }) + _liftSession = StateObject(wrappedValue: liftSession) + // A gym session keeps ONE banner on the Lock Screen, its own — as the live-HR banner already + // stands aside for it. A sync started in the foreground mid-session starts no sync banner. + SyncLiveActivityController.shared.holdsBackNewBanner = { [weak liftSession] in + liftSession?.isActive == true + } // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a // bluetooth-central to receive it — and the re-score it triggers took nearly eight minutes on the // reporter's install, far longer than that wake survives. The pass is all-or-nothing, so being diff --git a/StrandiOS/Widgets/SyncLiveActivityController.swift b/StrandiOS/Widgets/SyncLiveActivityController.swift index c8e0e024d7..195dcb9b99 100644 --- a/StrandiOS/Widgets/SyncLiveActivityController.swift +++ b/StrandiOS/Widgets/SyncLiveActivityController.swift @@ -42,6 +42,11 @@ final class SyncLiveActivityController { private static let finalShownFor: TimeInterval = 8 private static let chunkMinInterval: TimeInterval = 2 + /// Asked before a foreground sync STARTS a banner; true holds it back. The Lift Log sets it for the + /// length of a gym session, whose own banner is the one on the Lock Screen. A banner the Sync Strap + /// shortcut started still updates and ends as before. + var holdsBackNewBanner: () -> Bool = { false } + private init() {} func attach(to live: LiveState) { @@ -107,7 +112,7 @@ final class SyncLiveActivityController { return } // Only the foreground may start one. A background automatic sync stays silent, honestly. - guard UIApplication.shared.applicationState == .active else { return } + guard UIApplication.shared.applicationState == .active, !holdsBackNewBanner() else { return } startedAt = Date() request(state: state(syncing(live))) } From d90e68df8c2c95d4a8c24a0886ee4ab0b7b49bf5 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:57:16 +0200 Subject: [PATCH 15/24] =?UTF-8?q?lift=20log:=20Lock=20Screen=20banner=20?= =?UTF-8?q?=E2=80=94=20icon=20and=20numbers=20nearer=20the=20edges,=20hear?= =?UTF-8?q?t=20rate=20over=20the=20clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 21 Sep 2026, with a screenshot: the exercise name, status and next set were cut too soon while the icon, heart rate and clock took a third of the banner. At the same sizes, the banner now uses less padding at its edges and stacks the heart rate over the clock, flush right, which gives the words back roughly half that column. A running `Text(timerInterval:)` takes all the width it is offered — a first try spread a working set's count-up across the banner — so the clock's width comes from a hidden "00:00" in the same font and the live clock is right-aligned over it. Simulator: a working set's count-up, a rest's countdown and the finished rest's 0:00 all sit under the heart rate; "Resting after set 4 — 9 x 60 kg" now shows whole where the screenshot cut it at "Resting after set 1...". Co-Authored-By: Claude Opus 5 --- StrandiOSWidgets/LiftLiveActivity.swift | 35 ++++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift index c807411a5a..4a3d7f342c 100644 --- a/StrandiOSWidgets/LiftLiveActivity.swift +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -60,13 +60,19 @@ struct LiftLiveActivity: Widget { } } + /// The Lock Screen clock's face, shared by the clock and the hidden template that sizes it. + private static let clockFont = Font.system(size: 22, weight: .bold, design: .rounded) + /// Green while working, amber through the rest — the sheet's and the bar's colour language. private func tint(_ state: LiftActivityAttributes.ContentState) -> Color { state.isResting ? StrandPalette.metricAmber : StrandPalette.statusPositive } private func lockScreen(_ state: LiftActivityAttributes.ContentState) -> some View { - HStack(spacing: 12) { + // Width goes to the words. The icon and the numbers sit nearer the banner's edges, and the heart + // rate stacks over the clock instead of beside it, so the exercise and the next set lose less to + // truncation — at the same sizes (Utku, 21 Sep 2026: "the writings are usually cut too quick"). + HStack(spacing: 10) { Image(systemName: "dumbbell.fill") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(tint(state)) @@ -90,17 +96,17 @@ struct LiftLiveActivity: Widget { .truncationMode(.tail) } - Spacer(minLength: 8) + Spacer(minLength: 6) - // Heart rate then clock, side by side — the minimised bar's layout, because this is the - // same bar seen from the Lock Screen. Stacking them looked misaligned: - // `Text(timerInterval:)` reserves width for the widest value it could show, so a - // trailing-aligned timer does not visually line up with the text under it. + // Heart rate over the clock, both flush right. A running `Text(timerInterval:)` takes all the + // width it is offered, so the clock's width comes from a hidden "00:00" in the same font — + // the widest a set or a rest shows under an hour — and the live clock is right-aligned over + // it. Sized from the timer itself, a working set's count-up spread across the whole banner. // // The heart rate is ALWAYS present, dash and all. A readout that vanishes when the strap // stops reading is indistinguishable from a missing feature — which is exactly how it // was first reported. - HStack(spacing: 10) { + VStack(alignment: .trailing, spacing: 2) { Label { Text(state.bpm.map(String.init) ?? "—").monospacedDigit() } icon: { @@ -111,11 +117,20 @@ struct LiftLiveActivity: Widget { ? StrandPalette.textTertiary : StrandPalette.metricRose) - clock(state, tint: tint(state)) - .font(.system(size: 22, weight: .bold, design: .rounded)) + Text(verbatim: "00:00") + .font(Self.clockFont) + .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + clock(state, tint: tint(state)) + .font(Self.clockFont) + .multilineTextAlignment(.trailing) + } } } - .padding() + .padding(.vertical, 14) + .padding(.leading, 10) + .padding(.trailing, 12) } /// Counts DOWN through a rest (the number you act on) and UP through a set, both self-ticking. From 5efeeaee28064de5a84a803cbab821a0f05c3b47 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:20:28 +0200 Subject: [PATCH 16/24] lift log: after iOS restarts NOOP, keep the Lock Screen banner instead of ending it Utku's gym session of 21 Sep 2026: the Lock Screen lit on every strap step at first, then stayed dark "most of the time" while the buzzes and the session carried on. His strap log shows iOS closing NOOP in the background and relaunching it four times in 28 minutes (20:34:58, 20:42:52, 20:58:46, 21:02:20), and every strap step after a background relaunch logging "no Lift Log banner is running" until he opened NOOP. The cause was ours. The saved session came back only when the first screen appeared, but the banner is driven from the app's root view, whose publishers fire as soon as it is built. That first push found no session and ended the banner iOS had kept alive through the restart; the next push asked for a new one, which iOS refuses to an app that is not on screen. Traced in the simulator from ActivityKit's own log: the gym build ends the surviving banner two seconds after a relaunch (then, being on screen there, starts another), while with this change the same banner keeps receiving updates. - `LiftSessionController.resumeSaved` picks the session up in `StrandiOSApp.init`, before any view or publisher exists, and logs "Lift Log: session picked up again after NOOP restarted". - `LiftLiveActivityController` logs when it picks up a banner after a restart, lets go of one the lifter swiped away or iOS ended, and no longer asks iOS for a new banner from the background, where it always throws (several times a second); it logs once that the banner returns when NOOP is next opened. - RootTabView's resume is gone; the resume test is in LiftSessionPersistenceTests. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 16 ++++++++ StrandTests/LiftSessionPersistenceTests.swift | 29 ++++++++++++++ StrandiOS/App/RootTabView.swift | 9 ++--- StrandiOS/App/StrandiOSApp.swift | 11 ++++- .../Widgets/LiftLiveActivityController.swift | 40 ++++++++++++++++++- 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index a71cb08272..b50043d423 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -132,6 +132,22 @@ final class LiftSessionController: ObservableObject { persist() } + /// Pick up the session a previous run of NOOP left going, as the app process starts. + /// + /// iOS closes NOOP in the background and relaunches it when the strap next sends something — four + /// times in 28 minutes of one gym session (strap log, 21 Sep 2026). The session used to come back only + /// when the first screen appeared, and the Lock Screen banner is driven by the same screen: its first + /// push found no session, ended the banner, and iOS allows a new one only while NOOP is open. Every + /// strap step after a restart then lit nothing ("no Lift Log banner is running") until NOOP was opened. + /// Resumed here, before any screen exists, the session is back before anything asks about it, and + /// the banner iOS kept on the Lock Screen is picked up again instead. The line it logs is how a later + /// strap log shows a restart in the middle of a session. + func resumeSaved(from defaults: UserDefaults = .standard) { + guard !isActive, let snapshot = LiftSessionPersistence.load(from: defaults) else { return } + resume(from: snapshot) + log("Lift Log: session picked up again after NOOP restarted") + } + /// Rehydrate an interrupted session found on disk. Does NOT present the sheet: the session comes /// back as the bottom bar, and the user opens it if they want to. func resume(from snapshot: LiftSessionPersistence.Snapshot, present: Bool = false) { diff --git a/StrandTests/LiftSessionPersistenceTests.swift b/StrandTests/LiftSessionPersistenceTests.swift index 2625df269a..a8f332fea3 100644 --- a/StrandTests/LiftSessionPersistenceTests.swift +++ b/StrandTests/LiftSessionPersistenceTests.swift @@ -39,6 +39,35 @@ final class LiftSessionPersistenceTests: XCTestCase { """.utf8) } + /// iOS closes NOOP in the background and relaunches it — four times in one gym session (21 Sep 2026). + /// The session is picked up as the process starts, before any screen, and says so in the strap log. + @MainActor + func testASessionIsPickedUpAgainWhenNOOPStartsAndSaysSo() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "LiftSessionPersistenceTests.resume")) + defer { defaults.removePersistentDomain(forName: "LiftSessionPersistenceTests.resume") } + var lines: [String] = [] + var claimed = false + func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { claimed = $0 != nil }, + log: { lines.append($0) }) + } + + controller().resumeSaved(from: defaults) + XCTAssertTrue(lines.isEmpty, "nothing saved, nothing picked up") + + let snapshot = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + LiftSessionPersistence.store(snapshot, into: defaults) + let c = controller() + c.resumeSaved(from: defaults) + XCTAssertTrue(c.isActive) + XCTAssertFalse(c.isPresented, "it comes back as the bar, not as a sheet") + XCTAssertTrue(claimed, "the strap's double-tap is the session's again") + XCTAssertEqual(lines, ["Lift Log: session picked up again after NOOP restarted"]) + + c.resumeSaved(from: defaults) + XCTAssertEqual(lines.count, 1, "a running session is not picked up twice") + } + /// A session started on the previous build and still running when the update is installed must /// come back whole. It is the reason today's change can go on as a plain update rather than a wipe. func testASessionWrittenByThePreviousBuildStillResumes() throws { diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 8bad9075b1..33f62f011a 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -281,15 +281,12 @@ struct RootTabView: View { } } .animation(.easeInOut(duration: 0.25), value: liftSession.isActive) + // A session left running by a previous launch is back before this view exists + // (`LiftSessionController.resumeSaved`, from `StrandiOSApp.init`), as the BAR — not as a sheet + // thrown in the user's face; they open it when they want it. .sheet(isPresented: $liftSession.isPresented) { LiftSessionView { } } - // A session left running by a previous launch comes back as the BAR, not as a sheet thrown - // in the user's face — they open it when they want it. - .task { - guard !liftSession.isActive, let snapshot = LiftSessionPersistence.load() else { return } - liftSession.resume(from: snapshot) - } } /// Mandatory launch gates defer an external action. Once the shell is available, an explicit Home diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 50a6375094..f1ee145fef 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -27,8 +27,9 @@ struct StrandiOSApp: App { @State private var liveActivity = LiveActivityController() /// The Lift Log session's own Live Activity. Separate from the live-HR one above: while a gym /// session is open this is the banner that matters (it carries the heart rate too), so the HR - /// activity is suppressed rather than stacked beside it. - @State private var liftActivity = LiftLiveActivityController() + /// activity is suppressed rather than stacked beside it. Built in `init`, where the strap log it + /// writes to exists. + @State private var liftActivity: LiftLiveActivityController /// The live gym session. Owned HERE, at the app root, rather than by the screen that shows it: /// swiping the workout sheet away must not stop the clock, silence the strap or drop the /// double-tap handler. See `LiftSessionController`. @@ -101,11 +102,17 @@ struct StrandiOSApp: App { model?.live.append(log: line) }) _liftSession = StateObject(wrappedValue: liftSession) + _liftActivity = State(initialValue: LiftLiveActivityController(log: { [weak model] line in + model?.live.append(log: line) + })) // A gym session keeps ONE banner on the Lock Screen, its own — as the live-HR banner already // stands aside for it. A sync started in the foreground mid-session starts no sync banner. SyncLiveActivityController.shared.holdsBackNewBanner = { [weak liftSession] in liftSession?.isActive == true } + // Before any view or publisher exists: the first push to the Lock Screen banner must find the + // session already running, or it ends the banner iOS kept alive across the restart. + liftSession.resumeSaved() // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a // bluetooth-central to receive it — and the re-score it triggers took nearly eight minutes on the // reporter's install, far longer than that wake survives. The pass is all-or-nothing, so being diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index 787620724a..d41f5f8e91 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -18,6 +18,11 @@ import UIKit @MainActor final class LiftLiveActivityController { private var activity: Activity? + /// Writes to NOOP's strap log — only when the banner's situation CHANGES (picked up after a restart, + /// or waiting for NOOP to be opened), never per push. + private let log: (String) -> Void + /// Set while a session runs with no banner that NOOP may start, so the reason is logged once. + private var waitingForForeground = false private var lastPush: Date = .distantPast private var lastSignature: String? /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is @@ -52,6 +57,10 @@ final class LiftLiveActivityController { } } + init(log: @escaping (String) -> Void = { _ in }) { + self.log = log + } + /// Drive the activity from the session's current state. `state` nil means no session is running, /// which ends any activity that is showing. /// @@ -69,10 +78,15 @@ final class LiftLiveActivityController { func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false) -> LightUp? { guard authInfo.areActivitiesEnabled else { return alert ? .noBanner : nil } + // A banner the lifter swiped off the Lock Screen, or one iOS ended, takes no more updates: let it + // go, so the session is not left pushing to — and trying to light — a banner nobody can see. + if let current = activity, !Self.isShowing(current) { activity = nil } // Re-adopt an activity that outlived a previous app session — ActivityKit keeps them alive // across relaunches, and a fresh controller starts with `activity == nil`. Without this we // could neither update nor END one already on the Lock Screen, and could spawn a duplicate. - if activity == nil { activity = Activity.activities.first } + let adopted = activity == nil + ? Activity.activities.first(where: Self.isShowing) : nil + if let adopted { activity = adopted } // Shares the existing Live Activity opt-out rather than adding a second switch: a user who // turned Live Activities off meant all of them. @@ -80,6 +94,10 @@ final class LiftLiveActivityController { if activity != nil { Task { await end() } } return alert ? .noBanner : nil } + if adopted != nil { + log("Lift Log: Lock Screen banner picked up again after NOOP restarted") + waitingForForeground = false + } // Everything a person would notice, EXCLUDING the clocks (which tick client-side) and the // heart rate (handled by its own interval below). @@ -113,6 +131,17 @@ final class LiftLiveActivityController { } return alert ? (lightsScreen ? .askedIOS : .appOnScreen) : nil } else { + // iOS starts a Live Activity only for the app on screen; asked from the background it throws, + // and this runs several times a second. The banner comes back the next time NOOP is opened. + guard UIApplication.shared.applicationState == .active else { + if !waitingForForeground { + waitingForForeground = true + log("Lift Log: no Lock Screen banner — iOS starts one only while NOOP is open, so it " + + "comes back the next time NOOP is opened") + } + return alert ? .noBanner : nil + } + waitingForForeground = false // Set synchronously before any await, so a second tick arriving while `Activity.request` // is still in flight bails here instead of creating a duplicate activity. guard !isStarting else { return alert ? .noBanner : nil } @@ -141,6 +170,15 @@ final class LiftLiveActivityController { } activity = nil lastSignature = nil + waitingForForeground = false + } + + /// Still on the Lock Screen and taking updates: not ended by the app or iOS, not swiped away. + private static func isShowing(_ activity: Activity) -> Bool { + switch activity.activityState { + case .ended, .dismissed: return false + default: return true + } } } #endif From bdc7709fdfa37d8db8dfe361166004fd4d9f4ff0 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:20:28 +0200 Subject: [PATCH 17/24] lift log: minimised bar laid out like the Lock Screen banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 21 Sep 2026, with a screenshot of the in-app bar: apply the Lock Screen banner's layout here too — heart rate over the timer, both at the right, and the icon nearer the left edge, so the words get more room. Same sizes as before. The check button stays at the far right. The clock's width comes from a hidden "00:00" in its font, as on the Lock Screen, so the words do not shift when the clock gains or loses a digit. Simulator: "Ready for the next set — 9 x 60 kg" and "Set 1 — 9 x 70 kg" show whole, with the heart rate right-aligned over the clock. Co-Authored-By: Claude Opus 5 --- Strand/Screens/LiftSessionBar.swift | 64 ++++++++++++++++++----------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index cb34844b0c..961d293f97 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -30,7 +30,11 @@ struct LiftSessionBar: View { Button { session.isPresented = true } label: { - HStack(spacing: NoopMetrics.gap) { + // The Lock Screen banner's layout (`LiftLiveActivity`), because this is the same banner + // seen inside the app: the icon and the numbers sit near the edges and the heart rate + // stacks over the clock, so the words get the width (Utku, 21 Sep 2026, with a screenshot + // of the bar: "more place for writings"). Same sizes as before. + HStack(spacing: 10) { Image(systemName: "dumbbell.fill") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint(engine)) @@ -54,30 +58,43 @@ struct LiftSessionBar: View { .truncationMode(.tail) } - Spacer(minLength: 0) + Spacer(minLength: 6) - // ALWAYS shown, dash included. An earlier version hid it whenever there was no - // reading, to save width on a crowded capsule — and the first thing that - // produced was "there is no HR in the minimised tab", because an absent readout - // is indistinguishable from an absent feature. Mid-workout the difference - // matters: a dash says the strap is not reading, which is something to act on. - HStack(spacing: 3) { - Image(systemName: "heart.fill") - .font(.system(size: 10, weight: .semibold)) - Text(model.bpm.map(String.init) ?? "—") - .font(StrandFont.captionNumber) + // Heart rate over the clock, both flush right. The clock's width comes from a hidden + // "00:00" in its font — the widest a set or a rest shows under an hour — so the words + // beside it do not shift each time the clock gains or loses a digit. + // + // The heart rate is ALWAYS shown, dash included. An earlier version hid it whenever + // there was no reading, to save width on a crowded capsule — and the first thing + // that produced was "there is no HR in the minimised tab", because an absent readout + // is indistinguishable from an absent feature. Mid-workout the difference matters: a + // dash says the strap is not reading, which is something to act on. + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10, weight: .semibold)) + Text(model.bpm.map(String.init) ?? "—") + .font(StrandFont.captionNumber) + .monospacedDigit() + } + .foregroundStyle(model.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + .accessibilityLabel(model.bpm.map { String(localized: "Heart rate \($0)") } + ?? String(localized: "Heart rate")) + + Text(verbatim: "00:00") + .font(StrandFont.bodyNumber) .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + Text(bigClock(engine)) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint(engine)) + .monospacedDigit() + .fixedSize() + } } - .foregroundStyle(model.bpm == nil - ? StrandPalette.textTertiary - : StrandPalette.metricRose) - .accessibilityLabel(model.bpm.map { String(localized: "Heart rate \($0)") } - ?? String(localized: "Heart rate")) - - Text(bigClock(engine)) - .font(StrandFont.bodyNumber) - .foregroundStyle(tint(engine)) - .monospacedDigit() // The same action the sheet's button performs, so a set can be closed out // without opening anything. @@ -89,7 +106,8 @@ struct LiftSessionBar: View { .buttonStyle(.plain) .accessibilityLabel("Next") } - .padding(.horizontal, 14) + .padding(.leading, 12) + .padding(.trailing, 8) .padding(.vertical, 10) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().stroke(tint(engine).opacity(0.35), lineWidth: 1)) From 2fceb8af755747ea6b0e42bc0b1b64692553fe5a Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:20:43 +0200 Subject: [PATCH 18/24] lift log: add an exercise during a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku, 21 Sep 2026: during a running session, add an exercise the program does not have — one done before, picked from his saved exercises, or a new one, which is saved. It asks only for the muscles (primary and secondary); sets, rest and max RPE are set later in the program editor. - "Add exercise" at the end of the session sheet opens `LiftSessionExerciseSheet`: the exercise name with the user's own "Used before" names (picking one brings its muscles), and the muscle picker. The name is remembered in `liftExercise` like one typed in the program editor. - The exercise joins at the end of the sheet as ONE set planned at 0 kg x 0 reps, no max RPE (0 is not on the 1-10 scale) and the default rest; its row shows zeros in grey until numbers are typed, or last session's numbers when the exercise was done before. Add set / remove set work as on any line, and Undo removes it. `LiftSessionEngine.addExercise` (bounded at 200 lines, the importer's per-program cap), `LiftSessionController.addExercise`. - Finishing asks, in the existing Program question, whether the program keeps the new exercises ("New: Pec deck · sets: 2") along with any changed set counts. "Update program" appends each as a line with the session's set count and its heaviest done set, else 0 x 0 (`programAfterSession`, which `LiftSessionView.writeProgram` now calls). - The added line and its future program-line id survive a relaunch in the crash snapshot (optional field: a plain update, no wipe). - The name suggestions, remembering a name, and the muscle picker move out of `LiftProgramItemSheet` into `LiftExercisePicking.swift`, shared by both pickers, with no change to the program editor. - Six new strings, all ten languages. Simulator: added "Pec deck" (Chest, Front delts) mid-session, did a set at 22.5 x 12, added a second set, discarded it at finish and chose Update program. The store then held the new program line (2 sets, 12 x 22.5 kg, no rest or max RPE), the remembered exercise with its muscles, and the session's sets (the discarded one as 0 x 0); the week's card counted Chest 1, Front delts 0.5. No schema change. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 44 ++- Strand/Data/LiftSessionEngine.swift | 39 ++- Strand/Data/LiftSessionPersistence.swift | 10 +- Strand/Resources/Localizable.xcstrings | 18 ++ Strand/Screens/LiftExercisePicking.swift | 163 +++++++++++ Strand/Screens/LiftProgramItemSheet.swift | 137 +-------- Strand/Screens/LiftSessionExerciseSheet.swift | 151 ++++++++++ Strand/Screens/LiftSessionView.swift | 92 +++++- StrandTests/LiftSessionAddExerciseTests.swift | 268 ++++++++++++++++++ 9 files changed, 771 insertions(+), 151 deletions(-) create mode 100644 Strand/Screens/LiftExercisePicking.swift create mode 100644 Strand/Screens/LiftSessionExerciseSheet.swift create mode 100644 StrandTests/LiftSessionAddExerciseTests.swift diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index b50043d423..0900c7a595 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -443,6 +443,21 @@ final class LiftSessionController: ObservableObject { return true } + /// Add an exercise the program does not have (Utku, 21 Sep 2026), at the end of the sheet: one set, + /// planned as 0 kg × 0 reps with no max RPE and the default rest, so its row shows zeros until + /// numbers are typed — or last session's numbers, when the exercise has been done before. It + /// carries the id its program line will have if finishing adds it. Returns whether it was added. + @discardableResult + func addExercise(_ name: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle]) -> Bool { + let line = LiftPlanItem(exercise: name, primaryMuscle: primaryMuscle, + secondaryMuscles: secondaryMuscles.filter { $0 != primaryMuscle }, + targetSets: 1, targetRepsLow: 0, targetWeightKg: 0, + programItemId: UUID().uuidString, addedInSession: true) + guard engine?.addExercise(line) == true else { return false } + persist() + return true + } + /// Drop the last pending set of an exercise. See `LiftSessionEngine.canRemoveSet(fromExercise:)` /// for what "can" means — a completed set is never removed this way. @discardableResult @@ -568,7 +583,8 @@ final class LiftSessionController: ObservableObject { /// Lines whose set count in this session differs from the program's current one. A line with no /// count counts as one set, as it does when a session starts, and a line deleted from the program - /// since is skipped rather than resurrected. + /// since is skipped rather than resurrected — as is a line added during the session, which the + /// program does not have yet (`programAfterSession`). static func setCountChanges(plan: [LiftPlanItem], program items: [LiftProgramItemRow]) -> [SetCountChange] { let byId = Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) return plan.compactMap { line in @@ -617,6 +633,32 @@ final class LiftSessionController: ObservableObject { return (a.reps ?? -1) > (b.reps ?? -1) } + /// The program's lines after this session — what `LiftSessionView.save` writes. + /// + /// Every line already in the program takes its heaviest done set (`applyingHeaviestSets`, always). + /// With `keepingChanges` — the answer to the finish sheet's program question — changed set counts + /// move too (`applying`), and each exercise added during the session becomes a new line at the end, + /// in the order added: the session's set count for it, its heaviest done set's weight and reps, else + /// 0 kg × 0 reps, and nothing else (Utku, 21 Sep 2026: the rest is set later in the program editor). + static func programAfterSession(_ sets: [FinishedSet], plan: [LiftPlanItem], + program items: [LiftProgramItemRow], keepingChanges: Bool, + programId: String, deviceId: String) -> [LiftProgramItemRow] { + var lines = items + if keepingChanges { + lines = applying(setCountChanges(plan: plan, program: items), to: lines) + let next = (items.map(\.ord).max() ?? -1) + 1 + for (offset, line) in plan.filter(\.addedInSession).enumerated() { + guard let id = line.programItemId, !lines.contains(where: { $0.id == id }) else { continue } + lines.append(LiftProgramItemRow( + id: id, deviceId: deviceId, programId: programId, ord: next + offset, + exercise: line.exercise, targetSets: line.targetSets, + targetRepsLow: 0, targetRepsHigh: nil, targetRpe: nil, targetWeightKg: 0, + restSec: nil, note: nil)) + } + } + return applyingHeaviestSets(sets, plan: plan, to: lines) + } + /// The program's lines with `changes` applied. Only `targetSets` moves. static func applying(_ changes: [SetCountChange], to items: [LiftProgramItemRow]) -> [LiftProgramItemRow] { let counts = Dictionary(changes.map { ($0.itemId, $0.to) }, uniquingKeysWith: { first, _ in first }) diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index c3c5df5330..3e33e6e490 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -42,8 +42,12 @@ struct LiftPlanItem: Equatable { var note: String? /// The `liftProgramItem.id` this line was flattened from, so a set count changed during the session /// can be offered back to the program when it is finished. Nil for a line with no program behind - /// it, which is then never offered. + /// it, which is then never offered. A line added during the session carries the id its program + /// line will have if finishing adds it (`addedInSession`). var programItemId: String? + /// Added while the session ran (`LiftSessionEngine.addExercise`), so the program has no line for it + /// yet; finishing offers to add one. + var addedInSession: Bool /// Rest used when a program line does not specify one. Two minutes sits in the middle of the /// range the hypertrophy literature uses for compound work, and is only a starting value: what @@ -60,7 +64,8 @@ struct LiftPlanItem: Equatable { targetRpe: Double? = nil, targetWeightKg: Double? = nil, note: String? = nil, - programItemId: String? = nil) { + programItemId: String? = nil, + addedInSession: Bool = false) { self.exercise = exercise self.primaryMuscle = primaryMuscle self.secondaryMuscles = secondaryMuscles @@ -72,6 +77,7 @@ struct LiftPlanItem: Equatable { self.targetWeightKg = targetWeightKg self.note = note self.programItemId = programItemId + self.addedInSession = addedInSession } } @@ -115,10 +121,11 @@ struct LiftSessionEngine: Equatable { case finished } - /// The lines being worked. MUTABLE only in one dimension: how many sets a line holds, because a - /// gym decides that as it goes — a fifth set on a line that planned four, or dropping the last - /// one when the tank is empty. Nothing else about a line can change mid-session, so the plan - /// stays the snapshot it was at start. + /// The lines being worked. MUTABLE in two ways only, both because a gym decides them as it goes: + /// how many sets a line holds — a fifth set on a line that planned four, or dropping the last one + /// when the tank is empty — and a line added at the end for an exercise the program does not have. + /// Nothing about a line already there changes mid-session, so the plan stays the snapshot it was at + /// start, plus whatever was added. private(set) var plan: [LiftPlanItem] /// When the session began (unix seconds). let startTs: Int @@ -419,6 +426,26 @@ struct LiftSessionEngine: Equatable { return true } + // MARK: - Adding an exercise + + /// The most lines one session may hold: the importer's cap on one program, so a session grown during + /// the workout stays inside the bound its crash snapshot (written on every change) was sized for. + static let maxExercises = 200 + + /// Append an exercise the program does not have, as ONE set at the end of the sheet (Utku, 21 Sep + /// 2026). Any set count the caller gives is ignored: a line starts with one set, and ⊕/⊖ change it + /// like any other. Undoable, like adding a set. Returns false when nothing was added — the session + /// is finished, or already at `maxExercises`. + @discardableResult + mutating func addExercise(_ item: LiftPlanItem) -> Bool { + guard stage != .finished, plan.count < LiftSessionEngine.maxExercises else { return false } + pushHistory() + var line = item + line.targetSets = 1 + plan.append(line) + return true + } + /// End the session. The rest that was running is closed out first, so its measured duration is /// not silently lost. mutating func finish(now: Int) { diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index 6f0dd4c80c..e6017fe333 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -64,6 +64,9 @@ enum LiftSessionPersistence { /// set count could be changed mid-session, which decodes to nil and simply skips the /// write-back — the session itself is unaffected. var programItemId: String? + /// True for a line added during the session. Absent from a snapshot written before lines + /// could be added, which decodes to nil: a line from the program. + var addedInSession: Bool? } /// The stage as a flat, forward-compatible record rather than an encoded enum: a persisted @@ -151,7 +154,9 @@ enum LiftSessionPersistence { targetRpe: $0.targetRpe, targetWeightKg: $0.targetWeightKg, note: $0.note, - programItemId: $0.programItemId) + programItemId: $0.programItemId, + // Written only when true, so a session nobody added to encodes as before. + addedInSession: $0.addedInSession ? true : nil) }, stage: box(engine.stage), sets: engine.sets.map { @@ -204,7 +209,8 @@ enum LiftSessionPersistence { targetRpe: $0.targetRpe, targetWeightKg: $0.targetWeightKg, note: $0.note, - programItemId: $0.programItemId) + programItemId: $0.programItemId, + addedInSession: $0.addedInSession ?? false) } let sets = s.sets.map { LiftRecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 50c891311a..d62de7d5b3 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1899,6 +1899,24 @@ "You changed the number of sets. Keep the new counts in the program for next time?": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Du hast die Anzahl der Sätze geändert. Die neuen Zahlen fürs nächste Mal ins Programm übernehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You changed the number of sets. Keep the new counts in the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has cambiado el número de series. ¿Guardar las nuevas cifras en el programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as modifié le nombre de séries. Garder les nouveaux nombres dans le programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai cambiato il numero di serie. Tenere i nuovi numeri nel programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zmieniono liczbę serii. Zachować nowe liczby w programie na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alteraste o número de séries. Guardar os novos números no programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Количество подходов изменено. Сохранить новые значения в программе на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你更改了组数。要把新的组数保存到计划里,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你更改了組數。要把新的組數儲存到計畫裡,下次使用嗎?"}} } }, + "Pick one you have done before, or type a new name.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wähle eine, die du schon gemacht hast, oder gib einen neuen Namen ein."}}, "en": {"stringUnit": {"state": "translated", "value": "Pick one you have done before, or type a new name."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige uno que ya hayas hecho o escribe un nombre nuevo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisis-en un que tu as déjà fait, ou tape un nouveau nom."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegline uno che hai già fatto o scrivi un nome nuovo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz jedno z wykonanych wcześniej albo wpisz nową nazwę."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolhe um que já fizeste ou escreve um nome novo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбери одно из уже выполненных или введи новое название."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择做过的动作,或输入新名称。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇做過的動作,或輸入新名稱。"}} + } }, + "It joins this session with one set, its weight and reps at 0 until you type what you lift. Finishing asks whether the program keeps it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sie kommt mit einem Satz in diese Einheit, Gewicht und Wiederholungen auf 0, bis du einträgst, was du hebst. Beim Beenden wirst du gefragt, ob das Programm sie behält."}}, "en": {"stringUnit": {"state": "translated", "value": "It joins this session with one set, its weight and reps at 0 until you type what you lift. Finishing asks whether the program keeps it."}}, "es": {"stringUnit": {"state": "translated", "value": "Se añade a esta sesión con una serie, con peso y repeticiones a 0 hasta que escribas lo que levantas. Al terminar se te pregunta si el programa lo conserva."}}, "fr": {"stringUnit": {"state": "translated", "value": "Il rejoint cette séance avec une série, poids et répétitions à 0 jusqu'à ce que tu saisisses ce que tu soulèves. À la fin, on te demande si le programme le garde."}}, "it": {"stringUnit": {"state": "translated", "value": "Si aggiunge a questa sessione con una serie, peso e ripetizioni a 0 finché non scrivi cosa sollevi. Alla fine ti viene chiesto se il programma lo tiene."}}, "pl": {"stringUnit": {"state": "translated", "value": "Trafia do tej sesji z jedną serią, z ciężarem i powtórzeniami 0, dopóki nie wpiszesz, co podnosisz. Przy zakończeniu padnie pytanie, czy program ma je zachować."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Entra nesta sessão com uma série, peso e repetições a 0 até escreveres o que levantas. Ao terminar, é-te perguntado se o programa o mantém."}}, "ru": {"stringUnit": {"state": "translated", "value": "Оно добавляется в эту сессию с одним подходом, вес и повторения — 0, пока не введёшь, что поднимаешь. При завершении спросим, оставить ли его в программе."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "它会以一组加入本次训练,重量和次数为 0,直到你输入实际举起的数值。结束时会询问是否将它保留在计划中。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "它會以一組加入本次訓練,重量和次數為 0,直到你輸入實際舉起的數值。結束時會詢問是否將它保留在計畫中。"}} + } }, + "Add to session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zur Einheit hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add to session"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir a la sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter à la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi alla sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj do sesji"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar à sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить в сессию"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "加入本次训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "加入本次訓練"}} + } }, + "You added exercises and changed the number of sets. Keep these changes in the program for next time?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Du hast Übungen hinzugefügt und die Anzahl der Sätze geändert. Diese Änderungen fürs nächste Mal ins Programm übernehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You added exercises and changed the number of sets. Keep these changes in the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has añadido ejercicios y cambiado el número de series. ¿Guardar estos cambios en el programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as ajouté des exercices et modifié le nombre de séries. Garder ces changements dans le programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai aggiunto esercizi e cambiato il numero di serie. Tenere queste modifiche nel programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano ćwiczenia i zmieniono liczbę serii. Zachować te zmiany w programie na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionaste exercícios e alteraste o número de séries. Guardar estas alterações no programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлены упражнения и изменено количество подходов. Сохранить эти изменения в программе на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你添加了动作并更改了组数。要把这些更改保存到计划里,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你新增了動作並更改了組數。要把這些更改儲存到計畫裡,下次使用嗎?"}} + } }, + "You added exercises. Add them to the program for next time?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Du hast Übungen hinzugefügt. Fürs nächste Mal ins Programm aufnehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You added exercises. Add them to the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has añadido ejercicios. ¿Añadirlos al programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as ajouté des exercices. Les ajouter au programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai aggiunto esercizi. Aggiungerli al programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano ćwiczenia. Dodać je do programu na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionaste exercícios. Adicioná-los ao programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлены упражнения. Добавить их в программу на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你添加了动作。要把它们加入计划,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你新增了動作。要把它們加入計畫,下次使用嗎?"}} + } }, + "New: %@ · sets: %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Neu: %1$@ · Sätze: %2$lld"}}, "en": {"stringUnit": {"state": "translated", "value": "New: %@ · sets: %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Nuevo: %1$@ · series: %2$lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Nouveau : %1$@ · séries : %2$lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Nuovo: %1$@ · serie: %2$lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nowe: %1$@ · serie: %2$lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Novo: %1$@ · séries: %2$lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Новое: %1$@ · подходов: %2$lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "新增:%1$@ · 组数:%2$lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增:%1$@ · 組數:%2$lld"}} + } }, "%@: %lld → %lld sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld Sätze"}}, "en": {"stringUnit": {"state": "translated", "value": "%@: %lld → %lld sets"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$lld → %3$lld séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld serii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld подх."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$lld → %3$lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$lld → %3$lld 組"}} } }, diff --git a/Strand/Screens/LiftExercisePicking.swift b/Strand/Screens/LiftExercisePicking.swift new file mode 100644 index 0000000000..228078e08d --- /dev/null +++ b/Strand/Screens/LiftExercisePicking.swift @@ -0,0 +1,163 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// What the two exercise pickers share: the program line editor (`LiftProgramItemSheet`) and the running +// session's Add exercise sheet (`LiftSessionExerciseSheet`). The user's own exercise names offered back, +// a name remembered with its muscles, and the muscle classification itself — one copy of each, so the two +// pickers cannot drift about what a name is or which muscles it works. + +/// The user's own exercise names (`liftExercise`), as the pickers read and write them. +enum LiftExerciseVocabulary { + + /// Names matching what has been typed so far, minus an exact match (no point suggesting the thing + /// already in the box), most recently used first as the store orders them. Capped — this is a hint, + /// not a browser. + static func suggestions(_ vocabulary: [LiftExerciseRow], matching typed: String, + limit: Int = 6) -> [LiftExerciseRow] { + let query = typed.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return Array(vocabulary.prefix(limit)) } + return Array(vocabulary + .filter { $0.name.lowercased().contains(query) && $0.name.lowercased() != query } + .prefix(limit)) + } + + /// Remember `name` with its muscles, so it is offered back next time. `upsertLiftExercises` is keyed + /// on (deviceId, name), so a known name is updated — its muscles, and when it was last used — never + /// duplicated. Throws `WhoopStore.LiftExerciseVocabularyFull` for a NEW name once the vocabulary is + /// full, which the caller explains rather than dropping the name silently. + static func remember(_ name: String, primary: LiftMuscle?, secondaries: [LiftMuscle], + known vocabulary: [LiftExerciseRow], deviceId: String, + in store: WhoopStore) async throws { + let now = Int(Date().timeIntervalSince1970) + let existing = vocabulary.first { $0.name == name } + _ = try await store.upsertLiftExercises([LiftExerciseRow( + id: existing?.id ?? UUID().uuidString, + deviceId: deviceId, + name: name, + primaryMuscle: primary, + secondaryMuscles: secondaries, + createdAt: existing?.createdAt ?? now, + lastUsedTs: now)]) + } + + /// Secondaries in the vocabulary's canonical order rather than `Set` iteration order, so the stored + /// list is stable between saves instead of reshuffling on every edit. + static func ordered(_ secondaries: Set, excluding primary: LiftMuscle?) -> [LiftMuscle] { + LiftMuscle.ordered.filter { secondaries.contains($0) && $0 != primary } + } +} + +/// One remembered exercise as a picker lists it: its name, and the muscles it is known by. +struct LiftExerciseSuggestionLabel: View { + let row: LiftExerciseRow + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.up.left") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + VStack(alignment: .leading, spacing: 1) { + Text(row.name) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Text(LiftMuscleSummary.line(primary: row.primaryMuscle, secondaries: row.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } +} + +/// The muscle classification: the primary muscle (a direct set) and the muscles an exercise also works +/// (half a set each). Asked once per exercise and remembered with its name; leaving it unset is allowed — +/// an unclassified exercise still counts toward volume and session load, it simply claims no muscle it +/// was never assigned. +struct LiftMusclePicker: View { + @Binding var primary: LiftMuscle? + @Binding var secondaries: Set + + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Muscles", overline: "Counted once per exercise") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("Primary").strandOverline() + Menu { + Button("Not classified") { primary = nil } + ForEach(LiftMuscle.Region.allCases, id: \.self) { region in + Section(region.displayName) { + ForEach(LiftMuscle.inRegion(region), id: \.self) { muscle in + Button(muscle.displayName) { select(primary: muscle) } + } + } + } + } label: { + HStack { + Text(primary?.displayName ?? String(localized: "Not classified")) + .font(StrandFont.body) + .foregroundStyle(primary == nil + ? StrandPalette.textTertiary + : StrandPalette.textPrimary) + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .contentShape(Rectangle()) + } + .accessibilityLabel("Primary muscle") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Also works (counted as half a set)").strandOverline() + LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 8)], + alignment: .leading, spacing: 8) { + ForEach(LiftMuscle.allCases, id: \.self) { muscle in + if muscle != primary { + secondaryChip(muscle) + } + } + } + } + + Text("Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private func secondaryChip(_ muscle: LiftMuscle) -> some View { + let on = secondaries.contains(muscle) + return Button { + if on { secondaries.remove(muscle) } else { secondaries.insert(muscle) } + } label: { + Text(muscle.displayName) + .font(StrandFont.caption) + .foregroundStyle(on ? StrandPalette.effortColor : StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(on ? StrandPalette.effortColor.opacity(0.14) : StrandPalette.surfaceRaised) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(on ? [.isSelected] : []) + } + + /// Setting a primary that is also ticked as a secondary drops it from the secondaries: one + /// muscle can never be credited twice for the same set. + private func select(primary muscle: LiftMuscle) { + primary = muscle + secondaries.remove(muscle) + } +} diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index a350a92c61..c1adedfeda 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -65,15 +65,8 @@ struct LiftProgramItemSheet: View { } private var canSave: Bool { !trimmedExercise.isEmpty && !maxRpeInvalid } - /// Vocabulary entries matching what has been typed so far, minus an exact match (no point - /// suggesting the thing already in the box). Capped — this is a hint, not a browser. private var suggestions: [LiftExerciseRow] { - let query = trimmedExercise.lowercased() - guard !query.isEmpty else { return Array(vocabulary.prefix(6)) } - return vocabulary - .filter { $0.name.lowercased().contains(query) && $0.name.lowercased() != query } - .prefix(6) - .map { $0 } + LiftExerciseVocabulary.suggestions(vocabulary, matching: exercise) } var body: some View { @@ -83,7 +76,7 @@ struct LiftProgramItemSheet: View { ) { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { exerciseSection - muscleSection + LiftMusclePicker(primary: $primary, secondaries: $secondaries) targetsSection noteSection footer @@ -148,22 +141,7 @@ struct LiftProgramItemSheet: View { Button { adopt(row) } label: { - HStack(spacing: 8) { - Image(systemName: "arrow.up.left") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - VStack(alignment: .leading, spacing: 1) { - Text(row.name) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textPrimary) - Text(LiftMuscleSummary.line(primary: row.primaryMuscle, - secondaries: row.secondaryMuscles)) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - } - Spacer(minLength: 0) - } - .contentShape(Rectangle()) + LiftExerciseSuggestionLabel(row: row) } .buttonStyle(.plain) @@ -188,83 +166,6 @@ struct LiftProgramItemSheet: View { } } - // MARK: - Muscle classification - - private var muscleSection: some View { - VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Muscles", overline: "Counted once per exercise") - NoopCard { - VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 6) { - Text("Primary").strandOverline() - Menu { - Button("Not classified") { primary = nil } - ForEach(LiftMuscle.Region.allCases, id: \.self) { region in - Section(region.displayName) { - ForEach(LiftMuscle.inRegion(region), id: \.self) { muscle in - Button(muscle.displayName) { select(primary: muscle) } - } - } - } - } label: { - HStack { - Text(primary?.displayName ?? String(localized: "Not classified")) - .font(StrandFont.body) - .foregroundStyle(primary == nil - ? StrandPalette.textTertiary - : StrandPalette.textPrimary) - Spacer(minLength: 0) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - } - .contentShape(Rectangle()) - } - .accessibilityLabel("Primary muscle") - } - - VStack(alignment: .leading, spacing: 6) { - Text("Also works (counted as half a set)").strandOverline() - LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 8)], - alignment: .leading, spacing: 8) { - ForEach(LiftMuscle.allCases, id: \.self) { muscle in - if muscle != primary { - secondaryChip(muscle) - } - } - } - } - - Text("Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - - private func secondaryChip(_ muscle: LiftMuscle) -> some View { - let on = secondaries.contains(muscle) - return Button { - if on { secondaries.remove(muscle) } else { secondaries.insert(muscle) } - } label: { - Text(muscle.displayName) - .font(StrandFont.caption) - .foregroundStyle(on ? StrandPalette.effortColor : StrandPalette.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(on ? StrandPalette.effortColor.opacity(0.14) : StrandPalette.surfaceRaised) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityAddTraits(on ? [.isSelected] : []) - } - // MARK: - Targets private var targetsSection: some View { @@ -388,13 +289,6 @@ struct LiftProgramItemSheet: View { focused = nil } - /// Setting a primary that is also ticked as a secondary drops it from the secondaries: one - /// muscle can never be credited twice for the same set. - private func select(primary muscle: LiftMuscle) { - primary = muscle - secondaries.remove(muscle) - } - private func loadIfNeeded() async { guard !loaded else { return } loaded = true @@ -424,22 +318,13 @@ struct LiftProgramItemSheet: View { let name = trimmedExercise // Remember the exercise (and its classification) in the vocabulary, so it is offered back - // next time. `upsertLiftExercises` is keyed on (deviceId, name), so re-saving updates rather - // than duplicating. + // next time. if let store = await repo.storeHandle() { - let now = Int(Date().timeIntervalSince1970) - let existing = vocabulary.first { $0.name == name } - let row = LiftExerciseRow( - id: existing?.id ?? UUID().uuidString, - deviceId: repo.deviceId, - name: name, - primaryMuscle: primary, - secondaryMuscles: orderedSecondaries, - createdAt: existing?.createdAt ?? now, - lastUsedTs: now - ) do { - _ = try await store.upsertLiftExercises([row]) + try await LiftExerciseVocabulary.remember( + name, primary: primary, + secondaries: LiftExerciseVocabulary.ordered(secondaries, excluding: primary), + known: vocabulary, deviceId: repo.deviceId, in: store) } catch let full as WhoopStore.LiftExerciseVocabularyFull { // Refused rather than silently dropped: the user typed a name and deserves to know // it was not remembered. @@ -472,10 +357,4 @@ struct LiftProgramItemSheet: View { )) dismiss() } - - /// Secondaries in the vocabulary's canonical order rather than `Set` iteration order, so the - /// stored list is stable between saves instead of reshuffling on every edit. - private var orderedSecondaries: [LiftMuscle] { - LiftMuscle.ordered.filter { secondaries.contains($0) && $0 != primary } - } } diff --git a/Strand/Screens/LiftSessionExerciseSheet.swift b/Strand/Screens/LiftSessionExerciseSheet.swift new file mode 100644 index 0000000000..f1663a694e --- /dev/null +++ b/Strand/Screens/LiftSessionExerciseSheet.swift @@ -0,0 +1,151 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Add an exercise to the running session (Utku, 21 Sep 2026): one done before, picked from the user's own +// exercise names, or a new one, typed and given its muscles — and remembered, like a name typed into the +// program editor. It joins the session at the end of the sheet as one set planned at 0 kg × 0 reps; ⊕/⊖ +// change its sets like any other line, and finishing asks whether the program keeps it. Sets, rest and +// max RPE are not asked here: they belong to the program editor, later. + +struct LiftSessionExerciseSheet: View { + /// Handed the exercise once it is remembered; the session adds it. + let onAdd: (_ name: String, _ primary: LiftMuscle?, _ secondaries: [LiftMuscle]) -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var exercise = "" + @State private var primary: LiftMuscle? + @State private var secondaries: Set = [] + /// The user's own exercise names, most recently used first. + @State private var vocabulary: [LiftExerciseRow] = [] + /// Set when the vocabulary is full, so the refusal is explained rather than silent. + @State private var vocabularyFullLimit: Int? + @State private var adding = false + + @FocusState private var focused: Field? + private enum Field: Hashable { case exercise } + + private var trimmedExercise: String { + exercise.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canAdd: Bool { !trimmedExercise.isEmpty && !adding } + + var body: some View { + ScreenScaffold(title: "Add exercise", + subtitle: "Pick one you have done before, or type a new name.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + exerciseSection + LiftMusclePicker(primary: $primary, secondaries: $secondaries) + Text("It joins this session with one set, its weight and reps at 0 until you type what you lift. Finishing asks whether the program keeps it.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 520, height: 640) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { await load() } + // A name typed out in full that is already known brings its muscles with it, as picking it from + // the list does — unless muscles were already chosen here. + .onChange(of: exercise) { _ in + guard primary == nil, secondaries.isEmpty, + let known = vocabulary.first(where: { $0.name == trimmedExercise }) else { return } + primary = known.primaryMuscle + secondaries = Set(known.secondaryMuscles) + } + .alert("You've saved the most exercises NOOP remembers", + isPresented: Binding(get: { vocabularyFullLimit != nil }, + set: { if !$0 { vocabularyFullLimit = nil } })) { + Button("OK", role: .cancel) { vocabularyFullLimit = nil } + } message: { + Text("Forget one you no longer use and this one will save. Your logged sessions are never affected.") + } + } + + private var exerciseSection: some View { + let suggestions = LiftExerciseVocabulary.suggestions(vocabulary, matching: exercise, limit: 8) + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercise", overline: "Movement") + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + TextField("Incline dumbbell press", text: $exercise) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .exercise) + if !suggestions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Used before").strandOverline() + ForEach(suggestions, id: \.id) { row in + Button { adopt(row) } label: { LiftExerciseSuggestionLabel(row: row) } + .buttonStyle(.plain) + } + } + } + } + } + } + } + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Add to session") { Task { await add() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 200) + .disabled(!canAdd) + .opacity(canAdd ? 1 : NoopButtonMetrics.disabledOpacity) + } + } + + /// Take a known exercise, with the muscles it is already known by. + private func adopt(_ row: LiftExerciseRow) { + exercise = row.name + primary = row.primaryMuscle + secondaries = Set(row.secondaryMuscles) + focused = nil + } + + private func load() async { + guard let store = await repo.storeHandle() else { return } + vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + } + + /// Remember the exercise — a new name is saved to the vocabulary, a known one is marked used — then + /// hand it to the session. + private func add() async { + guard canAdd else { return } + adding = true + defer { adding = false } + let name = trimmedExercise + let ordered = LiftExerciseVocabulary.ordered(secondaries, excluding: primary) + if let store = await repo.storeHandle() { + do { + try await LiftExerciseVocabulary.remember(name, primary: primary, secondaries: ordered, + known: vocabulary, deviceId: repo.deviceId, + in: store) + } catch let full as WhoopStore.LiftExerciseVocabularyFull { + vocabularyFullLimit = full.limit + return + } catch { + // Still added: every set the session saves carries its own copy of the name and muscles, + // so a name the vocabulary could not take this once costs nothing that is logged. + } + } + onAdd(name, primary, ordered) + dismiss() + } +} diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index f0ff64ccbd..93fcc0e9d0 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -37,6 +37,9 @@ struct LiftSessionView: View { @State private var programChoice: ProgramChoice? /// Program lines whose set count this session changed, read when the finish sheet opens. @State private var setCountChanges: [LiftSessionController.SetCountChange] = [] + @State private var addingExercise = false + /// The card to bring into view once an exercise has been added — the new one, at the end. + @State private var scrollTarget: Int? private enum UnfinishedChoice: Hashable { case complete, discard } private enum ProgramChoice: Hashable { case update, keep } @@ -89,7 +92,9 @@ struct LiftSessionView: View { .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) .dismissesKeyboardOnTap($focused) - .task { await loadLastTime() } + // Re-read whenever the session's exercises change, so an exercise added mid-session that was + // done before shows last time's numbers in grey, like every other line. + .task(id: engine?.plan.map(\.exercise)) { await loadLastTime() } // Release a field's draft once the user leaves it, so the row returns to the canonical // formatting ("45.50" typed becomes "45.5"). The single-argument form on purpose: the // two-argument `onChange` is macOS 14+ and this file also builds for macOS 13. @@ -109,6 +114,7 @@ struct LiftSessionView: View { ForEach(Array(engine.plan.enumerated()), id: \.offset) { index, item in exerciseCard(engine, index: index, item: item) } + addExerciseRow(engine) Color.clear.frame(height: 8) } .padding(.horizontal, NoopMetrics.screenPadding) @@ -120,9 +126,48 @@ struct LiftSessionView: View { guard let slot else { return } withAnimation { proxy.scrollTo(slot.exerciseIndex, anchor: .top) } } + .onChange(of: scrollTarget) { target in + guard let target else { return } + withAnimation { proxy.scrollTo(target, anchor: .top) } + scrollTarget = nil + } + .sheet(isPresented: $addingExercise) { + LiftSessionExerciseSheet { name, primary, secondaries in + guard session.addExercise(name, primaryMuscle: primary, + secondaryMuscles: secondaries) else { return } + scrollTarget = (session.engine?.plan.count ?? 1) - 1 + } + } } } + /// Add an exercise the program does not have — at the END of the sheet, after everything planned, + /// because that is where it goes: the program's lines keep their order, and the new one is tapped to + /// start whenever the lifter gets to it (Utku, 21 Sep 2026). Finishing asks whether the program keeps + /// it; until then it changes this session only, like ⊕/⊖. + private func addExerciseRow(_ engine: LiftSessionEngine) -> some View { + let canAdd = engine.plan.count < LiftSessionEngine.maxExercises + return Button { + addingExercise = true + } label: { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 17, weight: .semibold)) + Text("Add exercise").font(StrandFont.body) + } + .foregroundStyle(canAdd ? StrandPalette.effortColor : StrandPalette.textTertiary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) + .strokeBorder(StrandPalette.textTertiary.opacity(0.35), + style: StrokeStyle(lineWidth: 1, dash: [5, 4]))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + } + private func header(_ engine: LiftSessionEngine) -> some View { VStack(alignment: .leading, spacing: 4) { Text(session.programName ?? String(localized: "Session")) @@ -600,8 +645,9 @@ struct LiftSessionView: View { private var finishSheet: some View { let unfinished = session.unfinishedSlots.count + let asksAboutProgram = !setCountChanges.isEmpty || !addedExercises.isEmpty let answered = (unfinished == 0 || unfinishedChoice != nil) - && (setCountChanges.isEmpty || programChoice != nil) + && (!asksAboutProgram || programChoice != nil) return ScreenScaffold(title: "Finish session", subtitle: "One number for the whole session, so a leg day can be compared with a run.") { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { @@ -621,7 +667,7 @@ struct LiftSessionView: View { } } if unfinished > 0 { unfinishedCard(count: unfinished) } - if !setCountChanges.isEmpty { programCard } + if asksAboutProgram { programCard } // One way to save. Session RPE above is optional, so an empty field is simply no rating; // a separate "Skip" saved exactly the same way and read as a second choice. @@ -705,12 +751,19 @@ struct LiftSessionView: View { } } - /// Set counts changed with ⊕/⊖ during the session. The program keeps them only if asked to. + /// Exercises added during the session, which the program does not have yet. + private var addedExercises: [LiftPlanItem] { + session.engine?.plan.filter(\.addedInSession) ?? [] + } + + /// Set counts changed with ⊕/⊖, and exercises added, during the session. The program keeps them only + /// if asked to — one answer for all of them, listed so the lifter sees what "update" would write. private var programCard: some View { - NoopCard { + let added = addedExercises + return NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.gap) { Text("Program").strandOverline() - Text("You changed the number of sets. Keep the new counts in the program for next time?") + Text(programQuestion(countsChanged: !setCountChanges.isEmpty, exercisesAdded: !added.isEmpty)) .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) .fixedSize(horizontal: false, vertical: true) @@ -719,6 +772,11 @@ struct LiftSessionView: View { .font(StrandFont.bodyNumber) .foregroundStyle(StrandPalette.textSecondary) } + ForEach(Array(added.enumerated()), id: \.offset) { _, line in + Text("New: \(line.exercise) · sets: \(line.targetSets)") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textSecondary) + } Picker("Program", selection: $programChoice) { Text("Update program").tag(ProgramChoice?.some(.update)) Text("Keep as it was").tag(ProgramChoice?.some(.keep)) @@ -729,6 +787,15 @@ struct LiftSessionView: View { } } + /// The program question, worded for what actually changed. + private func programQuestion(countsChanged: Bool, exercisesAdded: Bool) -> LocalizedStringKey { + switch (countsChanged, exercisesAdded) { + case (true, true): return "You added exercises and changed the number of sets. Keep these changes in the program for next time?" + case (false, true): return "You added exercises. Add them to the program for next time?" + default: return "You changed the number of sets. Keep the new counts in the program for next time?" + } + } + // MARK: - Loading and saving /// What was lifted for each of this session's exercises LAST time, by set number — the middle @@ -835,8 +902,9 @@ struct LiftSessionView: View { setCountChanges = LiftSessionController.setCountChanges(plan: plan, program: rows) } - /// Carry this session onto its program: each line's heaviest done set becomes its weight and reps - /// (always, Utku 21 Sep 2026), and its set count changes only when the user chose to keep them. + /// Carry this session onto its program (`LiftSessionController.programAfterSession`): each line's + /// heaviest done set becomes its weight and reps (always, Utku 21 Sep 2026); changed set counts and + /// exercises added during the session reach it only when the user chose to keep them. /// /// Re-reads the lines, so a program edited elsewhere while the session ran keeps every other change /// and a line deleted since is not resurrected. The store call replaces the lines wholesale, so @@ -845,11 +913,9 @@ struct LiftSessionView: View { sets: [LiftSessionController.FinishedSet]) async { guard let programId = session.programId, let rows = try? await store.liftProgramItems(programId: programId) else { return } - var edited = LiftSessionController.applyingHeaviestSets(sets, plan: plan, to: rows) - if programChoice == .update { - edited = LiftSessionController.applying( - LiftSessionController.setCountChanges(plan: plan, program: rows), to: edited) - } + let edited = LiftSessionController.programAfterSession( + sets, plan: plan, program: rows, keepingChanges: programChoice == .update, + programId: programId, deviceId: repo.deviceId) guard edited != rows else { return } _ = try? await store.replaceLiftProgramItems(programId: programId, items: edited) } diff --git a/StrandTests/LiftSessionAddExerciseTests.swift b/StrandTests/LiftSessionAddExerciseTests.swift new file mode 100644 index 0000000000..f6debe71fb --- /dev/null +++ b/StrandTests/LiftSessionAddExerciseTests.swift @@ -0,0 +1,268 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// An exercise added while the session runs (Utku, 21 Sep 2026): it joins at the end as one set planned at +/// 0 kg × 0 reps, works like any other line, survives a relaunch, and reaches the program only when +/// finishing is told to keep the changes. +@MainActor +final class LiftSessionAddExerciseTests: XCTestCase { + + private let t0 = 1_700_000_000 + + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 2, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50, programItemId: "bench")] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// The program behind `plan()`: one line, id "bench". + private func programRows() -> [LiftProgramItemRow] { + [LiftProgramItemRow(id: "bench", deviceId: "d", programId: "p", ord: 0, exercise: "Bench press", + targetSets: 2, targetRepsLow: 10, targetRepsHigh: nil, targetRpe: 8, + targetWeightKg: 50, restSec: 60, note: "Pause")] + } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + // MARK: - The engine + + func testAnAddedExerciseJoinsAtTheEndWithOneSet() { + var engine = LiftSessionEngine(plan: plan(), startTs: t0) + XCTAssertTrue(engine.addExercise(LiftPlanItem(exercise: "Cable fly", targetSets: 4))) + XCTAssertEqual(engine.plan.map(\.exercise), ["Bench press", "Cable fly"]) + XCTAssertEqual(engine.plan[1].targetSets, 1, "a line starts with one set, whatever it was given") + XCTAssertEqual(engine.slots(forExercise: 1), [slot(1, 1)]) + XCTAssertEqual(engine.unperformedSlots.count, 3) + } + + /// With everything planned done, the session waits at 0:00 — and the added set is where the next tap goes. + func testTheNextTapReachesAnExerciseAddedAfterEverythingElseWasDone() { + var engine = LiftSessionEngine(plan: plan(), startTs: t0) + for i in 1...5 { engine.advance(now: t0 + i * 100) } // both bench sets, both rests, then on + XCTAssertTrue(engine.allCompleted) + XCTAssertEqual(engine.stage, .resting(slot(0, 2), endsAt: t0 + 500), "sheet complete: waiting") + + engine.addExercise(LiftPlanItem(exercise: "Cable fly")) + XCTAssertFalse(engine.allCompleted) + XCTAssertEqual(engine.upcomingSlot, slot(1, 1)) + engine.advance(now: t0 + 600) + XCTAssertEqual(engine.stage, .working(slot(1, 1))) + } + + func testAddingAnExerciseCanBeUndone() { + var engine = LiftSessionEngine(plan: plan(), startTs: t0) + engine.addExercise(LiftPlanItem(exercise: "Cable fly")) + engine.undo() + XCTAssertEqual(engine.plan.map(\.exercise), ["Bench press"]) + } + + func testNothingIsAddedToAFinishedSessionOrPastTheBound() { + var finished = LiftSessionEngine(plan: plan(), startTs: t0) + finished.finish(now: t0 + 10) + XCTAssertFalse(finished.addExercise(LiftPlanItem(exercise: "Cable fly"))) + XCTAssertEqual(finished.plan.count, 1) + + let full = (0.. Date: Mon, 21 Sep 2026 22:25:14 +0200 Subject: [PATCH 19/24] lift log: running clocks read like the Lock Screen's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar, the session sheet's clocks and the rest band said "45s" and "0s" while the Lock Screen banner showed the same clock as "0:45" and "0:00" — two readouts of one fact in two formats, and "0:00 on every surface" was only true of the Lock Screen. They now use NOOP's own running-clock format, `ActiveWorkoutClock.clock` (the Live and Today workout clocks'): "0:45", "0:00", and "1:05:00" past an hour, as the Lock Screen's `Text(timerInterval:)` does, where `LiftFormat.duration` had no hours and read "75:23". `LiftFormat.duration` stays for a rest spoken about: a program line's "45s rest", a finished set's measured rest. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftFormat.swift | 4 +++- Strand/Screens/LiftSessionBar.swift | 7 ++++--- Strand/Screens/LiftSessionView.swift | 10 +++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Strand/Data/LiftFormat.swift b/Strand/Data/LiftFormat.swift index 37302165fa..6b769ed8a9 100644 --- a/Strand/Data/LiftFormat.swift +++ b/Strand/Data/LiftFormat.swift @@ -72,7 +72,9 @@ enum LiftFormat { // MARK: - Durations - /// A rest period as "2:00" / "45s" — minutes and seconds, which is how rest is spoken about. + /// A rest period as "2:00" / "45s" — minutes and seconds, which is how rest is spoken about: a program + /// line's rest, a finished set's measured rest. A clock that is RUNNING (the bar, the session sheet) + /// is written `ActiveWorkoutClock.clock` instead, as the Lock Screen writes it. static func duration(_ seconds: Int) -> String { guard seconds >= 60 else { return "\(seconds)s" } return String(format: "%d:%02d", seconds / 60, seconds % 60) diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 961d293f97..2938cbbaba 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -126,11 +126,12 @@ struct LiftSessionBar: View { } } - /// Rest counts DOWN (that is the number you act on); everything else counts up. + /// Rest counts DOWN (that is the number you act on); everything else counts up. Written as the Lock + /// Screen writes the same clock — "0:45", "0:00", "1:05:00" — through NOOP's one running-clock format. private func bigClock(_ engine: LiftSessionEngine) -> String { if let remaining = engine.restRemaining(now: session.now) { - return LiftFormat.duration(remaining) + return ActiveWorkoutClock.clock(remaining) } - return LiftFormat.duration(max(0, session.now - engine.stageStartedAt)) + return ActiveWorkoutClock.clock(session.now - engine.stageStartedAt) } } diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 93fcc0e9d0..314da5cf5a 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -404,7 +404,7 @@ struct LiftSessionView: View { Text("Rest period").strandOverline() .foregroundStyle(StrandPalette.metricAmber) Spacer(minLength: 0) - Text(LiftFormat.duration(remaining)) + Text(ActiveWorkoutClock.clock(remaining)) .font(StrandFont.captionNumber) .monospacedDigit() .foregroundStyle(StrandPalette.metricAmber) @@ -541,7 +541,7 @@ struct LiftSessionView: View { VStack(spacing: NoopMetrics.rowSpacing) { HStack(spacing: 14) { clock(String(localized: "Session"), - LiftFormat.duration(max(0, session.now - engine.startTs)), + ActiveWorkoutClock.clock(session.now - engine.startTs), tint: StrandPalette.textPrimary) stageClock(engine) heartRate() @@ -616,18 +616,18 @@ struct LiftSessionView: View { switch engine.stage { case .working: clock(String(localized: "This set"), - LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), + ActiveWorkoutClock.clock(session.now - engine.stageStartedAt), tint: StrandPalette.statusPositive) case .resting: // "Rest period", never "Rest": the catalog's "Rest" key is NOOP's SLEEP metric, so this // label rendered as "Erholung" (recovery) in German — the exact collision CLAUDE.md and // the handover brief both warn about. Reintroduced by the workout-sheet rewrite. clock(String(localized: "Rest period"), - LiftFormat.duration(engine.restRemaining(now: session.now) ?? 0), + ActiveWorkoutClock.clock(engine.restRemaining(now: session.now) ?? 0), tint: StrandPalette.metricAmber) case .warmup, .finished: clock(String(localized: "Warm-up"), - LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), + ActiveWorkoutClock.clock(session.now - engine.stageStartedAt), tint: StrandPalette.textSecondary) } } From 2eea5e08069b4e3628711c4ac04a67956aa03db3 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:23:20 +0200 Subject: [PATCH 20/24] lift log: a running session does no work between taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku's crash reports of 21 Sep: iOS killed NOOP for background CPU three times in that evening's gym session (20:34, 20:42, 20:58) and once that morning (09:17, the previous build) — "48 seconds cpu time over 49-60 seconds, exceeding limit of 80% cpu over 60 seconds", NOOP not on screen, its main thread busy redrawing SwiftUI views and laying out text. Every kill cost the log before it and, until e4e391f7, the Lock Screen banner. The Lift Log was redrawing far more than it showed: - the session published a tick every second to every screen watching it: the whole tab shell (RootTabView), the session sheet, the bar and the Lift Log hub — on screen or not; - the session sheet (a hundred text fields) also watched LiveState, which it never read and which changes with every strap-log line, heartbeat and R-R packet, and AppModel for one number. Upstream already rules this out for Today (the PERF note on TodayView: only small leaf views may watch per-second state); the Lift Log broke it. Now: - no tick. A rest's two moments — the warning buzz 5 s before its end, and its end — are one-shot timers set when the rest starts and replaced when it changes (`scheduleRestTimers`, `restEventTimes`); - the running clocks and the heart rate are small self-updating views (`LiftLiveReadouts.swift`: a TimelineView per clock, `LiftHeartRate` alone watching AppModel), so a tick or a beat redraws one number; - the sheet no longer watches LiveState or AppModel; - the Lock Screen banner follows `changesSettled` (each change once it has landed; `$engine` fired before the change, so its pushes showed the step before), plus a push when NOOP comes on screen. Measured in the simulator, 60 s idle, no strap (so no heartbeat or log traffic, which the sheet also redrew on before): NOOP alone 6.29 CPU-s; a session with the bar 8.35 -> 6.14; with the sheet open 9.96 -> 6.59. LiftSessionTimingTests pins it: nothing is published between taps, a rest's end is published once and its warning buzzes once, an undone rest fires nothing — each seen to fail with the tick restored. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 101 +++++++++++++----- Strand/Screens/LiftLiveReadouts.swift | 64 +++++++++++ Strand/Screens/LiftSessionBar.swift | 40 ++----- Strand/Screens/LiftSessionView.swift | 63 +++++------ StrandTests/LiftSessionTimingTests.swift | 84 +++++++++++++++ StrandiOS/App/StrandiOSApp.swift | 13 ++- .../Widgets/LiftLiveActivityController.swift | 6 +- 7 files changed, 266 insertions(+), 105 deletions(-) create mode 100644 Strand/Screens/LiftLiveReadouts.swift create mode 100644 StrandTests/LiftSessionTimingTests.swift diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 0900c7a595..72c3ca1bbf 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -13,7 +13,7 @@ import StrandAnalytics // present. // // A workout outlives the screen you happen to be looking at, so the session has to as well. This -// controller owns the engine, the tick, the buzz gating and the persistence. The sheet is a +// controller owns the engine, the rest's timers, the buzz gating and the persistence. The sheet is a // rendering of it; the bottom bar is another. Dismissing either changes nothing about the session. // // The strap gesture is claimed for the LIFETIME OF THE SESSION rather than the lifetime of a view, @@ -22,13 +22,11 @@ import StrandAnalytics @MainActor final class LiftSessionController: ObservableObject { - /// The running session, or nil when none is in flight. - @Published private(set) var engine: LiftSessionEngine? + /// The running session, or nil when none is in flight. Every change re-arms the rest's timers + /// (`scheduleRestTimers`), which only act when the rest itself changed. + @Published private(set) var engine: LiftSessionEngine? { didSet { scheduleRestTimers() } } @Published private(set) var programId: String? @Published private(set) var programName: String? - /// Ticks every second while a session runs, so views can redraw clocks off one shared timer - /// rather than each starting their own. - @Published private(set) var now = Int(Date().timeIntervalSince1970) /// True while the full sheet is presented; false when minimised to the bottom bar. @Published var isPresented = false @@ -42,6 +40,15 @@ final class LiftSessionController: ObservableObject { /// the banner, that one lit update is usually the only update the step causes. let strapStepTaken = PassthroughSubject() + /// Anything about the session changed, once the change has landed and settled — what the Lock Screen + /// banner follows. `objectWillChange` fires BEFORE a change lands, so a banner pushed from it showed the + /// step before; the debounce also folds a burst (typing a number, the several changes of one tap) into one + /// push. Built once, so its subscribers keep one pipeline. + private(set) lazy var changesSettled: AnyPublisher = objectWillChange + .debounce(for: .milliseconds(250), scheduler: RunLoop.main) + .map { _ in () } + .eraseToAnyPublisher() + var isActive: Bool { engine != nil && engine?.isFinished == false } /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so @@ -85,7 +92,9 @@ final class LiftSessionController: ObservableObject { /// and hands it over; until it does (a session resumed straight into the bar after a relaunch, say) /// the grey numbers fall through to the program's target, which is the layer below. @Published private var lastSession: [String: [Int: LiftSetCarry]] = [:] - private var ticker: AnyCancellable? + /// The running rest's warning and end, and the end they were set for — see `scheduleRestTimers`. + private var restTimers: [Task] = [] + private var scheduledRestEnd: Int? /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. private let buzz: (UInt8) -> Void @@ -125,10 +134,8 @@ final class LiftSessionController: ObservableObject { self.programId = programId self.programName = programName warnedFor = nil - now = stamp isPresented = true claimStrap() - startTicking() persist() } @@ -159,19 +166,17 @@ final class LiftSessionController: ObservableObject { // to prevent, whether it is lost to a blur or to a relaunch. pendingValues = LiftSessionPersistence.pendingValues(from: snapshot) pendingWarmups = LiftSessionPersistence.pendingWarmups(from: snapshot) - now = Int(Date().timeIntervalSince1970) // Suppress the warning for a rest that is ALREADY inside its final seconds. Without this, // reopening a session mid-rest greets the user with three buzzes for a rest they have been // watching count down all along. if case .resting(_, let endsAt) = engine?.stage, - endsAt - now <= LiftSessionController.restWarningLeadSec { + endsAt - Self.unixNow <= LiftSessionController.restWarningLeadSec { warnedFor = endsAt } else { warnedFor = nil } isPresented = present claimStrap() - startTicking() } /// Give up the session without saving. @@ -196,8 +201,6 @@ final class LiftSessionController: ObservableObject { pendingWarmups = [] pendingValues = [:] isPresented = false - ticker?.cancel() - ticker = nil setStrapHandler(nil) } @@ -209,16 +212,8 @@ final class LiftSessionController: ObservableObject { setStrapHandler({ [weak self] in self?.advance(fromStrap: true) }) } - private func startTicking() { - ticker?.cancel() - ticker = Timer.publish(every: 1, on: .main, in: .common) - .autoconnect() - .sink { [weak self] instant in - guard let self else { return } - self.now = Int(instant.timeIntervalSince1970) - self.fireRestWarningIfDue() - } - } + /// The current unix second. Read when needed; nothing about a session is stored per second. + static var unixNow: Int { Int(Date().timeIntervalSince1970) } // MARK: - Actions @@ -246,7 +241,6 @@ final class LiftSessionController: ObservableObject { // before they are applied (`setNumbers`). if fromStrap { strapStepTaken.send() } applyPendingInput() - now = stamp warnedFor = nil persist() } @@ -310,7 +304,7 @@ final class LiftSessionController: ObservableObject { let detail = setNumbers(for: slot, system: system) switch engine.stage { case .resting(_, let endsAt): - let ready = endsAt <= now + let ready = endsAt <= Self.unixNow return Presentation( isResting: true, exercise: item.exercise, status: ready ? String(localized: "Ready for the next set") @@ -429,7 +423,6 @@ final class LiftSessionController: ObservableObject { engine?.start(slot, now: stamp) // Starting a slot drops any record it had, so its warm-up mark reverts to pending — which is // where it already lives. - now = stamp warnedFor = nil persist() } @@ -670,16 +663,66 @@ final class LiftSessionController: ObservableObject { } } - // MARK: - The rest warning + // MARK: - The rest's two moments + // + // A running session changes with time alone at two moments of each rest: the warning buzz + // `restWarningLeadSec` before it ends, and its end. Each gets a one-shot timer, set when the rest starts + // and replaced whenever the rest does; between taps a session does no work at all. + // + // It used to tick once a second, and publish the tick to every screen watching the session — the whole + // tab shell, the session sheet, the bar, the Lift Log hub — so all of them were redrawn every second, on + // screen or not. iOS killed NOOP four times in one gym session for background CPU (Utku's crash reports, + // 21 Sep 2026: over 80% for 60 s, busy redrawing SwiftUI views), and every kill cost a Lock Screen banner + // and the log before it. The clocks on screen tick by themselves, and only while shown (`LiftRunningClock`). + + /// Re-arm the rest's timers when the rest changed — a new rest, a rest undone or cut short, no rest. + private func scheduleRestTimers() { + let endsAt: Int? = { if case .resting(_, let end) = engine?.stage { return end }; return nil }() + guard endsAt != scheduledRestEnd else { return } + scheduledRestEnd = endsAt + restTimers.forEach { $0.cancel() } + restTimers = [] + guard let endsAt else { return } + let times = Self.restEventTimes(endsAt: endsAt, now: Self.unixNow) + restTimers.append(after(times.warning) { $0.fireRestWarningIfDue() }) + if let end = times.end { + restTimers.append(after(end) { $0.restDidEnd() }) + } + } + + /// When a rest's warning and end fire, in unix seconds. The warning comes `restWarningLeadSec` before the + /// end, or one second from now when the rest is already inside that window (a short rest, none at all, + /// or the rest left once every set is done) — when the once-a-second tick used to fire it, and clear of + /// the confirming buzz the same tap just sent. The end fires only for a rest still to run. + static func restEventTimes(endsAt: Int, now: Int) -> (warning: Int, end: Int?) { + (warning: max(now + 1, endsAt - restWarningLeadSec), end: endsAt > now ? endsAt : nil) + } + + /// Run `action` on the main actor at unix second `unix`, unless cancelled first. + private func after(_ unix: Int, + _ action: @escaping @MainActor (LiftSessionController) -> Void) -> Task { + let delay = Date(timeIntervalSince1970: TimeInterval(unix)).timeIntervalSinceNow + return Task { [weak self] in + if delay > 0 { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } + guard !Task.isCancelled, let self else { return } + action(self) + } + } private func fireRestWarningIfDue() { guard let engine, case .resting(_, let endsAt) = engine.stage else { return } guard warnedFor != endsAt else { return } - guard endsAt - now <= LiftSessionController.restWarningLeadSec else { return } + guard endsAt - Self.unixNow <= LiftSessionController.restWarningLeadSec else { return } warnedFor = endsAt buzz(LiftSessionController.restWarningBuzzes) } + /// The rest is over: the one moment a session's words change with time alone — on screen and, through + /// `changesSettled`, on the Lock Screen, "Resting after set 2" becomes "Ready for the next set". + private func restDidEnd() { + objectWillChange.send() + } + // MARK: - Persistence private func persist() { diff --git a/Strand/Screens/LiftLiveReadouts.swift b/Strand/Screens/LiftLiveReadouts.swift new file mode 100644 index 0000000000..8fd085044a --- /dev/null +++ b/Strand/Screens/LiftLiveReadouts.swift @@ -0,0 +1,64 @@ +import SwiftUI +import StrandDesign + +// The two numbers on the session's surfaces that change on their own — the running clock and the live heart +// rate — each as its own small view, so a tick or a heartbeat redraws that one number and nothing around it. +// +// They used to be read by the screens that show them: the whole session sheet and the minimised bar watched +// the session's once-a-second tick and every change the app model published, and the sheet also watched the +// strap's live state, which changes with every log line and every beat. So the sheet — a hundred text fields — +// was redrawn several times a second for as long as it was open, even with NOOP off screen, and iOS killed +// NOOP for background CPU four times in one gym session (21 Sep 2026). + +/// A running clock that ticks by itself while it is shown and costs nothing while it is not: a `TimelineView` +/// redraws this one text each second, aligned to the whole second, and SwiftUI runs a timeline only for a view +/// on screen. `seconds` turns the current unix second into what the clock reads; the format is NOOP's +/// `ActiveWorkoutClock.clock`, the one the Lock Screen's clock also reads as. +struct LiftRunningClock: View { + let seconds: (Int) -> Int + + var body: some View { + TimelineView(.periodic(from: Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970)), by: 1)) { + context in + Text(ActiveWorkoutClock.clock(seconds(Int(context.date.timeIntervalSince1970)))) + .monospacedDigit() + } + } +} + +/// The live heart rate: the smoothed, spike-filtered `AppModel.bpm` every screen shows, never the raw per-beat +/// number. ALWAYS shown, a dash when the strap is not reading: an earlier version hid it with no reading, and +/// the first thing that produced was "there is no HR in the minimised tab" — an absent readout is +/// indistinguishable from an absent feature, while a dash says the strap is not reading, which is something to +/// act on. Display only: nothing here feeds a score; Effort stays what the strap measured. +struct LiftHeartRate: View { + enum Style { + /// Heart and number in caption size — the minimised bar. + case compact + /// The number alone in body size, under the sheet's "HR" label. + case plain + } + + let style: Style + @EnvironmentObject private var model: AppModel + + var body: some View { + let tint = model.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose + let value = Text(model.bpm.map(String.init) ?? "—").monospacedDigit() + switch style { + case .compact: + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10, weight: .semibold)) + value.font(StrandFont.captionNumber) + } + .foregroundStyle(tint) + .accessibilityLabel(model.bpm.map { String(localized: "Heart rate \($0)") } + ?? String(localized: "Heart rate")) + case .plain: + value + .font(StrandFont.bodyNumber) + .foregroundStyle(tint) + } + } +} diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 2938cbbaba..a4a01f6bcb 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -16,10 +16,6 @@ import StrandDesign struct LiftSessionBar: View { @EnvironmentObject var session: LiftSessionController - /// Live heart rate, same source as the sheet's control bar: the smoothed, spike-filtered value, - /// never the raw per-beat number. - @EnvironmentObject private var model: AppModel - @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } @@ -60,38 +56,21 @@ struct LiftSessionBar: View { Spacer(minLength: 6) - // Heart rate over the clock, both flush right. The clock's width comes from a hidden - // "00:00" in its font — the widest a set or a rest shows under an hour — so the words - // beside it do not shift each time the clock gains or loses a digit. - // - // The heart rate is ALWAYS shown, dash included. An earlier version hid it whenever - // there was no reading, to save width on a crowded capsule — and the first thing - // that produced was "there is no HR in the minimised tab", because an absent readout - // is indistinguishable from an absent feature. Mid-workout the difference matters: a - // dash says the strap is not reading, which is something to act on. + // Heart rate over the clock, both flush right, each its own small view that updates + // itself (`LiftLiveReadouts.swift`), so a beat or a tick redraws one number, not the bar. + // The clock's width comes from a hidden "00:00" in its font — the widest a set or a rest + // shows under an hour — so the words beside it do not shift when it gains a digit. VStack(alignment: .trailing, spacing: 2) { - HStack(spacing: 3) { - Image(systemName: "heart.fill") - .font(.system(size: 10, weight: .semibold)) - Text(model.bpm.map(String.init) ?? "—") - .font(StrandFont.captionNumber) - .monospacedDigit() - } - .foregroundStyle(model.bpm == nil - ? StrandPalette.textTertiary - : StrandPalette.metricRose) - .accessibilityLabel(model.bpm.map { String(localized: "Heart rate \($0)") } - ?? String(localized: "Heart rate")) + LiftHeartRate(style: .compact) Text(verbatim: "00:00") .font(StrandFont.bodyNumber) .monospacedDigit() .hidden() .overlay(alignment: .trailing) { - Text(bigClock(engine)) + bigClock(engine) .font(StrandFont.bodyNumber) .foregroundStyle(tint(engine)) - .monospacedDigit() .fixedSize() } } @@ -128,10 +107,7 @@ struct LiftSessionBar: View { /// Rest counts DOWN (that is the number you act on); everything else counts up. Written as the Lock /// Screen writes the same clock — "0:45", "0:00", "1:05:00" — through NOOP's one running-clock format. - private func bigClock(_ engine: LiftSessionEngine) -> String { - if let remaining = engine.restRemaining(now: session.now) { - return ActiveWorkoutClock.clock(remaining) - } - return ActiveWorkoutClock.clock(session.now - engine.stageStartedAt) + private func bigClock(_ engine: LiftSessionEngine) -> LiftRunningClock { + LiftRunningClock { now in engine.restRemaining(now: now) ?? now - engine.stageStartedAt } } } diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 314da5cf5a..6c6e78c928 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -19,8 +19,9 @@ import WhoopStore // because a workout outlives the screen you happen to be looking at. struct LiftSessionView: View { + // Only what the sheet draws from. The live heart rate and the running clocks are their own small views + // (`LiftLiveReadouts.swift`): watched from here, every beat, log line and tick redrew the whole sheet. @EnvironmentObject var repo: Repository - @EnvironmentObject var live: LiveState @EnvironmentObject var session: LiftSessionController @Environment(\.dismiss) private var dismiss @@ -44,10 +45,6 @@ struct LiftSessionView: View { private enum UnfinishedChoice: Hashable { case complete, discard } private enum ProgramChoice: Hashable { case update, keep } - /// For the live heart rate on the control bar. `AppModel.bpm` is the smoothed, spike-filtered - /// value every screen is supposed to show — never the raw per-beat number, which swings with HRV. - @EnvironmentObject private var model: AppModel - @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } @@ -399,14 +396,12 @@ struct LiftSessionView: View { /// control bar is pinned to the bottom and this is where your eyes already are — and once the /// sheet is scrolled to a later exercise, the band is the only thing that says which rest. private func restBand(_ engine: LiftSessionEngine) -> some View { - let remaining = engine.restRemaining(now: session.now) ?? 0 - return HStack(spacing: 8) { + HStack(spacing: 8) { Text("Rest period").strandOverline() .foregroundStyle(StrandPalette.metricAmber) Spacer(minLength: 0) - Text(ActiveWorkoutClock.clock(remaining)) + LiftRunningClock { engine.restRemaining(now: $0) ?? 0 } .font(StrandFont.captionNumber) - .monospacedDigit() .foregroundStyle(StrandPalette.metricAmber) } .lineLimit(1) @@ -540,9 +535,7 @@ struct LiftSessionView: View { private func controlBar(_ engine: LiftSessionEngine) -> some View { VStack(spacing: NoopMetrics.rowSpacing) { HStack(spacing: 14) { - clock(String(localized: "Session"), - ActiveWorkoutClock.clock(session.now - engine.startTs), - tint: StrandPalette.textPrimary) + clock(String(localized: "Session"), tint: StrandPalette.textPrimary) { $0 - engine.startTs } stageClock(engine) heartRate() Spacer(minLength: 0) @@ -586,28 +579,26 @@ struct LiftSessionView: View { /// /// It belongs here and not in the scrolling sheet: this strip is the part that never scrolls /// away, and a glance mid-set is the whole use — you are holding a bar, not browsing. Asked for - /// after a real session. - /// - /// Shown even when there is no value, as "—", the same way `LiveView` reports it. A row that - /// disappears when the strap stops streaming would shift the clocks beside it and leave the user - /// wondering whether the reading is missing or the feature is; a dash says which. - /// - /// This is display only. Nothing here feeds a score — Effort stays HR-derived from what the - /// strap MEASURED over the session window, computed by the analytics engine, not by this view. + /// after a real session. Always shown, dash included, and display only (`LiftHeartRate`). private func heartRate() -> some View { - clock(String(localized: "HR"), - model.bpm.map(String.init) ?? "—", - tint: model.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose) + labelled(String(localized: "HR")) { LiftHeartRate(style: .plain) } } - private func clock(_ label: String, _ value: String, tint: Color) -> some View { + /// A running clock under its label. `seconds` turns the current unix second into what it reads. + private func clock(_ label: String, tint: Color, seconds: @escaping (Int) -> Int) -> some View { + labelled(label) { + LiftRunningClock(seconds: seconds) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint) + } + } + + private func labelled(_ label: String, @ViewBuilder value: () -> Value) -> some View { VStack(alignment: .leading, spacing: 1) { Text(label).strandOverline() .lineLimit(1) .minimumScaleFactor(0.7) - Text(value) - .font(StrandFont.bodyNumber) - .foregroundStyle(tint) + value() } } @@ -615,20 +606,20 @@ struct LiftSessionView: View { private func stageClock(_ engine: LiftSessionEngine) -> some View { switch engine.stage { case .working: - clock(String(localized: "This set"), - ActiveWorkoutClock.clock(session.now - engine.stageStartedAt), - tint: StrandPalette.statusPositive) + clock(String(localized: "This set"), tint: StrandPalette.statusPositive) { + $0 - engine.stageStartedAt + } case .resting: // "Rest period", never "Rest": the catalog's "Rest" key is NOOP's SLEEP metric, so this // label rendered as "Erholung" (recovery) in German — the exact collision CLAUDE.md and // the handover brief both warn about. Reintroduced by the workout-sheet rewrite. - clock(String(localized: "Rest period"), - ActiveWorkoutClock.clock(engine.restRemaining(now: session.now) ?? 0), - tint: StrandPalette.metricAmber) + clock(String(localized: "Rest period"), tint: StrandPalette.metricAmber) { + engine.restRemaining(now: $0) ?? 0 + } case .warmup, .finished: - clock(String(localized: "Warm-up"), - ActiveWorkoutClock.clock(session.now - engine.stageStartedAt), - tint: StrandPalette.textSecondary) + clock(String(localized: "Warm-up"), tint: StrandPalette.textSecondary) { + $0 - engine.stageStartedAt + } } } diff --git a/StrandTests/LiftSessionTimingTests.swift b/StrandTests/LiftSessionTimingTests.swift new file mode 100644 index 0000000000..192fde3831 --- /dev/null +++ b/StrandTests/LiftSessionTimingTests.swift @@ -0,0 +1,84 @@ +import XCTest +import Combine +@testable import Strand +import WhoopStore + +/// A running session does no work between taps: no once-a-second tick, only a rest's two moments. +/// +/// From Utku's crash reports of 21 Sep 2026: iOS killed NOOP four times in one gym session for background CPU +/// (over 80% for 60 s, redrawing SwiftUI views). The session published a tick every second to every screen +/// watching it, so each of them was redrawn every second, on screen or not. +@MainActor +final class LiftSessionTimingTests: XCTestCase { + + private func plan(restSec: Int) -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 2, restSec: restSec)] + } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + /// Nothing is published while nothing happens — the property that stops a session from redrawing every + /// screen that watches it once a second. + func testASessionPublishesNothingBetweenTaps() { + let c = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + c.start(plan: plan(restSec: 90), programId: "p", programName: "Upper A") + c.advance() // a set being worked: its clock runs on screen + var changes = 0 + let watching = c.objectWillChange.sink { changes += 1 } + RunLoop.main.run(until: Date().addingTimeInterval(2.5)) + watching.cancel() + XCTAssertEqual(changes, 0, "a running set changes nothing the session publishes; its clock ticks by itself") + } + + /// A rest's end is the one moment the words change with time alone, and the warning buzz still comes. + func testARestsEndIsPublishedOnceAndItsWarningBuzzes() { + var buzzes: [UInt8] = [] + let c = LiftSessionController(buzz: { buzzes.append($0) }, setStrapHandler: { _ in }) + c.start(plan: plan(restSec: 2), programId: "p", programName: "Upper A") + c.advance() // set 1 working + c.advance() // set 1 done: a 2 s rest + XCTAssertEqual(c.presentation(system: .metric)?.status, "Resting after set 1") + var changes = 0 + let watching = c.objectWillChange.sink { changes += 1 } + RunLoop.main.run(until: Date().addingTimeInterval(3.2)) + watching.cancel() + XCTAssertEqual(changes, 1, "the rest's end, once") + XCTAssertEqual(buzzes, [LiftSessionController.restWarningBuzzes], "the warning, once") + XCTAssertEqual(c.presentation(system: .metric)?.status, "Ready for the next set") + } + + /// When the warning and the end fire. A rest inside the warning window warns a second from now, clear of + /// the tap's own buzz; a rest already over has no end to wait for. + func testRestEventTimes() { + let now = 1_700_000_000 + let lead = LiftSessionController.restWarningLeadSec + let long = LiftSessionController.restEventTimes(endsAt: now + 90, now: now) + XCTAssertEqual(long.warning, now + 90 - lead) + XCTAssertEqual(long.end, now + 90) + let short = LiftSessionController.restEventTimes(endsAt: now + 3, now: now) + XCTAssertEqual(short.warning, now + 1) + XCTAssertEqual(short.end, now + 3) + let over = LiftSessionController.restEventTimes(endsAt: now, now: now) + XCTAssertEqual(over.warning, now + 1) + XCTAssertNil(over.end) + } + + /// Undoing out of a rest cancels its timers: no warning, no end, for a rest that no longer runs. + func testARestUndoneFiresNothing() { + var buzzes: [UInt8] = [] + let c = LiftSessionController(buzz: { buzzes.append($0) }, setStrapHandler: { _ in }) + c.start(plan: plan(restSec: 2), programId: "p", programName: "Upper A") + c.advance() + c.advance() // resting, 2 s + c.undo() // back to working set 1 + var changes = 0 + let watching = c.objectWillChange.sink { changes += 1 } + RunLoop.main.run(until: Date().addingTimeInterval(3.2)) + watching.cancel() + XCTAssertEqual(changes, 0) + XCTAssertEqual(buzzes, []) + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index f1ee145fef..6652aaa4fa 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -250,11 +250,11 @@ struct StrandiOSApp: App { effort: day?.strain.map { Int($0.rounded()) } ) } - // The gym session's own banner. Driven off the session's 1 Hz tick so a stage change - // reaches the Lock Screen promptly; the controller decides what is actually worth - // pushing, since the widget's clocks tick on their own. - .onReceive(liftSession.$now) { _ in pushLiftActivity() } - .onReceive(liftSession.$engine) { _ in pushLiftActivity() } + // The gym session's own banner follows each change to the session once it has landed — + // a stage, typed numbers, a rest's end — and the heart rate above; the controller decides + // what is worth pushing, and the banner's clocks tick on their own. A strap step is pushed + // at once, with its light-up alert, below. + .onReceive(liftSession.changesSettled) { _ in pushLiftActivity() } // A strap double-tap lights the Lock Screen on the step it took. .onReceive(liftSession.strapStepTaken) { _ in pushLiftActivity(alert: true) } // #911/#759: republish the Home/Lock-Screen widget whenever the dashboard caches actually @@ -347,6 +347,9 @@ struct StrandiOSApp: App { .onChange(of: scenePhase) { _, phase in if phase == .active { model.drainPendingIntents(router: router) + // iOS starts a Lift Log banner only for an app on screen, so a banner lost while NOOP was in + // the background comes back now, whether or not the strap is sending anything. + pushLiftActivity() // End a "Connecting…" sync island whose sync never came, rather than leave it greyed. SyncLiveActivityController.shared.reconcile(live: model.live) // Re-arm the strap's smart alarm on foreground: the firmware alarm is a single instant diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index d41f5f8e91..991380d68c 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -26,9 +26,9 @@ final class LiftLiveActivityController { private var lastPush: Date = .distantPast private var lastSignature: String? /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is - /// consulted on every session tick and its value only changes via Settings. + /// consulted on every push and its value only changes via Settings. private let authInfo = ActivityAuthorizationInfo() - /// Guards against two ticks both firing `Activity.request` before the first has returned. + /// Guards against two pushes both firing `Activity.request` before the first has returned. private var isStarting = false /// Heart rate moves constantly; everything else does not. A change in HR alone is worth a push, /// but not more often than this, or a session becomes one push per second. @@ -142,7 +142,7 @@ final class LiftLiveActivityController { return alert ? .noBanner : nil } waitingForForeground = false - // Set synchronously before any await, so a second tick arriving while `Activity.request` + // Set synchronously before any await, so a second push arriving while `Activity.request` // is still in flight bails here instead of creating a duplicate activity. guard !isStarting else { return alert ? .noBanner : nil } isStarting = true From 4966e334dbc340df944673c717dfe1b0d528c3f5 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:25:57 +0200 Subject: [PATCH 21/24] lift log: the Dynamic Island shows the heart rate, and its clock keeps to the edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utku's sixth gym session (22 Sep): the island's clock sat adrift in the middle with blank to its right, and the heart rate — which the Lock Screen banner shows — was nowhere. Both come from the compact regions. A running Text(timerInterval:) takes every point it is offered, so the trailing region stretched the island and left the digits floating in it; the Lock Screen already solves this with a hidden "00:00" in the same font and the live clock right-aligned over it, and the island now does the same. Leading carries the heart rate (the dumbbell stands in until the strap reports one), which is what the space was for. Simulator, same session and a heart rate of 128: before, the pill spans most of the screen with "0:10" in its middle; after, it is a third narrower with the heart rate at the left edge and the clock at the right. Co-Authored-By: Claude Opus 5 --- StrandiOSWidgets/LiftLiveActivity.swift | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift index 4a3d7f342c..57dad8eb03 100644 --- a/StrandiOSWidgets/LiftLiveActivity.swift +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -50,10 +50,29 @@ struct LiftLiveActivity: Widget { } } } compactLeading: { - Image(systemName: "dumbbell.fill").foregroundStyle(tint) + // The heart rate, where the island has the room for it, with the dumbbell standing in until + // the strap reports one — so this side is never the blank it was (Utku, 22 Sep 2026). + Label { + Text(context.state.bpm.map(String.init) ?? "").monospacedDigit() + } icon: { + Image(systemName: context.state.bpm == nil ? "dumbbell.fill" : "heart.fill") + } + .font(Self.islandFont) + .foregroundStyle(context.state.bpm == nil ? tint : StrandPalette.metricRose) } compactTrailing: { - clock(context.state, tint: tint) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + // Sized like the Lock Screen's clock: a running `Text(timerInterval:)` takes every point it + // is offered, which stretched the island and left the digits adrift in its middle with blank + // to their right (Utku, 22 Sep 2026). A hidden "00:00" in the same font gives the region the + // width of the clock itself, and the live one is right-aligned over it. + Text(verbatim: "00:00") + .font(Self.islandFont) + .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + clock(context.state, tint: tint) + .font(Self.islandFont) + .multilineTextAlignment(.trailing) + } } minimal: { Image(systemName: "dumbbell.fill").foregroundStyle(tint) } @@ -62,6 +81,8 @@ struct LiftLiveActivity: Widget { /// The Lock Screen clock's face, shared by the clock and the hidden template that sizes it. private static let clockFont = Font.system(size: 22, weight: .bold, design: .rounded) + /// The Dynamic Island's compact face, shared by its heart rate, its clock and that clock's template. + private static let islandFont = Font.system(size: 13, weight: .semibold, design: .rounded) /// Green while working, amber through the rest — the sheet's and the bar's colour language. private func tint(_ state: LiftActivityAttributes.ContentState) -> Color { From 3764aca690a8f11983a075081d4281b7f2d0d29a Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:25:57 +0200 Subject: [PATCH 22/24] lift log: a heart rate moves the Lock Screen banner rarely, not every 10 s Utku's sixth gym session (22 Sep): after 20:42 the Lock Screen lit 5-10 seconds after a strap double-tap, while the buzz stayed immediate; it recovered by 21:00. The strap log shows every step's alert leaving the app at once (tap to buzz 0.26-0.74 s all night), so the wait was on iOS's side of the call, and the app's own contribution to that is how much of the activity budget it spends on nothing: the banner was pushed for the live heart rate every 10 s, about 409 pushes across his session, each waking the widget extension to re-render. Now a heart rate alone moves the banner only when it has changed by at least 2 bpm and at most every 30 s (5 s for the strap appearing or disappearing, which is a change of state rather than a moving number): about 143 pushes over the same session. Everything a person would notice still pushes at once, carrying the current heart rate, and the clocks tick client-side as before. The per-second work goes too. StrandiOSApp's live-HR closure called pushLiftActivity() on every tick, building a whole presentation (localized strings, formatted numbers) and a content state before the controller decided not to send it. It now calls updateHeartRate, which touches nothing unless the policy says the number is worth a push. LiftBannerPushPolicy is pure and lives in Strand/Data so StrandTests covers it, since no CI compiles the app targets. Five tests; removing the interval or the presence floor each fails them. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftBannerPushPolicy.swift | 48 +++++++++++++++++ StrandTests/LiftBannerPushPolicyTests.swift | 53 +++++++++++++++++++ StrandiOS/App/StrandiOSApp.swift | 5 +- .../Widgets/LiftLiveActivityController.swift | 29 ++++++++-- 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 Strand/Data/LiftBannerPushPolicy.swift create mode 100644 StrandTests/LiftBannerPushPolicyTests.swift diff --git a/Strand/Data/LiftBannerPushPolicy.swift b/Strand/Data/LiftBannerPushPolicy.swift new file mode 100644 index 0000000000..c7fd6f1517 --- /dev/null +++ b/Strand/Data/LiftBannerPushPolicy.swift @@ -0,0 +1,48 @@ +import Foundation + +/// When a new heart rate is worth pushing the gym session's Lock Screen banner. +/// +/// WHY THIS EXISTS. Live heart rate arrives once a second while a strap streams, and the banner shows it. +/// Pushing the banner for each one spends the phone twice over: every push wakes the widget extension to +/// re-render the Lock Screen and the Dynamic Island, and ActivityKit budgets how often an app may update an +/// activity — an app that spends that budget on a number nobody is reading has none left for the update that +/// matters, the one carrying the light-up alert on a strap double-tap. On 22 Sep 2026 a 75-minute session sent +/// about 450 heart-rate pushes (one per 10 s) and 30 alerts, and the light-ups ran 5–10 seconds late for the +/// middle of the session while the buzz stayed immediate. +/// +/// So a heart rate alone moves the banner rarely: only when it has changed enough to read differently, and not +/// more often than `interval`. Everything else a person would notice — the stage, the set, the numbers — still +/// pushes at once, carrying whatever the heart rate is at that moment, and the clocks tick client-side without +/// any push at all. Appearing or disappearing (the strap dropping, or coming back) is a visible change rather +/// than a moving number, so it is allowed sooner. +/// +/// Pure and platform-free so `StrandTests` covers it: the controller it serves lives in the iOS app target, +/// which no CI job compiles (`AGENTS.md`). +enum LiftBannerPushPolicy { + + /// The shortest time between two pushes caused by the heart rate alone. A glance at the Lock Screen + /// between sets wants a number that is current to the set, not to the second. + static let interval: TimeInterval = 30 + + /// How far the heart rate must have moved to be worth a push of its own. Under this it is the same + /// reading with noise on it. + static let step = 2 + + /// The floor for the strap appearing or disappearing (a number becoming "—", or the reverse): rare, and + /// a visible change of state rather than a moving number. + static let presenceInterval: TimeInterval = 5 + + /// `shown` is the heart rate the banner is currently showing, `latest` what the app has now; nil is the + /// dash the banner shows with no live strap. `sinceLastPush` is how long ago the banner was last pushed + /// for any reason. + static func heartRateDue(shown: Int?, latest: Int?, sinceLastPush: TimeInterval) -> Bool { + switch (shown, latest) { + case let (shown?, latest?): + return abs(latest - shown) >= step && sinceLastPush >= interval + case (nil, nil): + return false + default: + return sinceLastPush >= presenceInterval + } + } +} diff --git a/StrandTests/LiftBannerPushPolicyTests.swift b/StrandTests/LiftBannerPushPolicyTests.swift new file mode 100644 index 0000000000..0f3dc12f5c --- /dev/null +++ b/StrandTests/LiftBannerPushPolicyTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import Strand + +/// What the gym banner may spend on a heart rate. The session of 22 Sep 2026 pushed it about 450 times in 75 +/// minutes for a number nobody was reading, and the light-ups a strap double-tap asks for ran 5–10 s late in +/// the middle of it. +final class LiftBannerPushPolicyTests: XCTestCase { + + func testTheSameReadingNeverPushes() { + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: 128, sinceLastPush: 600)) + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: nil, latest: nil, sinceLastPush: 600)) + } + + func testNoiseNeverPushes() { + // One beat apart is the same reading with noise on it, however long ago the last push was. + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: 129, sinceLastPush: 600)) + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: 127, sinceLastPush: 600)) + } + + func testARealChangePushesOnlyOnceTheIntervalHasPassed() { + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 120, latest: 145, sinceLastPush: 29)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: 120, latest: 145, sinceLastPush: 30)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: 145, latest: 120, sinceLastPush: 30)) + } + + /// A strap dropping (or coming back) changes the banner from a number to a dash: a visible change of + /// state, not a moving number, so it is allowed sooner — but still not on every tick. + func testTheStrapAppearingOrDisappearingIsAllowedSooner() { + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: nil, sinceLastPush: 4)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: nil, sinceLastPush: 5)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: nil, latest: 128, sinceLastPush: 5)) + } + + /// The cost this exists for: a strap streaming once a second through a 75-minute session. + func testASessionOfTicksBecomesAHandfulOfPushes() { + var since: TimeInterval = 0 + var shown: Int? = 100 + var pushes = 0 + for second in 0..<(75 * 60) { + // A heart rate that drifts across a wide range, as a working set does. + let latest = 100 + Int((sin(Double(second) / 40) * 35).rounded()) + if LiftBannerPushPolicy.heartRateDue(shown: shown, latest: latest, sinceLastPush: since) { + pushes += 1 + shown = latest + since = 0 + } else { + since += 1 + } + } + XCTAssertLessThanOrEqual(pushes, 150, "a heart rate alone must not push more than twice a minute") + XCTAssertGreaterThan(pushes, 40, "it still follows a moving heart rate") + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 6652aaa4fa..67e93f8b89 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -235,7 +235,10 @@ struct StrandiOSApp: App { connected: model.live.connected && !liftSession.isActive && !model.live.backfilling, effort: day?.strain.map { Int($0.rounded()) } ) - pushLiftActivity() + // The gym banner's own cheap path: no presentation is built here, and a heart rate moves + // the banner only when `LiftBannerPushPolicy` says it is worth a push. Everything else + // about the session pushes through `pushLiftActivity` below, carrying the current number. + liftActivity.updateHeartRate(model.live.connected ? (model.bpm ?? model.live.heartRate) : nil) } // End the Live Activity the moment the link drops, even if no further HR tick arrives. .onReceive(model.live.$connected) { isConnected in diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index 991380d68c..1beefdb4c4 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -25,14 +25,14 @@ final class LiftLiveActivityController { private var waitingForForeground = false private var lastPush: Date = .distantPast private var lastSignature: String? + /// The state the banner is showing, so a heart-rate tick can push a copy of it without the app + /// building a whole presentation again (`updateHeartRate`). + private var lastState: LiftActivityAttributes.ContentState? /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is /// consulted on every push and its value only changes via Settings. private let authInfo = ActivityAuthorizationInfo() /// Guards against two pushes both firing `Activity.request` before the first has returned. private var isStarting = false - /// Heart rate moves constantly; everything else does not. A change in HR alone is worth a push, - /// but not more often than this, or a session becomes one push per second. - private static let heartRateMinInterval: TimeInterval = 10 /// How long iOS may keep showing the activity as fresh without a push. Generous, because a long /// rest legitimately produces no content change at all — the clock is ticking client-side. private static let staleAfter: TimeInterval = 15 * 60 @@ -109,7 +109,8 @@ final class LiftLiveActivityController { ].joined(separator: "|") let contentChanged = signature != lastSignature - let heartRateDue = Date().timeIntervalSince(lastPush) >= Self.heartRateMinInterval + let heartRateDue = LiftBannerPushPolicy.heartRateDue( + shown: lastState?.bpm, latest: state.bpm, sinceLastPush: Date().timeIntervalSince(lastPush)) let content = ActivityContent(state: state, staleDate: Date().addingTimeInterval(Self.staleAfter)) @@ -118,6 +119,7 @@ final class LiftLiveActivityController { let lightsScreen = alert && !appOnScreen guard contentChanged || heartRateDue || lightsScreen else { return alert ? .appOnScreen : nil } lastSignature = signature + lastState = state lastPush = Date() if lightsScreen { let stepAlert = AlertConfiguration( @@ -152,6 +154,7 @@ final class LiftLiveActivityController { content: content, pushType: nil) lastSignature = signature + lastState = state lastPush = Date() } catch { activity = nil @@ -162,6 +165,23 @@ final class LiftLiveActivityController { } } + /// A new live heart rate, straight from the HR stream: the cheap path, called once a second. + /// + /// It never builds a presentation and never starts a banner — only a banner already on the Lock Screen + /// takes a heart-rate push, and only when `LiftBannerPushPolicy` says the number is worth one. Everything + /// else the banner shows comes from `update(state:alert:)`. + func updateHeartRate(_ bpm: Int?) { + guard let activity, let state = lastState, state.bpm != bpm else { return } + guard LiftBannerPushPolicy.heartRateDue(shown: state.bpm, latest: bpm, + sinceLastPush: Date().timeIntervalSince(lastPush)) else { return } + var next = state + next.bpm = bpm + lastState = next + lastPush = Date() + let content = ActivityContent(state: next, staleDate: Date().addingTimeInterval(Self.staleAfter)) + Task { await activity.update(content) } + } + func end() async { // End every lift activity, not just the cached handle — covers a straggler from a previous // app session that was never re-adopted, and any rare duplicate. @@ -170,6 +190,7 @@ final class LiftLiveActivityController { } activity = nil lastSignature = nil + lastState = nil waitingForForeground = false } From 839616adddca961314dd572dc238747e32ae36b2 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:20:49 +0200 Subject: [PATCH 23/24] lift log: its strap-log lines carry their own time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOOP's strap log takes each line's time from whoever writes it, and the Lift Log's lines never added one: all 98 in Utku's 22 Sep session — every double-tap handed over, every light-up alert, every session or banner picked up after a restart — sat between stamped lines with no clock of their own, so when a step happened had to be inferred from its neighbours. That is the one question a gym log is read for. AppModel.stamped() puts the same HH:mm:ss prefix BLEManager uses in front of them, at the five places the Lift Log writes: the double-tap dispatch and its debounce line, the two late/duplicate gesture lines in FrameRouter, and the session, banner and light-up lines routed through StrandiOSApp's log closures. Nothing else about the log changes -- not its content, its rate or its buffer. Two tests; removing the prefix fails both. Co-Authored-By: Claude Opus 5 --- Strand/App/AppModel.swift | 14 ++++++++++--- Strand/BLE/FrameRouter.swift | 8 +++++--- StrandTests/LiftLogLineStampTests.swift | 26 +++++++++++++++++++++++++ StrandiOS/App/StrandiOSApp.swift | 6 +++--- 4 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 StrandTests/LiftLogLineStampTests.swift diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 8b607e82f2..588a25d37e 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -33,6 +33,14 @@ final class AppModel: ObservableObject { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f }() + /// One of our own strap-log lines, stamped like the lines around it. NOOP's log takes each line's time from + /// whoever writes it, and the Lift Log's lines arrived without one: all 98 of them in Utku's 22 Sep session, + /// so the moment a tap or a step happened had to be inferred from the neighbouring lines. A diagnostic says + /// when it happened. + static func stamped(_ line: String) -> String { + "[\(logTimeFormatter.string(from: Date()))] \(line)" + } + /// The CANONICAL imported/computed id ("my-whoop"). The WHOOP-IMPORT target (`WhoopImporter`), the /// FusionSource `.whoopImport` mapping, and a manually-saved workout all land under THIS stable id, and /// the engine writes its computed scores under the matching `-noop` sibling. It must NOT follow the @@ -1718,16 +1726,16 @@ final class AppModel: ObservableObject { let now = Date() let since = now.timeIntervalSince(lastDoubleTapAt) guard since > 1.2 else { // debounce repeats - live.append(log: String(format: "Double-tap ignored: %.1f s after the previous one (debounce 1.2 s)", since)) + live.append(log: Self.stamped(String(format: "Double-tap ignored: %.1f s after the previous one (debounce 1.2 s)", since))) return } lastDoubleTapAt = now if let override = strapDoubleTapOverride { - live.append(log: "Double-tap → Lift Log: next") + live.append(log: Self.stamped("Double-tap → Lift Log: next")) override() return } - live.append(log: "Double-tap → \(behavior.doubleTapAction.label)") + live.append(log: Self.stamped("Double-tap → \(behavior.doubleTapAction.label)")) runMacAction(behavior.doubleTapAction, shortcut: behavior.doubleTapShortcut) } diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 7d0bdc8689..d95196f81b 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -812,8 +812,9 @@ public final class FrameRouter { // register" can be checked: a live dispatch leaves "Double-tap → …" at that moment, and a // tap that only ever came through a sync leaves just this. It asserts only the delivery seen. if ev.hasPrefix("DOUBLE_TAP"), age > 0, age <= FrameRouter.lateGestureLogSeconds { - state.append(log: "Double-tap (strap time \(ts)) arrived \(age) s late during a sync; " - + "not acted on (live window \(FrameRouter.liveGestureWindowSeconds) s)") + state.append(log: AppModel.stamped( + "Double-tap (strap time \(ts)) arrived \(age) s late during a sync; " + + "not acted on (live window \(FrameRouter.liveGestureWindowSeconds) s)")) } return } @@ -872,7 +873,8 @@ public final class FrameRouter { guard !dispatchedDoubleTapEventTs.contains(ts) else { // Only a gesture actually held back leaves a line, so a tap reported as missing can be // told apart from a replay being suppressed. - state.append(log: "Double-tap (strap time \(ts)) not dispatched: that event was already handled") + state.append(log: AppModel.stamped( + "Double-tap (strap time \(ts)) not dispatched: that event was already handled")) return } // Prune BEFORE appending, so the event just accepted is always the one kept. diff --git a/StrandTests/LiftLogLineStampTests.swift b/StrandTests/LiftLogLineStampTests.swift new file mode 100644 index 0000000000..8215e7c008 --- /dev/null +++ b/StrandTests/LiftLogLineStampTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import Strand + +/// Every line the Lift Log writes into NOOP's strap log carries its own time. +/// +/// NOOP's log takes each line's time from whoever writes it. The Lift Log's lines did not: all 98 of them in +/// Utku's 22 Sep session — every double-tap, every light-up, every session picked up after a restart — so the +/// moment one happened had to be inferred from the neighbouring lines, which is exactly what a strap log is read +/// for. +@MainActor +final class LiftLogLineStampTests: XCTestCase { + + func testAStampedLineLeadsWithItsOwnClock() { + let line = AppModel.stamped("Double-tap → Lift Log: next") + XCTAssertNotNil(line.range(of: #"^\[\d\d:\d\d:\d\d\] Double-tap → Lift Log: next$"#, options: .regularExpression), + line) + } + + /// The same shape as the lines it sits between — `BLEManager`'s own `HH:mm:ss` — so a reader and + /// `dist/tools/strap-log.py` see one format, not two. + func testItMatchesTheShapeOfTheLinesAroundIt() { + let stamp = AppModel.logTimeFormatter.string(from: Date()) + XCTAssertNotNil(stamp.range(of: #"^\d\d:\d\d:\d\d$"#, options: .regularExpression), stamp) + XCTAssertTrue(AppModel.stamped("x").hasPrefix("[\(stamp.prefix(2))")) + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 67e93f8b89..3911ac645b 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -99,11 +99,11 @@ struct StrandiOSApp: App { model?.strapDoubleTapOverride = handler }, log: { [weak model] line in - model?.live.append(log: line) + model?.live.append(log: AppModel.stamped(line)) }) _liftSession = StateObject(wrappedValue: liftSession) _liftActivity = State(initialValue: LiftLiveActivityController(log: { [weak model] line in - model?.live.append(log: line) + model?.live.append(log: AppModel.stamped(line)) })) // A gym session keeps ONE banner on the Lock Screen, its own — as the live-HR banner already // stands aside for it. A sync started in the foreground mid-session starts no sync banner. @@ -439,7 +439,7 @@ struct StrandiOSApp: App { restEndsAt: p.restEndsAt), alert: alert) // One line per strap step into NOOP's strap log: whether the Lock Screen was asked to light. - if let lightUp { model.live.append(log: lightUp.logLine) } + if let lightUp { model.live.append(log: AppModel.stamped(lightUp.logLine)) } } } From 8b05e0bb53e0ef9b55f88f3965e41246252ba9a7 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:07:47 +0200 Subject: [PATCH 24/24] parity: re-derive the twin-map authority after rebasing onto 751fa1d8 Generated with `parity_ledger.py --refresh-derived --base upstream/main`, never edited by hand. The branch's own refresh commit was written against a56840bb; upstream has since merged the diagnostics work and migrated the authority itself (751fa1d8), so the pairs this branch adds - LiftMetrics' isPerformed and deleteLiftSets - are re-derived on top of that. Co-Authored-By: Claude Opus 5 --- Tools/parity_twin_map.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index d1eef6e041..5d24c5ebb3 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,14 +19,14 @@ }, "authority": { "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4455, "sha256": "90e4c076d5a9f04e767051baa9d9029f6aa936a35faa4130fb7e294448f5fb35"}, + "functions": {"count": 4459, "sha256": "4734adec2001e8c458b5f1813925f75733ba69c933c7b5297af86611785568d4"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, "constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"}, - "file_pairs": {"count": 68, "sha256": "414dbafb27e1e35cf65cff54f6ff780f102f009762c1dfb12d91b0980fe30854"}, - "function_pairs": {"count": 176, "sha256": "f9bd271e0786632d3831e5df61cd4ad13bf2b234475d58e0d8eabf27b65fb738"}, + "file_pairs": {"count": 69, "sha256": "27a0b67ba151c215a896ebc05ffa34ec03a4af23d27faa90dfed3f2e0ba4c733"}, + "function_pairs": {"count": 178, "sha256": "8e5cfab248e447e474f1bdab6145745d6d050a137bdf28f5916e90106ada0267"}, "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, "constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"}, - "unpaired_files": {"count": 384, "sha256": "17285ce29f015a373969bbcb13042b100a15d580825785f824680e0880b5d777"}, + "unpaired_files": {"count": 382, "sha256": "65483465681e7d561092e17aa259349ac1fb122688f9bd551300e0d6c56c25a8"}, "unpaired_functions": {"count": 4109, "sha256": "2c60f7e79cc2933746e70e441ea133c8d430b9cc9050c067d2701dadb9d4ad33"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"}