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/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift index 1f4a801699..af57e6d02f 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 } @@ -196,6 +199,17 @@ 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, ["target_max_rpe", "max_rpe", "rpe_max", "target_rpe", "rpe"]) + if let rpe = maxRpe, !(1...10).contains(rpe) { + maxRpe = nil + if warnings.count < maxWarnings { + 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")) + } + } + // 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)) }) 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 18e381be5d..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? - /// Target RPE on the user's own 1-10 scale. + /// 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? @@ -586,6 +587,23 @@ 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 } + 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 +662,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 +674,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 +698,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 +717,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/App/AppModel.swift b/Strand/App/AppModel.swift index 7f800cfb00..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 @@ -1712,22 +1720,22 @@ 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() 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 89712797c8..d95196f81b 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) } @@ -804,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 } @@ -864,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/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/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/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 922116bf77..72c3ca1bbf 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. // @@ -12,7 +13,7 @@ import WhoopStore // 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, @@ -21,13 +22,11 @@ import WhoopStore @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 @@ -35,6 +34,21 @@ 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 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 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() + + /// 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 @@ -78,12 +92,20 @@ 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 /// 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 @@ -92,11 +114,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 = 5 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 @@ -107,13 +134,27 @@ final class LiftSessionController: ObservableObject { self.programId = programId self.programName = programName warnedFor = nil - now = stamp isPresented = true claimStrap() - startTicking() 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) { @@ -125,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. @@ -158,48 +197,76 @@ final class LiftSessionController: ObservableObject { programId = nil programName = nil warnedFor = nil + lastStrapStepAt = nil pendingWarmups = [] pendingValues = [:] isPresented = false - ticker?.cancel() - ticker = nil 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) } - }) - } - - 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() - } + setStrapHandler({ [weak self] in self?.advance(fromStrap: true) }) } + /// The current unix second. Read when needed; nothing about a session is stored per second. + static var unixNow: Int { Int(Date().timeIntervalSince1970) } + // 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) + // 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() } + /// 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. 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. + static func isKnock(secondsSinceLastStep: Int, stage: LiftSessionEngine.Stage, now: Int) -> Bool { + guard (0.. 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) } 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") : 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)) @@ -340,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() } @@ -354,6 +436,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 @@ -409,9 +506,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 leave them out. - 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 @@ -427,35 +524,48 @@ 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 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) - 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 } + // 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 shown = values(of: set.slot) + 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) { 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 ?? planned(slot) : nil, isWarmup: pendingWarmups.contains(slot), startTs: nil, endTs: nil, restSec: nil)) } return out } + /// 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) } + } + /// A program line whose set count this session changed. struct SetCountChange: Equatable { var itemId: String @@ -466,7 +576,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 @@ -477,6 +588,70 @@ 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 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 }) @@ -488,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/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 8134b7a54f..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 @@ -219,6 +226,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 } @@ -283,13 +307,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 @@ -405,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 420de042a8..d62de7d5b3 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": "補全"}} @@ -1893,12 +1893,30 @@ "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": "你更改了組數。要把新的組數儲存到計畫裡,下次使用嗎?"}} } }, + "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 組"}} } }, @@ -1914,12 +1932,30 @@ "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 保留在「編輯各組」中,可以補填。"}} + } }, + "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, 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 %@"}} + } }, "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": "為 %@ 新增一組"}} } }, @@ -2067,6 +2103,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": "沒有進行中的訓練" } } } }, @@ -2205,9 +2247,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/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 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/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/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..c1adedfeda 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,22 +51,22 @@ 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. 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 { @@ -75,7 +76,7 @@ struct LiftProgramItemSheet: View { ) { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { exerciseSection - muscleSection + LiftMusclePicker(primary: $primary, secondaries: $secondaries) targetsSection noteSection footer @@ -140,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) @@ -180,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 { @@ -280,10 +189,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. 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) + } + 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, and a set you leave unrated saves it as its rating.") + .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) @@ -365,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 @@ -383,6 +300,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 } @@ -400,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. @@ -436,11 +345,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) }, @@ -449,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/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 9299882f3c..a4a01f6bcb 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -16,59 +16,64 @@ 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 } 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: { - 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)) .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`). + Text(shown.next) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .truncationMode(.tail) } - Spacer(minLength: 0) + Spacer(minLength: 6) + + // 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) { + LiftHeartRate(style: .compact) - // 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) + Text(verbatim: "00:00") + .font(StrandFont.bodyNumber) .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + bigClock(engine) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint(engine)) + .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. @@ -80,7 +85,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)) @@ -99,27 +105,9 @@ 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 String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") - } - 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) { - return LiftFormat.duration(remaining) - } - return LiftFormat.duration(max(0, session.now - engine.stageStartedAt)) + /// 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) -> LiftRunningClock { + LiftRunningClock { now in engine.restRemaining(now: now) ?? now - engine.stageStartedAt } } } 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/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 28b9bc44b0..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 @@ -37,14 +38,13 @@ 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 } - /// 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 } @@ -89,7 +89,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 +111,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,7 +123,46 @@ 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 { @@ -354,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(LiftFormat.duration(remaining)) + LiftRunningClock { engine.restRemaining(now: $0) ?? 0 } .font(StrandFont.captionNumber) - .monospacedDigit() .foregroundStyle(StrandPalette.metricAmber) } .lineLimit(1) @@ -404,10 +444,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. + /// 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 { - engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" + if let planned = engine.planItem(for: slot)?.targetRpe { return LiftFormat.trim(planned) } + return engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" } private func display(_ kg: Double) -> String { @@ -493,9 +535,7 @@ struct LiftSessionView: View { private func controlBar(_ engine: LiftSessionEngine) -> some View { VStack(spacing: NoopMetrics.rowSpacing) { HStack(spacing: 14) { - clock(String(localized: "Session"), - LiftFormat.duration(max(0, session.now - engine.startTs)), - tint: StrandPalette.textPrimary) + clock(String(localized: "Session"), tint: StrandPalette.textPrimary) { $0 - engine.startTs } stageClock(engine) heartRate() Spacer(minLength: 0) @@ -539,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) } + } + + /// 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 clock(_ label: String, _ value: String, tint: Color) -> some View { + 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() } } @@ -568,20 +606,20 @@ struct LiftSessionView: View { private func stageClock(_ engine: LiftSessionEngine) -> some View { switch engine.stage { case .working: - clock(String(localized: "This set"), - LiftFormat.duration(max(0, 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"), - LiftFormat.duration(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"), - LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), - tint: StrandPalette.textSecondary) + clock(String(localized: "Warm-up"), tint: StrandPalette.textSecondary) { + $0 - engine.stageStartedAt + } } } @@ -598,8 +636,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) { @@ -619,31 +658,23 @@ 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) - } + 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. + 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: { @@ -677,14 +708,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 leave them out. + /// 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) @@ -694,20 +726,35 @@ 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) + } } } } - /// 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) @@ -716,6 +763,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)) @@ -726,6 +778,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 @@ -761,16 +822,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 empty a session completely: 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 { - if programChoice == .update { - await writeSetCountsToProgram(store: store, plan: engine.plan) - } + // 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 { + await writeProgram(store: store, plan: engine.plan, sets: finished) await finishAndDismiss() return } @@ -802,9 +860,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 @@ -837,18 +893,22 @@ 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 (`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 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)) + 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) } /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer diff --git a/StrandTests/FrameRouterDoubleTapDedupTests.swift b/StrandTests/FrameRouterDoubleTapDedupTests.swift index 1daba46e42..64386435d0 100644 --- a/StrandTests/FrameRouterDoubleTapDedupTests.swift +++ b/StrandTests/FrameRouterDoubleTapDedupTests.swift @@ -172,6 +172,25 @@ final class FrameRouterDoubleTapDedupTests: XCTestCase { XCTAssertEqual(fired, 3, "re-walking the banked log adds no gestures") } + // MARK: - The confirming buzz + + /// The double-tap is handed on before the same event kicks a sync. Whatever the tap triggers — + /// the Lift Log's confirming buzz — is then written to the strap ahead of the sync request, which + /// the strap would otherwise answer with a history transfer before playing the buzz (strap log, + /// 16 Sep 2026: those taps buzzed 1.0–2.8 s late). + @MainActor + func testADoubleTapIsHandedOnBeforeItsEventKicksASync() { + let live = LiveState() + var order: [String] = [] + live.onDoubleTap = { order.append("double-tap") } + let r = router(live) + r.onSyncTrigger = { order.append("sync") } + + r.handle(frame: bytes(doubleTapHex)) + + XCTAssertEqual(order, ["double-tap", "sync"]) + } + // MARK: - Evidence for a tap that "did not register" /// A held-back replay leaves a line, so a missed tap can be told apart from a suppressed replay. 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/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/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.. 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/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index c1edd8837f..6776305b2c 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 @@ -114,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 @@ -188,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 65fa330ba8..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 left out ("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,24 +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 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. - 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") - 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") + 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 }) } - func testUnfinishedSetsAreTheUntypedAndTheNeverStarted() { - XCTAssertEqual(halfDoneSession().unfinishedSlots, - [slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + /// 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 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 @@ -74,9 +84,21 @@ final class LiftSessionFinishTests: XCTestCase { } } - func testDiscardingLeavesEveryUnfinishedSetOut() { + /// 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)]) + 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") + 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") + } } /// Completing saves the untyped and the never-started sets with the grey numbers the sheet showed: @@ -96,7 +118,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. @@ -113,6 +135,43 @@ final class LiftSessionFinishTests: XCTestCase { XCTAssertEqual(saved.first { $0.slot == slot(1, 2) }?.isWarmup, true) } + /// 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 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: 2, targetRepsLow: 5, targetRpe: 8)], + programId: nil, programName: nil) + c.advance() + 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. func testAFullyTypedSessionHasNothingUnfinished() { let c = controller() @@ -165,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() 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/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/StrandTests/LiftSessionStrapTapTests.swift b/StrandTests/LiftSessionStrapTapTests.swift new file mode 100644 index 0000000000..fdfc8ddfdc --- /dev/null +++ b/StrandTests/LiftSessionStrapTapTests.swift @@ -0,0 +1,123 @@ +import XCTest +import Combine +@testable import Strand +import WhoopStore + +/// A knock is not a double-tap. +/// +/// In the 16 Sep 2026 gym session 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 — +/// the arm going onto the bar — so nothing downstream could tell them from a tap, and each finished +/// a set seconds old: "it skipped two things when it should have done only one". The session now +/// holds back a strap tap that comes too soon after the last one it acted on, without a buzz, and +/// says so in the strap log. +@MainActor +final class LiftSessionStrapTapTests: XCTestCase { + + private var buzzes: [UInt8] = [] + private var logged: [String] = [] + private var strapHandler: (@MainActor () -> 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) + } + + /// 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)) + 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: 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)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: -5, stage: working, now: now), + "a clock that stepped back is not evidence of a knock") + } +} 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/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 9eafc39123..3911ac645b 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`. @@ -90,13 +91,28 @@ 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) }, setStrapHandler: { [weak model] handler in model?.strapDoubleTapOverride = handler - })) + }, + log: { [weak model] line in + model?.live.append(log: AppModel.stamped(line)) + }) + _liftSession = StateObject(wrappedValue: liftSession) + _liftActivity = State(initialValue: LiftLiveActivityController(log: { [weak model] line in + 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. + 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 @@ -219,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 @@ -234,11 +253,13 @@ 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 // 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 @@ -329,6 +350,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 @@ -394,25 +418,28 @@ 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) + liftActivity.update(state: nil) return } - liftActivity.update( - programName: liftSession.programName ?? String(localized: "Session"), + let lightUp = liftActivity.update( state: LiftActivityAttributes.ContentState( isResting: p.isResting, exercise: p.exercise, 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) + // 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: AppModel.stamped(lightUp.logLine)) } } } diff --git a/StrandiOS/Resources/lift-step-silence.caf b/StrandiOS/Resources/lift-step-silence.caf new file mode 100644 index 0000000000..7312e5e431 Binary files /dev/null and b/StrandiOS/Resources/lift-step-silence.caf differ diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index a170f736a3..1beefdb4c4 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -1,6 +1,7 @@ #if os(iOS) import Foundation import ActivityKit +import UIKit /// Starts, updates and ends the Lift Log session Live Activity. /// @@ -17,75 +18,170 @@ import ActivityKit @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? + /// 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 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. - 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 + /// A bundled sound file of silence. ActivityKit offers an alert only the default sound or a named + /// file, and a chime from a phone on a bench every set is not what the lifter asked for; the strap + /// 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" + } + } + } + + 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. - func update(programName: String, state: LiftActivityAttributes.ContentState?) { - 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 } + // 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. guard UnitPrefs.liveActivityEnabled(), let state else { if activity != nil { Task { await end() } } - return + 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). let signature = [ state.isResting ? "rest" : "work", state.exercise, state.status, - state.detail ?? "", state.progress, + state.detail ?? "", state.next, "\(state.stageStartedAt.timeIntervalSince1970)", "\(state.restEndsAt?.timeIntervalSince1970 ?? 0)", ].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)) if let activity { - guard contentChanged || heartRateDue else { return } + let appOnScreen = UIApplication.shared.applicationState == .active + let lightsScreen = alert && !appOnScreen + guard contentChanged || heartRateDue || lightsScreen else { return alert ? .appOnScreen : nil } lastSignature = signature + lastState = state lastPush = Date() - Task { await activity.update(content) } + if lightsScreen { + let stepAlert = AlertConfiguration( + title: LocalizedStringResource(stringLiteral: state.exercise), + body: LocalizedStringResource(stringLiteral: state.detail.map { "\(state.status) — \($0)" } + ?? state.status), + sound: .named(Self.silentAlertSound)) + Task { await activity.update(content, alertConfiguration: stepAlert) } + } 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` + // 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 push 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( - attributes: LiftActivityAttributes(programName: programName), + attributes: LiftActivityAttributes(), content: content, pushType: nil) lastSignature = signature + lastState = state lastPush = Date() } catch { 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 } } + /// 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. @@ -94,6 +190,16 @@ final class LiftLiveActivityController { } activity = nil lastSignature = nil + lastState = 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 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))) } diff --git a/StrandiOSShared/LiftActivityAttributes.swift b/StrandiOSShared/LiftActivityAttributes.swift index f867ed1796..8a64a5d8b3 100644 --- a/StrandiOSShared/LiftActivityAttributes.swift +++ b/StrandiOSShared/LiftActivityAttributes.swift @@ -27,34 +27,31 @@ public struct LiftActivityAttributes: ActivityAttributes { /// Nil when neither reps nor weight is known. public var detail: String? public var bpm: Int? - /// "3 of 19 sets done", localized APP-SIDE. The widget extension ships no string catalog, so - /// every word it renders has to arrive already translated — the same reason `status` and - /// `detail` are strings rather than numbers. - public var progress: String + /// "Next: Set 3 · Bench press", localized APP-SIDE (`LiftSessionController.nextLine`). The widget + /// extension ships no string catalog, so every word it renders has to arrive already translated + /// — the same reason `status` and `detail` are strings rather than numbers. + public var next: String /// When the current stage began — the widget counts UP from here while working. public var stageStartedAt: Date /// When the running rest is due to end; the widget counts DOWN to it. Nil while working. public var restEndsAt: Date? public init(isResting: Bool, exercise: String, status: String, detail: String?, - bpm: Int?, progress: String, + bpm: Int?, next: String, stageStartedAt: Date, restEndsAt: Date?) { self.isResting = isResting self.exercise = exercise self.status = status self.detail = detail self.bpm = bpm - self.progress = progress + self.next = next self.stageStartedAt = stageStartedAt self.restEndsAt = restEndsAt } } - /// 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 diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift index f419ebaaa4..57dad8eb03 100644 --- a/StrandiOSWidgets/LiftLiveActivity.swift +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -17,7 +17,7 @@ import StrandDesign struct LiftLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LiftActivityAttributes.self) { context in - lockScreen(context.state, program: context.attributes.programName) + lockScreen(context.state) .activityBackgroundTint(StrandPalette.surfaceBase) .activitySystemActionForegroundColor(StrandPalette.textPrimary) } dynamicIsland: { context in @@ -50,24 +50,50 @@ 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) } } } + /// 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 { state.isResting ? StrandPalette.metricAmber : StrandPalette.statusPositive } - private func lockScreen(_ state: LiftActivityAttributes.ContentState, - program: String) -> some View { - HStack(spacing: 12) { + private func lockScreen(_ state: LiftActivityAttributes.ContentState) -> some View { + // 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)) @@ -81,29 +107,27 @@ 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) + 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: { @@ -114,11 +138,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. @@ -128,19 +161,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) } } diff --git a/Tools/make_lift_program_template.py b/Tools/make_lift_program_template.py index de2983a2f0..c14edcbc8f 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, 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), ("", 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/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"} 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/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/main/java/com/noop/data/LiftEntities.kt b/android/app/src/main/java/com/noop/data/LiftEntities.kt index a2578e24a2..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, - /** Target RPE on the user's own 1-10 scale. */ + /** 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/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 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 diff --git a/docs/LIFT_LOG_PROGRAM_IMPORT.md b/docs/LIFT_LOG_PROGRAM_IMPORT.md index 0e3f93c93b..0e37d74d29 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 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 ba1875f188..071d6f60ed 100644 Binary files a/docs/lift-log-program-template.xlsx and b/docs/lift-log-program-template.xlsx differ