diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift new file mode 100644 index 0000000000..2d7fb96855 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -0,0 +1,247 @@ +import Foundation +import WhoopStore + +// Training metrics for the Lift Log. +// +// Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the +// whole design constraint: NOOP shows a few honest numbers rather than one invented score, because a +// composite "workout score out of 100" feels satisfying and tells you nothing about what to change. +// +// PURE. No store, no clock, no UI — the inputs are rows and the outputs are numbers, so the whole +// surface is unit-testable with no strap, no database and no simulator. +// +// WHAT IS DELIBERATELY ABSENT, and must stay absent: +// +// • Anything that feeds `workout.strain` or daily Effort. NOOP's strain is HR-measured (Karvonen +// %HRR -> Edwards TRIMP). There is no validated public path from typed sets/reps/weight to a +// cardiovascular-strain equivalent — WHOOP's own muscular load runs velocity-based algorithms +// over strap accelerometer/gyroscope under an unpublished model — and deriving one here is the +// exact case `CLAUDE.md` warns about after the withdrawn PPG->HR estimate (#194). +// +// • Per-exercise muscle weightings ("bench press = 0.7 triceps"). There is no published table to +// take them from. Inventing one would make every downstream per-muscle figure fiction wearing +// the costume of precision. The direct/indirect split is the resolution the evidence supports. +// +// • Acute:chronic workload ratios or any injury-risk / overtraining warning. The construct's +// validity is actively disputed, and a health warning from a non-medical app is either ignored +// or believed — both bad. + +public enum LiftMetrics { + + // MARK: - Volume load (tonnage) + + /// Σ (weight × reps) over WORKING sets, in kilograms. Nil when nothing countable was logged. + /// + /// Warm-ups are excluded because the training literature counts working sets, and a warm-up + /// double counted as volume would flatter every session. A set missing either its weight or its + /// reps contributes nothing rather than a guess. + /// + /// Good for: tracking progression WITHIN one exercise across weeks. Not comparable between + /// exercises — 100 kg of leg press is not 100 kg of squat — and not comparable between people. + public static func volumeLoadKg(_ sets: [LiftSetRow]) -> Double? { + let total = sets.reduce(into: 0.0) { sum, s in + guard !s.isWarmup, let w = s.weightKg, let r = s.reps, w > 0, r > 0 else { return } + sum += w * Double(r) + } + return total > 0 ? total : nil + } + + // MARK: - Session load (Foster sRPE-TL) + + /// Session RPE × duration in minutes. + /// + /// Foster's session-RPE training load. The reason it earns a place next to volume: it is + /// validated across BOTH resistance and endurance training, which makes it the only figure in + /// the app that puts a leg day and a run on one comparable scale. + /// + /// Nil when the session was not rated — a skipped rating must never be read as an effortless 0. + public static func sessionLoad(sessionRpe: Double?, durationSec: Int) -> Double? { + guard let rpe = sessionRpe, rpe > 0, durationSec > 0 else { return nil } + return rpe * (Double(durationSec) / 60.0) + } + + // MARK: - Estimated one-rep max (Epley) + + /// The rep ceiling above which a 1RM estimate stops being worth showing. + /// + /// Every 1RM formula is a straight-line fit to a curved relationship, and the error grows with + /// reps: a 20-rep set says far more about endurance than about maximal strength. Twelve is the + /// conventional upper bound where the formulas are least unreliable. + public static let oneRepMaxRepCeiling = 12 + + /// Epley: `w × (1 + reps/30)`. Nil for a set that cannot support an estimate. + /// + /// A single rep returns the weight itself — the formula's own +3.3% at one rep is an artefact of + /// the fit, not a claim that a single you just completed was really 3% heavier. + public static func estimatedOneRepMaxKg(weightKg: Double?, reps: Int?) -> Double? { + guard let w = weightKg, let r = reps, w > 0, r > 0, r <= oneRepMaxRepCeiling else { return nil } + guard r > 1 else { return w } + return w * (1.0 + Double(r) / 30.0) + } + + // MARK: - Per-exercise summary + + public struct ExerciseSummary: Equatable { + public let exercise: String + /// Working sets only — the tally the dose-response literature is built on. + public let workingSets: Int + public let warmupSets: Int + public let volumeKg: Double? + /// The session's best set for this exercise, ranked by ESTIMATED 1RM rather than by raw + /// weight: 90 kg × 10 is a better set than 100 kg × 5, and ranking by weight alone would + /// hide that. Falls back to the heaviest set when no set supports an estimate. + public let bestWeightKg: Double? + public let bestReps: Int? + public let bestEstimatedOneRepMaxKg: Double? + + public init(exercise: String, workingSets: Int, warmupSets: Int, volumeKg: Double?, + bestWeightKg: Double?, bestReps: Int?, bestEstimatedOneRepMaxKg: Double?) { + self.exercise = exercise + self.workingSets = workingSets + self.warmupSets = warmupSets + self.volumeKg = volumeKg + self.bestWeightKg = bestWeightKg + self.bestReps = bestReps + self.bestEstimatedOneRepMaxKg = bestEstimatedOneRepMaxKg + } + } + + /// 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. + public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] { + var order: [String] = [] + var grouped: [String: [LiftSetRow]] = [:] + for s in sets.sorted(by: { $0.ord < $1.ord }) { + if grouped[s.exercise] == nil { order.append(s.exercise) } + grouped[s.exercise, default: []].append(s) + } + return order.map { name in + let rows = grouped[name] ?? [] + let working = rows.filter { !$0.isWarmup } + + // Rank by estimated 1RM where possible; otherwise by raw weight, so an exercise logged + // only at high reps still reports a best set rather than nothing. + let best = working.max { a, b in + let ea = estimatedOneRepMaxKg(weightKg: a.weightKg, reps: a.reps) + let eb = estimatedOneRepMaxKg(weightKg: b.weightKg, reps: b.reps) + if let ea, let eb { return ea < eb } + if ea != nil { return false } + if eb != nil { return true } + return (a.weightKg ?? 0) < (b.weightKg ?? 0) + } + return ExerciseSummary( + exercise: name, + workingSets: working.count, + warmupSets: rows.count - working.count, + volumeKg: volumeLoadKg(rows), + bestWeightKg: best?.weightKg, + bestReps: best?.reps, + bestEstimatedOneRepMaxKg: estimatedOneRepMaxKg(weightKg: best?.weightKg, + reps: best?.reps)) + } + } + + // MARK: - RPE profile + + public struct RpeProfile: Equatable { + public let mean: Double? + public let ratedSets: Int + public let unratedSets: Int + public let setsAtOrAboveThreshold: Int + public let threshold: Double + + public init(mean: Double?, ratedSets: Int, unratedSets: Int, + setsAtOrAboveThreshold: Int, threshold: Double) { + self.mean = mean + self.ratedSets = ratedSets + self.unratedSets = unratedSets + self.setsAtOrAboveThreshold = setsAtOrAboveThreshold + self.threshold = threshold + } + } + + /// The default "this set was close to failure" line. Informational only. + public static let hardSetRpeThreshold = 8.0 + + /// How close to failure the working sets were. + /// + /// Reported SEPARATELY from the set counts and never as a filter on them. The tempting move is + /// to count only sets at RPE >= 7 toward a muscle's weekly total, since proximity to failure is + /// what makes a set count biologically. Doing that would compare a smaller number against + /// reference doses derived from UNFILTERED working-set counts — quietly changing the scale. + /// `unratedSets` is surfaced so a mean computed from three of twelve sets is visibly thin. + public static func rpeProfile(_ sets: [LiftSetRow], + threshold: Double = hardSetRpeThreshold) -> RpeProfile { + let working = sets.filter { !$0.isWarmup } + let rated = working.compactMap(\.rpe) + let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) + return RpeProfile(mean: mean, + ratedSets: rated.count, + unratedSets: working.count - rated.count, + setsAtOrAboveThreshold: rated.filter { $0 >= threshold }.count, + threshold: threshold) + } + + // MARK: - Sets per muscle + + public struct MuscleCounts: Equatable { + /// direct × 1.0 + indirect × 0.5 — the published fractional method. + public let fractional: [LiftMuscle: Double] + public let direct: [LiftMuscle: Int] + public let indirect: [LiftMuscle: Int] + + public init(fractional: [LiftMuscle: Double], direct: [LiftMuscle: Int], + indirect: [LiftMuscle: Int]) { + self.fractional = fractional + self.direct = direct + self.indirect = indirect + } + } + + /// Fractional set counts per muscle over the given sets. + /// + /// The 0.5 for an indirect set is NOT a house convention: the 2025 Sports Medicine dose-response + /// meta-regression compared counting a secondary mover's set as 1.0 ("total"), 0.5 + /// ("fractional") and 0.0 ("direct"), found the evidence strongest for fractional, and used it + /// in its primary 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 + /// 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. + 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 { + if let p = s.primaryMuscle { + direct[p, default: 0] += 1 + fractional[p, default: 0] += LiftMuscle.directSetCredit + } + for m in s.secondaryMuscles where m != s.primaryMuscle { + indirect[m, default: 0] += 1 + fractional[m, default: 0] += LiftMuscle.indirectSetCredit + } + } + return MuscleCounts(fractional: fractional, direct: direct, indirect: indirect) + } + + // MARK: - The reference band + + /// Weekly fractional sets per muscle, from the same dose-response meta-regression the 0.5 + /// credit comes from. + /// + /// PRESENTED AS A BAND WITH ITS SOURCE NAMED, NEVER AS A PERSONAL PRESCRIPTION. NOOP is not a + /// medical device and does not tell anyone what their body needs; it says what the research + /// associates with growth and leaves the conclusion to the reader. + public enum ReferenceDose { + /// Below roughly this, hypertrophy is not reliably detectable. + public static let hypertrophyMinimumSetsPerWeek = 4.0 + /// Strength keeps improving from a single weekly set. + public static let strengthMinimumSetsPerWeek = 1.0 + /// Beyond roughly this, added volume stops reliably beating the smallest detectable effect + /// FOR STRENGTH. Hypertrophy has no identified ceiling — gains continue with strongly + /// diminishing returns, and the uncertainty widens as volume rises. + public static let strengthPlateauSetsPerWeek = 4.0 + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift new file mode 100644 index 0000000000..ad53c331a5 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift @@ -0,0 +1,118 @@ +import XCTest +import GRDB +import WhoopStore +@testable import StrandAnalytics + +/// The per-muscle set count is computed TWICE, by two different pieces of code, and both are shown +/// to the user: +/// +/// * `WhoopStore.liftSetCounts` — SQL, feeds the Lift Log hub's "last 7 days" card; +/// * `LiftMetrics.muscleCounts` — in memory, feeds a finished session's detail sheet. +/// +/// It is the load-bearing figure of the whole feature — the one the reference doses are compared +/// against — so the two disagreeing would mean two screens reporting different numbers for the same +/// sets, with nothing to notice. They are pinned against each other here rather than each being +/// pinned to its own expectation, because agreeing with a literal is not the same as agreeing with +/// each other. +/// +/// This test exists because they DID differ: the in-memory version excluded a muscle listed both as +/// primary and secondary, and the SQL version did not. Nothing showed it, because the write path +/// strips the primary on the way in — so the divergence was invisible until some future writer +/// forgot to. The malformed row below is written with raw SQL precisely because the public API +/// cannot produce one. +final class LiftMetricsStoreAgreementTests: XCTestCase { + + private let day = 1_700_000_000 + + /// 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 { + let store = try await WhoopStore.inMemory() + let writer = store.registryWriter + try await writer.write { db in + try db.execute(sql: """ + INSERT INTO liftSession (id, deviceId, startTs, endTs, sport, programId, programName, + sessionRpe, note) + VALUES ('s1', 'dev', ?, ?, 'Strength Training', NULL, NULL, NULL, NULL) + """, arguments: [self.day, self.day + 3600]) + for (i, r) in rows.enumerated() { + try db.execute(sql: """ + 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]) + } + } + return store + } + + /// The same rows, as `LiftMetrics` would receive them from the store. + private func rows(_ store: WhoopStore) async throws -> [LiftSetRow] { + try await store.liftSets(sessionId: "s1") + } + + private func assertAgree(_ store: WhoopStore, + file: StaticString = #filePath, line: UInt = #line) async throws { + let sql = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + let memory = LiftMetrics.muscleCounts(try await rows(store)) + XCTAssertEqual(sql.direct, memory.direct, "direct counts diverged", file: file, line: line) + XCTAssertEqual(sql.indirect, memory.indirect, "indirect counts diverged", file: file, line: line) + XCTAssertEqual(sql.fractional, memory.fractional, "fractional counts diverged", file: file, line: line) + } + + /// The ordinary shape: a primary and two distinct secondaries. + func testBothImplementationsAgreeOnAWellFormedSet() async throws { + let store = try await store([(primary: "chest", secondary: "frontDelts,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit) + XCTAssertEqual(counts.fractional[.triceps], LiftMuscle.indirectSetCredit) + } + + /// THE ONE THAT WAS BROKEN. A row that lists its own primary among the secondaries must be + /// credited once, as direct, by BOTH — not 1.0 by one screen and 1.5 by the other. + func testBothImplementationsAgreeWhenARowListsItsPrimaryAsASecondary() async throws { + let store = try await store([(primary: "chest", secondary: "chest,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.chest], 1) + XCTAssertNil(counts.indirect[.chest], "the primary must not also be counted as indirect") + XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit, + "1.0, never 1.5 — the same muscle cannot be worked twice by one set") + } + + /// Warm-ups are excluded on both sides. They are excluded from volume and the per-muscle counts + /// by design, so a difference here would inflate the figure the doses are compared against. + func testBothImplementationsAgreeThatWarmUpsDoNotCount() async throws { + let store = try await store([ + (primary: "quads", secondary: "glutes", warmup: 1), + (primary: "quads", secondary: "glutes", warmup: 0), + ]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.quads], 1, "only the working set counts") + } + + /// An unclassified set contributes nothing rather than defaulting into a bucket. + func testBothImplementationsAgreeOnAnUnclassifiedSet() async throws { + let store = try await store([(primary: nil, secondary: "", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertTrue(counts.fractional.isEmpty, "no muscle was named, so no muscle is credited") + } + + /// A token no longer in the vocabulary is ignored by both rather than crashing or counting. + func testBothImplementationsAgreeOnAnUnknownToken() async throws { + let store = try await store([(primary: "shoulders", secondary: "pecs,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.indirect[.triceps], 1, "the recognisable half still counts") + XCTAssertTrue(counts.direct.isEmpty) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift new file mode 100644 index 0000000000..c4278551d5 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import StrandAnalytics +import WhoopStore + +/// The Lift Log's training metrics. Pure arithmetic, so every figure the user is shown can be +/// checked here against a number worked out by hand. +final class LiftMetricsTests: XCTestCase { + + private var ord = 0 + + /// A working set. Defaults chosen so a test only states what it is actually about. + private func set(_ exercise: String = "Bench press", + weight: Double? = 100, reps: Int? = 5, rpe: Double? = nil, + warmup: Bool = false, + start: Int? = nil, end: Int? = nil, rest: Int? = nil, + primary: LiftMuscle? = .chest, + secondary: [LiftMuscle] = []) -> LiftSetRow { + ord += 1 + return LiftSetRow(id: UUID().uuidString, deviceId: "dev", sessionId: "s", + ord: ord, exercise: exercise, + primaryMuscle: primary, secondaryMuscles: secondary, + setIndex: ord, weightKg: weight, reps: reps, rpe: rpe, + isWarmup: warmup, startTs: start, endTs: end, restSec: rest, note: nil) + } + + // MARK: - Volume load + + func testVolumeIsWeightTimesRepsSummed() { + let v = LiftMetrics.volumeLoadKg([set(weight: 100, reps: 5), set(weight: 80, reps: 10)]) + XCTAssertEqual(v!, 100 * 5 + 80 * 10, accuracy: 0.001) // 1300 + } + + func testWarmUpsAreExcludedFromVolume() { + let v = LiftMetrics.volumeLoadKg([set(weight: 100, reps: 5), + set(weight: 40, reps: 10, warmup: true)]) + XCTAssertEqual(v!, 500, accuracy: 0.001, + "the literature counts working sets; a warm-up would flatter every session") + } + + func testASetMissingWeightOrRepsContributesNothingRatherThanAGuess() { + XCTAssertNil(LiftMetrics.volumeLoadKg([set(weight: nil, reps: 5)])) + XCTAssertNil(LiftMetrics.volumeLoadKg([set(weight: 100, reps: nil)])) + XCTAssertNil(LiftMetrics.volumeLoadKg([])) + } + + func testBodyweightWorkLoggedWithNoWeightIsNotCountedAsVolume() { + // Pull-ups logged as reps only: real training, but no tonnage figure is honest for it. + XCTAssertNil(LiftMetrics.volumeLoadKg([set("Pull-up", weight: nil, reps: 12)])) + } + + // MARK: - Session load (Foster sRPE-TL) + + func testSessionLoadIsRpeTimesMinutes() { + XCTAssertEqual(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 3600)!, 420, accuracy: 0.001) + XCTAssertEqual(LiftMetrics.sessionLoad(sessionRpe: 8, durationSec: 1800)!, 240, accuracy: 0.001) + } + + func testAnUnratedSessionHasNoLoadRatherThanZero() { + XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: nil, durationSec: 3600), + "a skipped rating must never read as an effortless session") + XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 0)) + } + + // MARK: - Estimated 1RM (Epley) + + func testEpleyMatchesTheFormula() { + // 100 × (1 + 5/30) = 116.67 + XCTAssertEqual(LiftMetrics.estimatedOneRepMaxKg(weightKg: 100, reps: 5)!, 116.6667, accuracy: 0.001) + } + + func testASingleReturnsTheWeightItself() { + XCTAssertEqual(LiftMetrics.estimatedOneRepMaxKg(weightKg: 140, reps: 1)!, 140, accuracy: 0.001, + "the formula's +3.3% at one rep is an artefact of the fit, not a heavier single") + } + + func testHighRepSetsGetNoEstimate() { + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 60, reps: 20), + "a 20-rep set describes endurance, not a maximum") + XCTAssertNotNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 60, reps: 12), "12 is the ceiling") + } + + func testNoEstimateWithoutBothNumbers() { + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: nil, reps: 5)) + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 100, reps: nil)) + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 0, reps: 5)) + } + + // MARK: - Per exercise + + func testExercisesComeBackInTheOrderTheyWerePerformed() { + let s = [set("Squat"), set("Bench press"), set("Squat")] + XCTAssertEqual(LiftMetrics.perExercise(s).map(\.exercise), ["Squat", "Bench press"], + "a session reads back in the order it happened, not alphabetically") + } + + func testBestSetIsRankedByEstimatedOneRepMaxNotRawWeight() { + // 100×5 → 116.7 ; 90×10 → 120.0. The lighter set is the better one. + let summaries = LiftMetrics.perExercise([set(weight: 100, reps: 5), set(weight: 90, reps: 10)]) + XCTAssertEqual(summaries.count, 1) + XCTAssertEqual(summaries[0].bestWeightKg!, 90, accuracy: 0.001) + XCTAssertEqual(summaries[0].bestReps, 10) + XCTAssertEqual(summaries[0].bestEstimatedOneRepMaxKg!, 120, accuracy: 0.001) + } + + func testBestSetFallsBackToHeaviestWhenNoSetSupportsAnEstimate() { + let summaries = LiftMetrics.perExercise([set(weight: 60, reps: 20), set(weight: 70, reps: 25)]) + XCTAssertEqual(summaries[0].bestWeightKg!, 70, accuracy: 0.001) + XCTAssertNil(summaries[0].bestEstimatedOneRepMaxKg, "still no 1RM claim from a 25-rep set") + } + + func testWorkingAndWarmUpSetsAreCountedSeparately() { + let summaries = LiftMetrics.perExercise([ + set(warmup: true), set(warmup: true), set(), set(), set(), + ]) + XCTAssertEqual(summaries[0].workingSets, 3) + XCTAssertEqual(summaries[0].warmupSets, 2) + } + + // MARK: - RPE profile + + func testRpeProfileAveragesOnlyRatedWorkingSets() { + let p = LiftMetrics.rpeProfile([set(rpe: 7), set(rpe: 9), set(rpe: nil)]) + XCTAssertEqual(p.mean!, 8.0, accuracy: 0.001) + XCTAssertEqual(p.ratedSets, 2) + XCTAssertEqual(p.unratedSets, 1, "an unrated set is surfaced, so a thin mean is visibly thin") + } + + func testHardSetsAreCountedAtOrAboveTheThreshold() { + let p = LiftMetrics.rpeProfile([set(rpe: 7.5), set(rpe: 8), set(rpe: 9.5)]) + XCTAssertEqual(p.setsAtOrAboveThreshold, 2, "at the threshold counts, not just above it") + } + + func testAWarmUpNeverEntersTheRpeProfile() { + let p = LiftMetrics.rpeProfile([set(rpe: 3, warmup: true), set(rpe: 9)]) + XCTAssertEqual(p.mean!, 9.0, accuracy: 0.001) + XCTAssertEqual(p.ratedSets, 1) + } + + func testNothingRatedMeansNoMean() { + XCTAssertNil(LiftMetrics.rpeProfile([set(rpe: nil), set(rpe: nil)]).mean) + } + + // MARK: - Sets per muscle + + func testDirectSetsCountOnceAndIndirectSetsCountHalf() { + let s = [set(primary: .chest, secondary: [.triceps, .frontDelts])] + let c = LiftMetrics.muscleCounts(s) + XCTAssertEqual(c.fractional[.chest]!, 1.0, accuracy: 0.001) + XCTAssertEqual(c.fractional[.triceps]!, 0.5, accuracy: 0.001) + XCTAssertEqual(c.fractional[.frontDelts]!, 0.5, accuracy: 0.001) + XCTAssertEqual(c.direct[.chest], 1) + XCTAssertEqual(c.indirect[.triceps], 1) + } + + func testTheCreditsMatchThePublishedMethod() { + // Pinned deliberately: these constants and the reference doses were derived under the SAME + // operationalisation, so changing one without the other silently invalidates the comparison. + XCTAssertEqual(LiftMuscle.directSetCredit, 1.0) + XCTAssertEqual(LiftMuscle.indirectSetCredit, 0.5) + } + + func testAMuscleIsNeverCreditedTwiceForOneSet() { + // A malformed row listing the primary among its secondaries must not double-count. + let s = [set(primary: .chest, secondary: [.chest, .triceps])] + let c = LiftMetrics.muscleCounts(s) + XCTAssertEqual(c.fractional[.chest]!, 1.0, accuracy: 0.001) + XCTAssertNil(c.indirect[.chest]) + } + + func testWarmUpsDoNotCountTowardAnyMuscle() { + let c = LiftMetrics.muscleCounts([set(warmup: true, primary: .chest, secondary: [.triceps])]) + XCTAssertTrue(c.fractional.isEmpty) + } + + func testSetCountsAreNotFilteredByRpe() { + // Load-bearing: the reference doses come from UNFILTERED working-set counts. Filtering to + // "hard" sets would compare a smaller number against a scale built from a larger one. + let c = LiftMetrics.muscleCounts([set(rpe: 5), set(rpe: nil), set(rpe: 10)]) + XCTAssertEqual(c.fractional[.chest]!, 3.0, accuracy: 0.001, + "an easy set and an unrated set still count toward the dose") + } + + func testAnUnclassifiedExerciseClaimsNoMuscle() { + let s = [set(primary: nil, secondary: [])] + XCTAssertTrue(LiftMetrics.muscleCounts(s).fractional.isEmpty) + XCTAssertNotNil(LiftMetrics.volumeLoadKg(s), "but it still counts toward volume") + } + + // MARK: - The reference band + + func testTheReferenceDosesAreTheOnesTheCreditsWereDerivedUnder() { + XCTAssertEqual(LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek, 4.0) + XCTAssertEqual(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek, 1.0) + XCTAssertEqual(LiftMetrics.ReferenceDose.strengthPlateauSetsPerWeek, 4.0) + } +} diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift new file mode 100644 index 0000000000..1f4a801699 --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -0,0 +1,329 @@ +import Foundation +import WhoopStore +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. +// 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. +// +// This reads the filled-in sheet and produces programs. It never writes: the caller decides what to +// do with the result, which keeps this parser pure and testable with no store and no app. +// +// TWO FORMATS, ONE PARSER. `.xlsx` is what the shipped template is, because a spreadsheet can lock +// its own structure and offer the muscle vocabulary as a dropdown — the user cannot mistype a muscle +// name that has to match a closed token set. CSV is accepted too, because every spreadsheet program +// on every platform can save it, and because a user who rebuilds the sheet by hand should not be +// blocked. Detection is by ZIP magic bytes, the same way `DataBackup` tells a zipped backup from a +// bare SQLite file. + +/// One exercise line read from the sheet — the TARGETS, mirroring `liftProgramItem`. +public struct ImportedProgramLine: Sendable, Equatable { + public var exercise: String + public var primaryMuscle: LiftMuscle? + public var secondaryMuscles: [LiftMuscle] + public var targetSets: Int? + public var targetReps: Int? + public var targetWeightKg: Double? + public var restSec: Int? + public var note: String? + + public init(exercise: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle], + targetSets: Int?, targetReps: Int?, targetWeightKg: Double?, + restSec: Int?, note: String?) { + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = secondaryMuscles + self.targetSets = targetSets + self.targetReps = targetReps + self.targetWeightKg = targetWeightKg + self.restSec = restSec + self.note = note + } +} + +/// One program: a name, an optional note, and its lines IN SHEET ORDER. +public struct ImportedProgram: Sendable, Equatable { + public var name: String + public var note: String? + public var lines: [ImportedProgramLine] + + public init(name: String, note: String?, lines: [ImportedProgramLine]) { + self.name = name + self.note = note + self.lines = lines + } +} + +/// What a sheet produced, plus everything that was wrong with it. +/// +/// Warnings are per-row and NON-fatal by design. A sheet with one misspelled muscle should import +/// eleven good lines and tell the user about the twelfth, not refuse the file — the alternative is a +/// user fixing one typo at a time through twelve round trips. +public struct LiftProgramImportResult: Sendable, Equatable { + public var programs: [ImportedProgram] + public var warnings: [String] + + public init(programs: [ImportedProgram], warnings: [String]) { + self.programs = programs + self.warnings = warnings + } +} + +public enum LiftProgramSheetImporter { + + public enum ImportError: Error, Equatable { + /// Not a spreadsheet this can read at all. + case unreadable + /// Readable, but without the columns that make it a program sheet. + case missingColumns([String]) + /// Read fine, but there was nothing in it. + case empty + /// Too big to be a program sheet — see `maxFileBytes`. + case tooLarge + } + + // MARK: - Bounds + // + // A program is a few dozen rows. Everything below is far above any real sheet and far below + // anything that could hurt: this feature is a convenience, and it must never be the reason the + // app is slow, runs out of memory, or writes a database nobody wants. + + /// A filled template is ~9 KB; a hand-built sheet with a year of programs is still well under a + /// megabyte. 8 MB is generous enough that no honest file is refused, and small enough that + /// reading it whole into memory on a phone is nothing. + public static let maxFileBytes = 8 * 1024 * 1024 + + /// Rows considered from a sheet. A spreadsheet can carry a million empty rows, and some writers + /// emit them; parsing them all is wasted work and the result would be unusable anyway. + static let maxRows = 5_000 + + /// Exercise lines kept per program. A program with more lines than this is not a program. + static let maxLinesPerProgram = 200 + + /// Programs created from one file. + static let maxPrograms = 50 + + /// Warnings reported. A pathological sheet could otherwise produce thousands, which helps nobody + /// and makes the preview unscrollable; the count is still reported honestly (see `warnings`). + static let maxWarnings = 50 + + /// Column keys, after `HeaderNorm.normalize`. Several spellings map to the same field so a user + /// who retypes the header — or translates it — is not punished for it. + private static let exerciseKeys = ["exercise", "movement", "lift"] + private static let programKeys = ["program", "programme", "workout", "routine", "day"] + private static let programNoteKeys = ["program_note", "programme_note", "workout_note", "routine_note"] + private static let primaryKeys = ["primary_muscle", "primary", "muscle"] + private static let secondaryKeys = ["secondary_muscles", "secondary_muscle", "secondary"] + private static let setsKeys = ["sets", "working_sets", "target_sets"] + private static let repsKeys = ["reps", "rep", "target_reps", "repetitions"] + private static let weightKeys = ["weight_kg", "weight", "kg", "load", "load_kg"] + private static let restKeys = ["rest_sec", "rest_seconds", "rest", "rest_s"] + private static let noteKeys = ["note", "technique_note", "notes", "cue"] + + /// Parse a filled-in template. Detects `.xlsx` by its ZIP magic bytes, else treats it as CSV. + public static func parse(data: Data) throws -> LiftProgramImportResult { + // Refuse before doing any work. An .xlsx is a ZIP, so a hostile one can be small on disk and + // enormous expanded; `XlsxSheet` bounds the expansion separately. This bound is on what the + // caller handed us. + guard data.count <= maxFileBytes else { throw ImportError.tooLarge } + + let candidates: [XlsxSheet.Sheet] + if isZip(data) { + // Every sheet, in tab order — the workbook may carry instructions, notes or the user's + // own scratch alongside the program, and only this parser can tell which is which. + candidates = try XlsxSheet.sheets(from: data) + } else { + let table = CSVTable(data: data) + guard !table.headers.isEmpty else { throw ImportError.unreadable } + candidates = [XlsxSheet.Sheet(headerKeys: Set(table.normalizedHeaders.filter { !$0.isEmpty }), + rows: table.rows)] + } + + // An exercise column is the one thing a program sheet cannot do without, so it is also how a + // program sheet is recognised among several. First match in tab order wins. + // + // Matched on HEADERS, not on rows, so the three failures stay distinguishable: a file that is + // not a program sheet at all, and a correct template nobody has filled in yet, are different + // mistakes and deserve different advice. + guard let sheet = candidates.first(where: { + !$0.headerKeys.isDisjoint(with: exerciseKeys) + }) else { + throw ImportError.missingColumns(["exercise"]) + } + let rows = sheet.rows + guard !rows.isEmpty else { throw ImportError.empty } + + var programs: [ImportedProgram] = [] + var indexByName: [String: Int] = [:] + var warnings: [String] = [] + + var truncated = false + for (i, row) in rows.prefix(maxRows).enumerated() { + // Spreadsheets are full of trailing blank rows; they are not an error. + let exercise = value(row, exerciseKeys)?.trimmed ?? "" + if exercise.isEmpty { + if row.values.contains(where: { !$0.trimmed.isEmpty }), warnings.count < maxWarnings { + warnings.append(rowMessage(i, "no exercise name, so the row was skipped")) + } + continue + } + + let programName = value(row, programKeys)?.trimmed.nilIfEmpty ?? "Imported program" + + var primary: LiftMuscle? + if let raw = value(row, primaryKeys)?.trimmed.nilIfEmpty { + primary = LiftMuscle(sheetName: raw) + if primary == nil, warnings.count < maxWarnings { + warnings.append(rowMessage(i, "\"\(raw)\" is not a muscle group, so \"\(exercise)\" was left unclassified")) + } + } + + var secondary: [LiftMuscle] = [] + if let raw = value(row, secondaryKeys)?.trimmed.nilIfEmpty { + for part in raw.split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "/" || $0 == "|" }) { + let token = String(part).trimmed + guard !token.isEmpty else { continue } + if let m = LiftMuscle(sheetName: token) { + // The store excludes the primary from the secondary list, so do it here too + // rather than leaving a row that says "chest, chest". + if m != primary, !secondary.contains(m) { secondary.append(m) } + } else if warnings.count < maxWarnings { + warnings.append(rowMessage(i, "\"\(token)\" is not a muscle group and was ignored")) + } + } + } + + // 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. + let line = ImportedProgramLine( + exercise: exercise, + primaryMuscle: primary, + secondaryMuscles: secondary, + targetSets: intValue(row, setsKeys), + targetReps: intValue(row, repsKeys), + targetWeightKg: doubleValue(row, weightKeys), + restSec: intValue(row, restKeys), + note: value(row, noteKeys)?.trimmed.nilIfEmpty + .map { String($0.prefix(WhoopStore.maxExerciseNoteLength)) }) + + if let idx = indexByName[programName.lowercased()] { + guard programs[idx].lines.count < maxLinesPerProgram else { truncated = true; continue } + programs[idx].lines.append(line) + if programs[idx].note == nil { + programs[idx].note = programNote(row) + } + } else { + guard programs.count < maxPrograms else { truncated = true; continue } + indexByName[programName.lowercased()] = programs.count + programs.append(ImportedProgram( + name: programName, + note: programNote(row), + lines: [line])) + } + } + + // Say so rather than silently importing a subset: a user whose sheet was cut off must be able + // to see that it was, and the preview is where they would notice. + if rows.count > maxRows || truncated { + warnings.append("The sheet is larger than a program can be, so only the first " + + "\(maxPrograms) programs and \(maxLinesPerProgram) exercises each were read.") + } + + guard !programs.isEmpty else { throw ImportError.empty } + return LiftProgramImportResult(programs: programs, warnings: warnings) + } + + // MARK: - Helpers + + private static func isZip(_ data: Data) -> Bool { + data.count >= 4 && data[data.startIndex] == 0x50 && data[data.startIndex + 1] == 0x4B + } + + /// Warnings name the SHEET's own row number so the user can go straight to it: the header is + /// row 1, so the first data row is row 2. + private static func rowMessage(_ i: Int, _ text: String) -> String { + "Row \(i + 2): \(text)" + } + + private static func programNote(_ row: [String: String]) -> String? { + value(row, programNoteKeys)?.trimmed.nilIfEmpty + .map { String($0.prefix(WhoopStore.maxProgramNoteLength)) } + } + + private static func value(_ row: [String: String], _ keys: [String]) -> String? { + for k in keys { + if let v = row[k], !v.trimmed.isEmpty { return v } + } + return nil + } + + private static func intValue(_ row: [String: String], _ keys: [String]) -> Int? { + guard let raw = value(row, keys) else { return nil } + return doubleFrom(raw).map { Int($0.rounded()) } + } + + private static func doubleValue(_ row: [String: String], _ keys: [String]) -> Double? { + guard let raw = value(row, keys) else { return nil } + return doubleFrom(raw) + } + + /// Numbers as spreadsheets actually write them: "60", "60.5", "60,5" (comma decimal in most of + /// Europe), "60 kg", "2 min". Anything with no digits at all is nil rather than 0 — a blank cell + /// means "not planned", and 0 would be a planned zero. + static func doubleFrom(_ raw: String) -> Double? { + var cleaned = "" + var seenSeparator = false + for ch in raw { + if ch.isNumber { cleaned.append(ch) } + else if (ch == "." || ch == ",") && !seenSeparator && !cleaned.isEmpty { + cleaned.append(".") + seenSeparator = true + } else if ch == "-" && cleaned.isEmpty { + cleaned.append(ch) + } + } + guard cleaned.contains(where: { $0.isNumber }) else { return nil } + return Double(cleaned) + } +} + +extension LiftMuscle { + /// Resolve a muscle written by a person in a spreadsheet. + /// + /// Accepts the stored token (`frontDelts`) and the English display name ("Front delts"), and is + /// insensitive to case, spaces, hyphens and underscores — so "front delts", "Front-Delts" and + /// "FRONTDELTS" all land on the same case. Deliberately NOT fuzzy beyond that: the muscle + /// vocabulary is a stored-data contract, and guessing that "shoulders" means front delts would + /// silently put sets in a bucket the user did not choose. + init?(sheetName raw: String) { + let key = raw.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX")) + .lowercased() + .filter { $0.isLetter || $0.isNumber } + guard !key.isEmpty else { return nil } + for m in LiftMuscle.allCases where m.rawValue.lowercased() == key { + self = m + return + } + // The English display names, which is what the shipped template's dropdown offers. + let byName: [String: LiftMuscle] = [ + "chest": .chest, "frontdelts": .frontDelts, "sidedelts": .sideDelts, + "reardelts": .rearDelts, "triceps": .triceps, "lats": .lats, + "upperback": .upperBack, "traps": .traps, "biceps": .biceps, + "forearms": .forearms, "quads": .quads, "hamstrings": .hamstrings, + "glutes": .glutes, "adductors": .adductors, "abductors": .abductors, + "calves": .calves, "abs": .abs, "obliques": .obliques, + "lowerback": .lowerBack, "neck": .neck, + ] + guard let m = byName[key] else { return nil } + self = m + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift new file mode 100644 index 0000000000..eb1bd11baf --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -0,0 +1,286 @@ +import Foundation +import ZIPFoundation + +// A deliberately small `.xlsx` reader: enough to read a filled-in template, and nothing else. +// +// An .xlsx is a ZIP of XML. Reading one properly — styles, number formats, dates, formulas, +// streaming — is a library's worth of work. This reads each worksheet as text, in tab order, which is +// all a program sheet needs, and is honest about that: no formula evaluation (a cell's last cached +// value is used, which is what Excel wrote), no date coercion, no styling. +// +// Only ZIPFoundation and Foundation's own XMLParser are used, both already in the package. Nothing +// new enters the dependency graph for this. +enum XlsxSheet { + + /// One worksheet, reduced to what the caller needs to recognise and read it. + /// + /// Headers are kept SEPARATE from rows so an empty sheet is still identifiable. A downloaded + /// template that nobody has filled in has the right columns and no data, and that has to be + /// reported as "no exercises yet" rather than "wrong file" — the user is holding the right file. + struct Sheet { + var headerKeys: Set + var rows: [[String: String]] + } + + /// Every worksheet, in workbook (tab) order. + /// + /// Every sheet rather than the first, because the caller is looking for a particular KIND of + /// sheet and only it can recognise one. A user who reorders tabs, keeps the instructions page in + /// front, or pastes the data into their own workbook still gets an import; picking by position + /// would refuse all three. + static func sheets(from data: Data) throws -> [Sheet] { + try grids(from: data).map(headerKeyed) + } + + /// One grid's header row applied to the rows beneath it. + private static func headerKeyed(_ grid: [[String]]) -> Sheet { + guard let headerRow = grid.first else { return Sheet(headerKeys: [], rows: []) } + let keys = headerRow.map { HeaderNorm.normalize($0) } + var out: [[String: String]] = [] + for cells in grid.dropFirst() { + // Blank rows are ordinary in a spreadsheet — a user leaves space at the bottom. + if cells.allSatisfy({ $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { continue } + var dict: [String: String] = [:] + for (i, key) in keys.enumerated() where !key.isEmpty { + let v = i < cells.count ? cells[i] : "" + if dict[key] == nil || dict[key]!.isEmpty { dict[key] = v } + } + out.append(dict) + } + return Sheet(headerKeys: Set(keys.filter { !$0.isEmpty }), rows: out) + } + + /// Every worksheet as a grid of strings, in workbook (tab) order. + static func grids(from data: Data) throws -> [[[String]]] { + guard let archive = try? Archive(data: data, accessMode: .read) else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + // Shared strings are optional: a sheet written with inline strings has no such part. + var shared: [String] = [] + if let pool = try? entryData(archive, "xl/sharedStrings.xml") { + shared = SharedStrings.parse(pool) + } + let parts = worksheetPaths(archive) + guard !parts.isEmpty else { throw LiftProgramSheetImporter.ImportError.unreadable } + return parts.compactMap { path in + (try? entryData(archive, path)).map { SheetParser.parse($0, shared: shared) } + } + } + + /// Worksheet part paths in TAB order, resolved through the workbook. + /// + /// `xl/worksheets/sheet1.xml` is a stable id, not a position: a user who drags the instructions + /// tab in front of the data tab, or a writer that numbers parts differently, leaves `sheet1.xml` + /// sitting behind another sheet. So take `` order from `xl/workbook.xml` — that IS tab + /// order — and map each `r:id` through `xl/_rels/workbook.xml.rels`. + /// + /// Falls back to every worksheet part sorted by name, for a file whose workbook part is missing + /// or unreadable. Order matters less there than not losing the data entirely. + private static func worksheetPaths(_ archive: Archive) -> [String] { + if let workbook = try? entryData(archive, "xl/workbook.xml"), + let rels = try? entryData(archive, "xl/_rels/workbook.xml.rels") { + let ids = WorkbookOrder.sheetRelationshipIds(workbook) + let targets = WorkbookOrder.targets(in: rels) + let paths = ids.compactMap { targets[$0] }.map { target -> String in + // Targets are written relative to the workbook part, which lives in `xl/`. + target.hasPrefix("/") ? String(target.dropFirst()) : "xl/" + target + } + if !paths.isEmpty { return paths } + } + return archive.map(\.path) + .filter { $0.hasPrefix("xl/worksheets/") && $0.hasSuffix(".xml") } + .sorted() + } + + /// One part's raw bytes, by path. Internal, for tests that need to assert on the XML itself — + /// the shipped template's sheet protection is not visible through the parsed grid. + static func rawPart(_ data: Data, path: String) -> Data? { + guard let archive = try? Archive(data: data, accessMode: .read) else { return nil } + return try? entryData(archive, path) + } + + /// Ceiling on ONE decompressed part. + /// + /// Measured on the expanded stream, not the archive, because that is the only bound that means + /// anything: an .xlsx is a ZIP, and a zip bomb is by definition tiny compressed and enormous + /// expanded, so a limit on the file's own size is trivially defeated. `DataBackup` guards its + /// restore the same way and for the same reason (#1807). 64 MB is orders of magnitude above any + /// real worksheet and still bounded. + static let maxPartBytes = 64 * 1024 * 1024 + + private static func entryData(_ archive: Archive, _ path: String) throws -> Data { + guard let entry = archive[path] else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + var out = Data() + out.reserveCapacity(min(Int(entry.uncompressedSize), 1 << 20)) + _ = try archive.extract(entry, bufferSize: 64 * 1024, skipCRC32: true) { chunk in + guard out.count + chunk.count <= maxPartBytes else { + throw LiftProgramSheetImporter.ImportError.tooLarge + } + out.append(chunk) + } + return out + } + + /// Reads just enough of `workbook.xml` and its rels to put the worksheets in tab order. + final class WorkbookOrder: NSObject, XMLParserDelegate { + private var ids: [String] = [] + private var targets: [String: String] = [:] + private var collectingRels = false + + /// `r:id` of every ``, in document order — which is tab order. + static func sheetRelationshipIds(_ data: Data) -> [String] { + let d = WorkbookOrder() + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.ids + } + + /// Relationship id -> target path. + static func targets(in rels: Data) -> [String: String] { + let d = WorkbookOrder() + d.collectingRels = true + let p = XMLParser(data: rels) + p.delegate = d + p.parse() + return d.targets + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + if collectingRels { + if name == "Relationship", let id = attributes["Id"], let target = attributes["Target"] { + targets[id] = target + } + return + } + // The r:id attribute arrives qualified or not depending on the writer. + if name == "sheet", let rid = attributes["r:id"] ?? attributes["id"] { + ids.append(rid) + } + } + } + + /// `xl/sharedStrings.xml` — the string pool most cells point into. + private final class SharedStrings: NSObject, XMLParserDelegate { + private var strings: [String] = [] + private var current: String? + private var collecting = false + + static func parse(_ data: Data) -> [String] { + let d = SharedStrings() + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.strings + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + if name == "si" { current = "" } + // A rich-text run splits one logical string across several elements; they concatenate. + if name == "t" { collecting = true } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collecting { current = (current ?? "") + string } + } + + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, + qualifiedName: String?) { + if name == "t" { collecting = false } + if name == "si" { + strings.append(current ?? "") + current = nil + } + } + } + + /// A worksheet part: rows of cells, placed by their `r` reference so gaps stay gaps. + private final class SheetParser: NSObject, XMLParserDelegate { + private var shared: [String] = [] + private var grid: [[String]] = [] + private var row: [String] = [] + private var cellRef = "" + private var cellType = "" + private var text: String? + private var collecting = false + + static func parse(_ data: Data, shared: [String]) -> [[String]] { + let d = SheetParser() + d.shared = shared + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.grid + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + switch name { + case "row": + row = [] + case "c": + cellRef = attributes["r"] ?? "" + cellType = attributes["t"] ?? "" + text = nil + case "v", "t": + collecting = true + text = text ?? "" + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collecting { text = (text ?? "") + string } + } + + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, + qualifiedName: String?) { + switch name { + case "v", "t": + collecting = false + case "c": + // t="s" means the value is an index into the shared-string pool; anything else is + // the literal text (inline strings arrive already resolved through ). + var value = text ?? "" + if cellType == "s", let i = Int(value), i >= 0, i < shared.count { + value = shared[i] + } + let column = SheetParser.columnIndex(cellRef) + if column >= 0 { + while row.count <= column { row.append("") } + row[column] = value + } else { + row.append(value) + } + text = nil + case "row": + grid.append(row) + row = [] + default: + break + } + } + + /// "C7" -> 2. Zero-based, so a skipped column stays an empty cell rather than shifting every + /// value after it one place left — which would silently move reps into the weight column. + static func columnIndex(_ ref: String) -> Int { + var n = 0 + var any = false + for ch in ref.uppercased() { + guard let ascii = ch.asciiValue else { break } + if ascii >= 65, ascii <= 90 { + n = n * 26 + Int(ascii - 64) + any = true + } else { + break + } + } + return any ? n - 1 : -1 + } + } +} diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift new file mode 100644 index 0000000000..a71024a5e2 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -0,0 +1,257 @@ +import XCTest +import WhoopStore +@testable import StrandImport + +/// Building a Lift Log program from a spreadsheet filled in on a computer. +/// +/// Both fixtures carry the SAME content in the two accepted formats, so one set of expectations +/// covers the `.xlsx` reader and the CSV path and neither can drift from the other. +final class LiftProgramSheetImporterTests: XCTestCase { + + private func parse(_ fixture: String) throws -> LiftProgramImportResult { + try LiftProgramSheetImporter.parse(data: Fixtures.data(fixture)) + } + + // MARK: - The two formats agree + + func testXlsxAndCsvProduceTheSameProgramsFromTheSameContent() throws { + let x = try parse("lift_program_filled.xlsx") + let c = try parse("lift_program_filled.csv") + XCTAssertEqual(x.programs, c.programs, + "one parser, two containers — the container must not change the result") + } + + // MARK: - Grouping + + func testRowsAreGroupedIntoProgramsInSheetOrder() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertEqual(r.programs.map(\.name), ["Lower A", "Upper A"]) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), + ["Leg Press midfoot", "Lying Leg Curl", "Leg Extension"]) + XCTAssertEqual(r.programs[1].lines.map(\.exercise), + ["Incline dumbbell press", "Cable fly", "Lat pulldown"], + "a blank row in the middle separates nothing — it is just an empty row") + } + + func testTheProgramNoteIsTakenFromWhicheverRowCarriesIt() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertEqual(r.programs[0].note, "Belt from set 3") + XCTAssertNil(r.programs[1].note) + } + + // MARK: - Targets + + func testTargetsAreReadIncludingEuropeanDecimalsAndUnitSuffixes() throws { + let r = try parse("lift_program_filled.xlsx") + let press = r.programs[0].lines[0] + XCTAssertEqual(press.targetSets, 3) + XCTAssertEqual(press.targetReps, 10) + XCTAssertEqual(press.targetWeightKg, 50) + XCTAssertEqual(press.restSec, 90) + XCTAssertEqual(press.note, "Slow eccentric") + + // "40,5 kg" — a comma decimal and a unit, which is what a European spreadsheet writes. + XCTAssertEqual(r.programs[0].lines[2].targetWeightKg, 40.5) + } + + func testABlankTargetStaysNilRatherThanBecomingZero() throws { + let r = try parse("lift_program_filled.xlsx") + let fly = r.programs[1].lines[1] + XCTAssertNil(fly.targetWeightKg, "a blank cell means not planned; 0 would be a planned zero") + XCTAssertNil(fly.restSec) + XCTAssertNil(fly.note) + } + + // MARK: - Muscles + + func testMusclesResolveAndSecondariesSplitOnCommas() throws { + let r = try parse("lift_program_filled.xlsx") + let incline = r.programs[1].lines[0] + XCTAssertEqual(incline.primaryMuscle, .chest) + XCTAssertEqual(incline.secondaryMuscles, [.frontDelts, .triceps]) + } + + /// An unrecognised muscle warns and leaves the line unclassified — it never guesses, and it never + /// throws away the row. The vocabulary is a stored-data contract; deciding that "Shoulders" means + /// front delts would put sets in a bucket the user did not choose. + func testAnUnknownMuscleWarnsButKeepsTheExercise() throws { + let r = try parse("lift_program_filled.xlsx") + let fly = r.programs[1].lines[1] + XCTAssertEqual(fly.exercise, "Cable fly") + XCTAssertNil(fly.primaryMuscle) + XCTAssertTrue(r.warnings.contains { $0.contains("Shoulders") && $0.contains("Cable fly") }, + "the warning must name the value AND the exercise: \(r.warnings)") + } + + func testAWarningNamesTheSheetsOwnRowNumber() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 6:") }, + "Cable fly is the 5th data row, which is row 6 in the sheet: \(r.warnings)") + } + + func testMuscleNamesAreCaseAndSpacingInsensitiveAndAcceptStoredTokens() { + for spelling in ["Front delts", "front delts", "FRONT-DELTS", "frontdelts", "frontDelts"] { + XCTAssertEqual(LiftMuscle(sheetName: spelling), .frontDelts, "failed on \(spelling)") + } + XCTAssertNil(LiftMuscle(sheetName: "shoulders"), "no guessing beyond spelling") + XCTAssertNil(LiftMuscle(sheetName: " ")) + } + + func testEveryMuscleInTheVocabularyIsReachableByItsEnglishName() { + // The shipped template's dropdown offers these; if a case were added to LiftMuscle without a + // name here, it would be offered and then rejected on import. + for m in LiftMuscle.allCases { + XCTAssertEqual(LiftMuscle(sheetName: m.rawValue), m) + } + } + + // MARK: - The shipped template itself + + /// The empty template in `docs/` is the file users actually download. Parsing it must not throw + /// on its headers, and it must contain no programs — an unfilled template imports nothing. + func testTheShippedTemplateHasTheHeadersTheImporterExpects() throws { + let data = try templateData() + + // The header row is what the importer matches on, so it is what must not drift. (`rows` + // is empty for an unfilled template by design — every data row is blank.) + 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"] { + XCTAssertTrue(headers.contains(expected), + "the shipped template lost the \"\(expected)\" column: \(headers)") + } + + // And an UNFILLED template is empty, not a program of blank exercises. + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: data)) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .empty) + } + } + + /// `xl/worksheets/sheet1.xml` is a stable id, not a position. This fixture puts the instructions + /// sheet FIRST in tab order with the program data still in sheet1.xml — reading by filename would + /// import the instructions page as a program, or find no exercise column and refuse the file. + func testTheFirstTabIsFoundThroughTheWorkbookNotTheFilename() throws { + let r = try parse("lift_program_tabs_reordered.xlsx") + XCTAssertEqual(r.programs.count, 1, "the instructions tab is first; the data must still be found") + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat"]) + XCTAssertEqual(r.programs[0].lines[0].targetWeightKg, 100) + } + + /// The template must not permit the one edit that breaks the import. In OOXML these + /// `sheetProtection` flags answer "is this PREVENTED" and default to true, so an attribute set to + /// "0" ALLOWS the thing it names — an earlier template shipped `insertColumns="0"`, permitting + /// exactly the change that shifts reps into the weight column. + func testTheShippedTemplateLocksColumnEditsAndAllowsRowEdits() throws { + let xml = try templateSheetXml() + XCTAssertTrue(xml.contains("sheet=\"1\""), "the sheet must actually be protected") + XCTAssertFalse(xml.contains("insertColumns=\"0\""), "inserting columns must stay prevented") + XCTAssertFalse(xml.contains("deleteColumns=\"0\""), "deleting columns must stay prevented") + XCTAssertTrue(xml.contains("insertRows=\"0\""), "a long routine needs more rows") + XCTAssertTrue(xml.contains("selectUnlockedCells=\"0\""), "the data cells must be typable") + } + + // MARK: - Refusals + + func testAFileWithNoExerciseColumnIsRefusedWithThatReason() { + let csv = "Program,Sets,Reps\nLower A,3,10\n" + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: Data(csv.utf8))) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .missingColumns(["exercise"])) + } + } + + func testNonsenseIsRefusedRatherThanImportedAsOneStrangeProgram() { + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: Data([0x00, 0x01, 0x02]))) + } + + /// Only the exercise is required; a sheet with nothing else still makes a usable program that can + /// be finished in the app. + func testAnExerciseOnlySheetImports() throws { + let csv = "Exercise\nBack squat\nBench press\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, 1) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat", "Bench press"]) + XCTAssertNil(r.programs[0].lines[0].targetSets) + } + + /// Excel in most of Europe writes CSV with semicolons. `CSVTable` sniffs the delimiter, and this + /// pins that the lift path benefits from it. + func testASemicolonDelimitedCsvIsRead() throws { + let csv = "Program;Exercise;Primary muscle;Sets;Reps\nLower A;Back squat;Quads;5;5\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines[0].exercise, "Back squat") + XCTAssertEqual(r.programs[0].lines[0].primaryMuscle, .quads) + XCTAssertEqual(r.programs[0].lines[0].targetSets, 5) + } + + // MARK: - Bounds + // + // This feature is a convenience. It must never be the reason the app is slow, runs out of + // memory, or writes a database nobody wants — so every input it accepts is bounded, and the + // bounds are pinned here. + + func testAnAbsurdlyLargeFileIsRefusedBeforeAnyParsing() { + let big = Data(count: LiftProgramSheetImporter.maxFileBytes + 1) + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: big)) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .tooLarge) + } + } + + func testTooManyProgramsAreTruncatedAndSaidSo() throws { + var csv = "Program,Exercise\n" + for i in 0..<(LiftProgramSheetImporter.maxPrograms + 10) { + csv += "Program \(i),Exercise \(i)\n" + } + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, LiftProgramSheetImporter.maxPrograms) + XCTAssertTrue(r.warnings.contains { $0.contains("larger than a program can be") }, + "a truncated import must say it was truncated: \(r.warnings)") + } + + func testTooManyLinesInOneProgramAreTruncated() throws { + var csv = "Program,Exercise\n" + for i in 0..<(LiftProgramSheetImporter.maxLinesPerProgram + 10) { + csv += "One,Exercise \(i)\n" + } + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, 1) + XCTAssertEqual(r.programs[0].lines.count, LiftProgramSheetImporter.maxLinesPerProgram) + } + + /// A sheet full of bad muscle names must not produce thousands of warnings — the preview would + /// be unscrollable and the user no better informed. + func testWarningsAreCapped() throws { + // Deliberately under `maxLinesPerProgram`, so this isolates the WARNING cap rather than + // tripping the line cap and testing two things at once. + let rows = LiftProgramSheetImporter.maxLinesPerProgram - 10 + var csv = "Exercise,Primary muscle\n" + for i in 0.. Data { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("docs/lift-log-program-template.xlsx") + return try Data(contentsOf: url) + } + + private func templateSheetXml() throws -> String { + let data = try templateData() + guard let part = XlsxSheet.rawPart(data, path: "xl/worksheets/sheet1.xml"), + let xml = String(data: part, encoding: .utf8) else { + XCTFail("the template has no first worksheet part") + return "" + } + return xml + } +} + diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv new file mode 100644 index 0000000000..c5924b5ff1 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv @@ -0,0 +1,8 @@ +Program,Program note,Exercise,Primary muscle,Secondary muscles,Sets,Reps,Weight kg,Rest sec,Note +Lower A,Belt from set 3,Leg Press midfoot,Quads,Glutes,3,10,50,90,Slow eccentric +Lower A,,Lying Leg Curl,Hamstrings,Calves,3,8,30,90, +Lower A,,Leg Extension,Quads,,3,12,"40,5 kg",60, +Upper A,,Incline dumbbell press,Chest,"Front delts, Triceps",4,8,22.5,120,Pause at the bottom +Upper A,,Cable fly,Shoulders,,3,15,,, +,,,,,,,,, +Upper A,,Lat pulldown,Lats,Biceps,3,10,60,90, diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.xlsx b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.xlsx new file mode 100644 index 0000000000..5cef2c1cca Binary files /dev/null and b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.xlsx differ diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_tabs_reordered.xlsx b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_tabs_reordered.xlsx new file mode 100644 index 0000000000..cb570dc0ee Binary files /dev/null and b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_tabs_reordered.xlsx differ diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index b618be49d4..18e381be5d 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -129,6 +129,8 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { public var targetRepsHigh: Int? /// Target RPE on the user's own 1-10 scale. public var targetRpe: Double? + /// Planned working weight in kilograms (v41). A program line plans a weight, not only reps. + public var targetWeightKg: Double? /// Intended rest after each set, seconds. public var restSec: Int? /// The user's own technique cue, stored and shown back verbatim. @@ -144,6 +146,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { targetRepsLow: Int?, targetRepsHigh: Int?, targetRpe: Double?, + targetWeightKg: Double?, restSec: Int?, note: String? ) { @@ -156,6 +159,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { self.targetRepsLow = targetRepsLow self.targetRepsHigh = targetRepsHigh self.targetRpe = targetRpe + self.targetWeightKg = targetWeightKg self.restSec = restSec self.note = note } @@ -171,6 +175,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { targetRepsLow: row["targetRepsLow"], targetRepsHigh: row["targetRepsHigh"], targetRpe: row["targetRpe"], + targetWeightKg: row["targetWeightKg"], restSec: row["restSec"], note: row["note"] ) @@ -191,6 +196,9 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { public var programId: String? /// The program's name AS IT WAS when the session ran, so a later rename never rewrites history. public var programName: String? + /// Session RPE, 0-10 Borg CR10 (v41). A NUMBER, not a note: Foster's session load is sRPE x + /// duration, so the rating has to be computable. Nil when the user skipped rating the session. + public var sessionRpe: Double? public var note: String? public init( @@ -201,6 +209,7 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { sport: String, programId: String?, programName: String?, + sessionRpe: Double?, note: String? ) { self.id = id @@ -210,6 +219,7 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { self.sport = sport self.programId = programId self.programName = programName + self.sessionRpe = sessionRpe self.note = note } @@ -222,6 +232,7 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { sport: row["sport"], programId: row["programId"], programName: row["programName"], + sessionRpe: row["sessionRpe"], note: row["note"] ) } @@ -331,12 +342,60 @@ extension WhoopStore { /// twice updates its classification and recency instead of duplicating it. A muscle field is /// only overwritten when the caller supplies one, so merely using an exercise never erases the /// classification the user set for it. + /// How many exercises one device remembers. + /// + /// A cap exists because the vocabulary is typo-accumulating by design: every misspelling becomes + /// a permanent picker entry ("Chest supported row3"). It is set high enough that no real + /// training history reaches it — a broad lifter's whole vocabulary is well under a hundred — so + /// hitting it means something has gone wrong, and the honest response is to say so rather than + /// to silently drop what the user typed or to evict something they still use. + /// + /// An EXISTING name always updates, cap or no cap: only genuinely NEW names are refused. + public static let maxRememberedExercises = 500 + + /// How long a PROGRAM note may be. + /// + /// 120 because that is what can actually be SEEN: the hub renders it under the program name at + /// `lineLimit(3)` in caption type, in a column narrowed by the Start button — roughly three + /// lines on a phone. A longer note is not + /// stored-and-shown, it is stored-and-silently-truncated, and typing into a field that quietly + /// discards the end is worse than a field that stops. + public static let maxProgramNoteLength = 120 + + /// How long an EXERCISE (technique) note may be. + /// + /// 200, about four lines. Longer than a program note because it is a cue you read BETWEEN sets + /// — "slow eccentric, pause at the bottom, don't let the elbows flare" — and shorter than + /// unlimited because it renders directly above the set rows and every line pushes them down the + /// screen. Both are a MAXIMUM, not a target — a one-line note is usually the better note. + public static let maxExerciseNoteLength = 200 + + /// Thrown when the vocabulary is full and the name is a new one. + public struct LiftExerciseVocabularyFull: Error, Equatable { + public let limit: Int + public init(limit: Int) { self.limit = limit } + } + @discardableResult public func upsertLiftExercises(_ rows: [LiftExerciseRow]) async throws -> Int { guard !rows.isEmpty else { return 0 } return try syncWrite { db in var n = 0 for r in rows { + // Only a NEW name can push the vocabulary over: re-saving one that already exists is + // an update and must always be allowed, or a user at the cap could no longer correct + // the classification of an exercise they use every week. + let known = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM liftExercise WHERE deviceId = ? AND name = ? + """, arguments: [r.deviceId, r.name]) ?? 0 + if known == 0 { + let total = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM liftExercise WHERE deviceId = ? + """, arguments: [r.deviceId]) ?? 0 + if total >= WhoopStore.maxRememberedExercises { + throw LiftExerciseVocabularyFull(limit: WhoopStore.maxRememberedExercises) + } + } try db.execute(sql: """ INSERT INTO liftExercise (id, deviceId, name, primaryMuscle, secondaryMuscles, createdAt, lastUsedTs) @@ -450,11 +509,12 @@ extension WhoopStore { try db.execute(sql: """ INSERT INTO liftProgramItem (id, deviceId, programId, ord, exercise, targetSets, - targetRepsLow, targetRepsHigh, targetRpe, restSec, note) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + targetRepsLow, targetRepsHigh, targetRpe, targetWeightKg, restSec, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ r.id, r.deviceId, r.programId, r.ord, r.exercise, r.targetSets, - r.targetRepsLow, r.targetRepsHigh, r.targetRpe, r.restSec, r.note, + r.targetRepsLow, r.targetRepsHigh, r.targetRpe, r.targetWeightKg, + r.restSec, r.note, ]) n += db.changesCount } @@ -486,16 +546,18 @@ extension WhoopStore { for r in rows { try db.execute(sql: """ INSERT INTO liftSession - (id, deviceId, startTs, endTs, sport, programId, programName, note) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (id, deviceId, startTs, endTs, sport, programId, programName, + sessionRpe, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(deviceId, startTs, sport) DO UPDATE SET endTs = excluded.endTs, programId = excluded.programId, programName = excluded.programName, + sessionRpe = excluded.sessionRpe, note = excluded.note """, arguments: [ r.id, r.deviceId, r.startTs, r.endTs, r.sport, - r.programId, r.programName, r.note, + r.programId, r.programName, r.sessionRpe, r.note, ]) n += db.changesCount } @@ -619,7 +681,7 @@ extension WhoopStore { /// 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 - /// separately by `liftRpeProfile` instead, where it can inform without corrupting the count. + /// separately by `LiftMetrics.rpeProfile` instead, where it can inform without corrupting the count. public func liftSetCounts( deviceId: String, fromTs: Int, @@ -641,7 +703,16 @@ extension WhoopStore { if let token: String = row["primaryMuscle"], let m = LiftMuscle(rawValue: token) { direct[m, default: 0] += 1 } - for m in LiftMuscle.decodeList(row["secondaryMuscles"]) { + // A muscle listed BOTH as primary and as secondary is credited once, as direct. + // `LiftMuscle.encodeList(_:excluding:)` already strips the primary on the way in, so + // today no stored row needs this — but "today no row needs it" is not a guarantee, + // and without the guard this aggregation and `LiftMetrics.muscleCounts` (which has + // always had it) would report DIFFERENT numbers for the same set: the hub's weekly + // card and the session detail, disagreeing, with nothing to catch it. The two are + // pinned against each other by + // `LiftMetricsStoreAgreementTests.testBothImplementationsAgree…`. + let primary: LiftMuscle? = (row["primaryMuscle"] as String?).flatMap(LiftMuscle.init(rawValue:)) + for m in LiftMuscle.decodeList(row["secondaryMuscles"]) where m != primary { indirect[m, default: 0] += 1 } } @@ -652,50 +723,4 @@ extension WhoopStore { } } - /// How hard the working sets in a window actually were, reported separately from the counts. - /// - /// Proximity to failure is what makes a set count biologically, but it is NOT folded into - /// `liftSetCounts` — see that method for why. Sets with no RPE recorded are excluded from the - /// average and reported as `unrated`, rather than being silently treated as easy or as hard. - public func liftRpeProfile( - deviceId: String, - fromTs: Int, - toTs: Int, - hardThreshold: Double = 7 - ) async throws -> (workingSets: Int, rated: Int, unrated: Int, meanRpe: Double?, atOrAboveThreshold: Int) { - try syncRead { db in - let rows = try Row.fetchAll(db, sql: """ - SELECT s.rpe AS rpe - FROM liftSet s - JOIN liftSession sess ON sess.id = s.sessionId - WHERE s.deviceId = ? - AND sess.startTs >= ? AND sess.startTs <= ? - AND s.isWarmup = 0 - """, arguments: [deviceId, fromTs, toTs]) - - var rated: [Double] = [] - var unrated = 0 - for row in rows { - if let v: Double = row["rpe"] { rated.append(v) } else { unrated += 1 } - } - let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) - return (workingSets: rows.count, - rated: rated.count, - unrated: unrated, - meanRpe: mean, - atOrAboveThreshold: rated.filter { $0 >= hardThreshold }.count) - } - } - - /// Distinct exercise names this device has ever logged, alphabetical — the suggestion list for - /// the program editor, built from the user's own history rather than a shipped catalogue. - public func liftExercisesLogged(deviceId: String) async throws -> [String] { - try syncRead { db in - try String.fetchAll(db, sql: """ - SELECT DISTINCT exercise FROM liftSet - WHERE deviceId = ? - ORDER BY exercise ASC - """, arguments: [deviceId]) - } - } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index f4c88a3047..f85dec111b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -82,6 +82,109 @@ final class LiftLogStoreTests: XCTestCase { } } + // MARK: - Targets and the session rating, stored as numbers + + func testTargetWeightRoundTripsOnAProgramLine() async throws { + let store = try await WhoopStore.inMemory() + let programId = UUID().uuidString + let item = LiftProgramItemRow( + id: UUID().uuidString, deviceId: "dev", programId: programId, ord: 0, + exercise: "Back squat", targetSets: 5, targetRepsLow: 5, targetRepsHigh: nil, + targetRpe: nil, targetWeightKg: 102.5, restSec: 180, note: nil) + _ = try await store.replaceLiftProgramItems(programId: programId, items: [item]) + + let back = try await store.liftProgramItems(programId: programId) + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].targetWeightKg, 102.5) + XCTAssertEqual(back[0].targetRepsLow, 5) + } + + func testSessionRpeRoundTripsAsANumber() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftSessionRow( + id: UUID().uuidString, deviceId: "dev", startTs: 1_700_000_000, endTs: 1_700_003_600, + sport: "Strength Training", programId: nil, programName: "Upper A", + sessionRpe: 7.5, note: nil) + _ = try await store.upsertLiftSessions([row]) + + let back = try await store.liftSessions(deviceId: "dev", fromTs: 1_700_000_000, + toTs: 1_700_000_000).first + XCTAssertEqual(back?.sessionRpe, 7.5) + } + + func testSessionRpeIsOptionalSoASkippedRatingIsNotAZero() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftSessionRow( + id: UUID().uuidString, deviceId: "dev", startTs: 1_700_000_500, endTs: nil, + sport: "Strength Training", programId: nil, programName: nil, + sessionRpe: nil, note: nil) + _ = try await store.upsertLiftSessions([row]) + + let back = try await store.liftSessions(deviceId: "dev", fromTs: 1_700_000_500, + toTs: 1_700_000_500).first + XCTAssertNil(back?.sessionRpe, + "a skipped rating must stay nil — a 0 would read as 'effortless' and corrupt the load") + } + + // MARK: - The vocabulary cap + + func testAnExistingExerciseAlwaysUpdatesEvenAtTheCap() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftExerciseRow(id: UUID().uuidString, deviceId: "dev", name: "Bench press", + primaryMuscle: .chest, secondaryMuscles: [], + createdAt: 1_700_000_000, lastUsedTs: nil) + _ = try await store.upsertLiftExercises([row]) + // Re-saving a KNOWN name is an update, not a new entry — it must never be refused, or a user + // at the cap could no longer fix the classification of something they train weekly. + var reclassified = row + reclassified.primaryMuscle = .triceps + _ = try await store.upsertLiftExercises([reclassified]) + + let back = try await store.liftExercises(deviceId: "dev") + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].primaryMuscle, .triceps) + } + + func testANewExerciseIsRefusedOnceTheVocabularyIsFull() async throws { + let store = try await WhoopStore.inMemory() + let limit = WhoopStore.maxRememberedExercises + let rows = (0.. LiftSessionRow { LiftSessionRow(id: id, deviceId: dev, startTs: startTs, endTs: endTs, sport: sport, - programId: programId, programName: "Upper A", note: nil) + programId: programId, programName: "Upper A", sessionRpe: nil, note: nil) } private func mkSet(id: String, sessionId: String, ord: Int, setIndex: Int, diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index c8c533b22c..815dc3e145 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1634,10 +1634,28 @@ final class AppModel: ObservableObject { // MARK: - Physical inputs / wear automation + /// Set by a running Lift Log session to CLAIM the strap's double-tap for the duration of that + /// session, so a set can be logged without picking the phone up — the one cue that works with the + /// phone face-down on a bench. Cleared when the session ends, handing the gesture straight back to + /// whatever the user has configured; nothing about their setting is read or written. + /// + /// 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)? + private func handleDoubleTap() { let now = Date() - guard now.timeIntervalSince(lastDoubleTapAt) > 1.2 else { return } // debounce repeats + 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)) + return + } lastDoubleTapAt = now + if let override = strapDoubleTapOverride { + live.append(log: "Double-tap → Lift Log: next") + override() + return + } live.append(log: "Double-tap → \(behavior.doubleTapAction.label)") runMacAction(behavior.doubleTapAction, shortcut: behavior.doubleTapShortcut) } diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index ee8d7acf3d..d0f1fb618f 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -465,7 +465,7 @@ public final class FrameRouter { // 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") { - state.onDoubleTap?() + dispatchDoubleTapOnce(eventTimestamp: parsed.parsed["event_timestamp"]?.intValue) } else if ev.hasPrefix("WRIST_ON") { if !state.worn { state.worn = true; state.onWristChange?(true) } } else if ev.hasPrefix("WRIST_OFF") { @@ -653,6 +653,10 @@ public final class FrameRouter { /// backfill offload (old ts) is ignored, but a real-time one fires even mid-sync. static let liveGestureWindowSeconds = 45 + /// How far back a DOUBLE_TAP arriving through a sync still earns a log line. Ten minutes covers a + /// gym session's syncs; anything older is ordinary history being offloaded, and stays silent. + static let lateGestureLogSeconds = 600 + /// Parse an EVENT frame and fire ONLY the live physical-gesture handlers (double-tap / wrist) iff the /// event is recent. Called for offload frames during backfill — where `handle(frame:)` is skipped — /// so a real-time gesture still works mid-offload (#69: the 5/MG offload runs for minutes). `now` @@ -698,13 +702,85 @@ public final class FrameRouter { guard parsed.ok, parsed.crcOK != false else { return } guard parsed.typeName == "EVENT", let ev = parsed.parsed["event"]?.stringValue else { return } guard let ts = parsed.parsed["event_timestamp"]?.intValue, ts > 0 else { return } // fail closed - guard abs(now - ts) <= FrameRouter.liveGestureWindowSeconds else { return } + let age = now - ts + guard abs(age) <= FrameRouter.liveGestureWindowSeconds else { + // A recent double-tap reaching us through a sync gets a line, so a tap reported as "did not + // 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)") + } + return + } if ev.hasPrefix("DOUBLE_TAP") { - state.onDoubleTap?() + dispatchDoubleTapOnce(eventTimestamp: ts) } else 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) } } } + + // MARK: - Double-tap de-duplication + + /// `event_timestamp`s of the DOUBLE_TAPs recently handed to the app. + /// + /// ONE physical gesture can reach us TWICE. It arrives live through `handle(frame:)`, and then + /// again when the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over + /// every offload frame and accepts any event whose timestamp is within + /// `liveGestureWindowSeconds` (45 s) of now, which a gesture from moments ago obviously is. + /// + /// `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch that: the replay can land many + /// seconds after the tap, by which time the debounce has long expired. The result is a phantom + /// second gesture — and with the Lift Log claiming the double-tap, a phantom gesture silently + /// advances the session and costs a logged set. + /// + /// The event's OWN timestamp is what distinguishes the two cases: one gesture replayed carries + /// one timestamp, while two genuine taps carry two. Nil fails OPEN (dispatch), because a real + /// gesture must never be swallowed by a missing field. + /// + /// A SET, not a single slot. Remembering only the last dispatched timestamp suppresses a replay + /// solely when the replayed event is the most recent one dispatched, and a real offload does not + /// oblige. Interleave two taps and each replay looks new again: + /// + /// tap A live -> last = A + /// tap B live -> last = B + /// replay A -> A != B, dispatches + /// replay B -> B != A, dispatches + /// + /// Two phantom advances, and a multi-minute offload that re-walks its banked log repeats the + /// whole pattern — three taps measured as twelve in `FrameRouterDoubleTapDedupTests`. That is the + /// failure this de-duplication exists to prevent, surviving inside it. + /// + /// BOUNDED two ways, because this lives on the BLE path for the lifetime of a connection: entries + /// outside `liveGestureWindowSeconds` of the incoming event are dropped (past that, the freshness + /// guard in `dispatchLiveGestureIfFresh` refuses the replay anyway, so remembering it buys + /// nothing), and a hard cap covers a strap whose clock jumps rather than advances. + private var dispatchedDoubleTapEventTs: [Int] = [] + + /// Far above any plausible number of double-taps inside a 45-second window; a backstop against a + /// clock that jumps, not a working limit. + private static let dispatchedDoubleTapMemory = 32 + + private func dispatchDoubleTapOnce(eventTimestamp ts: Int?) { + if let ts { + 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") + return + } + // Prune BEFORE appending, so the event just accepted is always the one kept. + dispatchedDoubleTapEventTs.removeAll { + abs(ts - $0) > FrameRouter.liveGestureWindowSeconds + } + dispatchedDoubleTapEventTs.append(ts) + if dispatchedDoubleTapEventTs.count > FrameRouter.dispatchedDoubleTapMemory { + dispatchedDoubleTapEventTs.removeFirst( + dispatchedDoubleTapEventTs.count - FrameRouter.dispatchedDoubleTapMemory) + } + } + state.onDoubleTap?() + } } diff --git a/Strand/Data/HapticPrefs.swift b/Strand/Data/HapticPrefs.swift index 8788184185..833c1a8678 100644 --- a/Strand/Data/HapticPrefs.swift +++ b/Strand/Data/HapticPrefs.swift @@ -13,6 +13,10 @@ enum HapticPrefs { // Double-tap is deliberately NOT here: it's already opt-in via the DoubleTapAction picker. static let breathing = "haptics.breathing" static let intervals = "haptics.intervals" + /// The Lift Log rest timer's strap buzz, five seconds before the rest ends. Its own key rather + /// than sharing `intervals`: someone who wants a silent interval timer at home may well still + /// want the buzz at the rack, where the phone is face-down on a bench. + static let liftRest = "haptics.liftRest" static let liveSession = "haptics.liveSession" static let workout = "haptics.workout" diff --git a/Strand/Data/LiftFormat.swift b/Strand/Data/LiftFormat.swift new file mode 100644 index 0000000000..37302165fa --- /dev/null +++ b/Strand/Data/LiftFormat.swift @@ -0,0 +1,80 @@ +import Foundation + +// Number and unit formatting for the Lift Log. +// +// WEIGHT IS ALWAYS STORED IN KILOGRAMS. Only display and typed input are converted, using the +// unit system the user already picked for the whole app (`UnitPrefs.systemKey`) — the Lift Log +// deliberately does not add a second weight-unit setting of its own, so a pounds user gets pounds +// here for free and never has two settings that can disagree. +// +// Conversion goes through ONE constant in BOTH directions (`UnitFormatter.poundsPerKilogram`), so a typed +// value round-trips: enter 225 lb, store 102.058… kg, read it back and it renders 225 lb again. +// Using the exact 0.45359237 for input while displaying with the rounded 2.20462 would drift the +// number by a tenth on the way back and make the log look like it had edited itself. + +enum LiftFormat { + + // MARK: - Weight + + /// Convert a typed weight in the user's display unit to the kilograms that get stored. + static func kilograms(fromDisplay value: Double, system: UnitSystem) -> Double { + system == .imperial ? value / UnitFormatter.poundsPerKilogram : value + } + + /// Convert stored kilograms to the user's display unit, as a number (not a string) so callers + /// can put it straight into an editable text field. + static func display(fromKilograms kg: Double, system: UnitSystem) -> Double { + system == .imperial ? kg * UnitFormatter.poundsPerKilogram : kg + } + + /// A stored weight rendered for display with its unit: "60 kg", "132.5 lb". + static func weight(_ kg: Double?, system: UnitSystem) -> String { + guard let kg else { return "—" } + return "\(trim(display(fromKilograms: kg, system: system))) \(UnitFormatter.massUnit(system))" + } + + /// The bare unit label for a field suffix. + static func weightUnit(_ system: UnitSystem) -> String { UnitFormatter.massUnit(system) } + + // MARK: - Numbers + + /// A number with up to TWO decimals and no trailing noise: 8.0 → "8", 7.5 → "7.5", + /// 12.25 → "12.25", 45.50 → "45.5". + /// + /// Weights and RPE are entered as decimals but are usually whole, and "8.0 × 10" in a summary + /// line reads like a precision the user did not type — so a whole number loses its ".0". + /// + /// TWO decimals, not one. Gym plates come in quarter-kilos and microplates in smaller steps, so + /// 12.25 kg is a real weight a user types; formatting to one decimal turned it into 12.3 and, + /// because the entry field reads its text back through this function, that rounded value then + /// replaced what they typed. One decimal was silently lossy, not merely terse. + /// + /// Always renders "." regardless of locale — `String(format:)` takes no locale here — which + /// matches what the rest of the Lift Log displays. + static func trim(_ value: Double) -> String { + let rounded = (value * 100).rounded() / 100 + if rounded == rounded.rounded() && abs(rounded) < 1e9 { + return String(Int(rounded.rounded())) + } + var text = String(format: "%.2f", rounded) + while text.hasSuffix("0") { text.removeLast() } + if text.hasSuffix(".") { text.removeLast() } + return text + } + + /// Parse a typed number, accepting both "7.5" and the comma decimal separator "7,5" that most of + /// NOOP's shipped locales use on their keyboards. Returns nil for anything else. + static func number(_ text: String) -> Double? { + let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".") + guard !cleaned.isEmpty else { return nil } + return Double(cleaned) + } + + // MARK: - Durations + + /// A rest period as "2:00" / "45s" — minutes and seconds, which is how rest is spoken about. + 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/LiftMuscleNames.swift b/Strand/Data/LiftMuscleNames.swift new file mode 100644 index 0000000000..7b4a8abc15 --- /dev/null +++ b/Strand/Data/LiftMuscleNames.swift @@ -0,0 +1,87 @@ +import Foundation +import WhoopStore + +// Display names for the stored `LiftMuscle` vocabulary. +// +// `WhoopStore` deliberately holds no UI strings: `LiftMuscle`'s raw values are a stored-data +// contract (an Android twin must one day write byte-identical tokens), so the human-readable name +// has to live at the app layer, where it can be localized without touching what is written to the +// database. Renaming a label here is free; renaming a token is not — see the header of +// `LiftMuscle.swift`. +// +// Names are the ones lifters use, not the anatomical Latin: "Lats", not "latissimus dorsi". The +// vocabulary is meant to be picked from in a gym in a few seconds, and the granularity is already +// justified in the enum — the label's only job is to be recognised instantly. + +extension LiftMuscle { + /// Localized display name, e.g. "Front delts". + /// + /// A computed `String` rather than a `LocalizedStringKey` because callers interpolate it — into + /// a picker row, a summary line, an accessibility label — and an interpolated + /// `LocalizedStringKey` would look up the *interpolated* result as a key and miss. + var displayName: String { + switch self { + // Push + case .chest: return String(localized: "Chest") + case .frontDelts: return String(localized: "Front delts") + case .sideDelts: return String(localized: "Side delts") + case .rearDelts: return String(localized: "Rear delts") + case .triceps: return String(localized: "Triceps") + // Pull + case .lats: return String(localized: "Lats") + case .upperBack: return String(localized: "Upper back") + case .traps: return String(localized: "Traps") + case .biceps: return String(localized: "Biceps") + case .forearms: return String(localized: "Forearms") + // Legs + case .quads: return String(localized: "Quads") + case .hamstrings: return String(localized: "Hamstrings") + case .glutes: return String(localized: "Glutes") + case .adductors: return String(localized: "Adductors") + case .abductors: return String(localized: "Abductors") + case .calves: return String(localized: "Calves") + // Trunk + case .abs: return String(localized: "Abs") + case .obliques: return String(localized: "Obliques") + case .lowerBack: return String(localized: "Lower back") + case .neck: return String(localized: "Neck") + } + } +} + +extension LiftMuscle.Region { + /// Localized section title for the muscle picker. Presentation-only, like the region itself. + /// + /// "Push muscles", not "Push" — and for the same reason the rest timer says "Rest period" rather + /// than "Rest". The catalog's bare `"Push"` key is the TODAY screen's readiness nudge ("push + /// yourself"), translated accordingly: 推送 in Chinese is a push NOTIFICATION, and "Вперёд" in + /// Russian is "forward". Reusing it would have rendered this picker's section headers as words + /// from an unrelated feature. A generic English word is never safe as a key here. + var displayName: String { + switch self { + case .push: return String(localized: "Push muscles") + case .pull: return String(localized: "Pull muscles") + case .legs: return String(localized: "Leg muscles") + case .trunk: return String(localized: "Trunk muscles") + } + } +} + +// MARK: - Summarising a classification + +enum LiftMuscleSummary { + + /// One line describing how an exercise is classified, for a program row or a picker subtitle: + /// "Chest · Front delts, Triceps", or "Not classified" when it has no primary. + /// + /// The primary is listed first and separated from the secondaries, because the direct/indirect + /// split is what makes the per-muscle counts computable at all — collapsing them into one list + /// would hide the distinction the whole rollup rests on. + static func line(primary: LiftMuscle?, secondaries: [LiftMuscle]) -> String { + guard let primary else { return String(localized: "Not classified") } + let secondary = secondaries.filter { $0 != primary } + guard !secondary.isEmpty else { return primary.displayName } + let joined = secondary.map(\.displayName).joined(separator: ", ") + return "\(primary.displayName) · \(joined)" + } +} diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift new file mode 100644 index 0000000000..922116bf77 --- /dev/null +++ b/Strand/Data/LiftSessionController.swift @@ -0,0 +1,512 @@ +import Foundation +import Combine +import WhoopStore + +// The live session, owned ABOVE any screen. +// +// WHY THIS EXISTS. The session used to live inside the sheet that displayed it. Swiping that sheet +// down dismissed it, which tore down the view — and with it the strap's double-tap handler and the +// rest-timer tick. The session looked alive (it was still on disk, and "Resume" brought it back) but +// was deaf: taps did nothing, no buzz arrived, in the app or out of it. Re-entering also re-fired +// the five-second warning, because the "already buzzed" flag was view state that reset on every +// 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 +// 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, +// and handed back untouched when the session ends. + +@MainActor +final class LiftSessionController: ObservableObject { + + /// The running session, or nil when none is in flight. + @Published private(set) var engine: LiftSessionEngine? + @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 + + /// Bumped each time a finished session is written. The session sheet is presented above every + /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. + @Published private(set) var savedSessions = 0 + + 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 + /// re-opening the sheet mid-rest cannot re-fire it. + private var warnedFor: Int? + + /// Slots the user marked as a warm-up BEFORE performing them. You know a set is a warm-up on the + /// way in, not afterwards, but an unperformed set has no record to carry the flag — and inventing + /// one would create a set nobody did. So the mark is held here and applied the instant the set is + /// recorded. Owned by the controller rather than a view so it survives the sheet being minimised. + @Published private(set) var pendingWarmups: Set = [] + + /// Numbers typed into a set BEFORE it was performed, held exactly the way a warm-up mark is. + /// + /// Reported from a real session: "when I type something during an active set to other sets it + /// refreshes to the empty". It did — `LiftSessionView.write` could only edit a set that already + /// had a record, so every keystroke into a pending row was silently discarded, and the field only + /// LOOKED like it had taken until focus left and the draft was dropped. + /// + /// The engine invariant it ran into is real and stays: typing must never append a set, or a set + /// nobody performed becomes data. So the value is held here instead, shown back on the row, and + /// applied the instant the set is recorded — at which point it BEATS the carried plan, because a + /// number the user typed for this set is better evidence than the one the sheet guessed for it. + @Published private(set) var pendingValues: [LiftSlot: PendingSetValues] = [:] + + /// What a user typed into a set that has not happened yet. All optional: a row where only the + /// weight was typed keeps carrying its reps. + struct PendingSetValues: Equatable { + var weightKg: Double? + var reps: Int? + var rpe: Double? + + var isEmpty: Bool { weightKg == nil && reps == nil && rpe == nil } + } + + /// What the store holds for each exercise LAST session, keyed by exercise name then set number — + /// the middle layer of `LiftSessionEngine.carry(for:lastSession:)`. + /// + /// It lives here rather than in the sheet because the minimised bar and the Lock Screen show a + /// set's numbers too, and they must be the grey numbers the sheet shows. `LiftSessionView` loads it + /// 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? + + /// 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 + + /// 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 + /// mistaken for each other on a wrist that has been knocked about all session. + static let advanceConfirmBuzzes: UInt8 = 1 + static let restWarningBuzzes: UInt8 = 3 + /// How long before the rest ends the warning fires. + static let restWarningLeadSec = 5 + + init(buzz: @escaping (UInt8) -> Void, + setStrapHandler: @escaping ((() -> Void)?) -> Void) { + self.buzz = buzz + self.setStrapHandler = setStrapHandler + } + + // MARK: - Lifecycle + + func start(plan: [LiftPlanItem], programId: String?, programName: String?) { + let stamp = Int(Date().timeIntervalSince1970) + engine = LiftSessionEngine(plan: plan, startTs: stamp) + self.programId = programId + self.programName = programName + warnedFor = nil + now = stamp + isPresented = true + claimStrap() + startTicking() + persist() + } + + /// 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) { + engine = LiftSessionPersistence.engine(from: snapshot) + programId = snapshot.programId + programName = snapshot.programName + // Numbers typed for sets not yet performed, and warm-ups marked in advance, come back too: + // they are intent the user already expressed, and losing them is the bug this pair exists + // 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 { + warnedFor = endsAt + } else { + warnedFor = nil + } + isPresented = present + claimStrap() + startTicking() + } + + /// Give up the session without saving. + func discard() { + teardown() + LiftSessionPersistence.clear() + } + + /// Called once the session has been written to the store. + func finishedSaving() { + teardown() + LiftSessionPersistence.clear() + savedSessions += 1 + } + + private func teardown() { + engine = nil + programId = nil + programName = nil + warnedFor = nil + pendingWarmups = [] + pendingValues = [:] + isPresented = false + ticker?.cancel() + ticker = nil + setStrapHandler(nil) + } + + 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() + } + } + + // MARK: - Actions + + /// The one action. `fromStrap` earns a single confirming buzz. + 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) } + + let stamp = Int(Date().timeIntervalSince1970) + engine?.advance(now: stamp) + applyPendingInput() + now = stamp + warnedFor = nil + persist() + } + + // MARK: - Presentation + // + // What a running session looks like, resolved ONCE here rather than in each surface that shows + // it. The minimised bar and the Lock Screen Live Activity display the same four things — state, + // exercise, numbers, clock — and they must agree, including the wording. Two copies of this + // drifted the moment one of them was edited. + + struct Presentation: Equatable { + var isResting: Bool + /// The exercise being worked or rested from; the program's name when neither applies. + var exercise: String + /// "Set 2", "Resting after set 2", "Ready for the next set", "3 of 19 sets done". + var status: String + /// "8 x 30 kg", already unit-converted. Nil when neither reps nor weight is known. + var detail: String? + var setsDone: Int + var setsPlanned: Int + var stageStartedAt: Date + /// When the running rest is due to end. Nil while working. + var restEndsAt: Date? + } + + func presentation(system: UnitSystem) -> Presentation? { + guard let engine, !engine.isFinished else { return nil } + let done = engine.completedWorkingSets + let planned = engine.plannedWorkingSets + let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt)) + let fallback = String(localized: "\(done) of \(planned) sets done") + + 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, + stageStartedAt: started, restEndsAt: nil) + } + + let detail = setNumbers(for: slot, system: system) + switch engine.stage { + case .resting(_, let endsAt): + let ready = endsAt <= now + 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, + 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, + 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. + func setNumbers(for slot: LiftSlot, system: UnitSystem) -> String? { + guard engine != nil else { return nil } + let shown = values(of: slot) + + let weight = shown.weightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) + + " " + LiftFormat.weightUnit(system) + } + switch (shown.reps, weight) { + case (let r?, let w?): return "\(r) x \(w)" + case (let r?, nil): return String(localized: "\(r) reps") + case (nil, let w?): return w + case (nil, nil): return nil + } + } + + /// Hand over what the store knows about previous sessions. Called by the sheet once it has read + /// it; safe to call again if it reloads. + func setLastSession(_ values: [String: [Int: LiftSetCarry]]) { + lastSession = values + } + + /// The grey numbers a slot shows — the one chain every surface reads, so the sheet, the minimised + /// bar and the Lock Screen cannot disagree about them. + func carry(for slot: LiftSlot) -> LiftSetCarry { + engine?.carry(for: slot, lastSession: lastSessionSets(for: slot)) ?? .none + } + + /// What a slot counts as: typed numbers, else its grey ones. + func values(of slot: LiftSlot) -> LiftSetCarry { + engine?.values(of: slot, lastSession: lastSessionSets(for: slot)) ?? .none + } + + private func lastSessionSets(for slot: LiftSlot) -> [Int: LiftSetCarry] { + engine?.planItem(for: slot).flatMap { lastSession[$0.exercise] } ?? [:] + } + + /// Mark a slot as a warm-up (or not). Applies immediately when the set already exists, and is + /// remembered for when it does not yet. + func setWarmup(_ slot: LiftSlot, _ isWarmup: Bool) { + if isWarmup { pendingWarmups.insert(slot) } else { pendingWarmups.remove(slot) } + if let row = engine?.recordedSet(for: slot) { + engine?.updateSet(slot, weightKg: row.weightKg, reps: row.reps, + rpe: row.rpe, isWarmup: isWarmup) + } + persist() + } + + func isWarmup(_ slot: LiftSlot) -> Bool { + if let row = engine?.recordedSet(for: slot) { return row.isWarmup } + return pendingWarmups.contains(slot) + } + + /// Carry a pre-marked warm-up, and any numbers typed in advance, onto the set just recorded. + /// + /// The set is recorded with its timing only, so typed numbers become its own and a field left + /// untouched stays grey — typing only the weight does not blank the reps. + /// + /// The entry is CONSUMED. A redo (`start` on a completed slot) drops the record and should show + /// the ghosts again, exactly as it did before; leaving the entry behind would resurrect numbers + /// the user is in the middle of redoing. + private func applyPendingInput() { + guard let engine, let last = engine.sets.last else { return } + let typed = pendingValues.removeValue(forKey: last.slot) + let warmup = last.isWarmup || pendingWarmups.contains(last.slot) + guard typed != nil || warmup != last.isWarmup else { return } + self.engine?.updateSet(last.slot, + weightKg: typed?.weightKg ?? last.weightKg, + reps: typed?.reps ?? last.reps, + rpe: typed?.rpe ?? last.rpe, + isWarmup: warmup) + } + + /// Begin a specific set — the out-of-order path, for when a machine is occupied. + func start(_ slot: LiftSlot, fromStrap: Bool = false) { + guard engine != nil else { return } + if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } + let stamp = Int(Date().timeIntervalSince1970) + 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() + } + + /// Add one set to an exercise — the unplanned fifth set. Returns whether anything changed, which + /// is what tells the caller whether the program behind the session needs rewriting. + @discardableResult + func addSet(toExercise index: Int) -> Bool { + guard engine?.addSet(toExercise: index) == 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 + func removeSet(fromExercise index: Int) -> Bool { + guard let engine, engine.canRemoveSet(fromExercise: index) else { return false } + let dropped = LiftSlot(exerciseIndex: index, setIndex: engine.plan[index].targetSets) + self.engine?.removeSet(fromExercise: index) + // A slot that no longer exists must not keep a warm-up mark or typed numbers: adding the set + // back would return them silently, from input the user gave a set they then removed. + pendingWarmups.remove(dropped) + pendingValues.removeValue(forKey: dropped) + persist() + return true + } + + /// Fill in or correct a set's numbers — **any** set, at any time. + /// + /// A set that has been performed is edited in the engine. A set that has NOT been performed + /// cannot be (that would invent it), so its numbers are held in `pendingValues` until it is. + /// From the screen the two are indistinguishable, which is the point: the user asked to be able + /// to type into whichever row they are looking at, and being mid-set somewhere else is not a + /// reason to refuse. + func updateSet(_ slot: LiftSlot, weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { + if engine?.recordedSet(for: slot) != nil { + engine?.updateSet(slot, weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup) + } else if engine?.planItem(for: slot) != nil { + let values = PendingSetValues(weightKg: weightKg, reps: reps, rpe: rpe) + // Clearing the last field clears the entry rather than leaving an empty one behind, so + // the row goes back to showing the plan's grey ghost instead of a blank it has to keep. + if values.isEmpty { pendingValues.removeValue(forKey: slot) } + else { pendingValues[slot] = values } + } + persist() + } + + /// What a slot is currently showing: what it recorded, or what was typed into it in advance. + func enteredValues(for slot: LiftSlot) -> PendingSetValues { + if let row = engine?.recordedSet(for: slot) { + return PendingSetValues(weightKg: row.weightKg, reps: row.reps, rpe: row.rpe) + } + return pendingValues[slot] ?? PendingSetValues(weightKg: nil, reps: nil, rpe: nil) + } + + func undo() { + engine?.undo() + persist() + } + + func finish() { + engine?.finish(now: Int(Date().timeIntervalSince1970)) + persist() + } + + // 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 ?? [] } + + /// 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 + /// heart-rate window it never had. + struct FinishedSet: Equatable { + var slot: LiftSlot + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int? + var endTs: Int? + var restSec: Int? + } + + /// The sets the session saves. + /// + /// 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. + 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 } + 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, + isWarmup: pendingWarmups.contains(slot), + startTs: nil, endTs: nil, restSec: nil)) + } + return out + } + + /// A program line whose set count this session changed. + struct SetCountChange: Equatable { + var itemId: String + var exercise: String + var from: Int + var to: Int + } + + /// 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. + 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 + guard let id = line.programItemId, let row = byId[id] else { return nil } + let saved = max(1, row.targetSets ?? 1) + guard saved != line.targetSets else { return nil } + return SetCountChange(itemId: id, exercise: line.exercise, from: saved, to: line.targetSets) + } + } + + /// 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 }) + return items.map { row in + guard let sets = counts[row.id] else { return row } + var edited = row + edited.targetSets = sets + return edited + } + } + + // MARK: - The rest warning + + 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 } + warnedFor = endsAt + buzz(LiftSessionController.restWarningBuzzes) + } + + // MARK: - Persistence + + private func persist() { + guard let engine, !engine.isFinished else { return } + LiftSessionPersistence.store( + LiftSessionPersistence.snapshot(engine: engine, + programId: programId, + programName: programName, + pendingValues: pendingValues, + pendingWarmups: pendingWarmups)) + } +} diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift new file mode 100644 index 0000000000..8134b7a54f --- /dev/null +++ b/Strand/Data/LiftSessionEngine.swift @@ -0,0 +1,433 @@ +import Foundation +import WhoopStore + +// The session state machine: the heart of the Lift Log. +// +// A GYM IS NOT A QUEUE. The first version walked the plan strictly in order, one set at a time, and +// the first real session killed that: when a machine is occupied you move on and come back. So the +// plan is a SHEET of slots — every set of every exercise, all visible — and any pending slot can be +// started at any time. Order is a suggestion the session follows by default, not a rail. +// +// PURE (no timers, no store, no SwiftUI): it is the piece most likely to be wrong in a way that +// costs someone a logged set, and the only part testable with no strap, no database, no simulator. +// +// TIME ENTERS ONLY AS A PARAMETER, so a test can drive a whole session through a known timeline. +// Rest is an ABSOLUTE end instant, never a decrementing counter — a phone that sleeps through a rest +// must wake up telling the truth. +// +// THERE IS NO COOL-DOWN STAGE. Warm-up is the time before the first set and cool-down the time after +// the last; both fall out of the timestamps, so neither needs a stage of its own. The session ends +// when the user says it does. + +/// One set of one exercise — a position on the sheet. `setIndex` is 1-based within its exercise. +struct LiftSlot: Hashable { + var exerciseIndex: Int + var setIndex: Int +} + +/// One planned exercise line, flattened from a program for the session to run. +struct LiftPlanItem: Equatable { + var exercise: String + var primaryMuscle: LiftMuscle? + var secondaryMuscles: [LiftMuscle] + /// Planned working sets. Always >= 1 — a line scheduling zero sets could not be tapped through. + var targetSets: Int + /// Intended rest after each set, in seconds. + var restSec: Int + var targetRepsLow: Int? + var targetRepsHigh: Int? + var targetRpe: Double? + /// The weight the program plans, in kilograms. + var targetWeightKg: Double? + 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. + var programItemId: String? + + /// 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 + /// is actually rested is measured from the taps. + static let defaultRestSec = 120 + + init(exercise: String, + primaryMuscle: LiftMuscle? = nil, + secondaryMuscles: [LiftMuscle] = [], + targetSets: Int? = nil, + restSec: Int? = nil, + targetRepsLow: Int? = nil, + targetRepsHigh: Int? = nil, + targetRpe: Double? = nil, + targetWeightKg: Double? = nil, + note: String? = nil, + programItemId: String? = nil) { + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = secondaryMuscles + self.targetSets = max(1, targetSets ?? 1) + self.restSec = max(0, restSec ?? LiftPlanItem.defaultRestSec) + self.targetRepsLow = targetRepsLow + self.targetRepsHigh = targetRepsHigh + self.targetRpe = targetRpe + self.targetWeightKg = targetWeightKg + self.note = note + self.programItemId = programItemId + } +} + +/// One set as actually performed. +struct LiftRecordedSet: Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int + var endTs: Int + /// Rest actually taken after this set. Nil while the rest is still running, or when the user + /// moved on without resting. + var restSec: Int? + + var slot: LiftSlot { LiftSlot(exerciseIndex: exerciseIndex, setIndex: setIndex) } +} + +/// The numbers a set shows in grey: what it would save if completed without anything typed. +/// +/// Only weight and reps. RPE is deliberately absent — see `carry(for:lastSession:)`. +struct LiftSetCarry: Equatable { + var weightKg: Double? + var reps: Int? + + static let none = LiftSetCarry(weightKg: nil, reps: nil) +} + +struct LiftSessionEngine: Equatable { + + enum Stage: Equatable { + /// Before the first set — the warm-up. + case warmup + /// Performing a set. + case working(LiftSlot) + /// Resting after a set. `endsAt` is an absolute unix second. + case resting(LiftSlot, endsAt: Int) + /// Ended; ready to save. + 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. + private(set) var plan: [LiftPlanItem] + /// When the session began (unix seconds). + let startTs: Int + private(set) var stage: Stage + /// Completed sets in COMPLETION order — which is the order they happened, not the plan's order, + /// and is what `ord` is written from. + private(set) var sets: [LiftRecordedSet] + /// When the current stage began, so a set's duration is measurable. + private(set) var stageStartedAt: Int + + /// Undo stack: whole-state snapshots rather than inverse operations. A gym is a bad place to be + /// one tap ahead of yourself, and restoring a snapshot cannot get the arithmetic wrong the way a + /// hand-written inverse can. + private var history: [Snapshot] = [] + + /// The plan travels in the snapshot with the stage and the sets, and is restored with them. + /// + /// It has to. Once the set count can move, a stage saved under one count is only meaningful + /// under that count: undoing back past a removed set would otherwise leave the session working + /// a slot the sheet no longer draws — and completing it would write a set nobody could see. + private struct Snapshot: Equatable { + var plan: [LiftPlanItem] + var stage: Stage + var sets: [LiftRecordedSet] + var stageStartedAt: Int + } + + init(plan: [LiftPlanItem], startTs: Int) { + self.plan = plan + self.startTs = startTs + self.stage = .warmup + self.sets = [] + self.stageStartedAt = startTs + } + + /// Rebuild an interrupted session — see `LiftSessionPersistence`. + /// + /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and a + /// stack that survives a relaunch invites reaching back past a save boundary. + init(restoring plan: [LiftPlanItem], startTs: Int, stage: Stage, + sets: [LiftRecordedSet], stageStartedAt: Int) { + self.plan = plan + self.startTs = startTs + self.stage = stage + self.sets = sets + self.stageStartedAt = stageStartedAt + } + + // MARK: - The sheet + + /// Every slot of one exercise, in order — the rows the sheet draws. + func slots(forExercise index: Int) -> [LiftSlot] { + guard plan.indices.contains(index) else { return [] } + return (1...plan[index].targetSets).map { LiftSlot(exerciseIndex: index, setIndex: $0) } + } + + /// Every slot in the whole session, in plan order. + var allSlots: [LiftSlot] { plan.indices.flatMap { slots(forExercise: $0) } } + + func recordedSet(for slot: LiftSlot) -> LiftRecordedSet? { + sets.first { $0.slot == slot } + } + + func isCompleted(_ slot: LiftSlot) -> Bool { recordedSet(for: slot) != nil } + + /// The slot being worked or rested from. + var currentSlot: LiftSlot? { + switch stage { + case .working(let s): return s + case .resting(let s, _): return s + case .warmup, .finished: return nil + } + } + + /// The next slot the plan would suggest — the first uncompleted one in plan order. Nil when the + /// whole sheet is done. + /// + /// This is the SHEET's answer, used to open a session and to know when everything is done. It is + /// deliberately NOT what `advance` follows after a set — see `slotAfter(_:)`. + var nextPendingSlot: LiftSlot? { + allSlots.first { !isCompleted($0) } + } + + /// Where the session goes after finishing `slot`: the next uncompleted set of the SAME exercise, + /// and only once that exercise is finished, the first uncompleted slot in plan order. + /// + /// Plan order alone is wrong, and wrong in a way that costs sets. A gym is not a queue — the + /// whole point of being able to start any pending set is that machines get occupied — so a user + /// who skips exercise 1 and starts exercise 3 has an EARLIER slot still uncompleted. Following + /// plan order then throws them back to the machine they just walked away from, mid-exercise, + /// after every single set. Reported from a real session: "when I double-tap for the next set, it + /// reverts to the first set of the exercise I couldn't do earlier." + /// + /// Staying on the current exercise until it is finished is also simply what lifting is: you do + /// your sets on the machine you are standing at. The skipped exercise is not forgotten — it is + /// still pending, and it is what you get once the current one is done. + func slotAfter(_ slot: LiftSlot) -> LiftSlot? { + slots(forExercise: slot.exerciseIndex).first { !isCompleted($0) } ?? nextPendingSlot + } + + var allCompleted: Bool { nextPendingSlot == nil } + var isFinished: Bool { stage == .finished } + var canUndo: Bool { !history.isEmpty } + + var plannedWorkingSets: Int { plan.reduce(0) { $0 + $1.targetSets } } + var completedWorkingSets: Int { sets.filter { !$0.isWarmup }.count } + + func planItem(for slot: LiftSlot) -> LiftPlanItem? { + plan.indices.contains(slot.exerciseIndex) ? plan[slot.exerciseIndex] : nil + } + + /// Seconds left in the current rest, floored at zero. Nil when not resting. + /// + /// Floored rather than negative so an overrun rest reads "0:00" and waits, which is what it + /// actually means: the user has not moved on yet. + func restRemaining(now: Int) -> Int? { + guard case .resting(_, let endsAt) = stage else { return nil } + return max(0, endsAt - now) + } + + /// The nearest earlier set of this exercise already performed in THIS session. Nil for the first + /// set, or when nothing before it has been done yet. + func previousSetInSession(for slot: LiftSlot) -> LiftRecordedSet? { + (1.. LiftSetCarry { + let previous = previousSetInSession(for: slot).map { values(of: $0.slot, lastSession: lastSession) } + let last = lastSession[slot.setIndex] + let item = planItem(for: slot) + return LiftSetCarry( + weightKg: previous?.weightKg ?? last?.weightKg ?? item?.targetWeightKg, + reps: previous?.reps ?? last?.reps ?? item?.targetRepsLow) + } + + /// What a slot counts as: each number typed into it, else its grey number. + func values(of slot: LiftSlot, lastSession: [Int: LiftSetCarry]) -> LiftSetCarry { + let grey = carry(for: slot, lastSession: lastSession) + guard let set = recordedSet(for: slot) else { return grey } + 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 + } + } + + // MARK: - Actions + + /// Begin a specific set. Works from any stage, which is the whole point: a machine being busy + /// must not block the session. + /// + /// Starting a set while ANOTHER set is in progress abandons that one — nothing is recorded, + /// because nothing was finished. Starting an already-completed slot re-opens it for a redo, + /// dropping its previous record so the set is not counted twice. + mutating func start(_ slot: LiftSlot, now: Int) { + guard planItem(for: slot) != nil else { return } + pushHistory() + sets.removeAll { $0.slot == slot } + stage = .working(slot) + stageStartedAt = now + } + + /// The big button. Context decides what it means. + mutating func advance(now: Int) { + switch stage { + case .warmup: + guard let next = nextPendingSlot else { return } + start(next, now: now) + + case .working(let slot): + pushHistory() + // Timing only: the numbers stay grey until typed (see `carry(for:lastSession:)`). + sets.append(LiftRecordedSet( + exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: nil, reps: nil, rpe: nil, isWarmup: false, + startTs: stageStartedAt, endTs: now, restSec: nil)) + let rest = planItem(for: slot)?.restSec ?? LiftPlanItem.defaultRestSec + stage = .resting(slot, endsAt: now + rest) + stageStartedAt = now + + case .resting(let slot, _): + pushHistory() + // Record what was ACTUALLY rested — the figure each set row shows, and the one thing + // only the taps can know. + if let i = sets.firstIndex(where: { $0.slot == slot }) { + sets[i].restSec = max(0, now - stageStartedAt) + } + if let next = slotAfter(slot) { + stage = .working(next) + stageStartedAt = now + } else { + // Sheet complete: stay put rather than inventing a stage. The user finishes when + // they are ready, and the time until then is the cool-down. + stage = .resting(slot, endsAt: now) + stageStartedAt = now + } + + case .finished: + break + } + } + + /// Fill in (or correct) what a set actually was. Editing, never appending — the set already + /// exists, so typing can never create a phantom. No-op for a slot that has not been completed. + mutating func updateSet(_ slot: LiftSlot, + weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { + guard let i = sets.firstIndex(where: { $0.slot == slot }) else { return } + sets[i].weightKg = weightKg + sets[i].reps = reps + sets[i].rpe = rpe + sets[i].isWarmup = isWarmup + } + + // MARK: - Changing how many sets a line holds + // + // A program is what you INTENDED, and a gym argues with it. Five sets when the program says four + // is ordinary; so is stopping at three because the tank is empty. Until this existed the fifth + // set simply could not be recorded — the sheet drew exactly `1...targetSets` and there was no + // way past it — so the set was done and then lost, which is the failure this feature exists to + // prevent. + + /// The most sets one line may hold. A bound against a stuck finger, not a recommendation: every + /// added set is a row on the sheet and a slot in the crash snapshot. + static let maxSetsPerExercise = 20 + + /// Append one set to an exercise's line. Returns false when the line is already at the bound, so + /// a caller can tell "did nothing" from "done" without re-deriving the rule. + @discardableResult + mutating func addSet(toExercise index: Int) -> Bool { + guard plan.indices.contains(index), + plan[index].targetSets < LiftSessionEngine.maxSetsPerExercise else { return false } + pushHistory() + plan[index].targetSets += 1 + return true + } + + /// Whether the last set of a line can be dropped. + /// + /// Only the LAST one, and only while it is still pending. Set numbers are positions — the sheet + /// draws `1...targetSets` — so removing from the middle would renumber every set after it and + /// silently re-label what was already recorded. And a completed set is DATA: dropping it here + /// would delete a set the user actually performed, from a button whose job is to edit a plan. + /// The set being worked or rested from is excluded for the same reason it cannot be renumbered: + /// the session is standing on it. + func canRemoveSet(fromExercise index: Int) -> Bool { + guard plan.indices.contains(index), plan[index].targetSets > 1 else { return false } + let last = LiftSlot(exerciseIndex: index, setIndex: plan[index].targetSets) + return !isCompleted(last) && currentSlot != last + } + + /// Drop the last (pending) set of an exercise's line. Returns false when the rule above says no. + @discardableResult + mutating func removeSet(fromExercise index: Int) -> Bool { + guard canRemoveSet(fromExercise: index) else { return false } + pushHistory() + plan[index].targetSets -= 1 + 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) { + guard stage != .finished else { return } + pushHistory() + if case .resting(let slot, _) = stage, + let i = sets.firstIndex(where: { $0.slot == slot }), sets[i].restSec == nil { + sets[i].restSec = max(0, now - stageStartedAt) + } + stage = .finished + stageStartedAt = now + } + + mutating func undo() { + guard let previous = history.popLast() else { return } + plan = previous.plan + stage = previous.stage + sets = previous.sets + stageStartedAt = previous.stageStartedAt + } + + private mutating func pushHistory() { + history.append(Snapshot(plan: plan, stage: stage, + sets: sets, stageStartedAt: stageStartedAt)) + } +} diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift new file mode 100644 index 0000000000..6f0dd4c80c --- /dev/null +++ b/Strand/Data/LiftSessionPersistence.swift @@ -0,0 +1,252 @@ +import Foundation +import WhoopStore + +// Crash-safety for an in-flight gym session. +// +// A session is written to UserDefaults on every tap and rehydrated on launch, so a crash, a phone +// call, a flat battery or simply swiping the app away mid-workout costs nothing. This mirrors +// `ActiveWorkoutPersistence` exactly — a small `Codable` snapshot, unix-second anchors, and an +// encode/decode pair that is pure (no UserDefaults dependency of its own) so the round-trip is +// testable without touching the defaults database. +// +// Unix seconds rather than `Date` for the same reason the store uses them: an absolute instant +// survives suspension, timezone changes and a relaunch, and it is the only anchor that keeps a rest +// countdown honest. + +enum LiftSessionPersistence { + + /// The durable shape of an in-flight session — the minimum needed to rebuild the engine and still + /// finish and save the session after a relaunch. + struct Snapshot: Codable, Equatable { + var startSec: Int + var programId: String? + var programName: String? + /// The plan, flattened at start. Stored rather than re-read from the program so that editing + /// or deleting the program mid-session cannot change what is being run. + var plan: [PlanItem] + var stage: StageBox + var sets: [RecordedSet] + var stageStartedAt: Int + /// Numbers typed into sets that have NOT happened yet, and slots marked a warm-up in + /// advance. Both are intent the user has already expressed, so a crash must not cost them — + /// that is the whole point of this snapshot. + /// + /// Optional because a snapshot written before these existed does not carry them: it decodes + /// as absent and the session resumes with nothing pending, which is exactly right. + var pendingValues: [PendingValue]? + var pendingWarmups: [SlotBox]? + + struct PendingValue: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + } + + struct SlotBox: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + } + + struct PlanItem: Codable, Equatable { + var exercise: String + var primaryMuscle: String? + var secondaryMuscles: [String] + var targetSets: Int + var restSec: Int + var targetRepsLow: Int? + var targetRepsHigh: Int? + var targetRpe: Double? + var targetWeightKg: Double? + var note: String? + /// The program line this was flattened from. Absent from a snapshot written before the + /// 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? + } + + /// The stage as a flat, forward-compatible record rather than an encoded enum: a persisted + /// enum with associated values is a migration hazard the moment a case is added, and this + /// shape decodes to "warm-up" rather than to garbage if it is ever read by an older build. + struct StageBox: Codable, Equatable { + var kind: String // warmup | working | resting | cooldown | finished + var item: Int? + var set: Int? + var endsAt: Int? + } + + struct RecordedSet: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int + var endTs: Int + var restSec: Int? + } + } + + /// The single UserDefaults key (JSON-encoded `Snapshot`), namespaced like `noop.activeWorkout`. + static let defaultsKey = "noop.activeLiftSession" + + // MARK: - Codec + + static func encode(_ snapshot: Snapshot) -> Data? { + try? JSONEncoder().encode(snapshot) + } + + /// Decode a snapshot, bound-checking the untrusted persisted values. Returns nil for + /// nil/garbage/empty input or an implausible start time, so a corrupt write is treated as "no + /// session in flight" rather than reviving a broken screen the user cannot get out of. + static func decode(_ data: Data?) -> Snapshot? { + guard let data, !data.isEmpty, + let raw = try? JSONDecoder().decode(Snapshot.self, from: data), + raw.startSec > 1_000_000_000, // after 2001; not a zero/garbage anchor + raw.startSec < Int(Date().timeIntervalSince1970) + 86_400, + !raw.plan.isEmpty + else { return nil } + return raw + } + + // MARK: - UserDefaults + + static func store(_ snapshot: Snapshot?, into d: UserDefaults = .standard) { + guard let snapshot, let data = encode(snapshot) else { + d.removeObject(forKey: defaultsKey) + return + } + d.set(data, forKey: defaultsKey) + } + + static func load(from d: UserDefaults = .standard) -> Snapshot? { + decode(d.data(forKey: defaultsKey)) + } + + static func clear(_ d: UserDefaults = .standard) { + d.removeObject(forKey: defaultsKey) + } + + // MARK: - Engine bridge + + static func snapshot(engine: LiftSessionEngine, + programId: String?, + programName: String?, + pendingValues: [LiftSlot: LiftSessionController.PendingSetValues], + pendingWarmups: Set) -> Snapshot { + Snapshot( + startSec: engine.startTs, + programId: programId, + programName: programName, + plan: engine.plan.map { + Snapshot.PlanItem(exercise: $0.exercise, + primaryMuscle: $0.primaryMuscle?.rawValue, + secondaryMuscles: $0.secondaryMuscles.map(\.rawValue), + targetSets: $0.targetSets, + restSec: $0.restSec, + targetRepsLow: $0.targetRepsLow, + targetRepsHigh: $0.targetRepsHigh, + targetRpe: $0.targetRpe, + targetWeightKg: $0.targetWeightKg, + note: $0.note, + programItemId: $0.programItemId) + }, + stage: box(engine.stage), + sets: engine.sets.map { + Snapshot.RecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, + weightKg: $0.weightKg, reps: $0.reps, rpe: $0.rpe, + isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, + restSec: $0.restSec) + }, + stageStartedAt: engine.stageStartedAt, + // Sorted so the encoded snapshot is stable: a dictionary and a set have no order, and an + // unstable encoding would rewrite the defaults blob on every tick for no reason. + pendingValues: pendingValues + .sorted { ($0.key.exerciseIndex, $0.key.setIndex) < ($1.key.exerciseIndex, $1.key.setIndex) } + .map { slot, values in + Snapshot.PendingValue(exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: values.weightKg, reps: values.reps, rpe: values.rpe) + }, + pendingWarmups: pendingWarmups + .sorted { ($0.exerciseIndex, $0.setIndex) < ($1.exerciseIndex, $1.setIndex) } + .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + } + + /// The numbers typed in advance, back as the controller holds them. + static func pendingValues(from s: Snapshot) -> [LiftSlot: LiftSessionController.PendingSetValues] { + var out: [LiftSlot: LiftSessionController.PendingSetValues] = [:] + for p in s.pendingValues ?? [] { + out[LiftSlot(exerciseIndex: p.exerciseIndex, setIndex: p.setIndex)] = + LiftSessionController.PendingSetValues(weightKg: p.weightKg, reps: p.reps, rpe: p.rpe) + } + return out + } + + /// The warm-up marks made in advance, back as the controller holds them. + static func pendingWarmups(from s: Snapshot) -> Set { + Set((s.pendingWarmups ?? []).map { LiftSlot(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + } + + /// Rebuild an engine from a snapshot. Unknown muscle tokens are dropped rather than failing the + /// read, matching `LiftMuscle.decodeList`: a snapshot written by a newer build must not strand a + /// session in an older one. + static func engine(from s: Snapshot) -> LiftSessionEngine { + let plan = s.plan.map { + LiftPlanItem(exercise: $0.exercise, + primaryMuscle: $0.primaryMuscle.flatMap(LiftMuscle.init(rawValue:)), + secondaryMuscles: $0.secondaryMuscles.compactMap(LiftMuscle.init(rawValue:)), + targetSets: $0.targetSets, + restSec: $0.restSec, + targetRepsLow: $0.targetRepsLow, + targetRepsHigh: $0.targetRepsHigh, + targetRpe: $0.targetRpe, + targetWeightKg: $0.targetWeightKg, + note: $0.note, + programItemId: $0.programItemId) + } + let sets = s.sets.map { + LiftRecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, + weightKg: $0.weightKg, reps: $0.reps, rpe: $0.rpe, + isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, + restSec: $0.restSec) + } + return LiftSessionEngine(restoring: plan, startTs: s.startSec, + stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt) + } + + private static func box(_ stage: LiftSessionEngine.Stage) -> Snapshot.StageBox { + switch stage { + case .warmup: + return .init(kind: "warmup", item: nil, set: nil, endsAt: nil) + case .working(let slot): + return .init(kind: "working", item: slot.exerciseIndex, set: slot.setIndex, endsAt: nil) + case .resting(let slot, let endsAt): + return .init(kind: "resting", item: slot.exerciseIndex, set: slot.setIndex, endsAt: endsAt) + case .finished: + return .init(kind: "finished", item: nil, set: nil, endsAt: nil) + } + } + + /// Anything unrecognised — a `working`/`resting` box missing its indices, or the retired + /// "cooldown" kind written by an earlier build — falls back to the warm-up. + /// + /// That is a real recovery, not a shrug: every COMPLETED set is carried in `sets` regardless, so + /// nothing logged is lost. Only the cursor's position is forgotten, and the next tap simply + /// resumes at the first set still outstanding. + private static func unbox(_ box: Snapshot.StageBox) -> LiftSessionEngine.Stage { + switch box.kind { + case "working": + guard let i = box.item, let s = box.set else { return .warmup } + return .working(LiftSlot(exerciseIndex: i, setIndex: s)) + case "resting": + guard let i = box.item, let s = box.set, let e = box.endsAt else { return .warmup } + return .resting(LiftSlot(exerciseIndex: i, setIndex: s), endsAt: e) + case "finished": + return .finished + default: + return .warmup + } + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 1a6758dc5d..5027d56ce2 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,576 @@ { "sourceLanguage": "en", "strings": { + "%lld working sets · compare when you repeat this program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Arbeitssätze · vergleichbar, wenn du dieses Programm wiederholst"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series efectivas · compara al repetir este programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries de travail · à comparer quand tu refais ce programme"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie di lavoro · confronta quando ripeti questo programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii roboczych · porównuj, gdy powtarzasz ten program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries de trabalho · compara quando repetires este programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld рабочих подходов · сравнивай при повторе этой программы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 个正式组 · 重复同一计划时才可比较"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 個正式組 · 重複同一計畫時才可比較"}} + } }, + "%@ in sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ in Sätzen"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ en series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ en séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ nelle serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ w seriach"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ em séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ в подходах"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 在组内"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 在組內"}} + } }, + "Last 7 days · estimated": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Letzte 7 Tage · geschätzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Últimos 7 días · estimado"}}, "fr": {"stringUnit": {"state": "translated", "value": "7 derniers jours · estimé"}}, "it": {"stringUnit": {"state": "translated", "value": "Ultimi 7 giorni · stimato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ostatnie 7 dni · szacunkowo"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Últimos 7 dias · estimado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Последние 7 дней · оценка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最近 7 天 · 估算"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最近 7 天 · 估算"}} + } }, + "This session · estimated": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Einheit · geschätzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Esta sesión · estimado"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette séance · estimé"}}, "it": {"stringUnit": {"state": "translated", "value": "Questa sessione · stimato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ta sesja · szacunkowo"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esta sessão · estimado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Эта тренировка · оценка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "本次训练 · 估算"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "本次訓練 · 估算"}} + } }, + "Counted from the muscles you assigned each exercise: direct sets count once, indirect ones count as a half — the method the reference figures were derived under. An estimate from your own labels, not something measured off your body.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gezählt nach den Muskeln, die du jeder Übung zugeordnet hast: direkte Sätze zählen einfach, indirekte als halber Satz — die Methode, unter der die Referenzwerte ermittelt wurden. Eine Schätzung aus deinen eigenen Angaben, nichts an deinem Körper Gemessenes."}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta según los músculos que asignaste a cada ejercicio: las series directas cuentan una vez y las indirectas como media — el método con el que se obtuvieron las cifras de referencia. Es una estimación a partir de tus propias etiquetas, no algo medido en tu cuerpo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté d'après les muscles que tu as attribués à chaque exercice : les séries directes comptent une fois, les indirectes pour moitié — la méthode qui a servi à établir les valeurs de référence. Une estimation issue de tes propres libellés, pas une mesure prise sur ton corps."}}, "it": {"stringUnit": {"state": "translated", "value": "Conteggiato dai muscoli che hai assegnato a ogni esercizio: le serie dirette contano una volta, quelle indirette metà — il metodo con cui sono stati ricavati i valori di riferimento. Una stima dalle tue etichette, non qualcosa misurato sul tuo corpo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone na podstawie mięśni przypisanych do każdego ćwiczenia: serie bezpośrednie liczą się raz, pośrednie za pół — metoda, na której oparto wartości odniesienia. To szacunek z twoich własnych oznaczeń, a nie pomiar na twoim ciele."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado a partir dos músculos que atribuíste a cada exercício: as séries diretas contam uma vez e as indiretas metade — o método com que os valores de referência foram obtidos. É uma estimativa a partir das tuas etiquetas, não algo medido no teu corpo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Считается по мышцам, которые ты назначил каждому упражнению: прямые подходы идут за один, косвенные за половину — метод, по которому получены эталонные значения. Это оценка по твоим же меткам, а не измерение тела."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你为每个动作指定的肌群统计:直接组算一组,间接组算半组——参考数值正是用这个方法得出的。这是基于你自己标注的估算,而非在你身上测得的数据。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你為每個動作指定的肌群統計:直接組算一組,間接組算半組——參考數值正是用這個方法得出的。這是基於你自己標註的估算,而非在你身上測得的數據。"}} + } }, + "Counted from the muscles you assigned each exercise: direct sets count once, indirect ones half. The tick is about 4 sets a week — below that, studies across GROUPS of people stop reliably detecting growth. It is a research reference, not a target for you, and above it gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gezählt nach den Muskeln, die du jeder Übung zugeordnet hast: direkte Sätze zählen einfach, indirekte halb. Der Strich markiert etwa 4 Sätze pro Woche — darunter weisen Studien über GRUPPEN von Menschen Wachstum nicht mehr zuverlässig nach. Das ist ein Forschungsbezug, kein Ziel für dich; darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze, der Balken hat deshalb kein „voll“."}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta según los músculos que asignaste a cada ejercicio: las series directas cuentan una vez y las indirectas media. La marca señala unas 4 series por semana: por debajo, los estudios sobre GRUPOS de personas dejan de detectar crecimiento de forma fiable. Es una referencia de investigación, no un objetivo para ti; por encima se sigue ganando, con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté d'après les muscles que tu as attribués à chaque exercice : les séries directes comptent une fois, les indirectes pour moitié. Le repère marque environ 4 séries par semaine : en dessous, les études portant sur des GROUPES de personnes ne détectent plus la croissance de façon fiable. C'est une référence issue de la recherche, pas un objectif pour toi ; au-dessus, les gains continuent, avec des rendements très décroissants et sans plafond net, donc la barre n'a pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "Conteggiato dai muscoli che hai assegnato a ogni esercizio: le serie dirette contano una volta, quelle indirette metà. La tacca segna circa 4 serie a settimana: sotto quella soglia gli studi su GRUPPI di persone non rilevano più la crescita in modo affidabile. È un riferimento della ricerca, non un obiettivo per te; sopra i guadagni continuano, con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone na podstawie mięśni przypisanych do każdego ćwiczenia: serie bezpośrednie liczą się raz, pośrednie za pół. Znacznik to około 4 serie tygodniowo — poniżej badania na GRUPACH ludzi przestają wiarygodnie wykrywać wzrost. To odniesienie z badań, a nie cel dla ciebie; powyżej przyrosty trwają, przy mocno malejących zyskach i bez wyraźnego sufitu, więc pasek nie ma „pełna”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado a partir dos músculos que atribuíste a cada exercício: as séries diretas contam uma vez e as indiretas metade. A marca assinala cerca de 4 séries por semana — abaixo disso, os estudos sobre GRUPOS de pessoas deixam de detetar crescimento de forma fiável. É uma referência da investigação, não um objetivo para ti; acima, os ganhos continuam, com rendimentos muito decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Считается по мышцам, которые ты назначил каждому упражнению: прямые подходы идут за один, косвенные за половину. Отметка — примерно 4 подхода в неделю: ниже неё исследования на ГРУППАХ людей перестают надёжно фиксировать рост. Это ориентир из исследований, а не цель для тебя; выше прогресс продолжается, с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你为每个动作指定的肌群统计:直接组算一组,间接组算半组。刻度约为每周 4 组——低于此线,针对人群的研究就无法可靠地检测到增长。这是研究参考,不是给你设定的目标;高于它仍会继续增长,但收益显著递减且没有明确上限,所以这个条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你為每個動作指定的肌群統計:直接組算一組,間接組算半組。刻度約為每週 4 組——低於此線,針對人群的研究就無法可靠地偵測到增長。這是研究參考,不是給你設定的目標;高於它仍會繼續增長,但收益顯著遞減且沒有明確上限,所以這個條沒有「滿」。"}} + } }, + "%@ reps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 次"}} + } }, + "%lld reps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 次"}} + } }, + "%@ rest": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ Pause"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ de descanso"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ de repos"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ di recupero"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ przerwy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ de descanso"}}, "ru": {"stringUnit": {"state": "translated", "value": "отдых %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "休息 %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "休息 %@"}} + } }, + "%lld sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Sätze"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld подходов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 組"}} + } }, + "Anything you want to remember": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Alles, was du dir merken willst"}}, "es": {"stringUnit": {"state": "translated", "value": "Cualquier cosa que quieras recordar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tout ce que tu veux retenir"}}, "it": {"stringUnit": {"state": "translated", "value": "Qualsiasi cosa tu voglia ricordare"}}, "pl": {"stringUnit": {"state": "translated", "value": "Cokolwiek chcesz zapamiętać"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tudo o que queiras recordar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Всё, что хочешь запомнить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "任何你想记住的内容"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "任何你想記住的內容"}} + } }, + "Build a program once, then tap through it at the gym. Kept on %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Erstelle ein Programm einmal und tippe dich im Gym hindurch. Bleibt auf %@."}}, "es": {"stringUnit": {"state": "translated", "value": "Crea un programa una vez y ve tocando en el gimnasio. Se guarda en %@."}}, "fr": {"stringUnit": {"state": "translated", "value": "Crée un programme une fois, puis parcours-le à la salle. Conservé sur %@."}}, "it": {"stringUnit": {"state": "translated", "value": "Crea un programma una volta e scorrilo in palestra. Resta su %@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zbuduj program raz i przeklikaj go na siłowni. Zostaje na %@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Cria um programa uma vez e percorre-o no ginásio. Fica em %@."}}, "ru": {"stringUnit": {"state": "translated", "value": "Создай программу один раз и отмечай подходы в зале. Хранится на %@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "创建一次训练计划,然后在健身房逐组点按。保存在 %@ 上。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "建立一次訓練計畫,然後在健身房逐組點按。保存在 %@ 上。"}} + } }, + "Counted once per exercise": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zählt einmal pro Übung"}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta una vez por ejercicio"}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté une fois par exercice"}}, "it": {"stringUnit": {"state": "translated", "value": "Contato una volta per esercizio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone raz na ćwiczenie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado uma vez por exercício"}}, "ru": {"stringUnit": {"state": "translated", "value": "Учитывается один раз на упражнение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每个动作计一次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每個動作計一次"}} + } }, + "Details": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Details"}}, "es": {"stringUnit": {"state": "translated", "value": "Detalles"}}, "fr": {"stringUnit": {"state": "translated", "value": "Détails"}}, "it": {"stringUnit": {"state": "translated", "value": "Dettagli"}}, "pl": {"stringUnit": {"state": "translated", "value": "Szczegóły"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Detalhes"}}, "ru": {"stringUnit": {"state": "translated", "value": "Детали"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "详情"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "詳情"}} + } }, + "Edit exercise": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Übung bearbeiten"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar ejercicio"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier l'exercice"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica esercizio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj ćwiczenie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar exercício"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить упражнение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑动作"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯動作"}} + } }, + "Edit program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm bearbeiten"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier le programme"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить программу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯計畫"}} + } }, + "Finish session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einheit beenden"}}, "es": {"stringUnit": {"state": "translated", "value": "Terminar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Terminer la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Termina sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zakończ sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Terminar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "结束训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "結束訓練"}} + } }, + "In order": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Reihe nach"}}, "es": {"stringUnit": {"state": "translated", "value": "En orden"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans l'ordre"}}, "it": {"stringUnit": {"state": "translated", "value": "In ordine"}}, "pl": {"stringUnit": {"state": "translated", "value": "Po kolei"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Por ordem"}}, "ru": {"stringUnit": {"state": "translated", "value": "По порядку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "按顺序"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "按順序"}} + } }, + "In your words": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "In deinen Worten"}}, "es": {"stringUnit": {"state": "translated", "value": "En tus palabras"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans tes mots"}}, "it": {"stringUnit": {"state": "translated", "value": "Con parole tue"}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoimi słowami"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nas tuas palavras"}}, "ru": {"stringUnit": {"state": "translated", "value": "Своими словами"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "用你的话"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "用你的話"}} + } }, + "Incline dumbbell press": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schrägbankdrücken mit Kurzhanteln"}}, "es": {"stringUnit": {"state": "translated", "value": "Press inclinado con mancuernas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Développé incliné haltères"}}, "it": {"stringUnit": {"state": "translated", "value": "Panca inclinata con manubri"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wyciskanie hantli na skosie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Supino inclinado com halteres"}}, "ru": {"stringUnit": {"state": "translated", "value": "Жим гантелей на наклонной"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上斜哑铃卧推"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上斜啞鈴臥推"}} + } }, + "Lift Log": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trainingsbuch"}}, "es": {"stringUnit": {"state": "translated", "value": "Registro de fuerza"}}, "fr": {"stringUnit": {"state": "translated", "value": "Carnet de force"}}, "it": {"stringUnit": {"state": "translated", "value": "Diario dei pesi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dziennik siłowni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Registo de força"}}, "ru": {"stringUnit": {"state": "translated", "value": "Дневник тренировок"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "力量日志"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "力量日誌"}} + } }, + "No targets set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Keine Ziele gesetzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Sin objetivos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun objectif défini"}}, "it": {"stringUnit": {"state": "translated", "value": "Nessun obiettivo impostato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Brak celów"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sem objetivos definidos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Цели не заданы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未设定目标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未設定目標"}} + } }, + "One number for the whole session, so a leg day can be compared with a run.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Eine Zahl für die ganze Einheit, damit sich ein Beintag mit einem Lauf vergleichen lässt."}}, "es": {"stringUnit": {"state": "translated", "value": "Un número para toda la sesión, para poder comparar un día de piernas con una carrera."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un seul chiffre pour toute la séance, pour comparer un jour de jambes à une course."}}, "it": {"stringUnit": {"state": "translated", "value": "Un solo numero per tutta la sessione, così un giorno gambe si confronta con una corsa."}}, "pl": {"stringUnit": {"state": "translated", "value": "Jedna liczba dla całej sesji, żeby dzień nóg dało się porównać z biegiem."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um número para toda a sessão, para comparar um dia de pernas com uma corrida."}}, "ru": {"stringUnit": {"state": "translated", "value": "Одно число на всю тренировку, чтобы день ног можно было сравнить с пробежкой."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "整场训练一个数字,这样练腿日就能和跑步比较。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "整場訓練一個數字,這樣練腿日就能和跑步比較。"}} + } }, + "Reading your programs…": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programme werden gelesen …"}}, "es": {"stringUnit": {"state": "translated", "value": "Leyendo tus programas…"}}, "fr": {"stringUnit": {"state": "translated", "value": "Lecture de tes programmes…"}}, "it": {"stringUnit": {"state": "translated", "value": "Lettura dei tuoi programmi…"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wczytywanie programów…"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A ler os teus programas…"}}, "ru": {"stringUnit": {"state": "translated", "value": "Читаем твои программы…"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正在读取你的计划…"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正在讀取你的計畫…"}} + } }, + "Rest (seconds)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Pause (Sekunden)"}}, "es": {"stringUnit": {"state": "translated", "value": "Descanso (segundos)"}}, "fr": {"stringUnit": {"state": "translated", "value": "Repos (secondes)"}}, "it": {"stringUnit": {"state": "translated", "value": "Recupero (secondi)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przerwa (sekundy)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descanso (segundos)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отдых (секунды)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "休息(秒)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "休息(秒)"}} + } }, + "Set done": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz fertig"}}, "es": {"stringUnit": {"state": "translated", "value": "Serie hecha"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série terminée"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie completata"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria gotowa"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série concluída"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подход выполнен"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "完成这一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "完成這一組"}} + } }, + "Slow eccentric, pause at the bottom": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Langsam ablassen, unten kurz halten"}}, "es": {"stringUnit": {"state": "translated", "value": "Excéntrica lenta, pausa abajo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Excentrique lente, pause en bas"}}, "it": {"stringUnit": {"state": "translated", "value": "Eccentrica lenta, pausa in basso"}}, "pl": {"stringUnit": {"state": "translated", "value": "Powolna faza ekscentryczna, pauza na dole"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Excêntrica lenta, pausa em baixo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Медленная негативная фаза, пауза внизу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "慢速离心,底部停顿"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "慢速離心,底部停頓"}} + } }, + "Start first set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ersten Satz starten"}}, "es": {"stringUnit": {"state": "translated", "value": "Empezar la primera serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrer la première série"}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia la prima serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozpocznij pierwszą serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Iniciar a primeira série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Начать первый подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始第一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始第一組"}} + } }, + "Start next set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nächsten Satz starten"}}, "es": {"stringUnit": {"state": "translated", "value": "Empezar la siguiente serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrer la série suivante"}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia la serie successiva"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozpocznij następną serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Iniciar a série seguinte"}}, "ru": {"stringUnit": {"state": "translated", "value": "Начать следующий подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始下一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始下一組"}} + } }, + "Type any name you like. NOOP remembers it, with the muscles you give it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gib einen beliebigen Namen ein. NOOP merkt ihn sich, mit den Muskeln, die du zuordnest."}}, "es": {"stringUnit": {"state": "translated", "value": "Escribe el nombre que quieras. NOOP lo recuerda, con los músculos que le asignes."}}, "fr": {"stringUnit": {"state": "translated", "value": "Saisis le nom que tu veux. NOOP le retient, avec les muscles que tu lui attribues."}}, "it": {"stringUnit": {"state": "translated", "value": "Scrivi il nome che preferisci. NOOP lo ricorda, con i muscoli che gli assegni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wpisz dowolną nazwę. NOOP ją zapamięta wraz z mięśniami, które przypiszesz."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escreve o nome que quiseres. O NOOP guarda-o, com os músculos que lhe deres."}}, "ru": {"stringUnit": {"state": "translated", "value": "Введи любое название. NOOP запомнит его вместе с мышцами, которые ты укажешь."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "输入任意名称。NOOP 会连同你指定的肌群一起记住。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "輸入任意名稱。NOOP 會連同你指定的肌群一起記住。"}} + } }, + "Upper A": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oberkörper A"}}, "es": {"stringUnit": {"state": "translated", "value": "Tren superior A"}}, "fr": {"stringUnit": {"state": "translated", "value": "Haut du corps A"}}, "it": {"stringUnit": {"state": "translated", "value": "Parte alta A"}}, "pl": {"stringUnit": {"state": "translated", "value": "Góra A"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tronco superior A"}}, "ru": {"stringUnit": {"state": "translated", "value": "Верх A"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上肢 A"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上肢 A"}} + } }, + "Warm-up": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aufwärmen"}}, "es": {"stringUnit": {"state": "translated", "value": "Calentamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Échauffement"}}, "it": {"stringUnit": {"state": "translated", "value": "Riscaldamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozgrzewka"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Aquecimento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Разминка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "热身"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "熱身"}} + } }, + "What you're aiming for": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Was du anpeilst"}}, "es": {"stringUnit": {"state": "translated", "value": "Lo que buscas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce que tu vises"}}, "it": {"stringUnit": {"state": "translated", "value": "Il tuo obiettivo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Do czego dążysz"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O que procuras"}}, "ru": {"stringUnit": {"state": "translated", "value": "К чему стремишься"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你的目标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你的目標"}} + } }, + "Working sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} + } }, + "Your targets for each exercise. What you actually lift is recorded when you run it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Deine Ziele für jede Übung. Was du tatsächlich hebst, wird beim Durchführen aufgezeichnet."}}, "es": {"stringUnit": {"state": "translated", "value": "Tus objetivos para cada ejercicio. Lo que realmente levantas se registra al ejecutarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Tes objectifs pour chaque exercice. Ce que tu soulèves vraiment est enregistré pendant la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "I tuoi obiettivi per ogni esercizio. Ciò che sollevi davvero viene registrato durante la sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoje cele dla każdego ćwiczenia. To, co faktycznie podnosisz, zapisuje się podczas treningu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os teus objetivos para cada exercício. O que levantas mesmo é registado quando o executas."}}, "ru": {"stringUnit": {"state": "translated", "value": "Твои цели для каждого упражнения. Что ты поднял на самом деле, записывается во время тренировки."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每个动作的目标。你实际举起的重量会在训练时记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每個動作的目標。你實際舉起的重量會在訓練時記錄。"}} + } }, + "measured from heart rate": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "aus der Herzfrequenz gemessen"}}, "es": {"stringUnit": {"state": "translated", "value": "medido a partir de la frecuencia cardíaca"}}, "fr": {"stringUnit": {"state": "translated", "value": "mesuré à partir de la fréquence cardiaque"}}, "it": {"stringUnit": {"state": "translated", "value": "misurato dalla frequenza cardiaca"}}, "pl": {"stringUnit": {"state": "translated", "value": "mierzone z tętna"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "medido a partir da frequência cardíaca"}}, "ru": {"stringUnit": {"state": "translated", "value": "измерено по пульсу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "由心率测得"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "由心率測得"}} + } }, + "Chest": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Brust"}}, "es": {"stringUnit": {"state": "translated", "value": "Pecho"}}, "fr": {"stringUnit": {"state": "translated", "value": "Pectoraux"}}, "it": {"stringUnit": {"state": "translated", "value": "Petto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Klatka piersiowa"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Peito"}}, "ru": {"stringUnit": {"state": "translated", "value": "Грудь"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "胸部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "胸部"}} + } }, + "Front delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Vordere Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides anterior"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes antérieurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi anteriori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki przednie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides anteriores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Передние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "前束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "前束三角肌"}} + } }, + "Side delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Seitliche Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides lateral"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes latéraux"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi laterali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki boczne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides laterais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Средние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "中束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "中束三角肌"}} + } }, + "Rear delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Hintere Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides posterior"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes postérieurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi posteriori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki tylne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides posteriores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Задние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "后束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "後束三角肌"}} + } }, + "Triceps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Tríceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Triceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Tricipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Triceps"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tríceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Трицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "肱三头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "肱三頭肌"}} + } }, + "Lats": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Latissimus"}}, "es": {"stringUnit": {"state": "translated", "value": "Dorsales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dorsaux"}}, "it": {"stringUnit": {"state": "translated", "value": "Dorsali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Najszersze grzbietu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Dorsais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Широчайшие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "背阔肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "背闊肌"}} + } }, + "Upper back": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oberer Rücken"}}, "es": {"stringUnit": {"state": "translated", "value": "Espalda alta"}}, "fr": {"stringUnit": {"state": "translated", "value": "Haut du dos"}}, "it": {"stringUnit": {"state": "translated", "value": "Dorso alto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Górna część pleców"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Costas superiores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Верх спины"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上背"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上背"}} + } }, + "Traps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trapez"}}, "es": {"stringUnit": {"state": "translated", "value": "Trapecios"}}, "fr": {"stringUnit": {"state": "translated", "value": "Trapèzes"}}, "it": {"stringUnit": {"state": "translated", "value": "Trapezi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Czworoboczne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Trapézios"}}, "ru": {"stringUnit": {"state": "translated", "value": "Трапеции"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "斜方肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "斜方肌"}} + } }, + "Biceps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Bíceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Biceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Bicipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Biceps"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bíceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "肱二头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "肱二頭肌"}} + } }, + "Forearms": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unterarme"}}, "es": {"stringUnit": {"state": "translated", "value": "Antebrazos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Avant-bras"}}, "it": {"stringUnit": {"state": "translated", "value": "Avambracci"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przedramiona"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Antebraços"}}, "ru": {"stringUnit": {"state": "translated", "value": "Предплечья"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "前臂"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "前臂"}} + } }, + "Quads": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Quadrizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuádriceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Quadriceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Quadricipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Czworogłowe uda"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Quadríceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Квадрицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "股四头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "股四頭肌"}} + } }, + "Hamstrings": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Beinbeuger"}}, "es": {"stringUnit": {"state": "translated", "value": "Isquiotibiales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ischio-jambiers"}}, "it": {"stringUnit": {"state": "translated", "value": "Femorali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dwugłowe uda"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Isquiotibiais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бицепс бедра"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腘绳肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "膕繩肌"}} + } }, + "Glutes": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gesäß"}}, "es": {"stringUnit": {"state": "translated", "value": "Glúteos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fessiers"}}, "it": {"stringUnit": {"state": "translated", "value": "Glutei"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pośladki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Glúteos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ягодицы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "臀部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "臀部"}} + } }, + "Adductors": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Adduktoren"}}, "es": {"stringUnit": {"state": "translated", "value": "Aductores"}}, "fr": {"stringUnit": {"state": "translated", "value": "Adducteurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Adduttori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przywodziciele"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adutores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Приводящие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "内收肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "內收肌"}} + } }, + "Abductors": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abduktoren"}}, "es": {"stringUnit": {"state": "translated", "value": "Abductores"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abducteurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Abduttori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odwodziciele"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abdutores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отводящие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "外展肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "外展肌"}} + } }, + "Calves": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Waden"}}, "es": {"stringUnit": {"state": "translated", "value": "Gemelos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mollets"}}, "it": {"stringUnit": {"state": "translated", "value": "Polpacci"}}, "pl": {"stringUnit": {"state": "translated", "value": "Łydki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Gémeos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Икры"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "小腿"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "小腿"}} + } }, + "Abs": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bauch"}}, "es": {"stringUnit": {"state": "translated", "value": "Abdominales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abdominaux"}}, "it": {"stringUnit": {"state": "translated", "value": "Addominali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Brzuch"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abdominais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пресс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腹肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腹肌"}} + } }, + "Obliques": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schräge Bauchmuskeln"}}, "es": {"stringUnit": {"state": "translated", "value": "Oblicuos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Obliques"}}, "it": {"stringUnit": {"state": "translated", "value": "Obliqui"}}, "pl": {"stringUnit": {"state": "translated", "value": "Skośne brzucha"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Oblíquos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Косые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腹斜肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腹斜肌"}} + } }, + "Lower back": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unterer Rücken"}}, "es": {"stringUnit": {"state": "translated", "value": "Espalda baja"}}, "fr": {"stringUnit": {"state": "translated", "value": "Bas du dos"}}, "it": {"stringUnit": {"state": "translated", "value": "Lombari"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dolna część pleców"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Lombar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Поясница"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "下背"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "下背"}} + } }, + "Neck": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nacken"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuello"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cou"}}, "it": {"stringUnit": {"state": "translated", "value": "Collo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kark"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Pescoço"}}, "ru": {"stringUnit": {"state": "translated", "value": "Шея"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "颈部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "頸部"}} + } }, + "Push muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Drückmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de empuje"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles de poussée"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli di spinta"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie pchające"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos de empurrar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Жимовые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "推类肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "推類肌群"}} + } }, + "Pull muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zugmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de tracción"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles de tirage"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli di tirata"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie ciągnące"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos de puxar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Тяговые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "拉类肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "拉類肌群"}} + } }, + "Leg muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Beinmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de pierna"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles des jambes"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli delle gambe"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie nóg"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos das pernas"}}, "ru": {"stringUnit": {"state": "translated", "value": "Мышцы ног"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腿部肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腿部肌群"}} + } }, + "Trunk muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Rumpfmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos del tronco"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles du tronc"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli del tronco"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie tułowia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos do tronco"}}, "ru": {"stringUnit": {"state": "translated", "value": "Мышцы корпуса"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "躯干肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "軀幹肌群"}} + } }, + "Add set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add set"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增一組"}} + } }, + "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 組沒有輸入數字:未開始的組,或完成時沒有輸入的組。"}} + } }, + "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": "補全"}} + } }, + "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": "補全會用灰色顯示的數字儲存它們;放棄則不計入本次訓練。"}} + } }, + "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": "你更改了組數。要把新的組數儲存到計畫裡,下次使用嗎?"}} + } }, + "%@: %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 組"}} + } }, + "Update program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm aktualisieren"}}, "en": {"stringUnit": {"state": "translated", "value": "Update program"}}, "es": {"stringUnit": {"state": "translated", "value": "Actualizar programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mettre à jour"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiorna programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zaktualizuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Atualizar programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Обновить программу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "更新计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "更新計畫"}} + } }, + "Keep as it was": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unverändert lassen"}}, "en": {"stringUnit": {"state": "translated", "value": "Keep as it was"}}, "es": {"stringUnit": {"state": "translated", "value": "Dejar como estaba"}}, "fr": {"stringUnit": {"state": "translated", "value": "Laisser tel quel"}}, "it": {"stringUnit": {"state": "translated", "value": "Lascia com'era"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zostaw bez zmian"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deixar como estava"}}, "ru": {"stringUnit": {"state": "translated", "value": "Оставить как было"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "保持不变"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "保持不變"}} + } }, + "Choose an option above to save.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wähle oben eine Option, um zu speichern."}}, "en": {"stringUnit": {"state": "translated", "value": "Choose an option above to save."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige una opción arriba para guardar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisis une option ci-dessus pour enregistrer."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un'opzione qui sopra per salvare."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz opcję powyżej, aby zapisać."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolhe uma opção acima para guardar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Чтобы сохранить, выбери вариант выше."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "请先在上方选择一项再保存。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "請先在上方選擇一項再儲存。"}} + } }, + "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": "儲存變更"}} + } }, + "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": "為 %@ 新增一組"}} + } }, + "Remove the last set from %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Letzten Satz von %@ entfernen"}}, "en": {"stringUnit": {"state": "translated", "value": "Remove the last set from %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Quitar la última serie de %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Retirer la dernière série de %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Rimuovi l'ultima serie da %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Usuń ostatnią serię z %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Remover a última série de %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Убрать последний подход из %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "移除 %@ 的最后一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "移除 %@ 的最後一組"}} + } }, + "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Strich markiert etwa 4 Sätze pro Woche – den Punkt, unter dem die Forschung Wachstum nicht zuverlässig nachweist. Darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze; der Balken hat deshalb kein „voll“."}}, "en": {"stringUnit": {"state": "translated", "value": "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\"."}}, "es": {"stringUnit": {"state": "translated", "value": "La marca señala unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias continúan con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le repère marque environ 4 séries par semaine — le seuil en dessous duquel la recherche ne détecte pas de croissance de façon fiable. Au-dessus, les gains continuent avec de forts rendements décroissants et sans plafond net : la barre n'a donc pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "La tacca segna circa 4 serie a settimana: il punto sotto il quale la ricerca non rileva la crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Znacznik wskazuje około 4 serie tygodniowo — punkt, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej postępy trwają, z silnie malejącymi zyskami i bez wyraźnego pułapu, więc pasek nie ma stanu „pełny”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A marca assinala cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Отметка показывает примерно 4 подхода в неделю — точку, ниже которой исследования не фиксируют рост надёжно. Выше рост продолжается с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "刻度标示每周约 4 组 —— 低于此点,研究无法可靠地检测到增长。高于此点,收益仍在继续,但回报急剧递减且没有明确上限,因此这条进度条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "刻度標示每週約 4 組 —— 低於此點,研究無法可靠地偵測到增長。高於此點,收益仍在持續,但回報急劇遞減且沒有明確上限,因此這條進度條沒有「滿」。"}} + } }, + "%@: %@ sets, at or above the weekly floor of %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, auf oder über dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, at or above the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, en o por encima del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, au niveau ou au-dessus du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, pari o superiori al minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, na poziomie tygodniowego minimum %3$@ lub powyżej"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, no mínimo semanal de %3$@ ou acima"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, на недельном минимуме %3$@ или выше"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,达到或超过每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,達到或超過每週下限 %3$@"}} + } }, + "%@: %@ sets, below the weekly floor of %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, unter dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, below the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, por debajo del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, en dessous du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, sotto il minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, poniżej tygodniowego minimum %3$@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, abaixo do mínimo semanal de %3$@"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, ниже недельного минимума %3$@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,低于每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,低於每週下限 %3$@"}} + } }, + "Discard session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einheit verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard session"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Scarta la sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить сессию"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練"}} + } }, + "Discard this session?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Einheit verwerfen?"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard this session?"}}, "es": {"stringUnit": {"state": "translated", "value": "¿Descartar esta sesión?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner cette séance ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Scartare questa sessione?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzucić tę sesję?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar esta sessão?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить эту сессию?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練?"}} + } }, + "Keep going": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Weitermachen"}}, "en": {"stringUnit": {"state": "translated", "value": "Keep going"}}, "es": {"stringUnit": {"state": "translated", "value": "Seguir"}}, "fr": {"stringUnit": {"state": "translated", "value": "Continuer"}}, "it": {"stringUnit": {"state": "translated", "value": "Continua"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kontynuuj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Continuar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Продолжить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "继续"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "繼續"}} + } }, + "%lld recorded sets will be thrown away. Nothing is saved and no workout is created.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld aufgezeichnete Sätze werden verworfen. Nichts wird gespeichert und kein Workout angelegt."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld recorded sets will be thrown away. Nothing is saved and no workout is created."}}, "es": {"stringUnit": {"state": "translated", "value": "Se descartarán %lld series registradas. No se guarda nada ni se crea ningún entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries enregistrées seront supprimées. Rien n'est sauvegardé et aucun entraînement n'est créé."}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie registrate verranno eliminate. Non viene salvato nulla e non viene creato alcun allenamento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane serie (%lld) zostaną odrzucone. Nic nie zostanie zapisane i nie powstanie żaden trening."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries registadas serão descartadas. Nada é guardado e nenhum treino é criado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанных подходов будет удалено: %lld. Ничего не сохранится и тренировка не создастся."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将丢弃已记录的 %lld 组。不会保存任何内容,也不会创建训练记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將丟棄已記錄的 %lld 組。不會儲存任何內容,也不會建立訓練記錄。"}} + } }, + "%lld recorded sets will be removed, and so will the workout this session created. This cannot be undone.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld aufgezeichnete Sätze werden entfernt, ebenso das von dieser Einheit erstellte Workout. Das lässt sich nicht rückgängig machen."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld recorded sets will be removed, and so will the workout this session created. This cannot be undone."}}, "es": {"stringUnit": {"state": "translated", "value": "Se eliminarán %lld series registradas, y también el entrenamiento que creó esta sesión. Esto no se puede deshacer."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries enregistrées seront supprimées, ainsi que l'entraînement créé par cette séance. C'est irréversible."}}, "it": {"stringUnit": {"state": "translated", "value": "Verranno rimosse %lld serie registrate e anche l'allenamento creato da questa sessione. L'operazione non è reversibile."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane serie (%lld) zostaną usunięte, podobnie jak trening utworzony przez tę sesję. Tej operacji nie można cofnąć."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries registadas serão removidas, tal como o treino que esta sessão criou. Isto não pode ser anulado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанных подходов будет удалено: %lld, вместе с тренировкой, созданной этой сессией. Отменить это нельзя."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将移除已记录的 %lld 组,以及本次训练创建的锻炼记录。此操作无法撤销。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將移除已記錄的 %lld 組,以及本次訓練建立的鍛鍊記錄。此操作無法復原。"}} + } }, + "That file is too big to be a program sheet.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Datei ist zu groß für ein Programmblatt."}}, "en": {"stringUnit": {"state": "translated", "value": "That file is too big to be a program sheet."}}, "es": {"stringUnit": {"state": "translated", "value": "Ese archivo es demasiado grande para una hoja de programa."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier est trop volumineux pour une feuille de programme."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel file è troppo grande per un foglio di programma."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten plik jest za duży jak na arkusz programu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esse ficheiro é demasiado grande para uma folha de programa."}}, "ru": {"stringUnit": {"state": "translated", "value": "Этот файл слишком велик для таблицы программы."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件太大,不像是计划表格。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案太大,不像是計畫表格。"}} + } }, + "Worth checking": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zum Prüfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Worth checking"}}, "es": {"stringUnit": {"state": "translated", "value": "Conviene revisar"}}, "fr": {"stringUnit": {"state": "translated", "value": "À vérifier"}}, "it": {"stringUnit": {"state": "translated", "value": "Da controllare"}}, "pl": {"stringUnit": {"state": "translated", "value": "Warto sprawdzić"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A verificar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Стоит проверить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "需要检查"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "需要檢查"}} + } }, + "%lld exercises": { "localizations": { + "de": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld Übung"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld Übungen"}}}}}, "en": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld exercise"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld exercises"}}}}}, "es": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld ejercicio"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld ejercicios"}}}}}, "fr": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld exercice"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld exercices"}}}}}, "it": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld esercizio"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld esercizi"}}}}}, "pl": {"stringUnit": {"state": "translated", "value": "Ćwiczenia: %lld"}}, "pt-PT": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld exercício"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld exercícios"}}}}}, "ru": {"stringUnit": {"state": "translated", "value": "Упражнений: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 个动作"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 個動作"}} + } }, + "Import a program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm importieren"}}, "en": {"stringUnit": {"state": "translated", "value": "Import a program"}}, "es": {"stringUnit": {"state": "translated", "value": "Importar un programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Importer un programme"}}, "it": {"stringUnit": {"state": "translated", "value": "Importa un programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Importuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Importar um programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Импорт программы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导入计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯入計畫"}} + } }, + "Fill it in on a computer": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Am Computer ausfüllen"}}, "en": {"stringUnit": {"state": "translated", "value": "Fill it in on a computer"}}, "es": {"stringUnit": {"state": "translated", "value": "Rellénalo en un ordenador"}}, "fr": {"stringUnit": {"state": "translated", "value": "Remplis-le sur un ordinateur"}}, "it": {"stringUnit": {"state": "translated", "value": "Compilalo al computer"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wypełnij go na komputerze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Preenche-o num computador"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заполни на компьютере"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在电脑上填写"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在電腦上填寫"}} + } }, + "Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Lade die Vorlage aus dem NOOP-Repository, trage pro Übung eine Zeile ein und bring die Datei hierher. Excel, Numbers, Google Sheets und LibreOffice funktionieren alle – .xlsx oder .csv."}}, "en": {"stringUnit": {"state": "translated", "value": "Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv."}}, "es": {"stringUnit": {"state": "translated", "value": "Descarga la plantilla del repositorio de NOOP, rellena una fila por ejercicio y trae el archivo aquí. Excel, Numbers, Google Sheets y LibreOffice sirven — .xlsx o .csv."}}, "fr": {"stringUnit": {"state": "translated", "value": "Télécharge le modèle depuis le dépôt NOOP, remplis une ligne par exercice, puis apporte le fichier ici. Excel, Numbers, Google Sheets et LibreOffice fonctionnent tous — .xlsx ou .csv."}}, "it": {"stringUnit": {"state": "translated", "value": "Scarica il modello dal repository di NOOP, compila una riga per esercizio e porta qui il file. Excel, Numbers, Google Sheets e LibreOffice vanno tutti bene — .xlsx o .csv."}}, "pl": {"stringUnit": {"state": "translated", "value": "Pobierz szablon z repozytorium NOOP, wypełnij jeden wiersz na ćwiczenie i przynieś plik tutaj. Excel, Numbers, Google Sheets i LibreOffice — wszystkie działają — .xlsx lub .csv."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descarrega o modelo do repositório do NOOP, preenche uma linha por exercício e traz o ficheiro para aqui. Excel, Numbers, Google Sheets e LibreOffice funcionam todos — .xlsx ou .csv."}}, "ru": {"stringUnit": {"state": "translated", "value": "Скачай шаблон из репозитория NOOP, заполни по одной строке на упражнение и открой файл здесь. Excel, Numbers, Google Sheets и LibreOffice — подойдёт любой, .xlsx или .csv."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "从 NOOP 仓库下载模板,每个动作填一行,然后把文件拿到这里。Excel、Numbers、Google 表格和 LibreOffice 都可以 — .xlsx 或 .csv。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "從 NOOP 儲存庫下載範本,每個動作填一列,然後把檔案帶到這裡。Excel、Numbers、Google 試算表和 LibreOffice 都可以 — .xlsx 或 .csv。"}} + } }, + "Only the exercise name is required. Anything you leave blank can be filled in later, or during the session.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nur der Übungsname ist Pflicht. Alles, was du leer lässt, kannst du später oder während der Einheit ergänzen."}}, "en": {"stringUnit": {"state": "translated", "value": "Only the exercise name is required. Anything you leave blank can be filled in later, or during the session."}}, "es": {"stringUnit": {"state": "translated", "value": "Solo el nombre del ejercicio es obligatorio. Lo que dejes en blanco puedes rellenarlo después, o durante la sesión."}}, "fr": {"stringUnit": {"state": "translated", "value": "Seul le nom de l'exercice est obligatoire. Ce que tu laisses vide peut être complété plus tard, ou pendant la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "Solo il nome dell'esercizio è obbligatorio. Quello che lasci vuoto puoi compilarlo dopo, o durante la sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wymagana jest tylko nazwa ćwiczenia. To, co zostawisz puste, możesz uzupełnić później albo w trakcie sesji."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Só o nome do exercício é obrigatório. O que deixares em branco podes preencher depois, ou durante a sessão."}}, "ru": {"stringUnit": {"state": "translated", "value": "Обязательно только название упражнения. Всё, что оставишь пустым, можно заполнить позже или прямо во время сессии."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "只有动作名称是必填的。留空的内容可以稍后补上,或在训练中填写。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "只有動作名稱是必填的。留空的內容可以稍後補上,或在訓練中填寫。"}} + } }, + "Choose a file": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Datei auswählen"}}, "en": {"stringUnit": {"state": "translated", "value": "Choose a file"}}, "es": {"stringUnit": {"state": "translated", "value": "Elegir un archivo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisir un fichier"}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un file"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz plik"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolher um ficheiro"}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбрать файл"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择文件"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇檔案"}} + } }, + "Choose a different file": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Andere Datei auswählen"}}, "en": {"stringUnit": {"state": "translated", "value": "Choose a different file"}}, "es": {"stringUnit": {"state": "translated", "value": "Elegir otro archivo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisir un autre fichier"}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un altro file"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz inny plik"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolher outro ficheiro"}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбрать другой файл"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择其他文件"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇其他檔案"}} + } }, + "These lines still import — anything unclassified can be set in the app.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Zeilen werden trotzdem importiert – alles ohne Zuordnung kannst du in der App setzen."}}, "en": {"stringUnit": {"state": "translated", "value": "These lines still import — anything unclassified can be set in the app."}}, "es": {"stringUnit": {"state": "translated", "value": "Estas líneas se importan igualmente: lo que quede sin clasificar puedes ajustarlo en la app."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ces lignes sont quand même importées — ce qui n'est pas classé peut être réglé dans l'app."}}, "it": {"stringUnit": {"state": "translated", "value": "Queste righe vengono comunque importate: ciò che resta non classificato si imposta nell'app."}}, "pl": {"stringUnit": {"state": "translated", "value": "Te wiersze i tak zostaną zaimportowane — to, co nieprzypisane, ustawisz w aplikacji."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Estas linhas são importadas na mesma — o que ficar por classificar podes definir na app."}}, "ru": {"stringUnit": {"state": "translated", "value": "Эти строки всё равно импортируются — всё неразмеченное можно задать в приложении."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这些行仍会导入 — 未归类的部分可以在 App 里设置。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這些列仍會匯入 — 未歸類的部分可以在 App 裡設定。"}} + } }, + "That file could not be read.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Datei konnte nicht gelesen werden."}}, "en": {"stringUnit": {"state": "translated", "value": "That file could not be read."}}, "es": {"stringUnit": {"state": "translated", "value": "No se ha podido leer ese archivo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier n'a pas pu être lu."}}, "it": {"stringUnit": {"state": "translated", "value": "Non è stato possibile leggere quel file."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się odczytać tego pliku."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível ler esse ficheiro."}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось прочитать этот файл."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法读取该文件。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法讀取該檔案。"}} + } }, + "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Das sieht nicht nach einer Tabelle aus. Nutze die Vorlage, gespeichert als .xlsx oder .csv."}}, "en": {"stringUnit": {"state": "translated", "value": "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv."}}, "es": {"stringUnit": {"state": "translated", "value": "Eso no parece una hoja de cálculo. Usa la plantilla, guardada como .xlsx o .csv."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cela ne ressemble pas à un tableur. Utilise le modèle, enregistré en .xlsx ou .csv."}}, "it": {"stringUnit": {"state": "translated", "value": "Non sembra un foglio di calcolo. Usa il modello, salvato come .xlsx o .csv."}}, "pl": {"stringUnit": {"state": "translated", "value": "To nie wygląda na arkusz kalkulacyjny. Użyj szablonu zapisanego jako .xlsx lub .csv."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Isso não parece uma folha de cálculo. Usa o modelo, guardado como .xlsx ou .csv."}}, "ru": {"stringUnit": {"state": "translated", "value": "Это не похоже на таблицу. Используй шаблон, сохранённый как .xlsx или .csv."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这看起来不是电子表格。请使用模板,另存为 .xlsx 或 .csv。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這看起來不是試算表。請使用範本,另存為 .xlsx 或 .csv。"}} + } }, + "That sheet has no Exercise column. Use the template — the import matches on the header names.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Dieser Tabelle fehlt die Spalte „Exercise“. Nutze die Vorlage – der Import geht nach den Spaltennamen."}}, "en": {"stringUnit": {"state": "translated", "value": "That sheet has no Exercise column. Use the template — the import matches on the header names."}}, "es": {"stringUnit": {"state": "translated", "value": "Esa hoja no tiene columna «Exercise». Usa la plantilla: la importación se basa en los nombres de las cabeceras."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette feuille n'a pas de colonne « Exercise ». Utilise le modèle — l'import se base sur les noms d'en-tête."}}, "it": {"stringUnit": {"state": "translated", "value": "In quel foglio manca la colonna «Exercise». Usa il modello: l'importazione si basa sui nomi delle intestazioni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten arkusz nie ma kolumny „Exercise”. Użyj szablonu — import dopasowuje się po nazwach nagłówków."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Essa folha não tem coluna «Exercise». Usa o modelo — a importação baseia-se nos nomes dos cabeçalhos."}}, "ru": {"stringUnit": {"state": "translated", "value": "В этой таблице нет столбца «Exercise». Используй шаблон — импорт ориентируется на названия заголовков."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该表格没有 Exercise 列。请使用模板 — 导入是按表头名称匹配的。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該表格沒有 Exercise 欄。請使用範本 — 匯入是按標題名稱比對的。"}} + } }, + "That sheet has no exercises in it yet.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "In dieser Tabelle stehen noch keine Übungen."}}, "en": {"stringUnit": {"state": "translated", "value": "That sheet has no exercises in it yet."}}, "es": {"stringUnit": {"state": "translated", "value": "Esa hoja todavía no tiene ejercicios."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette feuille ne contient encore aucun exercice."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel foglio non contiene ancora esercizi."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten arkusz nie zawiera jeszcze ćwiczeń."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Essa folha ainda não tem exercícios."}}, "ru": {"stringUnit": {"state": "translated", "value": "В этой таблице пока нет упражнений."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该表格里还没有动作。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該表格裡還沒有動作。"}} + } }, + "Set %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Set %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Serie %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подход %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "第 %lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "第 %lld 組"}} + } }, + "Heart rate %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Herzfrequenz %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Heart rate %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Frecuencia cardíaca %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fréquence cardiaque %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Frequenza cardiaca %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Tętno %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Frequência cardíaca %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率 %lld"}} + } }, + "HR": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "HF"}}, "en": {"stringUnit": {"state": "translated", "value": "HR"}}, "es": {"stringUnit": {"state": "translated", "value": "FC"}}, "fr": {"stringUnit": {"state": "translated", "value": "FC"}}, "it": {"stringUnit": {"state": "translated", "value": "FC"}}, "pl": {"stringUnit": {"state": "translated", "value": "HR"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "FC"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率"}} + } }, + "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ein Programm ist ein Name und eine geordnete Liste von Übungen mit deinen Zielen – Arbeitssätze, Wiederholungen, Gewicht, Pause und deine eigene Technik-Notiz."}}, "en": {"stringUnit": {"state": "translated", "value": "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note."}}, "es": {"stringUnit": {"state": "translated", "value": "Un programa es un nombre y una lista ordenada de ejercicios con tus objetivos: series efectivas, repeticiones, peso, descanso y tu propia nota de técnica."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un programme, c'est un nom et une liste ordonnée d'exercices avec tes objectifs : séries de travail, répétitions, charge, repos et ta propre note de technique."}}, "it": {"stringUnit": {"state": "translated", "value": "Un programma è un nome e un elenco ordinato di esercizi con i tuoi obiettivi: serie di lavoro, ripetizioni, carico, recupero e la tua nota sulla tecnica."}}, "pl": {"stringUnit": {"state": "translated", "value": "Program to nazwa i uporządkowana lista ćwiczeń z Twoimi celami – serie robocze, powtórzenia, ciężar, przerwa i Twoja własna notatka o technice."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um programa é um nome e uma lista ordenada de exercícios com os teus objetivos: séries de trabalho, repetições, peso, descanso e a tua própria nota de técnica."}}, "ru": {"stringUnit": {"state": "translated", "value": "Программа — это название и упорядоченный список упражнений с твоими целями: рабочие подходы, повторения, вес, отдых и твоя заметка о технике."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "一个计划就是一个名称加上一份有序的动作列表,附带你的目标:正式组、次数、重量、休息时间,以及你自己的技术笔记。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "一個計畫就是一個名稱加上一份有序的動作列表,附帶你的目標:正式組、次數、重量、休息時間,以及你自己的技術筆記。"}} + } }, + "Set %lld — tap to mark it a warm-up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – tippen, um ihn als Aufwärmsatz zu markieren" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — tap to mark it a warm-up" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld: toca para marcarla como calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — touche pour la marquer comme échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — tocca per segnarla come riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – dotknij, aby oznaczyć jako rozgrzewkową" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — toca para marcar como aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — нажми, чтобы отметить как разминочный" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组——点按标记为热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組——點按標記為熱身組" } } + } }, + "Warm-up set — tap to make it a working set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmsatz – tippen, um ihn zum Arbeitssatz zu machen" } }, "en": { "stringUnit": { "state": "translated", "value": "Warm-up set — tap to make it a working set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie de calentamiento: toca para convertirla en serie efectiva" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série d'échauffement — touche pour en faire une série de travail" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie di riscaldamento — tocca per renderla una serie di lavoro" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria rozgrzewkowa – dotknij, aby zmienić na roboczą" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série de aquecimento — toca para a tornar série de trabalho" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминочный подход — нажми, чтобы сделать рабочим" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身组——点按改为正式组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身組——點按改為正式組" } } + } }, + "Forget": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記" } } + } }, + "Forget this exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diese Übung vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget this exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar este ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier cet exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica questo esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij to ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer este exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть это упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记这个动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記這個動作" } } + } }, + "Forget %@?": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%@ vergessen?" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget %@?" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Olvidar %@?" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier %@ ?" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimenticare %@?" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnieć %@?" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer %@?" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть %@?" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记「%@」?" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記「%@」?" } } + } }, + "It stops being offered here. Sessions you already logged with it are kept exactly as they are.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sie wird hier nicht mehr vorgeschlagen. Bereits damit aufgezeichnete Sitzungen bleiben unverändert." } }, "en": { "stringUnit": { "state": "translated", "value": "It stops being offered here. Sessions you already logged with it are kept exactly as they are." } }, "es": { "stringUnit": { "state": "translated", "value": "Deja de ofrecerse aquí. Las sesiones que ya registraste con él se mantienen tal cual." } }, "fr": { "stringUnit": { "state": "translated", "value": "Il cesse d'être proposé ici. Les séances déjà enregistrées avec lui restent intactes." } }, "it": { "stringUnit": { "state": "translated", "value": "Smette di essere proposto qui. Le sessioni già registrate con esso restano identiche." } }, "pl": { "stringUnit": { "state": "translated", "value": "Przestaje być proponowane tutaj. Zapisane już z nim sesje pozostają bez zmian." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Deixa de ser sugerido aqui. As sessões que já registaste com ele mantêm-se tal como estão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Оно перестанет предлагаться здесь. Уже записанные с ним сессии останутся без изменений." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "它不会再出现在建议里。你已经用它记录的训练完全不受影响。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "它不會再出現在建議裡。你已經用它記錄的訓練完全不受影響。" } } + } }, + "You've saved the most exercises NOOP remembers": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast so viele Übungen gespeichert, wie NOOP sich merkt" } }, "en": { "stringUnit": { "state": "translated", "value": "You've saved the most exercises NOOP remembers" } }, "es": { "stringUnit": { "state": "translated", "value": "Has guardado el máximo de ejercicios que NOOP recuerda" } }, "fr": { "stringUnit": { "state": "translated", "value": "Tu as enregistré le maximum d'exercices que NOOP retient" } }, "it": { "stringUnit": { "state": "translated", "value": "Hai salvato il massimo di esercizi che NOOP ricorda" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisałeś maksymalną liczbę ćwiczeń, jaką NOOP pamięta" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardaste o máximo de exercícios que o NOOP memoriza" } }, "ru": { "stringUnit": { "state": "translated", "value": "Ты сохранил столько упражнений, сколько NOOP запоминает" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你保存的动作已达 NOOP 记忆上限" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你儲存的動作已達 NOOP 記憶上限" } } + } }, + "Forget one you no longer use and this one will save. Your logged sessions are never affected.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergiss eine, die du nicht mehr nutzt, dann lässt sich diese speichern. Deine aufgezeichneten Sitzungen sind nie betroffen." } }, "en": { "stringUnit": { "state": "translated", "value": "Forget one you no longer use and this one will save. Your logged sessions are never affected." } }, "es": { "stringUnit": { "state": "translated", "value": "Olvida uno que ya no uses y este se guardará. Tus sesiones registradas nunca se ven afectadas." } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublie-en un que tu n'utilises plus et celui-ci sera enregistré. Tes séances enregistrées ne sont jamais touchées." } }, "it": { "stringUnit": { "state": "translated", "value": "Dimenticane uno che non usi più e questo verrà salvato. Le tue sessioni registrate non vengono mai toccate." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij jedno, którego już nie używasz, a to się zapisze. Twoje zapisane sesje nigdy nie są naruszane." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquece um que já não uses e este será guardado. As tuas sessões registadas nunca são afetadas." } }, "ru": { "stringUnit": { "state": "translated", "value": "Забудь то, которым больше не пользуешься, и это сохранится. Записанные сессии никогда не затрагиваются." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记一个你不再用的,这个就能保存。你已记录的训练不会受任何影响。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記一個你不再用的,這個就能儲存。你已記錄的訓練不會受任何影響。" } } + } }, + "Set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組" } } + } }, + "Reps": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wdh." } }, "en": { "stringUnit": { "state": "translated", "value": "Reps" } }, "es": { "stringUnit": { "state": "translated", "value": "Reps" } }, "fr": { "stringUnit": { "state": "translated", "value": "Réps" } }, "it": { "stringUnit": { "state": "translated", "value": "Rip." } }, "pl": { "stringUnit": { "state": "translated", "value": "Powt." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Reps" } }, "ru": { "stringUnit": { "state": "translated", "value": "Повт." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "次数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "次數" } } + } }, + "RPE": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE" } } + } }, + "Kg": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "kg" } }, "en": { "stringUnit": { "state": "translated", "value": "Kg" } }, "es": { "stringUnit": { "state": "translated", "value": "kg" } }, "fr": { "stringUnit": { "state": "translated", "value": "kg" } }, "it": { "stringUnit": { "state": "translated", "value": "kg" } }, "pl": { "stringUnit": { "state": "translated", "value": "kg" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "kg" } }, "ru": { "stringUnit": { "state": "translated", "value": "кг" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "公斤" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "公斤" } } + } }, + "Lb": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "lb" } }, "en": { "stringUnit": { "state": "translated", "value": "Lb" } }, "es": { "stringUnit": { "state": "translated", "value": "lb" } }, "fr": { "stringUnit": { "state": "translated", "value": "lb" } }, "it": { "stringUnit": { "state": "translated", "value": "lb" } }, "pl": { "stringUnit": { "state": "translated", "value": "lb" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "lb" } }, "ru": { "stringUnit": { "state": "translated", "value": "фунты" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "磅" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "磅" } } + } }, + "Start this set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diesen Satz starten" } }, "en": { "stringUnit": { "state": "translated", "value": "Start this set" } }, "es": { "stringUnit": { "state": "translated", "value": "Empezar esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Démarrer cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Avvia questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij tę serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Iniciar esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Начать этот подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "开始这一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開始這一組" } } + } }, + "Redo this set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diesen Satz wiederholen" } }, "en": { "stringUnit": { "state": "translated", "value": "Redo this set" } }, "es": { "stringUnit": { "state": "translated", "value": "Rehacer esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Refaire cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Rifai questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Powtórz tę serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Refazer esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Переделать подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重做这一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重做這一組" } } + } }, + "Open the running session": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Laufende Sitzung öffnen" } }, "en": { "stringUnit": { "state": "translated", "value": "Open the running session" } }, "es": { "stringUnit": { "state": "translated", "value": "Abrir la sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ouvrir la séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Apri la sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Otwórz trwającą sesję" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Abrir a sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Открыть текущую сессию" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "打开进行中的训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "打開進行中的訓練" } } + } }, + "How hard was the whole session? (1–10)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie schwer war die ganze Sitzung? (1–10)" } }, "en": { "stringUnit": { "state": "translated", "value": "How hard was the whole session? (1–10)" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Cómo de dura fue toda la sesión? (1–10)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Quelle a été la difficulté de la séance ? (1–10)" } }, "it": { "stringUnit": { "state": "translated", "value": "Quanto è stata dura l'intera sessione? (1–10)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak ciężka była cała sesja? (1–10)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quão difícil foi toda a sessão? (1–10)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Насколько тяжёлой была вся сессия? (1–10)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场训练有多吃力?(1–10)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場訓練有多吃力?(1–10)" } } + } }, + "%lld of %lld sets done": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld von %2$lld Sätzen erledigt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld of %lld sets done" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld de %2$lld series hechas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld sur %2$lld séries faites" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld di %2$lld serie fatte" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld z %2$lld serii zrobione" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld de %2$lld séries feitas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld из %2$lld подходов сделано" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已完成 %1$lld / %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已完成 %1$lld / %2$lld 組" } } + } }, + "Set %lld — working": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — working" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld — en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – trwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — идёт" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组进行中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組進行中" } } + } }, + "Resting after set %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause nach Satz %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting after set %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando tras la serie %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos après la série %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero dopo la serie %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa po serii %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar após a série %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых после подхода %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组后休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組後休息" } } + } }, + "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": "可以開始下一組" } } + } }, + "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": "沒有進行中的訓練" } } + } }, + "All sets done": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Alle Sätze erledigt" } }, "en": { "stringUnit": { "state": "translated", "value": "All sets done" } }, "es": { "stringUnit": { "state": "translated", "value": "Todas las series hechas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Toutes les séries faites" } }, "it": { "stringUnit": { "state": "translated", "value": "Tutte le serie fatte" } }, "pl": { "stringUnit": { "state": "translated", "value": "Wszystkie serie zrobione" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todas as séries feitas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Все подходы сделаны" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有组已完成" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有組已完成" } } + } }, + "This set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieser Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "This set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ta seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Этот подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "本组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "本組" } } + } }, + "RPE %@ × %@ min": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %@ min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分鐘" } } + } }, + "Best set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bester Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Best set" } }, "es": { "stringUnit": { "state": "translated", "value": "Mejor serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Meilleure série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie migliore" } }, "pl": { "stringUnit": { "state": "translated", "value": "Najlepsza seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Melhor série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Лучший подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最佳组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最佳組" } } + } }, + "Mean RPE": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Mittlerer RPE" } }, "en": { "stringUnit": { "state": "translated", "value": "Mean RPE" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE medio" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE moyen" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE medio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Średnie RPE" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE médio" } }, "ru": { "stringUnit": { "state": "translated", "value": "Средний RPE" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "平均 RPE" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "平均 RPE" } } + } }, + "How hard it felt": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie schwer es sich anfühlte" } }, "en": { "stringUnit": { "state": "translated", "value": "How hard it felt" } }, "es": { "stringUnit": { "state": "translated", "value": "Lo duro que se sintió" } }, "fr": { "stringUnit": { "state": "translated", "value": "À quel point c'était dur" } }, "it": { "stringUnit": { "state": "translated", "value": "Quanto è sembrato duro" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak ciężko było" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quão difícil pareceu" } }, "ru": { "stringUnit": { "state": "translated", "value": "Насколько было тяжело" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "感觉有多吃力" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "感覺有多吃力" } } + } }, + "Sets per muscle": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze pro Muskel" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets per muscle" } }, "es": { "stringUnit": { "state": "translated", "value": "Series por músculo" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries par muscle" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie per muscolo" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie na mięsień" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries por músculo" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы на мышцу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "每个肌群的组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "每個肌群的組數" } } + } }, + "Session load": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzungsbelastung" } }, "en": { "stringUnit": { "state": "translated", "value": "Session load" } }, "es": { "stringUnit": { "state": "translated", "value": "Carga de sesión" } }, "fr": { "stringUnit": { "state": "translated", "value": "Charge de séance" } }, "it": { "stringUnit": { "state": "translated", "value": "Carico della sessione" } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie sesji" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Carga da sessão" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нагрузка сессии" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "训练负荷" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "訓練負荷" } } + } }, + "Work vs rest": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Arbeit vs. Pause" } }, "en": { "stringUnit": { "state": "translated", "value": "Work vs rest" } }, "es": { "stringUnit": { "state": "translated", "value": "Trabajo vs descanso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Travail vs repos" } }, "it": { "stringUnit": { "state": "translated", "value": "Lavoro vs recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Praca vs przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Trabalho vs descanso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Работа и отдых" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "做功与休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "做功與休息" } } + } }, + "Figures": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zahlen" } }, "en": { "stringUnit": { "state": "translated", "value": "Figures" } }, "es": { "stringUnit": { "state": "translated", "value": "Cifras" } }, "fr": { "stringUnit": { "state": "translated", "value": "Chiffres" } }, "it": { "stringUnit": { "state": "translated", "value": "Numeri" } }, "pl": { "stringUnit": { "state": "translated", "value": "Liczby" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Números" } }, "ru": { "stringUnit": { "state": "translated", "value": "Показатели" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "数据" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "數據" } } + } }, + "As performed": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie ausgeführt" } }, "en": { "stringUnit": { "state": "translated", "value": "As performed" } }, "es": { "stringUnit": { "state": "translated", "value": "Tal como se hizo" } }, "fr": { "stringUnit": { "state": "translated", "value": "Tel qu'exécuté" } }, "it": { "stringUnit": { "state": "translated", "value": "Come eseguito" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak wykonano" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Tal como feito" } }, "ru": { "stringUnit": { "state": "translated", "value": "Как выполнено" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "实际完成" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "實際完成" } } + } }, + "Reading the session…": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung wird gelesen…" } }, "en": { "stringUnit": { "state": "translated", "value": "Reading the session…" } }, "es": { "stringUnit": { "state": "translated", "value": "Leyendo la sesión…" } }, "fr": { "stringUnit": { "state": "translated", "value": "Lecture de la séance…" } }, "it": { "stringUnit": { "state": "translated", "value": "Lettura della sessione…" } }, "pl": { "stringUnit": { "state": "translated", "value": "Wczytywanie sesji…" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A ler a sessão…" } }, "ru": { "stringUnit": { "state": "translated", "value": "Читаем сессию…" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在读取训练…" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在讀取訓練…" } } + } }, + "not rated": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "nicht bewertet" } }, "en": { "stringUnit": { "state": "translated", "value": "not rated" } }, "es": { "stringUnit": { "state": "translated", "value": "sin valorar" } }, "fr": { "stringUnit": { "state": "translated", "value": "non évaluée" } }, "it": { "stringUnit": { "state": "translated", "value": "non valutata" } }, "pl": { "stringUnit": { "state": "translated", "value": "bez oceny" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "sem classificação" } }, "ru": { "stringUnit": { "state": "translated", "value": "без оценки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未评分" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未評分" } } + } }, + "same as last time": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "wie letztes Mal" } }, "en": { "stringUnit": { "state": "translated", "value": "same as last time" } }, "es": { "stringUnit": { "state": "translated", "value": "igual que la última vez" } }, "fr": { "stringUnit": { "state": "translated", "value": "comme la dernière fois" } }, "it": { "stringUnit": { "state": "translated", "value": "come l'ultima volta" } }, "pl": { "stringUnit": { "state": "translated", "value": "tak jak ostatnio" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "igual à última vez" } }, "ru": { "stringUnit": { "state": "translated", "value": "как в прошлый раз" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "与上次相同" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "與上次相同" } } + } }, + "RPE %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %@" } } + } }, + "Sets at RPE %@ or above": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze bei RPE %@ oder höher" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets at RPE %@ or above" } }, "es": { "stringUnit": { "state": "translated", "value": "Series con RPE %@ o más" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries à RPE %@ ou plus" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie a RPE %@ o superiore" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie przy RPE %@ lub wyżej" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries com RPE %@ ou mais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы с RPE %@ и выше" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %@ 及以上的组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %@ 及以上的組數" } } + } }, + "%lld working sets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld Arbeitssätze" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld working sets" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld series efectivas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld séries de travail" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld serie di lavoro" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld serii roboczych" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld séries de trabalho" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld рабочих подходов" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个正式组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 個正式組" } } + } }, + "%@ under load": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%@ unter Last" } }, "en": { "stringUnit": { "state": "translated", "value": "%@ under load" } }, "es": { "stringUnit": { "state": "translated", "value": "%@ bajo carga" } }, "fr": { "stringUnit": { "state": "translated", "value": "%@ sous charge" } }, "it": { "stringUnit": { "state": "translated", "value": "%@ sotto carico" } }, "pl": { "stringUnit": { "state": "translated", "value": "%@ pod obciążeniem" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%@ sob carga" } }, "ru": { "stringUnit": { "state": "translated", "value": "%@ под нагрузкой" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "负重 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "負重 %@" } } + } }, + "RPE %@ × %lld min": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %lld min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld 分鐘" } } + } }, + "%lld direct · %lld indirect": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld direkt · %2$lld indirekt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld direct · %lld indirect" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld directas · %2$lld indirectas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld directes · %2$lld indirectes" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld dirette · %2$lld indirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld bezpośrednich · %2$lld pośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld diretas · %2$lld indiretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld прямых · %2$lld косвенных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$lld 直接 · %2$lld 间接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%1$lld 直接 · %2$lld 間接" } } + } }, + "%lld direct": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld direkt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld direct" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld directas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld directes" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld dirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld bezpośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld diretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld прямых" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 直接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 直接" } } + } }, + "%lld indirect": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld indirekt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld indirect" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld indirectas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld indirectes" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld indirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld pośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld indiretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld косвенных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 间接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 間接" } } + } }, + "≈ %@ estimated 1RM": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "≈ %@ geschätztes 1RM" } }, "en": { "stringUnit": { "state": "translated", "value": "≈ %@ estimated 1RM" } }, "es": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimado" } }, "fr": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimé" } }, "it": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM stimato" } }, "pl": { "stringUnit": { "state": "translated", "value": "≈ %@ szacowane 1RM" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimado" } }, "ru": { "stringUnit": { "state": "translated", "value": "≈ %@ расчётный 1ПМ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "≈ %@ 估算 1RM" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "≈ %@ 估算 1RM" } } + } }, + "%lld working sets weren't rated, so the mean is drawn from %lld.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld Arbeitssätze wurden nicht bewertet, der Mittelwert stammt also aus %2$lld." } }, "en": { "stringUnit": { "state": "translated", "value": "%lld working sets weren't rated, so the mean is drawn from %lld." } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld series efectivas no se valoraron, así que la media sale de %2$lld." } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld séries de travail n'ont pas été évaluées ; la moyenne porte donc sur %2$lld." } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld serie di lavoro non sono state valutate, quindi la media si basa su %2$lld." } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld serii roboczych nie oceniono, więc średnia pochodzi z %2$lld." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld séries de trabalho não foram classificadas, por isso a média vem de %2$lld." } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld рабочих подходов не оценены, поэтому среднее посчитано по %2$lld." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "有 %1$lld 个正式组未评分,因此平均值来自 %2$lld 组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "有 %1$lld 個正式組未評分,因此平均值來自 %2$lld 組。" } } + } }, + "Direct sets count once, indirect sets count as a half — the method the reference figures were derived under.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Direkte Sätze zählen einfach, indirekte als halber Satz – die Methode, unter der die Referenzwerte ermittelt wurden." } }, "en": { "stringUnit": { "state": "translated", "value": "Direct sets count once, indirect sets count as a half — the method the reference figures were derived under." } }, "es": { "stringUnit": { "state": "translated", "value": "Las series directas cuentan una vez y las indirectas media: el método con el que se obtuvieron las cifras de referencia." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séries directes comptent pour une, les indirectes pour une demie — la méthode sous laquelle les valeurs de référence ont été établies." } }, "it": { "stringUnit": { "state": "translated", "value": "Le serie dirette contano una volta, quelle indirette una metà: il metodo con cui sono stati ricavati i valori di riferimento." } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie bezpośrednie liczą się raz, pośrednie jako pół – metoda, według której wyznaczono wartości odniesienia." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As séries diretas contam uma vez e as indiretas meia — o método com que os valores de referência foram obtidos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Прямые подходы считаются за один, косвенные — за половину: именно так были получены эталонные значения." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "直接组计为一组,间接组计为半组——参考数值正是在这个方法下得出的。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "直接組計為一組,間接組計為半組——參考數值正是在這個方法下得出的。" } } + } }, + "Finished sessions land here, with every set you logged.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Abgeschlossene Sitzungen landen hier – mit jedem Satz, den du aufgezeichnet hast." } }, "en": { "stringUnit": { "state": "translated", "value": "Finished sessions land here, with every set you logged." } }, "es": { "stringUnit": { "state": "translated", "value": "Las sesiones terminadas aparecen aquí, con cada serie que registraste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séances terminées arrivent ici, avec chaque série que tu as enregistrée." } }, "it": { "stringUnit": { "state": "translated", "value": "Le sessioni concluse finiscono qui, con ogni serie che hai registrato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zakończone sesje trafiają tutaj, z każdą zapisaną serią." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As sessões terminadas aparecem aqui, com todas as séries que registaste." } }, "ru": { "stringUnit": { "state": "translated", "value": "Завершённые сессии попадают сюда — со всеми записанными подходами." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "完成的训练会出现在这里,包含你记录的每一组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "完成的訓練會出現在這裡,包含你記錄的每一組。" } } + } }, + "Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Kraftzahlen werden aus den Sätzen oben berechnet. Die Belastung bleibt aus der Herzfrequenz gemessen und wird nie aus Gewichten und Wiederholungen abgeleitet." } }, "en": { "stringUnit": { "state": "translated", "value": "Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps." } }, "es": { "stringUnit": { "state": "translated", "value": "Las cifras de fuerza salen de las series de arriba. El Esfuerzo se sigue midiendo por frecuencia cardíaca y nunca se deriva de pesos y repeticiones." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les chiffres de musculation sont calculés à partir des séries ci-dessus. L'Effort reste mesuré par la fréquence cardiaque et n'est jamais déduit des charges et répétitions." } }, "it": { "stringUnit": { "state": "translated", "value": "I numeri della forza sono calcolati dalle serie qui sopra. Lo Sforzo resta misurato dalla frequenza cardiaca e non viene mai derivato da pesi e ripetizioni." } }, "pl": { "stringUnit": { "state": "translated", "value": "Liczby siłowe wynikają z serii powyżej. Wysiłek nadal mierzony jest tętnem i nigdy nie jest wyprowadzany z ciężarów i powtórzeń." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Os números de força são calculados a partir das séries acima. O Esforço continua medido pela frequência cardíaca e nunca é derivado de pesos e repetições." } }, "ru": { "stringUnit": { "state": "translated", "value": "Силовые показатели считаются из подходов выше. Усилие по-прежнему измеряется по пульсу и никогда не выводится из весов и повторений." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "力量数据由上面的组次算出。消耗始终由心率测得,绝不会从重量和次数推导。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "力量數據由上面的組次算出。消耗始終由心率測得,絕不會從重量和次數推導。" } } + } }, + "None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine dieser Übungen hat eine Muskelgruppe. Trag eine bei der Übung ein, und jede künftige Sitzung zählt darauf ein." } }, "en": { "stringUnit": { "state": "translated", "value": "None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it." } }, "es": { "stringUnit": { "state": "translated", "value": "Ninguno de estos ejercicios tiene aún grupo muscular. Añade uno al ejercicio y todas las sesiones futuras contarán para él." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun de ces exercices n'a encore de groupe musculaire. Ajoutes-en un à l'exercice et chaque séance future comptera dedans." } }, "it": { "stringUnit": { "state": "translated", "value": "Nessuno di questi esercizi ha ancora un gruppo muscolare. Aggiungine uno all'esercizio e ogni sessione futura conterà su di esso." } }, "pl": { "stringUnit": { "state": "translated", "value": "Żadne z tych ćwiczeń nie ma jeszcze grupy mięśniowej. Dodaj ją do ćwiczenia, a każda kolejna sesja będzie się na nią liczyć." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nenhum destes exercícios tem ainda grupo muscular. Adiciona um ao exercício e todas as sessões futuras contam para ele." } }, "ru": { "stringUnit": { "state": "translated", "value": "Ни одно из этих упражнений пока не имеет мышечной группы. Добавь её к упражнению — и каждая следующая сессия пойдёт в зачёт." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这些动作还没有肌群。给动作添加一个,之后每次训练都会计入其中。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這些動作還沒有肌群。給動作新增一個,之後每次訓練都會計入其中。" } } + } }, + "Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sobald du eine Sitzung aufgezeichnet hast, siehst du hier, wie viele Sätze jeder Muskel diese Woche bekommen hat – im Vergleich zu dem, was die Forschung mit Wachstum verbindet." } }, "en": { "stringUnit": { "state": "translated", "value": "Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth." } }, "es": { "stringUnit": { "state": "translated", "value": "En cuanto registres una sesión, aquí verás cuántas series recibió cada músculo esta semana, frente a lo que la investigación asocia con el crecimiento." } }, "fr": { "stringUnit": { "state": "translated", "value": "Dès que tu auras enregistré une séance, tu verras ici combien de séries chaque muscle a reçues cette semaine, face à ce que la recherche associe à la croissance." } }, "it": { "stringUnit": { "state": "translated", "value": "Appena registri una sessione, qui vedrai quante serie ha ricevuto ogni muscolo questa settimana, rispetto a ciò che la ricerca associa alla crescita." } }, "pl": { "stringUnit": { "state": "translated", "value": "Gdy zapiszesz sesję, zobaczysz tu, ile serii dostał w tym tygodniu każdy mięsień, na tle tego, co badania wiążą ze wzrostem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Assim que registares uma sessão, verás aqui quantas séries cada músculo recebeu esta semana, face ao que a investigação associa ao crescimento." } }, "ru": { "stringUnit": { "state": "translated", "value": "Как только запишешь сессию, здесь появится, сколько подходов получила каждая мышца за неделю — рядом с тем, что исследования связывают с ростом." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "记录一次训练后,这里会显示本周每个肌群做了多少组,并与研究中和增长相关的数值对照。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "記錄一次訓練後,這裡會顯示本週每個肌群做了多少組,並與研究中和增長相關的數值對照。" } } + } }, + "The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Der Balken markiert etwa 4 Sätze pro Woche – die Grenze, unterhalb derer die Forschung kein verlässliches Wachstum feststellt. Darüber gehen die Zuwächse weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze." } }, "en": { "stringUnit": { "state": "translated", "value": "The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling." } }, "es": { "stringUnit": { "state": "translated", "value": "La barra marca unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias siguen con rendimientos muy decrecientes y sin techo claro." } }, "fr": { "stringUnit": { "state": "translated", "value": "La barre marque environ 4 séries par semaine — le seuil sous lequel la recherche ne détecte pas de croissance fiable. Au-dessus, les gains continuent avec des rendements fortement décroissants et sans plafond net." } }, "it": { "stringUnit": { "state": "translated", "value": "La barra segna circa 4 serie a settimana: la soglia sotto la quale la ricerca non rileva crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro." } }, "pl": { "stringUnit": { "state": "translated", "value": "Pasek oznacza około 4 serie tygodniowo – próg, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej przyrosty trwają, ale z silnie malejącym zwrotem i bez wyraźnego sufitu." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A barra marca cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro." } }, "ru": { "stringUnit": { "state": "translated", "value": "Полоса отмечает примерно 4 подхода в неделю — порог, ниже которого исследования не выявляют рост надёжно. Выше прирост продолжается, но с резко убывающей отдачей и без явного потолка." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这条线标记每周约 4 组——低于此值,研究无法可靠地检测到增长。高于此值,增长仍在继续,但收益急剧递减,且没有明确上限。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這條線標記每週約 4 組——低於此值,研究無法可靠地檢測到增長。高於此值,增長仍在繼續,但收益急劇遞減,且沒有明確上限。" } } + } }, + "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – das ist der Plan, nicht das Protokoll. Was du tatsächlich hebst, trägst du während der Sitzung Satz für Satz ein." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: esto es el plan, no el registro. Lo que realmente levantas se introduce serie a serie durante la sesión." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : ceci est le plan, pas le relevé. Ce que tu soulèves vraiment se saisit série par série pendant la séance." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: questo è il piano, non il registro. Quello che sollevi davvero lo inserisci serie per serie durante la sessione." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – to plan, nie zapis. To, co naprawdę podniesiesz, wpisujesz seria po serii w trakcie sesji." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: isto é o plano, não o registo. O que levantas de facto é introduzido série a série durante a sessão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — это план, а не запись. То, что ты действительно поднял, вводится подход за подходом во время сессии." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——这是计划,不是记录。你实际举起的重量,在训练中一组一组地输入。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——這是計畫,不是記錄。你實際舉起的重量,在訓練中一組一組地輸入。" } } + } }, + "Pre-filled with your target, or what you did last time. Correct it to what you actually lifted.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vorausgefüllt mit deinem Ziel oder dem, was du letztes Mal gemacht hast. Korrigiere es auf das, was du tatsächlich gehoben hast." } }, "en": { "stringUnit": { "state": "translated", "value": "Pre-filled with your target, or what you did last time. Correct it to what you actually lifted." } }, "es": { "stringUnit": { "state": "translated", "value": "Rellenado con tu objetivo o con lo que hiciste la última vez. Corrígelo con lo que de verdad levantaste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Pré-rempli avec ton objectif ou ce que tu as fait la dernière fois. Corrige-le avec ce que tu as vraiment soulevé." } }, "it": { "stringUnit": { "state": "translated", "value": "Precompilato con il tuo obiettivo o con quello che hai fatto l'ultima volta. Correggilo con ciò che hai davvero sollevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wypełnione Twoim celem lub tym, co robiłeś ostatnio. Popraw na to, co naprawdę podniosłeś." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Preenchido com o teu objetivo ou com o que fizeste da última vez. Corrige para o que levantaste realmente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Заполнено твоей целью или тем, что ты делал в прошлый раз. Исправь на то, что действительно поднял." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已填入你的目标,或你上次的数据。改成你实际举起的重量。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已填入你的目標,或你上次的資料。改成你實際舉起的重量。" } } + } }, + "What you just did — %@, set %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Was du gerade gemacht hast – %1$@, Satz %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "What you just did — %@, set %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Lo que acabas de hacer: %1$@, serie %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ce que tu viens de faire — %1$@, série %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Quello che hai appena fatto — %1$@, serie %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "To, co przed chwilą zrobiłeś — %1$@, seria %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O que acabaste de fazer — %1$@, série %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 組" } } + } }, + "This set %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieser Satz %@" } }, "en": { "stringUnit": { "state": "translated", "value": "This set %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Esta serie %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Cette série %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Questa serie %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ta seria %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esta série %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Этот подход %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "本组 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "本組 %@" } } + } }, + "Resting %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "休息 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "休息 %@" } } + } }, + "Warming up %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmen %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Warming up %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Calentando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Échauffement %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Riscaldamento %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozgrzewka %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A aquecer %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминка %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身 %@" } } + } }, + "Cooling down %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Abwärmen %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Cooling down %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Enfriando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Retour au calme %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Defaticamento %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Schłodzenie %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A arrefecer %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Заминка %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "放松 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "放鬆 %@" } } + } }, + "Enter the set you just did, then start the next one.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trag den Satz ein, den du gerade gemacht hast, und starte dann den nächsten." } }, "en": { "stringUnit": { "state": "translated", "value": "Enter the set you just did, then start the next one." } }, "es": { "stringUnit": { "state": "translated", "value": "Introduce la serie que acabas de hacer y luego empieza la siguiente." } }, "fr": { "stringUnit": { "state": "translated", "value": "Saisis la série que tu viens de faire, puis lance la suivante." } }, "it": { "stringUnit": { "state": "translated", "value": "Inserisci la serie che hai appena fatto, poi inizia la successiva." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wpisz serię, którą przed chwilą zrobiłeś, a potem zacznij następną." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Introduz a série que acabaste de fazer e depois começa a seguinte." } }, "ru": { "stringUnit": { "state": "translated", "value": "Введи подход, который только что сделал, затем начни следующий." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入你刚做完的那组,然后开始下一组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輸入你剛做完的那組,然後開始下一組。" } } + } }, + "Enter your last set, then finish and save.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trag deinen letzten Satz ein, dann abschließen und sichern." } }, "en": { "stringUnit": { "state": "translated", "value": "Enter your last set, then finish and save." } }, "es": { "stringUnit": { "state": "translated", "value": "Introduce tu última serie, luego termina y guarda." } }, "fr": { "stringUnit": { "state": "translated", "value": "Saisis ta dernière série, puis termine et enregistre." } }, "it": { "stringUnit": { "state": "translated", "value": "Inserisci l'ultima serie, poi concludi e salva." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wpisz ostatnią serię, a potem zakończ i zapisz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Introduz a tua última série, depois termina e guarda." } }, "ru": { "stringUnit": { "state": "translated", "value": "Введи последний подход, затем заверши и сохрани." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入最后一组,然后结束并保存。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輸入最後一組,然後結束並儲存。" } } + } }, + "Press the button, or double-tap your strap, when the set is done.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Drück den Knopf oder tippe zweimal auf dein Band, wenn der Satz fertig ist." } }, "en": { "stringUnit": { "state": "translated", "value": "Press the button, or double-tap your strap, when the set is done." } }, "es": { "stringUnit": { "state": "translated", "value": "Pulsa el botón, o toca dos veces tu banda, cuando termines la serie." } }, "fr": { "stringUnit": { "state": "translated", "value": "Appuie sur le bouton, ou tape deux fois sur ton bracelet, quand la série est finie." } }, "it": { "stringUnit": { "state": "translated", "value": "Premi il pulsante, o tocca due volte la fascia, quando la serie è finita." } }, "pl": { "stringUnit": { "state": "translated", "value": "Naciśnij przycisk albo stuknij dwukrotnie w opaskę, gdy skończysz serię." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Carrega no botão, ou toca duas vezes na tua banda, quando acabares a série." } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми кнопку или дважды коснись браслета, когда закончишь подход." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "做完这组后,按下按钮,或双击手环。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "做完這組後,按下按鈕,或輕點兩下手環。" } } + } }, + "That's the last set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Das war der letzte Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "That's the last set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esa fue la última serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "C'était la dernière série" } }, "it": { "stringUnit": { "state": "translated", "value": "Quella era l'ultima serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "To była ostatnia seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Essa foi a última série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Это был последний подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是最后一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是最後一組" } } + } }, + "Pre-filled with what you did last time. Change anything that's different today.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vorausgefüllt mit dem, was du letztes Mal gemacht hast. Ändere alles, was heute anders ist." } }, "en": { "stringUnit": { "state": "translated", "value": "Pre-filled with what you did last time. Change anything that's different today." } }, "es": { "stringUnit": { "state": "translated", "value": "Rellenado con lo que hiciste la última vez. Cambia lo que hoy sea distinto." } }, "fr": { "stringUnit": { "state": "translated", "value": "Pré-rempli avec ce que tu as fait la dernière fois. Modifie ce qui change aujourd'hui." } }, "it": { "stringUnit": { "state": "translated", "value": "Precompilato con quello che hai fatto l'ultima volta. Cambia ciò che oggi è diverso." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wypełnione tym, co robiłeś ostatnio. Zmień to, co dziś jest inne." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Preenchido com o que fizeste da última vez. Muda o que hoje for diferente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Заполнено тем, что ты делал в прошлый раз. Измени всё, что сегодня иначе." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已填入你上次的数据。今天有变化的地方改一下就行。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已填入你上次的資料。今天有變化的地方改一下就行。" } } + } }, + "This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Das ist der Sitzungs-RPE. Mal der Dauer der Sitzung ergibt er die Sitzungsbelastung – die eine Zahl, die sich über völlig verschiedene Trainingsarten hinweg vergleichen lässt." } }, "en": { "stringUnit": { "state": "translated", "value": "This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training." } }, "es": { "stringUnit": { "state": "translated", "value": "Esto es el RPE de sesión. Multiplicado por la duración da la carga de sesión: la única cifra comparable entre entrenamientos completamente distintos." } }, "fr": { "stringUnit": { "state": "translated", "value": "C'est le RPE de séance. Multiplié par la durée, il donne la charge de séance — le seul chiffre comparable entre des entraînements totalement différents." } }, "it": { "stringUnit": { "state": "translated", "value": "Questo è l'RPE della sessione. Moltiplicato per la durata dà il carico della sessione: l'unico numero confrontabile fra allenamenti completamente diversi." } }, "pl": { "stringUnit": { "state": "translated", "value": "To jest RPE sesji. Pomnożone przez czas trwania daje obciążenie sesji – jedyną liczbę porównywalną między zupełnie różnymi treningami." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Isto é o RPE da sessão. Multiplicado pela duração dá a carga da sessão — o único número comparável entre treinos completamente diferentes." } }, "ru": { "stringUnit": { "state": "translated", "value": "Это RPE сессии. Умноженный на её длительность, он даёт нагрузку сессии — единственную величину, сравнимую между совершенно разными тренировками." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是整场 RPE。乘以训练时长就得到训练负荷——唯一能在完全不同的训练之间比较的数字。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是整場 RPE。乘以訓練時長就得到訓練負荷——唯一能在完全不同的訓練之間比較的數字。" } } + } }, + "Discard": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Verwerfen" } }, "en": { "stringUnit": { "state": "translated", "value": "Discard" } }, "es": { "stringUnit": { "state": "translated", "value": "Descartar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Abandonner" } }, "it": { "stringUnit": { "state": "translated", "value": "Scarta" } }, "pl": { "stringUnit": { "state": "translated", "value": "Odrzuć" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Descartar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отбросить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "放弃" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "放棄" } } + } }, + "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": "組間休息" } } + } }, + "Resting": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "In recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдыхаешь" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "休息中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "休息中" } } + } }, + "Ready when you are": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereit, wenn du es bist" } }, "en": { "stringUnit": { "state": "translated", "value": "Ready when you are" } }, "es": { "stringUnit": { "state": "translated", "value": "Cuando quieras" } }, "fr": { "stringUnit": { "state": "translated", "value": "Quand tu veux" } }, "it": { "stringUnit": { "state": "translated", "value": "Quando vuoi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Gotowe, gdy będziesz gotowy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quando quiseres" } }, "ru": { "stringUnit": { "state": "translated", "value": "Готово, когда будешь готов" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "准备好就开始" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "準備好就開始" } } + } }, + "Warming up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmen" } }, "en": { "stringUnit": { "state": "translated", "value": "Warming up" } }, "es": { "stringUnit": { "state": "translated", "value": "Calentando" } }, "fr": { "stringUnit": { "state": "translated", "value": "Échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozgrzewka" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A aquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминка" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身中" } } + } }, + "Warm-up set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmsatz" } }, "en": { "stringUnit": { "state": "translated", "value": "Warm-up set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie de calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série d'échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie di riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria rozgrzewkowa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série de aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминочный подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身組" } } + } }, + "Everything before your first set counts as the warm-up. Nothing is being recorded yet.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Alles vor deinem ersten Satz zählt als Aufwärmen. Es wird noch nichts aufgezeichnet." } }, "en": { "stringUnit": { "state": "translated", "value": "Everything before your first set counts as the warm-up. Nothing is being recorded yet." } }, "es": { "stringUnit": { "state": "translated", "value": "Todo lo anterior a tu primera serie cuenta como calentamiento. Aún no se registra nada." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tout ce qui précède ta première série compte comme échauffement. Rien n'est encore enregistré." } }, "it": { "stringUnit": { "state": "translated", "value": "Tutto ciò che precede la prima serie conta come riscaldamento. Non viene ancora registrato nulla." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wszystko przed pierwszą serią liczy się jako rozgrzewka. Nic nie jest jeszcze zapisywane." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Tudo antes da tua primeira série conta como aquecimento. Ainda não está a ser registado nada." } }, "ru": { "stringUnit": { "state": "translated", "value": "Всё до первого подхода считается разминкой. Пока ничего не записывается." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第一组之前的一切都算热身。现在还没有开始记录。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第一組之前的一切都算熱身。現在還沒有開始記錄。" } } + } }, + "Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippe zum Abschließen. Die Sitzung wird als Workout gesichert und taucht wie jedes andere in Workouts und Heute auf." } }, "en": { "stringUnit": { "state": "translated", "value": "Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other." } }, "es": { "stringUnit": { "state": "translated", "value": "Toca para terminar. La sesión se guarda como un entrenamiento, así que aparece en Entrenamientos y Hoy como cualquier otro." } }, "fr": { "stringUnit": { "state": "translated", "value": "Touche pour terminer. La séance est enregistrée comme un entraînement et apparaît dans Entraînements et Aujourd'hui comme les autres." } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca per concludere. La sessione viene salvata come allenamento, quindi compare in Allenamenti e Oggi come tutti gli altri." } }, "pl": { "stringUnit": { "state": "translated", "value": "Dotknij, aby zakończyć. Sesja zapisuje się jako trening, więc pojawi się w Treningach i Dzisiaj jak każdy inny." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca para terminar. A sessão é guardada como treino, por isso aparece em Treinos e Hoje como qualquer outro." } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми, чтобы завершить. Сессия сохраняется как тренировка и появится в Тренировках и Сегодня, как любая другая." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "点按结束。这次训练会作为一次锻炼保存,和其他锻炼一样出现在「锻炼」和「今天」里。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "點按結束。這次訓練會作為一次鍛鍊儲存,和其他鍛鍊一樣出現在「鍛鍊」和「今天」裡。" } } + } }, + "Double-tap your strap to log a set without picking the phone up.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippe zweimal auf dein Band, um einen Satz zu speichern, ohne zum Telefon zu greifen." } }, "en": { "stringUnit": { "state": "translated", "value": "Double-tap your strap to log a set without picking the phone up." } }, "es": { "stringUnit": { "state": "translated", "value": "Toca dos veces tu banda para registrar una serie sin coger el teléfono." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tape deux fois sur ton bracelet pour enregistrer une série sans toucher au téléphone." } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca due volte la fascia per registrare una serie senza prendere il telefono." } }, "pl": { "stringUnit": { "state": "translated", "value": "Stuknij dwukrotnie w opaskę, aby zapisać serię bez sięgania po telefon." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca duas vezes na tua banda para registar uma série sem pegar no telemóvel." } }, "ru": { "stringUnit": { "state": "translated", "value": "Дважды коснись браслета, чтобы записать подход, не беря телефон в руки." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "双击手环即可记录一组,不用拿起手机。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輕點兩下手環即可記錄一組,不用拿起手機。" } } + } }, + "Session in progress": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Session in progress" } }, "es": { "stringUnit": { "state": "translated", "value": "Sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sesja w toku" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сессия идёт" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "训练进行中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "訓練進行中" } } + } }, + "You left a session running. Nothing was lost — pick it up where you stopped.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast eine Sitzung laufen lassen. Nichts ist verloren – mach da weiter, wo du aufgehört hast." } }, "en": { "stringUnit": { "state": "translated", "value": "You left a session running. Nothing was lost — pick it up where you stopped." } }, "es": { "stringUnit": { "state": "translated", "value": "Dejaste una sesión en marcha. No se ha perdido nada: retómala donde la dejaste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tu as laissé une séance en cours. Rien n'est perdu : reprends là où tu t'es arrêté." } }, "it": { "stringUnit": { "state": "translated", "value": "Hai lasciato una sessione in corso. Non è andato perso nulla: riprendi da dove eri." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zostawiłeś rozpoczętą sesję. Nic nie przepadło – wróć tam, gdzie skończyłeś." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Deixaste uma sessão a decorrer. Não se perdeu nada: retoma onde paraste." } }, "ru": { "stringUnit": { "state": "translated", "value": "У тебя осталась незавершённая сессия. Ничего не потеряно — продолжи с того места, где остановился." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你有一次训练还没结束。什么都没丢——从停下的地方继续吧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你有一次訓練還沒結束。什麼都沒丟——從停下的地方繼續吧。" } } + } }, + "Save session": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save session" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar sesión" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer la séance" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva sessione" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz sesję" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar sessão" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить сессию" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存訓練" } } + } }, + "Start this program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieses Programm starten" } }, "en": { "stringUnit": { "state": "translated", "value": "Start this program" } }, "es": { "stringUnit": { "state": "translated", "value": "Empezar este programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Démarrer ce programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Avvia questo programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij ten program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Iniciar este programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Начать эту программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "开始这个计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開始這個計畫" } } + } }, + "Tap to edit": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zum Bearbeiten tippen" } }, "en": { "stringUnit": { "state": "translated", "value": "Tap to edit" } }, "es": { "stringUnit": { "state": "translated", "value": "Toca para editar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Touche pour modifier" } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca per modificare" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dotknij, aby edytować" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca para editar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми, чтобы изменить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "点按编辑" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "點按編輯" } } + } }, + "Sets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets" } }, "es": { "stringUnit": { "state": "translated", "value": "Series" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組數" } } + } }, + "Elapsed": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergangen" } }, "en": { "stringUnit": { "state": "translated", "value": "Elapsed" } }, "es": { "stringUnit": { "state": "translated", "value": "Transcurrido" } }, "fr": { "stringUnit": { "state": "translated", "value": "Écoulé" } }, "it": { "stringUnit": { "state": "translated", "value": "Trascorso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Upłynęło" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Decorrido" } }, "ru": { "stringUnit": { "state": "translated", "value": "Прошло" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已用时" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已用時" } } + } }, + "Volume": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Volumen" } }, "en": { "stringUnit": { "state": "translated", "value": "Volume" } }, "es": { "stringUnit": { "state": "translated", "value": "Volumen" } }, "fr": { "stringUnit": { "state": "translated", "value": "Volume" } }, "it": { "stringUnit": { "state": "translated", "value": "Volume" } }, "pl": { "stringUnit": { "state": "translated", "value": "Objętość" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Volume" } }, "ru": { "stringUnit": { "state": "translated", "value": "Объём" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "容量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "容量" } } + } }, + "Set %lld of %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %1$lld von %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld of %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %1$lld de %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %1$lld sur %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %1$lld di %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %1$lld z %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %1$lld de %2$lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %1$lld из %2$lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %1$lld 组,共 %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %1$lld 組,共 %2$lld 組" } } + } }, + "session RPE %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzungs-RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "session RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE de sesión %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE de séance %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE della sessione %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE sesji %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE da sessão %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE сессии %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场 RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場 RPE %@" } } + } }, + "Weight (kg)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Gewicht (kg)" } }, "en": { "stringUnit": { "state": "translated", "value": "Weight (kg)" } }, "es": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Poids (kg)" } }, "it": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ciężar (kg)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вес (кг)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重量(公斤)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重量(公斤)" } } + } }, + "Weight (lb)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Gewicht (lb)" } }, "en": { "stringUnit": { "state": "translated", "value": "Weight (lb)" } }, "es": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Poids (lb)" } }, "it": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ciężar (lb)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вес (фунты)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重量(磅)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重量(磅)" } } + } }, "Heart rate, no reading": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz, keine Messung" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate, no reading" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca, sin lectura" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque, aucune mesure" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca, nessuna lettura" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno, brak odczytu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca, sem leitura" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс, нет данных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率,无读数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率,無讀數" } } } }, @@ -67,6 +637,93 @@ "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Die Synchronisierung ist abgeschlossen, aber NOOP konnte kein Schlafzeitfenster sicher erkennen. Lass den Strap verbunden und synchronisiere erneut; die ältere Nacht unten ist weiterhin dein zuletzt erkannter Schlaf." } }, "en": { "stringUnit": { "state": "translated", "value": "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep." } }, "es": { "stringUnit": { "state": "translated", "value": "La sincronización terminó, pero NOOP no pudo identificar con confianza un periodo de sueño. Mantén la pulsera conectada y vuelve a sincronizar; la noche anterior que aparece abajo sigue siendo tu último sueño detectado." } }, "fr": { "stringUnit": { "state": "translated", "value": "La synchronisation est terminée, mais NOOP n’a pas pu identifier avec certitude une période de sommeil. Gardez le bracelet connecté et relancez la synchronisation ; la nuit plus ancienne ci-dessous reste votre dernier sommeil détecté." } }, "it": { "stringUnit": { "state": "translated", "value": "La sincronizzazione è terminata, ma NOOP non ha identificato con certezza un intervallo di sonno. Tieni la fascia collegata e sincronizza di nuovo; la notte precedente qui sotto resta l’ultimo sonno rilevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja zakończyła się, ale NOOP nie zdołał wiarygodnie określić okna snu. Pozostaw opaskę połączoną i zsynchronizuj ponownie; starsza noc poniżej nadal jest ostatnim wykrytym snem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A sincronização terminou, mas a NOOP não conseguiu identificar com confiança um período de sono. Mantém a bracelete ligada e sincroniza novamente; a noite anterior abaixo continua a ser o teu último sono detetado." } }, "ru": { "stringUnit": { "state": "translated", "value": "Синхронизация завершена, но NOOP не смог уверенно определить окно сна. Оставьте браслет подключённым и повторите синхронизацию; более ранняя ночь ниже остаётся последним обнаруженным сном." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 无法可靠识别睡眠时段。请保持手环连接并再次同步;下方较早的一夜仍是最近一次检测到的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 無法可靠識別睡眠時段。請保持手環連接並再次同步;下方較早的一夜仍是最近一次偵測到的睡眠。" } } } }, + "Save exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer l'exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存動作" } } + } }, + "Your log book": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Logbuch" } }, "en": { "stringUnit": { "state": "translated", "value": "Your log book" } }, "es": { "stringUnit": { "state": "translated", "value": "Tu cuaderno" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ton carnet" } }, "it": { "stringUnit": { "state": "translated", "value": "Il tuo diario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Twój dziennik" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu diário" } }, "ru": { "stringUnit": { "state": "translated", "value": "Твой дневник" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你的训练日志" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你的訓練日誌" } } + } }, + "Programs, sessions and per-set history": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programme, Sitzungen und Satz-für-Satz-Verlauf" } }, "en": { "stringUnit": { "state": "translated", "value": "Programs, sessions and per-set history" } }, "es": { "stringUnit": { "state": "translated", "value": "Programas, sesiones e historial por serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programmes, séances et historique par série" } }, "it": { "stringUnit": { "state": "translated", "value": "Programmi, sessioni e storico per serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Programy, sesje i historia serii" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programas, sessões e histórico por série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программы, сессии и история по подходам" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划、训练与每组历史" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫、訓練與每組歷史" } } + } }, + "Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Krafttraining ergänzt Volumen und Satzzahlen. Deine Belastung ändert es nie – die wird weiterhin aus der Herzfrequenz gemessen." } }, "en": { "stringUnit": { "state": "translated", "value": "Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate." } }, "es": { "stringUnit": { "state": "translated", "value": "Las pesas añaden volumen y recuento de series. Nunca cambian tu Esfuerzo, que se sigue midiendo por la frecuencia cardíaca." } }, "fr": { "stringUnit": { "state": "translated", "value": "La musculation ajoute du volume et un décompte de séries. Elle ne change jamais ton Effort, toujours mesuré à partir de la fréquence cardiaque." } }, "it": { "stringUnit": { "state": "translated", "value": "I pesi aggiungono volume e conteggio delle serie. Non cambiano mai il tuo Sforzo, che resta misurato dalla frequenza cardiaca." } }, "pl": { "stringUnit": { "state": "translated", "value": "Trening siłowy dodaje objętość i liczbę serii. Nigdy nie zmienia Twojego Wysiłku, który nadal jest mierzony tętnem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Os pesos acrescentam volume e contagem de séries. Nunca alteram o teu Esforço, que continua medido pela frequência cardíaca." } }, "ru": { "stringUnit": { "state": "translated", "value": "Силовые добавляют объём и число подходов. Они никогда не меняют твоё Усилие — оно по-прежнему измеряется по пульсу." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "力量训练会增加容量和组数统计,但绝不会改变你的消耗——消耗始终由心率测得。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "力量訓練會增加容量和組數統計,但絕不會改變你的消耗——消耗始終由心率測得。" } } + } }, + "Programs": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "Programs" } }, "es": { "stringUnit": { "state": "translated", "value": "Programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programmes" } }, "it": { "stringUnit": { "state": "translated", "value": "Programmi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Programy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } + } }, + "New program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Neues Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "New program" } }, "es": { "stringUnit": { "state": "translated", "value": "Nuevo programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Nouveau programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Nuovo programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Nowy program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Novo programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Новая программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "新建计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "新增計畫" } } + } }, + "No programs yet": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "No programs yet" } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun programme pour l'instant" } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak programów" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программ пока нет" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有計畫" } } + } }, + "Program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "Program" } }, "es": { "stringUnit": { "state": "translated", "value": "Programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } + } }, + "Exercises": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übungen" } }, "en": { "stringUnit": { "state": "translated", "value": "Exercises" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejercicios" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exercices" } }, "it": { "stringUnit": { "state": "translated", "value": "Esercizi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ćwiczenia" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Exercícios" } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнения" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "動作" } } + } }, + "No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine Übungen. Füge unten die erste hinzu – du kannst jeden Namen eingeben, NOOP merkt ihn sich fürs nächste Mal." } }, "en": { "stringUnit": { "state": "translated", "value": "No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time." } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay ejercicios. Añade el primero abajo: puedes escribir el nombre que quieras y NOOP lo recordará para la próxima vez." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun exercice pour l'instant. Ajoute le premier ci-dessous : tu peux saisir le nom que tu veux, NOOP le retient pour la prochaine fois." } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun esercizio. Aggiungi il primo qui sotto: puoi scrivere il nome che preferisci e NOOP lo ricorda per la prossima volta." } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak ćwiczeń. Dodaj pierwsze poniżej – możesz wpisać dowolną nazwę, a NOOP zapamięta ją na następny raz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem exercícios. Adiciona o primeiro abaixo: podes escrever o nome que quiseres e o NOOP guarda-o para a próxima vez." } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнений пока нет. Добавь первое ниже — можешь ввести любое название, NOOP запомнит его на следующий раз." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有动作。在下面添加第一个——名称随你怎么写,NOOP 会记住它,下次直接用。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有動作。在下面新增第一個——名稱隨你怎麼寫,NOOP 會記住它,下次直接用。" } } + } }, + "Add exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung hinzufügen" } }, "en": { "stringUnit": { "state": "translated", "value": "Add exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Añadir ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ajouter un exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Aggiungi esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dodaj ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Adicionar exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Добавить упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "添加动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "新增動作" } } + } }, + "Move up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nach oben" } }, "en": { "stringUnit": { "state": "translated", "value": "Move up" } }, "es": { "stringUnit": { "state": "translated", "value": "Subir" } }, "fr": { "stringUnit": { "state": "translated", "value": "Monter" } }, "it": { "stringUnit": { "state": "translated", "value": "Sposta su" } }, "pl": { "stringUnit": { "state": "translated", "value": "W górę" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mover para cima" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вверх" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "上移" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "上移" } } + } }, + "Move down": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nach unten" } }, "en": { "stringUnit": { "state": "translated", "value": "Move down" } }, "es": { "stringUnit": { "state": "translated", "value": "Bajar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Descendre" } }, "it": { "stringUnit": { "state": "translated", "value": "Sposta giù" } }, "pl": { "stringUnit": { "state": "translated", "value": "W dół" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mover para baixo" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вниз" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下移" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下移" } } + } }, + "Remove exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung entfernen" } }, "en": { "stringUnit": { "state": "translated", "value": "Remove exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Quitar ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Retirer l'exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Rimuovi esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usuń ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Remover exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Убрать упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移除動作" } } + } }, + "Delete program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm löschen" } }, "en": { "stringUnit": { "state": "translated", "value": "Delete program" } }, "es": { "stringUnit": { "state": "translated", "value": "Eliminar programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Supprimer le programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Elimina programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usuń program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Удалить программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除計畫" } } + } }, + "Delete this program?": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieses Programm löschen?" } }, "en": { "stringUnit": { "state": "translated", "value": "Delete this program?" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Eliminar este programa?" } }, "fr": { "stringUnit": { "state": "translated", "value": "Supprimer ce programme ?" } }, "it": { "stringUnit": { "state": "translated", "value": "Eliminare questo programma?" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usunąć ten program?" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar este programa?" } }, "ru": { "stringUnit": { "state": "translated", "value": "Удалить эту программу?" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除这个计划?" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除這個計畫?" } } + } }, + "Sessions you already logged from it are kept.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereits damit aufgezeichnete Sitzungen bleiben erhalten." } }, "en": { "stringUnit": { "state": "translated", "value": "Sessions you already logged from it are kept." } }, "es": { "stringUnit": { "state": "translated", "value": "Las sesiones que ya registraste con él se conservan." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séances déjà enregistrées avec lui sont conservées." } }, "it": { "stringUnit": { "state": "translated", "value": "Le sessioni già registrate con esso vengono mantenute." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisane już sesje pozostaną." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As sessões que já registaste com ele são mantidas." } }, "ru": { "stringUnit": { "state": "translated", "value": "Уже записанные по ней сессии сохранятся." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经用它记录的训练会保留。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已經用它記錄的訓練會保留。" } } + } }, + "Save program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save program" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer le programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存計畫" } } + } }, + "Exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung" } }, "en": { "stringUnit": { "state": "translated", "value": "Exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "動作" } } + } }, + "Used before": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Schon verwendet" } }, "en": { "stringUnit": { "state": "translated", "value": "Used before" } }, "es": { "stringUnit": { "state": "translated", "value": "Ya usados" } }, "fr": { "stringUnit": { "state": "translated", "value": "Déjà utilisés" } }, "it": { "stringUnit": { "state": "translated", "value": "Già usati" } }, "pl": { "stringUnit": { "state": "translated", "value": "Już używane" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Já usados" } }, "ru": { "stringUnit": { "state": "translated", "value": "Уже использованные" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "用过的" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "用過的" } } + } }, + "Muscles": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Muskeln" } }, "en": { "stringUnit": { "state": "translated", "value": "Muscles" } }, "es": { "stringUnit": { "state": "translated", "value": "Músculos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Muscles" } }, "it": { "stringUnit": { "state": "translated", "value": "Muscoli" } }, "pl": { "stringUnit": { "state": "translated", "value": "Mięśnie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Músculos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мышцы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "肌群" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "肌群" } } + } }, + "Primary": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Primär" } }, "en": { "stringUnit": { "state": "translated", "value": "Primary" } }, "es": { "stringUnit": { "state": "translated", "value": "Principal" } }, "fr": { "stringUnit": { "state": "translated", "value": "Principal" } }, "it": { "stringUnit": { "state": "translated", "value": "Primario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Główny" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Principal" } }, "ru": { "stringUnit": { "state": "translated", "value": "Основная" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "主要" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "主要" } } + } }, + "Not classified": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nicht zugeordnet" } }, "en": { "stringUnit": { "state": "translated", "value": "Not classified" } }, "es": { "stringUnit": { "state": "translated", "value": "Sin clasificar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Non classé" } }, "it": { "stringUnit": { "state": "translated", "value": "Non classificato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Bez przypisania" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Sem classificação" } }, "ru": { "stringUnit": { "state": "translated", "value": "Без категории" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未分类" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未分類" } } + } }, + "Primary muscle": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Primärer Muskel" } }, "en": { "stringUnit": { "state": "translated", "value": "Primary muscle" } }, "es": { "stringUnit": { "state": "translated", "value": "Músculo principal" } }, "fr": { "stringUnit": { "state": "translated", "value": "Muscle principal" } }, "it": { "stringUnit": { "state": "translated", "value": "Muscolo primario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Główny mięsień" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Músculo principal" } }, "ru": { "stringUnit": { "state": "translated", "value": "Основная мышца" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "主要肌群" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "主要肌群" } } + } }, + "Also works (counted as half a set)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trainiert außerdem (zählt als halber Satz)" } }, "en": { "stringUnit": { "state": "translated", "value": "Also works (counted as half a set)" } }, "es": { "stringUnit": { "state": "translated", "value": "También trabaja (cuenta como media serie)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Sollicite aussi (compté comme une demi-série)" } }, "it": { "stringUnit": { "state": "translated", "value": "Coinvolge anche (conta come mezza serie)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Angażuje też (liczy się jako pół serii)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Também trabalha (conta como meia série)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Также задействует (считается за полподхода)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同时练到(计为半组)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同時練到(計為半組)" } } + } }, + "Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Direkte Sätze zählen einfach, indirekte als halber Satz. Erst diese Trennung macht die wöchentlichen Zahlen pro Muskel aussagekräftig." } }, "en": { "stringUnit": { "state": "translated", "value": "Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything." } }, "es": { "stringUnit": { "state": "translated", "value": "Las series directas cuentan una vez; las indirectas, media. Esa distinción es lo que da sentido a las cifras semanales por músculo." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séries directes comptent pour une, les indirectes pour une demie. C'est cette distinction qui donne un sens aux chiffres hebdomadaires par muscle." } }, "it": { "stringUnit": { "state": "translated", "value": "Le serie dirette contano una volta, quelle indirette una metà. È questa distinzione a dare senso ai numeri settimanali per muscolo." } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie bezpośrednie liczą się raz, pośrednie jako pół. To właśnie ten podział nadaje sens tygodniowym liczbom dla każdego mięśnia." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As séries diretas contam uma vez, as indiretas contam meia. É essa distinção que dá sentido aos números semanais por músculo." } }, "ru": { "stringUnit": { "state": "translated", "value": "Прямые подходы считаются за один, косвенные — за половину. Именно это разделение делает недельные цифры по мышцам осмысленными." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "直接组计为一组,间接组计为半组。正是这个区分,才让每周的各肌群数字有意义。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "直接組計為一組,間接組計為半組。正是這個區分,才讓每週的各肌群數字有意義。" } } + } }, + "Targets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Ziele" } }, "en": { "stringUnit": { "state": "translated", "value": "Targets" } }, "es": { "stringUnit": { "state": "translated", "value": "Objetivos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Objectifs" } }, "it": { "stringUnit": { "state": "translated", "value": "Obiettivi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Cele" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Objetivos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Цели" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "目标" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "目標" } } + } }, + "Every target is optional — fill in what you actually plan against.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – trag ein, wonach du dich tatsächlich richtest." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — fill in what you actually plan against." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: rellena solo aquello con lo que de verdad planificas." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : remplis seulement ce sur quoi tu te bases vraiment." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: compila solo ciò su cui pianifichi davvero." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – wpisz to, według czego naprawdę planujesz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: preenche apenas aquilo com que planeias de facto." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — заполняй то, на что действительно ориентируешься." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——只填你真正会照着做的那些。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——只填你真正會照著做的那些。" } } + } }, + "Technique note": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Technik-Notiz" } }, "en": { "stringUnit": { "state": "translated", "value": "Technique note" } }, "es": { "stringUnit": { "state": "translated", "value": "Nota de técnica" } }, "fr": { "stringUnit": { "state": "translated", "value": "Note de technique" } }, "it": { "stringUnit": { "state": "translated", "value": "Nota sulla tecnica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Notatka o technice" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nota de técnica" } }, "ru": { "stringUnit": { "state": "translated", "value": "Заметка о технике" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "技术笔记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "技術筆記" } } + } }, "RHR +%lld": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Ruhepuls +%lld" } }, "en": { "stringUnit": { "state": "translated", "value": "RHR +%lld" } }, "es": { "stringUnit": { "state": "translated", "value": "FC en reposo +%lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "FC au repos +%lld" } }, "it": { "stringUnit": { "state": "translated", "value": "FC a riposo +%lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno spocz. +%lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "FC em repouso +%lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс в покое +%lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "静息心率 +%lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "靜息心率 +%lld" } } } }, diff --git a/Strand/Screens/KeyboardDismiss.swift b/Strand/Screens/KeyboardDismiss.swift new file mode 100644 index 0000000000..7307fd15bc --- /dev/null +++ b/Strand/Screens/KeyboardDismiss.swift @@ -0,0 +1,29 @@ +import SwiftUI + +// Tap outside a field to put the keyboard away. +// +// The Lift Log's entry fields are numeric, so the keyboard has no return key to dismiss with, and +// the only way out was the "Done" toolbar button — which meant the keyboard sat over half the +// 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. + +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 }) + // 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) + #else + // macOS has a hardware keyboard and no on-screen one to dismiss. + self + #endif + } +} diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift new file mode 100644 index 0000000000..ce72933b6f --- /dev/null +++ b/Strand/Screens/LiftLogView.swift @@ -0,0 +1,392 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// The Lift Log: build a program once, then run it in the gym by tapping through it. +// +// This screen is the front door — it lists the saved programs and (from a later phase) the sessions +// run from them. It lives in the Effort colour world, like Workouts, because a finished session +// lands in the `workout` table beside every other workout. +// +// EFFORT IS NEVER MODIFIED HERE (load-bearing). NOOP's Effort is computed from heart rate alone +// (Karvonen %HRR → Edwards TRIMP, `StrainScorer`), and there is no validated public path from typed +// sets/reps/weight to a cardiovascular-strain equivalent — WHOOP's own muscular load runs +// velocity-based algorithms over strap accelerometer/gyroscope data under an unpublished model. +// So the lifting figures are shown BESIDE Effort and never folded into it, matching the choice the +// imported-lifting path already made (`strain: nil, // never a fabricated cardiovascular strain`). + +struct LiftLogView: View { + @EnvironmentObject var repo: Repository + + /// Saved programs, most-recently-touched first. Loaded off the store on appear/refresh. + @State private var programs: [LiftProgramRow] = [] + @State private var loaded = false + + /// The program being created or edited (nil = the editor is closed). + @State private var editing: ProgramEditTarget? + @State private var importing = false + /// The live session, owned at the app root so it survives this screen going away. + @EnvironmentObject private var session: LiftSessionController + /// Recent finished sessions, newest first. + @State private var history: [LiftSessionRow] = [] + /// This week's fractional sets per muscle. + @State private var weekCounts: [LiftMuscle: Double] = [:] + /// The session whose detail sheet is open. + @State private var viewing: SessionDetailTarget? + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + var body: some View { + ScreenScaffold( + title: "Lift Log", + subtitle: "Build a program once, then tap through it at the gym. Kept on \(Platform.deviceNounPhrase).", + onRefresh: { await load() } + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + headerCard + programsSection + weekSection + historySection + } + } + // Also on a saved session: the session sheet lives above this screen and cannot tell it + // directly, and a save does not always bump `refreshSeq`. + .task(id: "\(repo.refreshSeq)-\(session.savedSessions)") { await load() } + .sheet(item: $editing) { target in + LiftProgramEditorSheet(program: target.program) { + await load() + } + } + .sheet(isPresented: $importing) { + LiftProgramImportSheet { await load() } + } + .sheet(item: $viewing) { target in + LiftSessionDetailSheet(session: target.session) { await load() } + } + } + + // MARK: - Header + + private var headerCard: some View { + NoopCard(tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + HStack(spacing: NoopMetrics.rowSpacing) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(StrandPalette.effortColor) + .frame(width: 30, height: 30) + .background(StrandPalette.effortColor.opacity(0.14), + in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text("Your log book") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Programs, sessions and per-set history") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + } + Text("Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // MARK: - Programs + + private var programsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Programs", overline: "Saved") + + if !loaded { + ComingSoon(what: "Reading your programs…", symbol: "dumbbell") + } else if programs.isEmpty { + emptyState + } else { + ForEach(programs, id: \.id) { program in + programRow(program) + } + } + + HStack(spacing: NoopMetrics.rowSpacing) { + Button { + editing = ProgramEditTarget(id: "new", program: nil) + } label: { + Label("New program", systemImage: "plus") + } + .buttonStyle(NoopButtonStyle(.secondary)) + + // Filling a dozen exercise lines by hand on a phone is the most tedious thing in the + // feature; a spreadsheet on a computer does it in a couple of minutes. + Button { + importing = true + } label: { + Label("Import", systemImage: "tablecells") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + } + + private var emptyState: some View { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + Text("No programs yet") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func programRow(_ program: LiftProgramRow) -> some View { + NoopCard { + HStack(spacing: NoopMetrics.gap) { + Button { + editing = ProgramEditTarget(id: program.id, program: program) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(program.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let note = program.note, !note.isEmpty { + Text(note) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(3) + } + Text("Tap to edit") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(session.isActive ? "Running" : "Start") { Task { await start(program) } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 110) + .accessibilityLabel("Start this program") + } + } + } + + // MARK: - Start a session + + /// Flatten a program into the plan the session runs. The plan is SNAPSHOT at start: editing or + /// deleting the program mid-session cannot change what is being tapped through. + private func start(_ program: LiftProgramRow) async { + guard let store = await repo.storeHandle() else { return } + let items = (try? await store.liftProgramItems(programId: program.id)) ?? [] + guard !items.isEmpty else { return } + let vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + + let plan = items.map { item -> LiftPlanItem in + // The classification comes from the exercise vocabulary, which is the one place that owns + // it — the program line deliberately stores no muscle of its own to drift from. + let known = vocabulary.first { $0.name == item.exercise } + return LiftPlanItem(exercise: item.exercise, + primaryMuscle: known?.primaryMuscle, + secondaryMuscles: known?.secondaryMuscles ?? [], + targetSets: item.targetSets, + restSec: item.restSec, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, + note: item.note, + // Carried so a set added or dropped mid-session can be written back + // onto the line it came from, and be there next time. + programItemId: item.id) + } + // Refuse to start a second session over a running one: two live sessions would both claim + // the strap gesture and both write the in-flight snapshot. + guard !session.isActive else { + session.isPresented = true + return + } + session.start(plan: plan, programId: program.id, programName: program.name) + } + + // MARK: - This week, per muscle + + private var weekSection: some View { + let ordered = LiftMuscle.ordered.filter { (weekCounts[$0] ?? 0) > 0 } + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sets per muscle", overline: "Last 7 days · estimated") + if ordered.isEmpty { + NoopCard { + Text("Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + ForEach(ordered, id: \.self) { muscle in + muscleBar(muscle, sets: weekCounts[muscle] ?? 0) + } + // The band is named and sourced, never phrased as a target NOOP sets for + // anyone: this is not a medical device and does not prescribe. + Text("Counted from the muscles you assigned each exercise: direct sets count once, indirect ones half. The tick is about 4 sets a week — below that, studies across GROUPS of people stop reliably detecting growth. It is a research reference, not a target for you, and above it gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + } + } + } + } + + /// The span the weekly bar is drawn across. + /// + /// A DRAWING choice, not a dose. The evidence puts a floor at about 4 sets a week and identifies + /// NO ceiling for hypertrophy — gains continue above it with strongly diminishing returns — so + /// any bar maximum is arbitrary and must never be read as a target. 20 is chosen only because it + /// comfortably contains the range people actually train in, which puts the floor tick early on + /// the bar and makes a normal week read as progress rather than as "finished". + /// + /// The NUMBER beside the bar is the truth. The bar is context for it, and a count past 20 fills + /// the bar while the number keeps counting. + private static let weeklySetsBarSpan = 20.0 + + /// One muscle's week: the count, and where it sits relative to the evidence. + /// + /// This used to scale the bar 0...4 and turn it FULL and GREEN at four sets — so the screen said + /// "done" at the exact point the research says growth merely becomes *detectable*. It was telling + /// the user to stop at the starting line, and it contradicted the caption printed directly below + /// it. Now four sets is a TICK a fifth of the way along, and nothing on the bar ever reads as + /// complete, because nothing about the dose is. + private func muscleBar(_ muscle: LiftMuscle, sets: Double) -> some View { + let floor = LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek + let atOrAboveFloor = sets >= floor + let fill = min(1.0, sets / Self.weeklySetsBarSpan) + let tick = min(1.0, floor / Self.weeklySetsBarSpan) + + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(muscle.displayName) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + // Deliberately NOT a success colour. There is no success point to signal, and a + // green number is exactly what made four sets read as an achievement. + Text(LiftFormat.trim(sets)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(StrandPalette.surfaceRaised) + Capsule() + // Muted below the floor — below it growth is not reliably detectable, which + // is worth showing — and the ordinary accent above it. Never a "done" colour. + .fill(StrandPalette.effortColor.opacity(atOrAboveFloor ? 1.0 : 0.45)) + .frame(width: max(2, geo.size.width * fill)) + // The floor, marked where it actually falls. + Capsule() + .fill(StrandPalette.textPrimary.opacity(0.45)) + .frame(width: 2) + .offset(x: max(0, geo.size.width * tick - 1)) + .accessibilityHidden(true) + } + } + .frame(height: 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(atOrAboveFloor + ? String(localized: "\(muscle.displayName): \(LiftFormat.trim(sets)) sets, at or above the weekly floor of \(LiftFormat.trim(floor))") + : String(localized: "\(muscle.displayName): \(LiftFormat.trim(sets)) sets, below the weekly floor of \(LiftFormat.trim(floor))")) + } + } + + // MARK: - History + + private var historySection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sessions", overline: "Recent") + if history.isEmpty { + NoopCard { + Text("Finished sessions land here, with every set you logged.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + } else { + ForEach(history, id: \.id) { session in + Button { + viewing = SessionDetailTarget(id: session.id, session: session) + } label: { + historyRow(session) + } + .buttonStyle(.plain) + } + } + } + } + + private func historyRow(_ session: LiftSessionRow) -> some View { + NoopCard { + HStack(spacing: NoopMetrics.gap) { + VStack(alignment: .leading, spacing: 3) { + Text(session.programName ?? String(localized: "Session")) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(Date(timeIntervalSince1970: TimeInterval(session.startTs)) + .formatted(date: .abbreviated, time: .shortened)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .accessibilityHidden(true) + } + } + } + + // MARK: - Load + + private func load() async { + guard let store = await repo.storeHandle() else { return } + programs = (try? await store.liftPrograms(deviceId: repo.deviceId)) ?? [] + + let now = Int(Date().timeIntervalSince1970) + history = ((try? await store.liftSessions(deviceId: repo.deviceId, + fromTs: now - 180 * 86_400, + toTs: now)) ?? []) + .filter { $0.endTs != nil } // an abandoned session is not history + .sorted { $0.startTs > $1.startTs } + weekCounts = (try? await store.liftSetCounts(deviceId: repo.deviceId, + fromTs: now - 7 * 86_400, + toTs: now).fractional) ?? [:] + loaded = true + } +} + +/// The session whose detail is being read back. A wrapper rather than a retroactive `Identifiable` +/// on `LiftSessionRow`, keeping the store's row types free of app-layer conformances. +private struct SessionDetailTarget: Identifiable { + let id: String + let session: LiftSessionRow +} + + +/// Identifies what the editor sheet is editing. A wrapper rather than a retroactive `Identifiable` +/// on `LiftProgramRow`, so the store's row types stay free of app-layer conformances — and so +/// "new program" has an identity of its own to present on. +private struct ProgramEditTarget: Identifiable { + let id: String + let program: LiftProgramRow? +} diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift new file mode 100644 index 0000000000..4b9bf5fe1c --- /dev/null +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -0,0 +1,343 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Build or edit a program: a name and an ordered list of exercise lines carrying the TARGETS — +// working sets, reps, weight, rest and the user's own technique note. +// +// Lines are edited as local drafts and written in one go on Save, through +// `replaceLiftProgramItems`, which swaps the whole list transactionally. Editing a program never +// rewrites history: a session snapshots the program's NAME when it runs, so renaming "Upper A" or +// deleting it entirely leaves every past session reading exactly as it did. + +struct LiftProgramEditorSheet: View { + /// The program being edited, or nil to create a new one. + let program: LiftProgramRow? + /// Called after a successful save or delete, so the caller can reload. + let onSaved: () async -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var note: String = "" + /// The exercise lines, in display order. `ord` is assigned from the array index on save, so + /// reordering is just moving an element. + @State private var items: [LiftProgramItemRow] = [] + @State private var loaded = false + @State private var saving = false + + /// The line being added or edited (nil = that sheet is closed). + @State private var editingItem: ItemEditTarget? + @State private var confirmingDelete = false + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + @FocusState private var focused: Field? + private enum Field: Hashable { case name, note } + + private var isNew: Bool { program == nil } + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !saving + } + + var body: some View { + ScreenScaffold( + title: isNew ? "New program" : "Edit program", + subtitle: "Your targets for each exercise. What you actually lift is recorded when you run it." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + detailsSection + exercisesSection + if !isNew { deleteSection } + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + // A fixed frame, for the reason the other editor sheets document: a macOS sheet hosting a + // ScrollView needs a definite height or every row collapses to the top. + .frame(width: 520, height: 720) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { await loadIfNeeded() } + .sheet(item: $editingItem) { target in + LiftProgramItemSheet(item: target.item) { saved in + apply(saved, replacing: target.item) + } + } + } + + // MARK: - Name + note + + private var detailsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Program", overline: "Details") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + field("Name") { + TextField("Upper A", text: $name) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .name) + } + field("Note (optional)") { + TextField("Anything you want to remember", text: $note) + // Capped at what the hub can actually show (two caption lines). A field + // that silently discards the end is worse than one that stops. + .onChange(of: note) { new in + if new.count > WhoopStore.maxProgramNoteLength { + note = String(new.prefix(WhoopStore.maxProgramNoteLength)) + } + } + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .note) + } + } + } + } + } + + // MARK: - Exercise lines + + private var exercisesSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercises", overline: "In order") + + if items.isEmpty { + NoopCard { + Text("No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + itemRow(item, index: index) + } + } + + Button { + editingItem = ItemEditTarget(id: "new", item: nil) + } label: { + Label("Add exercise", systemImage: "plus") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + private func itemRow(_ item: LiftProgramItemRow, index: Int) -> some View { + NoopCard { + HStack(alignment: .top, spacing: NoopMetrics.gap) { + Button { + editingItem = ItemEditTarget(id: item.id, item: item) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(item.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(targetSummary(item)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + if let note = item.note, !note.isEmpty { + Text(note) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + VStack(spacing: NoopMetrics.rowSpacing) { + Button { + move(from: index, by: -1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(index == 0) + .accessibilityLabel("Move up") + + Button { + move(from: index, by: 1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(index == items.count - 1) + .accessibilityLabel("Move down") + + Button(role: .destructive) { + items.removeAll { $0.id == item.id } + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("Remove exercise") + } + .font(.system(size: 12, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + + /// "4 × 8 · 60 kg · 2:00 rest" — only the parts that were actually filled in. + private func targetSummary(_ item: LiftProgramItemRow) -> String { + var parts: [String] = [] + if let sets = item.targetSets { + if let reps = item.targetRepsLow { + parts.append("\(sets) × \(reps)") + } else { + parts.append(String(localized: "\(sets) sets")) + } + } + if let kg = item.targetWeightKg { + parts.append(LiftFormat.weight(kg, system: unitSystem)) + } + if let rest = item.restSec { + parts.append(String(localized: "\(LiftFormat.duration(rest)) rest")) + } + return parts.isEmpty ? String(localized: "No targets set") : parts.joined(separator: " · ") + } + + // MARK: - Delete + + private var deleteSection: some View { + VStack(alignment: .leading, spacing: 8) { + Button(role: .destructive) { + confirmingDelete = true + } label: { + Label("Delete program", systemImage: "trash") + } + .buttonStyle(NoopButtonStyle(.secondary)) + .confirmationDialog("Delete this program?", + isPresented: $confirmingDelete, + titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await deleteProgram() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("Sessions you already logged from it are kept.") + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + .disabled(!canSave) + .opacity(canSave ? 1 : NoopButtonMetrics.disabledOpacity) + .accessibilityLabel("Save program") + } + } + + // MARK: - Helpers + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func move(from index: Int, by offset: Int) { + let target = index + offset + guard items.indices.contains(index), items.indices.contains(target) else { return } + items.swapAt(index, target) + } + + /// Insert a new line, or replace an edited one in place so its position is kept. + private func apply(_ saved: LiftProgramItemRow, replacing old: LiftProgramItemRow?) { + guard let old, let index = items.firstIndex(where: { $0.id == old.id }) else { + items.append(saved) + return + } + items[index] = saved + } + + // MARK: - Load / save + + private func loadIfNeeded() async { + guard !loaded else { return } + loaded = true + guard let program else { return } + name = program.name + note = program.note ?? "" + guard let store = await repo.storeHandle() else { return } + items = (try? await store.liftProgramItems(programId: program.id)) ?? [] + } + + private func save() async { + guard canSave, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + let now = Int(Date().timeIntervalSince1970) + let id = program?.id ?? UUID().uuidString + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + + let row = LiftProgramRow( + id: id, + deviceId: repo.deviceId, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + note: trimmedNote.isEmpty ? nil : trimmedNote, + createdAt: program?.createdAt ?? now, + updatedAt: now, + archived: program?.archived ?? false + ) + _ = try? await store.upsertLiftPrograms([row]) + + // `ord` is the array index: reordering the list is all it takes to reorder the program. + let ordered = items.enumerated().map { index, item in + LiftProgramItemRow( + id: item.id, + deviceId: repo.deviceId, + programId: id, + ord: index, + exercise: item.exercise, + targetSets: item.targetSets, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, + restSec: item.restSec, + note: item.note + ) + } + _ = try? await store.replaceLiftProgramItems(programId: id, items: ordered) + + await onSaved() + dismiss() + } + + private func deleteProgram() async { + guard let program, let store = await repo.storeHandle() else { return } + _ = try? await store.deleteLiftProgram(id: program.id) + await onSaved() + dismiss() + } +} + +/// What the line editor is editing — a wrapper so "new line" has an identity to present on. +private struct ItemEditTarget: Identifiable { + let id: String + let item: LiftProgramItemRow? +} diff --git a/Strand/Screens/LiftProgramImportSheet.swift b/Strand/Screens/LiftProgramImportSheet.swift new file mode 100644 index 0000000000..1ed61a097e --- /dev/null +++ b/Strand/Screens/LiftProgramImportSheet.swift @@ -0,0 +1,268 @@ +import SwiftUI +import UniformTypeIdentifiers +import StrandDesign +import StrandImport +import WhoopStore + +// Build a program from a spreadsheet filled in on a computer. +// +// WHY. A program is a name plus an ordered list of exercise lines, each carrying an exercise, a +// muscle classification, sets, reps, a weight, a rest period and a technique note. Typing that for a +// dozen exercises on a phone is the most tedious thing in the feature, and it is exactly the work a +// keyboard and a spreadsheet are good at. `docs/lift-log-program-template.xlsx` is the sheet to fill. +// +// NOTHING IS WRITTEN UNTIL THE USER SAYS SO. The file is parsed, then shown back as what it WILL +// create — programs, their exercises, and every warning — and only then imported. A file picked by +// mistake, or a sheet with the wrong columns, costs a glance rather than a mess to undo. +struct LiftProgramImportSheet: View { + /// Called after programs have been written, so the hub can reload. + var onImported: () async -> Void + + @EnvironmentObject private var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var picking = false + @State private var parsed: LiftProgramImportResult? + @State private var failure: String? + @State private var importing = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + if let parsed { + preview(parsed) + } else { + intro + } + if let failure { + NoopCard { + Text(failure) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusCritical) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase) + .navigationTitle("Import a program") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if let parsed, !parsed.programs.isEmpty { + Button("Import") { Task { await performImport(parsed) } } + .disabled(importing) + } + } + } + } + .fileImporter(isPresented: $picking, + allowedContentTypes: Self.acceptedTypes, + allowsMultipleSelection: false) { result in + handle(result) + } + } + + /// What the picker will accept. `.spreadsheet` covers .xlsx and .numbers; CSV and plain text are + /// listed separately because a file exported from a spreadsheet often arrives typed as text. + private static let acceptedTypes: [UTType] = { + var types: [UTType] = [.spreadsheet, .commaSeparatedText, .plainText, .data] + if let xlsx = UTType(filenameExtension: "xlsx") { types.insert(xlsx, at: 0) } + return types + }() + + private var intro: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + Text("Fill it in on a computer") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Text("Only the exercise name is required. Anything you leave blank can be filled in later, or during the session.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Button { + failure = nil + picking = true + } label: { + Label("Choose a file", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) + } + } + + private func preview(_ result: LiftProgramImportResult) -> some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + ForEach(Array(result.programs.enumerated()), id: \.offset) { _, program in + NoopCard { + VStack(alignment: .leading, spacing: 6) { + Text(program.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("\(program.lines.count) exercises") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + ForEach(Array(program.lines.enumerated()), id: \.offset) { _, line in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(line.exercise) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 8) + Text(summary(line)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + } + } + + if !result.warnings.isEmpty { + NoopCard { + VStack(alignment: .leading, spacing: 6) { + // No count in the heading: the warnings are listed directly beneath it, so + // the number adds nothing — and it dodges plural agreement in ten languages. + Text("Worth checking") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.metricAmber) + // Shown in full rather than summarised: each one names a row the user can go + // and fix, and a count alone would send them hunting. + ForEach(Array(result.warnings.enumerated()), id: \.offset) { _, w in + Text(w) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Text("These lines still import — anything unclassified can be set in the app.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + Button { + failure = nil + parsed = nil + picking = true + } label: { + Label("Choose a different file", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + /// "3 x 10 · 50 kg · 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 rest = line.restSec { parts.append("\(rest)s") } + return parts.joined(separator: " · ") + } + + private func handle(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + failure = error.localizedDescription + case .success(let urls): + guard let url = urls.first else { return } + // A file picked from Files/iCloud is security-scoped; without this the read fails with a + // permissions error that looks like a corrupt file. + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + // Check the size BEFORE reading. `Data(contentsOf:)` on a picked file would otherwise + // pull the whole thing into memory first, which is exactly what the limit exists to + // prevent — and a phone is where that matters. + let bytes = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + guard bytes <= LiftProgramSheetImporter.maxFileBytes else { + parsed = nil + failure = message(for: .tooLarge) + return + } + let data = try Data(contentsOf: url) + parsed = try LiftProgramSheetImporter.parse(data: data) + failure = nil + } catch let error as LiftProgramSheetImporter.ImportError { + parsed = nil + failure = message(for: error) + } catch { + parsed = nil + failure = String(localized: "That file could not be read.") + } + } + } + + private func message(for error: LiftProgramSheetImporter.ImportError) -> String { + switch error { + case .unreadable: + return String(localized: "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv.") + case .missingColumns: + return String(localized: "That sheet has no Exercise column. Use the template — the import matches on the header names.") + case .empty: + return String(localized: "That sheet has no exercises in it yet.") + case .tooLarge: + return String(localized: "That file is too big to be a program sheet.") + } + } + + private func performImport(_ result: LiftProgramImportResult) async { + guard !importing, let store = await repo.storeHandle() else { return } + importing = true + defer { importing = false } + + let now = Int(Date().timeIntervalSince1970) + for program in result.programs { + let programId = UUID().uuidString + _ = try? await store.upsertLiftPrograms([LiftProgramRow( + id: programId, deviceId: repo.deviceId, name: program.name, note: program.note, + createdAt: now, updatedAt: now, archived: false)]) + + let items = program.lines.enumerated().map { index, line in + LiftProgramItemRow( + id: UUID().uuidString, deviceId: repo.deviceId, programId: programId, + ord: index, exercise: line.exercise, + 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, + targetWeightKg: line.targetWeightKg, + restSec: line.restSec, note: line.note) + } + _ = try? await store.replaceLiftProgramItems(programId: programId, items: items) + + // Remember the exercises too, with the classification the sheet gave them, so the + // picker offers them next time and the per-muscle rollup resolves the same name the + // same way. Best-effort: the vocabulary is capped, and hitting the cap must not fail + // an import of the programs themselves. + let exercises = program.lines.map { line in + LiftExerciseRow( + id: UUID().uuidString, deviceId: repo.deviceId, name: line.exercise, + primaryMuscle: line.primaryMuscle, + secondaryMuscles: line.secondaryMuscles, + createdAt: now, lastUsedTs: nil) + } + _ = try? await store.upsertLiftExercises(exercises) + } + + await onImported() + dismiss() + } +} diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift new file mode 100644 index 0000000000..d30ae1cbe9 --- /dev/null +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -0,0 +1,458 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Edit ONE exercise line of a program: which exercise, and the targets for it. +// +// NOOP SHIPS NO EXERCISE CATALOGUE — deliberately. The user types whatever they call the movement +// and it is remembered in `liftExercise` with the muscle group they gave it, then offered back next +// time. A shipped mapping of common exercises to muscles would be both a permanent maintenance +// burden and a correctness claim NOOP has no business making about someone else's technique. +// +// Classification is therefore a one-time, few-second action per exercise: pick the primary muscle +// (a direct set) and any secondaries (indirect, counted at half). It is asked once, on first use, +// and remembered thereafter. 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 LiftProgramItemSheet: View { + /// The line being edited, or nil to add a new one. + let item: LiftProgramItemRow? + /// Handed the finished line. The parent owns ordering and persistence. + let onSave: (LiftProgramItemRow) -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var exercise: String = "" + @State private var primary: LiftMuscle? + @State private var secondaries: Set = [] + + @State private var setsText: String = "" + @State private var repsText: String = "" + @State private var weightText: String = "" + @State private var restText: String = "" + @State private var note: String = "" + + /// The user's own exercise vocabulary, for suggestions and for adopting a known classification. + @State private var vocabulary: [LiftExerciseRow] = [] + @State private var loaded = false + /// The vocabulary entry the user is about to forget (nil = no confirmation showing). + @State private var forgetting: LiftExerciseRow? + /// Set when the vocabulary is full, so the refusal is explained rather than silent. + @State private var vocabularyFullLimit: Int? + + /// The app's existing metric/imperial preference — the Lift Log never adds a second weight unit + /// setting of its own, so the plan is typed in the same unit the session records in. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + private var weightLabel: LocalizedStringKey { + unitSystem == .imperial ? "Weight (lb)" : "Weight (kg)" + } + + @FocusState private var focused: Field? + private enum Field: Hashable { case exercise, sets, reps, weight, rest, note } + + private var trimmedExercise: String { + exercise.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canSave: Bool { !trimmedExercise.isEmpty } + + /// 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 } + } + + var body: some View { + ScreenScaffold( + title: item == nil ? "Add exercise" : "Edit exercise", + subtitle: "Type any name you like. NOOP remembers it, with the muscles you give it." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + exerciseSection + muscleSection + targetsSection + noteSection + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 520, height: 720) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { await loadIfNeeded() } + .confirmationDialog( + forgetting.map { Text(String(localized: "Forget \($0.name)?")) } ?? Text(""), + isPresented: Binding(get: { forgetting != nil }, + set: { if !$0 { forgetting = nil } }), + titleVisibility: .visible + ) { + Button("Forget", role: .destructive) { Task { await forget() } } + Button("Cancel", role: .cancel) { forgetting = nil } + } message: { + Text("It stops being offered here. Sessions you already logged with it are kept exactly as they are.") + } + .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.") + } + } + + /// Forget an exercise. The logged sets keep their own copy of the name and muscles, so this + /// removes it from the picker WITHOUT touching a single recorded session. + private func forget() async { + guard let row = forgetting, let store = await repo.storeHandle() else { return } + _ = try? await store.deleteLiftExercise(id: row.id) + vocabulary.removeAll { $0.id == row.id } + forgetting = nil + } + + // MARK: - Exercise name + suggestions + + private var exerciseSection: some View { + 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 + HStack(spacing: 8) { + 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()) + } + .buttonStyle(.plain) + + // A typo becomes a permanent picker entry otherwise. Forgetting a + // name is safe by construction: every logged set SNAPSHOTS its + // exercise name and classification, so history is untouched. + Button(role: .destructive) { + forgetting = row + } label: { + Image(systemName: "trash") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Forget this exercise") + } + } + } + } + } + } + } + } + + // 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 { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Targets", overline: "What you're aiming for") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: NoopMetrics.gap) { + field("Working sets") { + numberInput("4", text: $setsText, field: .sets) + } + field("Reps") { + numberInput("8", text: $repsText, field: .reps) + } + } + HStack(spacing: NoopMetrics.gap) { + field(weightLabel) { + numberInput("60", text: $weightText, field: .weight) + } + field("Rest (seconds)") { + 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. + 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) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private var noteSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Technique note", overline: "In your words") + NoopCard { + TextField("Slow eccentric, pause at the bottom", text: $note, axis: .vertical) + // A cue read between sets, and it renders directly above the set rows — every + // line pushes them down the screen. + .onChange(of: note) { new in + if new.count > WhoopStore.maxExerciseNoteLength { + note = String(new.prefix(WhoopStore.maxExerciseNoteLength)) + } + } + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1...4) + .focused($focused, equals: .note) + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + .disabled(!canSave) + .opacity(canSave ? 1 : NoopButtonMetrics.disabledOpacity) + .accessibilityLabel("Save exercise") + } + } + + // MARK: - Field helpers (the house form idiom) + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func numberInput(_ placeholder: LocalizedStringKey, + text: Binding, + field: Field) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + } + + // MARK: - Behaviour + + /// Take a known exercise from the vocabulary, including the classification it already carries — + /// so a movement is classified once and never asked about again. + private func adopt(_ row: LiftExerciseRow) { + exercise = row.name + primary = row.primaryMuscle + secondaries = Set(row.secondaryMuscles) + 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 + if let item { + exercise = item.exercise + setsText = item.targetSets.map(String.init) ?? "" + repsText = item.targetRepsLow.map(String.init) ?? "" + weightText = item.targetWeightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) + } ?? "" + restText = item.restSec.map(String.init) ?? "" + note = item.note ?? "" + } + guard let store = await repo.storeHandle() else { return } + vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + // An existing line adopts whatever classification its exercise already carries, so editing a + // line shows the muscles the exercise is known by rather than an empty picker. + if let item, let known = vocabulary.first(where: { $0.name == item.exercise }) { + primary = known.primaryMuscle + secondaries = Set(known.secondaryMuscles) + } + } + + private func save() async { + guard canSave else { return } + 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. + 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]) + } catch let full as WhoopStore.LiftExerciseVocabularyFull { + // Refused rather than silently dropped: the user typed a name and deserves to know + // it was not remembered. + vocabularyFullLimit = full.limit + return + } catch { + return + } + } + + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(LiftProgramItemRow( + id: item?.id ?? UUID().uuidString, + deviceId: repo.deviceId, + // Assigned properly by the parent on save; a placeholder here would be a second source + // of truth for ordering. + programId: item?.programId ?? "", + 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. + targetRepsLow: Int(repsText.trimmingCharacters(in: .whitespaces)), + targetRepsHigh: nil, + targetRpe: nil, + targetWeightKg: LiftFormat.number(weightText).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + }, + restSec: Int(restText.trimmingCharacters(in: .whitespaces)), + note: trimmedNote.isEmpty ? nil : trimmedNote + )) + 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 new file mode 100644 index 0000000000..9299882f3c --- /dev/null +++ b/Strand/Screens/LiftSessionBar.swift @@ -0,0 +1,125 @@ +import SwiftUI +import StrandDesign + +// The running session, condensed to a bar that sits above the tab bar wherever you are in the app. +// +// WHY IT EXISTS. Swiping the workout sheet down used to dismiss the session outright: the screen +// went away and took the strap handler and the tick with it, so taps and buzzes silently stopped +// working. Now swiping down MINIMISES to this bar. The session is still running — same clock, same +// strap gesture, same buzzes — and tapping the bar brings the full sheet back. +// +// It is deliberately a bar and not a badge: it has to show the one thing you need mid-workout +// without opening anything, which is how long is left of your rest. +// +// COLOUR MATCHES THE SHEET so the two read as one thing: green while working, amber while resting. + +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 { + Button { + session.isPresented = true + } label: { + HStack(spacing: NoopMetrics.gap) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint(engine)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(title(engine)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(subtitle(engine)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + // 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) + .monospacedDigit() + } + .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. + Button { session.advance() } label: { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(tint(engine)) + } + .buttonStyle(.plain) + .accessibilityLabel("Next") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(tint(engine).opacity(0.35), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Open the running session") + } + } + + private func tint(_ engine: LiftSessionEngine) -> Color { + switch engine.stage { + case .working: return StrandPalette.statusPositive + case .resting: return StrandPalette.metricAmber + default: return StrandPalette.effortColor + } + } + + + /// 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)) + } +} diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift new file mode 100644 index 0000000000..d7a9ef26ee --- /dev/null +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -0,0 +1,446 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// One finished session, read back in full: every set as performed, the session figures, and how +// each exercise compares with the last time you did it. +// +// This is the screen the whole feature exists to produce. A log book that cannot show you what you +// lifted last week is a diary. +// +// EVERY FIGURE IS ARITHMETIC THE USER CAN REDO BY HAND from the sets listed on the same screen — +// that is the design constraint, and it is why there is no single composite "workout score". The +// maths lives in `LiftMetrics` (pure, unit-tested); this file only lays it out. +// +// Effort is shown BESIDE the lifting figures and is never computed from them: it is whatever NOOP +// measured from heart rate over the session's window, filled in by the engine's own rescore pass. + +struct LiftSessionDetailSheet: View { + let session: LiftSessionRow + /// Called after the session is edited or deleted, so the hub can reload its list. + var onChanged: () async -> Void = {} + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var sets: [LiftSetRow] = [] + /// The `workout` row this session is pinned to, for the HR-measured figures. + @State private var workout: WorkoutRow? + /// Previous performance per exercise, for the "vs last time" comparison. + @State private var previousVolume: [String: Double] = [:] + @State private var loaded = false + @State private var confirmingDelete = false + @State private var deleting = false + @State private var editing = false + /// Session RPE as stored now. The edit sheet can correct it, and the session load must follow. + @State private var sessionRpe: Double? + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + private var durationSec: Int { + guard let end = session.endTs else { return 0 } + return max(0, end - session.startTs) + } + + var body: some View { + ScreenScaffold(title: "Session", subtitle: subtitle) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + if !loaded { + ComingSoon(what: "Reading the session…", symbol: "dumbbell") + } else { + figuresSection + exercisesSection + muscleSection + rpeSection + footnote + NoopButton("Edit sets", systemImage: "pencil", kind: .secondary) { editing = true } + deleteSection + } + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 780) + #endif + .background(StrandPalette.surfaceBase) + .task { await load() } + .sheet(isPresented: $editing) { + LiftSessionEditSheet(session: storedSession, sets: sets) { + await load() + await onChanged() + } + } + } + + /// The session as stored now, with any corrected RPE. + private var storedSession: LiftSessionRow { + var row = session + row.sessionRpe = sessionRpe + return row + } + + /// Remove a session that should not have been recorded — a mis-tap, or a test. + /// + /// Deletes the paired `workout` row TOO. A lift session writes one so the training lands in + /// Workouts and Today like any other, and the analytics engine fills its strain from the heart + /// rate measured over that window. Leaving it behind would keep the day's Effort inflated by a + /// session the user just said did not happen — which is worse than not being able to delete at + /// all, because it would look like the delete worked. + private var deleteSection: some View { + Button(role: .destructive) { + confirmingDelete = true + } label: { + Label("Delete session", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.statusCritical) + .padding(.top, NoopMetrics.gap) + .disabled(deleting) + .confirmationDialog("Delete this session?", + isPresented: $confirmingDelete, titleVisibility: .visible) { + 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.") + } + } + + private func deleteSession() async { + guard !deleting, let store = await repo.storeHandle() else { return } + deleting = true + defer { deleting = false } + + _ = try? await store.deleteLiftSession(id: session.id) // cascades to its sets + if let workout { await repo.deleteWorkout(workout) } + + await onChanged() + dismiss() + } + + private var subtitle: LocalizedStringKey { + let date = Date(timeIntervalSince1970: TimeInterval(session.startTs)) + .formatted(date: .abbreviated, time: .shortened) + if let name = session.programName, !name.isEmpty { + return "\(name) · \(date)" + } + return "\(date)" + } + + // MARK: - The session figures + + private var figuresSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("This session", overline: "Figures") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: NoopMetrics.rowSpacing)], + alignment: .leading, spacing: NoopMetrics.rowSpacing) { + // Weight x reps, summed. Exact, but only meaningful against the SAME program run + // again: 100 kg of leg press is not 100 kg of squat, so the total across different + // 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), + String(localized: "\(workingSetCount) working sets · compare when you repeat this program")) + + tile(String(localized: "Session load"), + sessionLoadText, + sessionLoadCaption) + + tile(String(localized: "Effort"), + workout?.strain.map { LiftFormat.trim($0) } ?? "—", + String(localized: "measured from heart rate")) + } + } + } + + private var workingSetCount: Int { sets.filter { !$0.isWarmup }.count } + + private var sessionLoadText: String { + guard let load = LiftMetrics.sessionLoad(sessionRpe: sessionRpe, + durationSec: durationSec) else { return "—" } + return String(Int(load.rounded())) + } + + /// The caption must show the SAME minutes the load was computed from. + /// + /// Showing whole minutes while computing from exact seconds silently breaks the one promise this + /// screen makes: that every figure is arithmetic you can redo by hand. A 2:40 session captioned + /// "× 2 min" invites the reader to check 8 × 2 = 16 against a displayed 21 and conclude the app + /// is making numbers up. + private var sessionLoadCaption: String { + guard let rpe = sessionRpe else { + return String(localized: "not rated") + } + let minutes = Double(durationSec) / 60.0 + return String(localized: "RPE \(LiftFormat.trim(rpe)) × \(LiftFormat.trim(minutes)) min") + } + + private func tile(_ label: String, _ value: String, _ caption: String) -> some View { + NoopCard(padding: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(label).strandOverline() + Text(value) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(caption) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Per exercise, with every set + + private var exercisesSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercises", overline: "As performed") + ForEach(LiftMetrics.perExercise(sets), 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 } + return NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Text(summary.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let first = rows.first { + Text(LiftMuscleSummary.line(primary: first.primaryMuscle, + secondaries: first.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + + ForEach(rows, id: \.id) { row in setLine(row) } + + Divider().background(StrandPalette.textTertiary.opacity(0.2)) + + HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.gap) { + VStack(alignment: .leading, spacing: 2) { + Text("Best set").strandOverline() + Text(bestSetText(summary)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + if let e1rm = summary.bestEstimatedOneRepMaxKg { + // "Estimated" is in the label, not a footnote: it is a formula off one + // set, not a measured maximum, and the word has to travel with it. + Text(String(localized: "≈ \(LiftFormat.weight(e1rm, system: unitSystem)) estimated 1RM")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 2) { + Text("Volume").strandOverline() + Text(LiftFormat.weight(summary.volumeKg, system: unitSystem)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + if let delta = volumeDeltaText(summary) { + Text(delta) + .font(StrandFont.caption) + .foregroundStyle(deltaColor(summary)) + } + } + } + } + } + } + + private func setLine(_ row: LiftSetRow) -> some View { + HStack(spacing: NoopMetrics.rowSpacing) { + Text(row.isWarmup ? String(localized: "W") : "\(row.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(row.isWarmup ? StrandPalette.textTertiary : StrandPalette.effortColor) + .frame(width: 18, alignment: .leading) + + Text(setValueText(row)) + .font(StrandFont.bodyNumber) + .foregroundStyle(row.isWarmup ? StrandPalette.textSecondary : StrandPalette.textPrimary) + + Spacer(minLength: 0) + + if let rpe = row.rpe { + Text(String(localized: "RPE \(LiftFormat.trim(rpe))")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + if let rest = row.restSec { + Text(LiftFormat.duration(rest)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + + private func setValueText(_ row: LiftSetRow) -> String { + let reps = row.reps.map(String.init) ?? "—" + guard let kg = row.weightKg else { + // Bodyweight work: reps alone, with no fabricated tonnage behind it. + return String(localized: "\(reps) reps") + } + return "\(LiftFormat.weight(kg, system: unitSystem)) × \(reps)" + } + + private func bestSetText(_ summary: LiftMetrics.ExerciseSummary) -> String { + guard let reps = summary.bestReps else { return "—" } + guard let kg = summary.bestWeightKg else { return String(localized: "\(reps) reps") } + return "\(LiftFormat.weight(kg, system: unitSystem)) × \(reps)" + } + + /// How this exercise's volume compares with the last session that included it. The single most + /// useful line in the screen: progression is a comparison, not a number. + private func volumeDeltaText(_ summary: LiftMetrics.ExerciseSummary) -> String? { + guard let now = summary.volumeKg, let before = previousVolume[summary.exercise], before > 0 + else { return nil } + let delta = now - before + guard abs(delta) >= 0.5 else { return String(localized: "same as last time") } + let sign = delta > 0 ? "+" : "−" + return "\(sign)\(LiftFormat.weight(abs(delta), system: unitSystem)) vs last time" + } + + private func deltaColor(_ summary: LiftMetrics.ExerciseSummary) -> Color { + guard let now = summary.volumeKg, let before = previousVolume[summary.exercise], before > 0 + else { return StrandPalette.textTertiary } + if now > before { return StrandPalette.statusPositive } + if now < before { return StrandPalette.textSecondary } + return StrandPalette.textTertiary + } + + // MARK: - Sets per muscle + + private var muscleSection: some View { + let counts = LiftMetrics.muscleCounts(sets) + 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") + if ordered.isEmpty { + NoopCard { + Text("None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + ForEach(ordered, id: \.self) { muscle in + HStack(spacing: NoopMetrics.rowSpacing) { + Text(muscle.displayName) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: 0) + Text(LiftFormat.trim(counts.fractional[muscle] ?? 0)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.effortColor) + Text(componentText(counts, muscle)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Text("Counted from the muscles you assigned each exercise: direct sets count once, indirect ones count as a half — the method the reference figures were derived under. An estimate from your own labels, not something measured off your body.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } + } + } + } + } + + /// "4 direct · 2 indirect" — so the total above is inspectable rather than asserted. + private func componentText(_ counts: LiftMetrics.MuscleCounts, _ muscle: LiftMuscle) -> String { + let d = counts.direct[muscle] ?? 0 + let i = counts.indirect[muscle] ?? 0 + if d > 0 && i > 0 { return String(localized: "\(d) direct · \(i) indirect") } + if d > 0 { return String(localized: "\(d) direct") } + return String(localized: "\(i) indirect") + } + + // MARK: - RPE profile + + private var rpeSection: some View { + let p = LiftMetrics.rpeProfile(sets) + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("How hard it felt", overline: "RPE") + NoopCard { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Mean RPE") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text(p.mean.map { LiftFormat.trim($0) } ?? "—") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + HStack { + Text(String(localized: "Sets at RPE \(LiftFormat.trim(p.threshold)) or above")) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text("\(p.setsAtOrAboveThreshold)") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + if p.unratedSets > 0 { + Text(String(localized: "\(p.unratedSets) working sets weren't rated, so the mean is drawn from \(p.ratedSets).")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var footnote: some View { + Text("Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Load + + private func load() async { + guard let store = await repo.storeHandle() else { sessionRpe = session.sessionRpe; loaded = true; return } + sets = (try? await store.liftSets(sessionId: session.id)) ?? [] + // Re-read rather than trusting the row this sheet was opened with: the edit sheet can change it. + let stored = try? await store.liftSessions(deviceId: session.deviceId, + fromTs: session.startTs, toTs: session.startTs) + if let row = stored?.first(where: { $0.id == session.id }) { + sessionRpe = row.sessionRpe + } else { + sessionRpe = session.sessionRpe + } + + // The workout row this session is pinned to, by that table's own natural key. + let rows = (try? await store.workouts(deviceId: repo.deviceId, + from: session.startTs - 1, + to: session.startTs + 1, limit: 10)) ?? [] + workout = rows.first { $0.startTs == session.startTs && $0.sport == session.sport } + + // Previous volume per exercise, for the "vs last time" line. + var previous: [String: Double] = [:] + for name in Set(sets.map(\.exercise)) { + let before = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: name, + before: session.startTs)) ?? [] + if let v = LiftMetrics.volumeLoadKg(before) { previous[name] = v } + } + previousVolume = previous + loaded = true + } +} diff --git a/Strand/Screens/LiftSessionEditSheet.swift b/Strand/Screens/LiftSessionEditSheet.swift new file mode 100644 index 0000000000..b70576ed1c --- /dev/null +++ b/Strand/Screens/LiftSessionEditSheet.swift @@ -0,0 +1,218 @@ +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. +// +// 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: +// re-parsing an untouched pound value into kilograms would otherwise nudge it by a rounding error. + +struct LiftSessionEditSheet: View { + let session: LiftSessionRow + let sets: [LiftSetRow] + /// Called after the changes are written, so the session screen can reload. + let onSaved: () async -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @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] = [:] + @State private var sessionRpeText = "" + @State private var originalSessionRpe = "" + @State private var saving = false + + @FocusState private var focused: Field? + private enum Field: Hashable { case weight(String), reps(String), rpe(String), sessionRpe } + + /// One set's fields, as typed. + struct SetForm: Equatable { + var weight: String + var reps: String + var rpe: String + var isWarmup: Bool + } + + private var hasChanges: Bool { form != original || sessionRpeText != originalSessionRpe } + + /// 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 } + } + + 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.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + sessionRpeCard + ForEach(exercises, id: \.self) { exerciseCard($0) } + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 780) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .onAppear(perform: fill) + } + + private var sessionRpeCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("How hard was the whole session? (1–10)").strandOverline() + field(.sessionRpe, text: Binding( + get: { sessionRpeText }, + set: { sessionRpeText = $0.replacingOccurrences(of: ",", with: ".") })) + } + } + } + + private func exerciseCard(_ exercise: String) -> some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Text(exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + HStack(spacing: 8) { + Text("Set").strandOverline() + .frame(width: LiftSessionView.setColumnWidth, alignment: .center) + Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ForEach(sets.filter { $0.exercise == exercise }.sorted { $0.ord < $1.ord }, id: \.id) { + setRow($0) + } + } + } + } + + /// 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 + return HStack(spacing: 8) { + Button { form[row.id]?.isWarmup.toggle() } label: { + Text(warmup ? String(localized: "W") : "\(row.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(warmup ? StrandPalette.metricAmber : StrandPalette.textSecondary) + .frame(width: LiftSessionView.setColumnWidth, alignment: .center) + .contentShape(Rectangle()) + } + .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)) + } + .padding(.vertical, 6) + } + + private func field(_ target: Field, text: Binding) -> some View { + TextField(Self.empty, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: target) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// 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 var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save changes") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 180) + .disabled(saving || !hasChanges) + .opacity(saving || !hasChanges ? NoopButtonMetrics.disabledOpacity : 1) + } + } + + private func fill() { + guard form.isEmpty else { return } + for row in sets { form[row.id] = Self.form(for: row, system: unitSystem) } + original = form + sessionRpeText = session.sessionRpe.map { LiftFormat.trim($0) } ?? "" + originalSessionRpe = sessionRpeText + } + + private func save() async { + guard !saving, let store = await repo.storeHandle() else { return } + 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) } + if sessionRpeText != originalSessionRpe { + var row = session + row.sessionRpe = LiftFormat.number(sessionRpeText) + _ = try? await store.upsertLiftSessions([row]) + } + await onSaved() + dismiss() + } + + /// A set's fields as the sheet opens, in the unit the app displays. + static func form(for row: LiftSetRow, system: UnitSystem) -> SetForm { + SetForm(weight: row.weightKg.map { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) } ?? "", + reps: row.reps.map(String.init) ?? "", + rpe: row.rpe.map { LiftFormat.trim($0) } ?? "", + isWarmup: row.isWarmup) + } + + /// `row` with every field that differs between `before` and `after` parsed back in. A blank field + /// clears its value. + 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) + } + edited.isWarmup = after.isWarmup + return edited + } + + private static let empty = "—" +} diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift new file mode 100644 index 0000000000..28b9bc44b0 --- /dev/null +++ b/Strand/Screens/LiftSessionView.swift @@ -0,0 +1,857 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// The workout sheet: every exercise and every set of the session, on one scrollable page. +// +// WHY A SHEET AND NOT A WIZARD. The first version showed one set at a time and walked the plan in +// order. In a real gym that fails twice over: you cannot see what is coming, and you cannot move on +// when a machine is occupied. So every set is a row, any pending row can be started, and finished +// rows stay on screen with what you lifted. +// +// COLOUR CARRIES STATE, so you can find your place at a glance from arm's length: +// green the set you are working now +// amber the rest that follows it +// done a completed set, with a check; numbers nobody typed stay grey +// +// The session itself lives in `LiftSessionController`, ABOVE this view. Swiping this sheet away +// minimises it to the bottom bar; the clock, the strap gesture and the buzzes all keep running, +// because a workout outlives the screen you happen to be looking at. + +struct LiftSessionView: View { + @EnvironmentObject var repo: Repository + @EnvironmentObject var live: LiveState + @EnvironmentObject var session: LiftSessionController + @Environment(\.dismiss) private var dismiss + + /// Called once the session has been written, so the hub can reload. + let onFinished: () async -> Void + + @State private var showingFinish = false + @State private var confirmingDiscard = false + @State private var sessionRpeText = "" + @State private var saving = false + /// The two questions finishing can ask. Nil until answered: saving waits for an answer rather than + /// deciding for the user. + @State private var unfinishedChoice: UnfinishedChoice? + @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] = [] + + 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 } + + @FocusState private var focused: FocusTarget? + private enum FocusTarget: Hashable { + case weight(LiftSlot), reps(LiftSlot), rpe(LiftSlot), sessionRpe + } + + /// What the user has TYPED into a field, held until they leave it. + /// + /// Without this a numeric field cannot accept a decimal at all. Each binding read its text back + /// out of the engine, so every keystroke round-tripped through `LiftFormat` and was replaced by + /// the canonical rendering of the parsed value. Typing "45." parsed to 45, re-rendered as "45", + /// and the point vanished as it was typed — then the next keystroke made "455". A user entering + /// 45.5 kg silently got 455 kg, which is the shape of bug this feature has to stop having. + /// + /// So while a field is focused it shows exactly what was typed; the parsed value still goes to + /// the engine and to disk on every keystroke, so nothing about durability changes. The draft is + /// dropped when focus leaves and the row goes back to the canonical formatting. + @State private var draft: [FocusTarget: String] = [:] + + private var engine: LiftSessionEngine? { session.engine } + + var body: some View { + Group { + if let engine { + VStack(spacing: 0) { + sheet(engine) + // The control bar never scrolls away: at the rack the clock and the one action have to + // be where your thumb already is. + controlBar(engine) + } + } else { + ComingSoon(what: "No session running", symbol: "dumbbell") + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 800) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { 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. + .onChange(of: focused) { now in + draft = draft.filter { $0.key == now } + } + .sheet(isPresented: $showingFinish) { finishSheet } + } + + // MARK: - The scrollable sheet + + private func sheet(_ engine: LiftSessionEngine) -> some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + header(engine) + ForEach(Array(engine.plan.enumerated()), id: \.offset) { index, item in + exerciseCard(engine, index: index, item: item) + } + Color.clear.frame(height: 8) + } + .padding(.horizontal, NoopMetrics.screenPadding) + .padding(.top, 18) + } + .onChange(of: engine.currentSlot) { slot in + // Follow the session down the sheet, but only when it moves on its own — scrolling + // back to read an earlier exercise must not be yanked away from. + guard let slot else { return } + withAnimation { proxy.scrollTo(slot.exerciseIndex, anchor: .top) } + } + } + } + + private func header(_ engine: LiftSessionEngine) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(session.programName ?? String(localized: "Session")) + .font(StrandFont.title1) + .foregroundStyle(StrandPalette.textPrimary) + Text(String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - One exercise, with all its sets + + private func exerciseCard(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + VStack(alignment: .leading, spacing: 2) { + Text(item.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(LiftMuscleSummary.line(primary: item.primaryMuscle, + secondaries: item.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + if let note = item.note, !note.isEmpty { + Text(note) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + // Belt and braces with the entry cap: the sets are what this screen is for, + // and a note must never be able to push them off it. + .lineLimit(4) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(StrandPalette.metricAmber.opacity(0.12), + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + columnHeadings + + ForEach(engine.slots(forExercise: index), id: \.self) { slot in + setRow(engine, slot: slot) + // The rest belongs BETWEEN two sets, because that is where it happens. + if isRestingAfter(engine, slot: slot) { restBand(engine) } + } + + setCountRow(engine, index: index, item: item) + } + } + .id(index) + } + + /// Add one more set, or drop the last planned one — at the END of the exercise, because that is + /// where the question comes up: you have done what was written down and have one more in you, or + /// you have not. Until this existed the sheet drew exactly `1...targetSets` and the extra set was + /// performed and then lost. + /// + /// The geometry mirrors a set row: the minus sits in the tick column, under the checks it undoes. + /// + /// **Both buttons change this session only.** Whether the program keeps the new count is asked + /// when the session is finished: a program is a plan for next time, and one extra set on a good + /// day is not always a new plan. + private func setCountRow(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { + let canAdd = item.targetSets < LiftSessionEngine.maxSetsPerExercise + let canRemove = engine.canRemoveSet(fromExercise: index) + + return HStack(spacing: 8) { + Button { + session.addSet(toExercise: 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 \(item.exercise)")) + + Button { + session.removeSet(fromExercise: index) + } label: { + Image(systemName: "minus.circle") + .font(.system(size: 17, weight: .semibold)) + // Dimmed rather than gone: the pair reads as one control, and a minus that + // disappears once the last set is done looks like a feature that broke. + .foregroundStyle(canRemove ? StrandPalette.textSecondary + : StrandPalette.textTertiary.opacity(0.4)) + .frame(width: Self.tickColumnWidth) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canRemove) + .accessibilityLabel(String(localized: "Remove the last set from \(item.exercise)")) + } + .padding(.top, 2) + .padding(.horizontal, 8) + } + + /// Width of the set-number column, shared by the heading and every row so the number sits + /// directly under its label. + /// + /// 34, not 26. `strandOverline` renders ALL-CAPS with +1.4 tracking, and at 26 the heading wrapped + /// mid-word — a real session photographed it reading "SE / T" over two lines. The headings are + /// also `lineLimit(1)` with a scale floor: this row is four short labels across a phone width in + /// ten languages, and a wrapped heading breaks the column alignment for every row beneath it. + static let setColumnWidth: CGFloat = 34 + + /// Width of the trailing tick column. Mirrored by a clear spacer in the heading row so the four + /// labels sit over the four things they name. + private static let tickColumnWidth: CGFloat = 30 + + private var columnHeadings: some View { + HStack(spacing: 8) { + Text("Set").strandOverline() + .frame(width: Self.setColumnWidth, alignment: .center) + Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Color.clear.frame(width: Self.tickColumnWidth) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + private var weightHeading: LocalizedStringKey { + unitSystem == .imperial ? "Lb" : "Kg" + } + + // MARK: - One set row + + private func setRow(_ engine: LiftSessionEngine, slot: LiftSlot) -> some View { + let recorded = engine.recordedSet(for: slot) + let isWorking = engine.stage == .working(slot) + + return HStack(spacing: 8) { + // The set number IS the warm-up toggle. Warm-ups are excluded from volume and from the + // per-muscle counts, so being unable to mark one silently inflates the single figure the + // whole feature rests on — it has to be reachable in one tap, without leaving the row. + Button { + toggleWarmup(slot) + } label: { + Text(isWarmup(slot) ? String(localized: "W") : "\(slot.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(isWarmup(slot) + ? StrandPalette.metricAmber + : (isWorking ? StrandPalette.textPrimary + : StrandPalette.textSecondary)) + .frame(width: Self.setColumnWidth, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isWarmup(slot) + ? String(localized: "Warm-up set — tap to make it a working set") + : String(localized: "Set \(slot.setIndex) — tap to mark it a warm-up")) + + numberField(slot: slot, field: .weight(slot), + text: weightBinding(slot), + ghost: ghostWeight(slot)) + numberField(slot: slot, field: .reps(slot), + text: repsBinding(slot), + ghost: ghostReps(slot)) + numberField(slot: slot, field: .rpe(slot), + text: rpeBinding(slot), + ghost: ghostRpe(engine, slot: slot)) + + // The tick both REPORTS and ACTS: filled when the set is done, and tappable to start + // this set when it is not — which is how you jump to a different exercise. + Button { + if recorded == nil { session.start(slot) } else { session.start(slot) } + } label: { + Image(systemName: recorded == nil ? "circle" : "checkmark.circle.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(recorded == nil + ? StrandPalette.textTertiary + : StrandPalette.statusPositive) + } + .buttonStyle(.plain) + .frame(width: Self.tickColumnWidth) + .accessibilityLabel(recorded == nil + ? String(localized: "Start this set") + : String(localized: "Redo this set")) + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(rowBackground(isWorking: isWorking, done: recorded != nil), + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + /// Warm-up state lives in the controller, so a mark survives the sheet being minimised and + /// applies however the set was closed out — button, strap, or the minimised bar. + private func isWarmup(_ slot: LiftSlot) -> Bool { session.isWarmup(slot) } + + private func toggleWarmup(_ slot: LiftSlot) { + session.setWarmup(slot, !session.isWarmup(slot)) + } + + /// Green = working now, faint = done, clear = still to come. + /// + /// Deliberately no amber case. Tinting the just-finished SET amber said the wrong thing: the set + /// is over, and what is running is the gap after it. The rest is drawn as its own band between + /// the two set rows instead — see `restBand`. + private func rowBackground(isWorking: Bool, done: Bool) -> Color { + if isWorking { return StrandPalette.statusPositive.opacity(0.20) } + if done { return StrandPalette.surfaceRaised.opacity(0.5) } + return .clear + } + + private func isRestingAfter(_ engine: LiftSessionEngine, slot: LiftSlot) -> Bool { + if case .resting(let s, _) = engine.stage { return s == slot } + return false + } + + /// The running rest, drawn as an amber band sitting BETWEEN the set that ended and the set that + /// follows — which is literally where a rest is. + /// + /// It replaces tinting the finished set's row amber. That read as "this set is amber" when the + /// set was already done, and from across a gym floor it was not obvious which gap was running. + /// A band in the gap is unambiguous at a glance, which is the whole requirement: you are looking + /// at this from a bench, not reading it. + /// + /// It carries the countdown as well as the colour. The control bar has the same number, but the + /// 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) { + Text("Rest period").strandOverline() + .foregroundStyle(StrandPalette.metricAmber) + Spacer(minLength: 0) + Text(LiftFormat.duration(remaining)) + .font(StrandFont.captionNumber) + .monospacedDigit() + .foregroundStyle(StrandPalette.metricAmber) + } + .lineLimit(1) + .minimumScaleFactor(0.75) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity) + .background(StrandPalette.metricAmber.opacity(0.22), + in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + .accessibilityElement(children: .combine) + } + + private func numberField(slot: LiftSlot, field: FocusTarget, + text: Binding, ghost: String) -> some View { + TextField(ghost, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Ghost values + // + // The grey numbers come from ONE chain, `LiftSessionController.carry(for:)`: this exercise earlier + // in the session (set 2 almost always mirrors set 1), then the same set last session, then the + // program's target. The minimised bar and the Lock Screen read the same chain. + // + // A set keeps its grey numbers after it is done, until something is typed over them — grey means + // "not entered". What they are worth is decided when the session is finished: every set without + // typed numbers is completed with them, or discarded, in one choice. + + private func ghostWeight(_ slot: LiftSlot) -> String { + session.carry(for: slot).weightKg.map { display($0) } ?? "—" + } + + private func ghostReps(_ slot: LiftSlot) -> String { + 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. + private func ghostRpe(_ engine: LiftSessionEngine, slot: LiftSlot) -> String { + engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" + } + + private func display(_ kg: Double) -> String { + LiftFormat.trim(LiftFormat.display(fromKilograms: kg, system: unitSystem)) + } + + // MARK: - Field bindings + // + // Each field reads and writes THROUGH the controller, so a keystroke lands in the engine and on + // disk immediately. + // + // TYPING INTO ANY SET, AT ANY TIME. A set that has already been performed is edited in place; one + // that has not is held in `LiftSessionController.pendingValues` and applied the moment it is + // recorded. The two are indistinguishable from the row, which is the requirement: being mid-set + // on one machine is no reason to refuse a correction to another row you are looking at. + // + // This used to be a claim rather than a behaviour — the comment here said the value was "held + // until the set is recorded" while `write` silently dropped it — and a real session found it: + // "when I type something during an active set to other sets it refreshes to the empty". + + /// A text binding that does not fight the user while they type: reads the draft if there is one, + /// otherwise the canonical rendering of what is stored. + /// + /// A typed comma becomes a point on the way in. iOS's `.decimalPad` labels its separator key + /// from the DEVICE's region — a German or French phone offers "," and the app cannot relabel it + /// — so the two would otherwise disagree with the "." this screen displays everywhere else. + /// Normalising here means the field always reads back in the notation it shows, whichever key + /// the keyboard happened to offer. + private func fieldBinding(_ field: FocusTarget, + formatted: @escaping () -> String, + store: @escaping (String) -> Void) -> Binding { + Binding( + get: { draft[field] ?? formatted() }, + set: { typed in + let text = typed.replacingOccurrences(of: ",", with: ".") + draft[field] = text + store(text) + }) + } + + private func weightBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.weight(slot), + formatted: { session.enteredValues(for: slot).weightKg.map { display($0) } ?? "" }, + store: { text in + let kg = LiftFormat.number(text).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + } + write(slot) { $0.weightKg = kg } + }) + } + + private func repsBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.reps(slot), + formatted: { session.enteredValues(for: slot).reps.map(String.init) ?? "" }, + store: { text in + write(slot) { $0.reps = Int(text.trimmingCharacters(in: .whitespaces)) } + }) + } + + private func rpeBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.rpe(slot), + formatted: { session.enteredValues(for: slot).rpe.map { LiftFormat.trim($0) } ?? "" }, + store: { text in write(slot) { $0.rpe = LiftFormat.number(text) } }) + } + + /// Apply one field change to a set, leaving its other fields as they were. + /// + /// Works whether or not the set has been performed — the controller decides where the value + /// lands. It reads the CURRENT entered values first, so editing the reps cannot blank a weight + /// that was typed a moment ago into the same pending row. + private func write(_ slot: LiftSlot, _ mutate: (inout LiftRecordedSet) -> Void) { + let entered = session.enteredValues(for: slot) + var row = LiftRecordedSet(exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: entered.weightKg, reps: entered.reps, rpe: entered.rpe, + isWarmup: session.isWarmup(slot), startTs: 0, endTs: 0, restSec: nil) + mutate(&row) + session.updateSet(slot, weightKg: row.weightKg, reps: row.reps, + rpe: row.rpe, isWarmup: row.isWarmup) + } + + // MARK: - The control bar + + 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) + stageClock(engine) + heartRate() + Spacer(minLength: 0) + Button { session.undo() } label: { + Image(systemName: "arrow.uturn.backward") + .font(.system(size: 15, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(engine.canUndo ? StrandPalette.textSecondary : StrandPalette.textTertiary) + .disabled(!engine.canUndo) + .accessibilityLabel("Undo") + } + + HStack(spacing: NoopMetrics.rowSpacing) { + Button { session.advance() } label: { + Text(actionLabel(engine)).frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) + + Button { + unfinishedChoice = nil + programChoice = nil + setCountChanges = [] + showingFinish = true + } label: { + Text("Finish") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + .padding(.horizontal, NoopMetrics.screenPadding) + .padding(.top, 10) + .padding(.bottom, 14) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + Rectangle().fill(StrandPalette.textTertiary.opacity(0.15)).frame(height: 0.5) + } + } + + /// Live heart rate, beside the clocks that are already pinned above the action button. + /// + /// 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. + private func heartRate() -> some View { + clock(String(localized: "HR"), + model.bpm.map(String.init) ?? "—", + tint: model.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose) + } + + private func clock(_ label: String, _ value: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label).strandOverline() + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(value) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint) + } + } + + @ViewBuilder + 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) + 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) + case .warmup, .finished: + clock(String(localized: "Warm-up"), + LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), + tint: StrandPalette.textSecondary) + } + } + + private func actionLabel(_ engine: LiftSessionEngine) -> LocalizedStringKey { + switch engine.stage { + case .warmup: return "Start first set" + case .working: return "Set done" + case .resting: return engine.allCompleted ? "All sets done" : "Start next set" + case .finished: return "Saving…" + } + } + + // MARK: - Finish + + private var finishSheet: some View { + let unfinished = session.unfinishedSlots.count + let answered = (unfinished == 0 || unfinishedChoice != nil) + && (setCountChanges.isEmpty || 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) { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("How hard was the whole session? (1–10)").strandOverline() + TextField("7", text: $sessionRpeText) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: .sessionRpe) + Text("This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + 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 !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. + Button(role: .destructive) { + confirmingDiscard = true + } label: { + Label("Discard session", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.statusCritical) + .padding(.top, 4) + .disabled(saving) + .confirmationDialog("Discard this session?", + isPresented: $confirmingDiscard, titleVisibility: .visible) { + Button("Discard", role: .destructive) { + session.discard() + showingFinish = false + } + Button("Keep going", role: .cancel) { } + } message: { + Text("\(engine?.completedWorkingSets ?? 0) recorded sets will be thrown away. Nothing is saved and no workout is created.") + } + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 460, height: 420) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .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. + 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.") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Picker("Unfinished sets", selection: $unfinishedChoice) { + Text("Complete them").tag(UnfinishedChoice?.some(.complete)) + Text("Discard them").tag(UnfinishedChoice?.some(.discard)) + } + .pickerStyle(.segmented) + .labelsHidden() + Text("Completing saves them with the grey numbers shown. Discarding leaves them out of the session.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// Set counts changed with ⊕/⊖ during the session. The program keeps them only if asked to. + private var programCard: some View { + 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?") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + ForEach(setCountChanges, id: \.itemId) { change in + Text("\(change.exercise): \(change.from) → \(change.to) sets") + .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)) + } + .pickerStyle(.segmented) + .labelsHidden() + } + } + } + + // MARK: - Loading and saving + + /// What was lifted for each of this session's exercises LAST time, by set number — the middle + /// layer of the grey numbers, handed to the controller that owns the chain. + private func loadLastTime() async { + guard let engine, let store = await repo.storeHandle() else { return } + var out: [String: [Int: LiftSetCarry]] = [:] + // One query per DISTINCT exercise, not per plan line. A program that programs the same + // movement twice — or an imported one with many lines — would otherwise re-ask the store the + // same question, and this runs when the sheet opens. + for exercise in NSOrderedSet(array: engine.plan.map(\.exercise)).compactMap({ $0 as? String }) { + let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: exercise, + before: engine.startTs)) ?? [] + var bySet: [Int: LiftSetCarry] = [:] + for r in rows where !r.isWarmup { + bySet[r.setIndex] = LiftSetCarry(weightKg: r.weightKg, reps: r.reps) + } + out[exercise] = bySet + } + session.setLastSession(out) + } + + private func save() async { + guard !saving, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + session.finish() + guard let engine = session.engine else { return } + let endTs = Int(Date().timeIntervalSince1970) + let sessionId = UUID().uuidString + // 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) + } + await finishAndDismiss() + return + } + + let row = LiftSessionRow( + id: sessionId, deviceId: repo.deviceId, + startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, + programId: session.programId, + // Snapshot the name: renaming or deleting the program never rewrites this session. + programName: session.programName, + sessionRpe: LiftFormat.number(sessionRpeText), + note: session.programName) + _ = try? await store.upsertLiftSessions([row]) + + // `ord` is COMPLETION order, which with out-of-order work is not the plan's order — and it + // is the order that actually happened, which is what a session should read back as. Sets + // completed at finish without being started come last. + let rows = finished.enumerated().map { ord, s -> LiftSetRow in + let item = engine.planItem(for: s.slot) + return LiftSetRow( + id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, + ord: ord, exercise: item?.exercise ?? "", + // Snapshot the classification AS IT WAS, so reclassifying later never rewrites what + // past weeks were counted as. + primaryMuscle: item?.primaryMuscle, + secondaryMuscles: item?.secondaryMuscles ?? [], + setIndex: s.slot.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, + isWarmup: s.isWarmup, startTs: s.startTs, endTs: s.endTs, + restSec: s.restSec, note: nil) + } + _ = try? await store.upsertLiftSets(rows) + if programChoice == .update { + await writeSetCountsToProgram(store: store, plan: engine.plan) + } + + // 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 + // it from the heart rate the strap MEASURED, never from typed sets and reps. + let workout = WorkoutRow( + startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, + source: "manual", durationS: Double(max(0, endTs - engine.startTs)), + energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: session.programName, steps: nil) + await repo.saveManualWorkout(workout) + + await finishAndDismiss() + } + + /// Close the session down and leave the sheet. Shared by the normal save and the nothing-to-file + /// path above, so the two cannot drift about what ending a session means. + private func finishAndDismiss() async { + session.finishedSaving() + await repo.refresh() + await onFinished() + showingFinish = false + dismiss() + } + + /// The program lines whose set count this session changed, for the finish sheet to ask about. + private func loadSetCountChanges() async { + guard let programId = session.programId, let plan = session.engine?.plan, + let store = await repo.storeHandle(), + let rows = try? await store.liftProgramItems(programId: programId) else { return } + setCountChanges = LiftSessionController.setCountChanges(plan: plan, program: rows) + } + + /// Save this session's set counts onto its program — only when the user chose to. + /// + /// 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 { + 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)) + } + + /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer + /// uses, so a typed session and an imported one land in one bucket with one icon. + static let sport = "Strength Training" +} diff --git a/StrandTests/FrameRouterDoubleTapDedupTests.swift b/StrandTests/FrameRouterDoubleTapDedupTests.swift new file mode 100644 index 0000000000..1daba46e42 --- /dev/null +++ b/StrandTests/FrameRouterDoubleTapDedupTests.swift @@ -0,0 +1,204 @@ +import XCTest +@testable import Strand +import WhoopProtocol + +/// ONE physical double-tap must reach the app ONCE. +/// +/// The strap's gesture arrives twice on a busy link: live through `handle(frame:)`, and again when +/// the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over every offload +/// frame and accepts any event timestamped within 45 s of now, which a gesture from moments ago +/// obviously is. `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch that, because the replay +/// can land many seconds later. +/// +/// Reported from a real gym session as "sometimes two double taps when I only did one". With the +/// Lift Log claiming the gesture, a phantom one silently advances the session and costs a logged +/// set — which is why this is pinned rather than left to the debounce. +final class FrameRouterDoubleTapDedupTests: XCTestCase { + + /// A real captured WHOOP 5 DOUBLE_TAP(14) frame; `event_timestamp` = 1780910464. + private let doubleTapHex = "aa0110000100208130340e008089266a3d2a000030b8df92" + private let doubleTapEventTs = 1_780_910_464 + + private func bytes(_ hex: String) -> [UInt8] { + stride(from: 0, to: hex.count, by: 2).compactMap { + let i = hex.index(hex.startIndex, offsetBy: $0) + let j = hex.index(i, offsetBy: 2) + return UInt8(hex[i.. FrameRouter { + let r = FrameRouter(state: live) + r.family = .whoop5 + return r + } + + @MainActor + func testTheSameGestureArrivingLiveThenOnTheOffloadPathFiresOnce() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + let frame = bytes(doubleTapHex) + + r.handle(frame: frame) // live + // The strap offloads its banked log seconds later; the SAME event is still "fresh". + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 10) + + XCTAssertEqual(fired, 1, "one gesture, one advance — the replay must be suppressed") + } + + @MainActor + func testARepeatedOffloadOfTheSameEventNeverFiresAgain() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + let frame = bytes(doubleTapHex) + + // A multi-minute offload re-walks the same records more than once. + for _ in 0..<5 { + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 5) + } + XCTAssertEqual(fired, 1) + } + + @MainActor + func testAGenuineSecondTapStillFires() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + r.handle(frame: bytes(doubleTapHex)) + // De-duplication keys on the event's OWN timestamp, so a real later gesture — which carries + // a different one — must not be swallowed. Guarding on "have we seen a double-tap at all" + // would break the feature entirely. + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 30) + XCTAssertEqual(fired, 1, "sanity: the same timestamp is still one gesture") + + // A frame with a DIFFERENT event timestamp is a different gesture. + live.onDoubleTap = { fired += 1 } + let second = FrameRouter(state: live) + second.family = .whoop5 + second.handle(frame: bytes(doubleTapHex)) + XCTAssertEqual(fired, 2, "a fresh router (a fresh gesture) still dispatches") + } + + @MainActor + func testAStaleReplayIsStillRejectedByTheFreshnessWindow() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + // Far outside `liveGestureWindowSeconds`: a historical replay, not a live gesture. This was + // already correct; pinned so the dedup change cannot be mistaken for the only guard. + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) + XCTAssertEqual(fired, 0) + } + + /// A second gesture: the captured frame with a DIFFERENT `event_timestamp` and a recomputed CRC. + /// + /// Minted rather than captured because the interleaved case needs two gestures reaching ONE + /// router, and the fixture carries a single timestamp. WHOOP 5 envelope: payload is + /// `frame[8..<20]`, its CRC32 is the trailing four bytes little-endian, and `event_timestamp` + /// sits at payload offset 4. The CRC16 header covers `frame[0..<6]` and is untouched. + private func doubleTapFrame(eventTs: Int) -> [UInt8] { + var f = bytes(doubleTapHex) + let ts = UInt32(eventTs) + for i in 0..<4 { f[12 + i] = UInt8((ts >> (8 * UInt32(i))) & 0xFF) } + let crc = crc32(f, 8, 20) + for i in 0..<4 { f[20 + i] = UInt8((crc >> (8 * UInt32(i))) & 0xFF) } + return f + } + + /// The mint has to produce a frame the parser accepts, or a test built on it proves nothing. + func testTheMintedSecondGestureIsAValidFrame() { + let f = doubleTapFrame(eventTs: doubleTapEventTs + 12) + let check = verifyFrame(f, family: .whoop5) + XCTAssertTrue(check.ok, "minted frame must pass both CRCs or the dedup tests are meaningless") + } + + /// TWO genuine taps, then an offload replaying BOTH — the case a single-slot memory cannot cover. + /// + /// Keeping only the last dispatched timestamp catches a replay solely when the replayed event is + /// the most recent one dispatched. Interleave them and each replay looks new: + /// + /// tap A live -> last = A + /// tap B live -> last = B + /// replay A -> A != B, dispatches again + /// replay B -> B != A, dispatches again + /// + /// Two phantom advances, which in a session is two sets silently lost — the exact failure this + /// de-duplication exists to prevent, surviving inside it. + @MainActor + func testTwoTapsReplayedTogetherStillFireOnlyTwice() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + let tsA = doubleTapEventTs + let tsB = doubleTapEventTs + 12 + r.handle(frame: doubleTapFrame(eventTs: tsA)) + r.handle(frame: doubleTapFrame(eventTs: tsB)) + XCTAssertEqual(fired, 2, "sanity: two genuine taps are two gestures") + + // The strap offloads its banked log a few seconds later, carrying both events. + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: tsA), now: tsB + 5) + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: tsB), now: tsB + 5) + + XCTAssertEqual(fired, 2, "a replay of EITHER tap must be suppressed, not just the most recent") + } + + /// Three taps and a full re-walk, the shape a multi-minute offload actually has. + @MainActor + func testAWholeBatchReplayOfSeveralTapsAddsNothing() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + let stamps = [doubleTapEventTs, doubleTapEventTs + 7, doubleTapEventTs + 19] + for ts in stamps { r.handle(frame: doubleTapFrame(eventTs: ts)) } + XCTAssertEqual(fired, 3) + + for _ in 0..<3 { + for ts in stamps { + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: ts), now: stamps[2] + 3) + } + } + XCTAssertEqual(fired, 3, "re-walking the banked log adds no gestures") + } + + // 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. + @MainActor + func testASuppressedReplayLeavesALogLine() { + let live = LiveState() + let r = router(live) + let frame = bytes(doubleTapHex) + r.handle(frame: frame) + let before = live.log.count + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 10) + XCTAssertTrue(live.log.dropFirst(before).contains { + $0.contains("\(doubleTapEventTs)") && $0.contains("already handled") + }) + } + + /// A recent double-tap that only reaches the app through a sync is logged; old history is not. + @MainActor + func testALateDoubleTapIsLoggedButOldHistoryIsNot() { + let live = LiveState() + let r = router(live) + let before = live.log.count + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 120) + XCTAssertTrue(live.log.dropFirst(before).contains { $0.contains("arrived 120 s late") }) + + let count = live.log.count + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) + XCTAssertEqual(live.log.count, count, "a replay from hours ago is history, not a missed tap") + } +} diff --git a/StrandTests/LiftFormatNumberTests.swift b/StrandTests/LiftFormatNumberTests.swift new file mode 100644 index 0000000000..fe7adef5de --- /dev/null +++ b/StrandTests/LiftFormatNumberTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import Strand + +/// Formatting and parsing for the numbers a user types at the rack. +/// +/// These exist because a real session could not enter a decimal weight at all. Two separate defects +/// combined: `trim` rendered one decimal place, and the entry fields read their text back through it +/// on every keystroke — so "45.5" became "45", then "455". The rounding half is pinned here; the +/// field half is pinned by `LiftSessionView`'s draft (there is no view test target for it). +final class LiftFormatNumberTests: XCTestCase { + + // MARK: - trim + + func testAWholeNumberLosesItsDecimalPoint() { + XCTAssertEqual(LiftFormat.trim(8), "8") + XCTAssertEqual(LiftFormat.trim(60.0), "60") + XCTAssertEqual(LiftFormat.trim(0), "0") + } + + func testOneDecimalSurvives() { + XCTAssertEqual(LiftFormat.trim(7.5), "7.5") + XCTAssertEqual(LiftFormat.trim(45.5), "45.5") + } + + /// The defect: gym plates come in quarter-kilos, and microplates in smaller steps, so 12.25 is a + /// weight people actually lift. One decimal turned it into "12.3" — and because the entry field + /// read that back, the rounded value replaced what was typed. + func testTwoDecimalsSurvive() { + XCTAssertEqual(LiftFormat.trim(12.25), "12.25") + XCTAssertEqual(LiftFormat.trim(2.75), "2.75") + XCTAssertEqual(LiftFormat.trim(102.05), "102.05") + } + + func testTrailingZeroesAreDropped() { + XCTAssertEqual(LiftFormat.trim(45.50), "45.5", "not 45.50") + XCTAssertEqual(LiftFormat.trim(45.00), "45", "not 45.00") + } + + /// Beyond two decimals is rounded, not truncated, and never renders a third digit. + func testBeyondTwoDecimalsRounds() { + XCTAssertEqual(LiftFormat.trim(12.256), "12.26") + XCTAssertEqual(LiftFormat.trim(12.254), "12.25") + XCTAssertEqual(LiftFormat.trim(1.0 / 3.0), "0.33") + } + + /// Always a point, never the device's separator: this is what the whole screen displays, and the + /// entry fields normalise a typed comma to match it. + func testTheSeparatorIsAlwaysAPoint() { + XCTAssertFalse(LiftFormat.trim(7.5).contains(",")) + XCTAssertTrue(LiftFormat.trim(7.5).contains(".")) + } + + // MARK: - number + + func testBothSeparatorsParse() { + XCTAssertEqual(LiftFormat.number("45.5"), 45.5) + XCTAssertEqual(LiftFormat.number("45,5"), 45.5, "a German or French keyboard offers the comma") + XCTAssertEqual(LiftFormat.number("12.25"), 12.25) + XCTAssertEqual(LiftFormat.number("12,25"), 12.25) + } + + /// A half-typed decimal must parse, or the value would vanish the instant the point is typed. + func testAPartiallyTypedDecimalParses() { + XCTAssertEqual(LiftFormat.number("45."), 45) + XCTAssertEqual(LiftFormat.number("45,"), 45) + } + + func testNonsenseIsNilRatherThanZero() { + XCTAssertNil(LiftFormat.number("")) + XCTAssertNil(LiftFormat.number(" ")) + XCTAssertNil(LiftFormat.number("kg")) + XCTAssertNil(LiftFormat.number(".")) + } + + /// The round trip the entry field performs on every keystroke. Whatever a user types must come + /// back as the same number — this is what silently produced 455 from 45.5. + func testTypedTextSurvivesTheRoundTripThroughTheField() { + for typed in ["45.5", "12.25", "60", "7.5", "102.05", "45,5"] { + guard let value = LiftFormat.number(typed) else { + XCTFail("\(typed) did not parse"); continue + } + let shown = LiftFormat.trim(value) + XCTAssertEqual(LiftFormat.number(shown), value, + "\(typed) rendered as \(shown), which no longer reads back as \(value)") + } + } +} diff --git a/StrandTests/LiftSessionEditTests.swift b/StrandTests/LiftSessionEditTests.swift new file mode 100644 index 0000000000..deab70005a --- /dev/null +++ b/StrandTests/LiftSessionEditTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Correcting a finished session. The parsing is the part that can quietly store the wrong number. +@MainActor +final class LiftSessionEditTests: XCTestCase { + + private func row(weightKg: Double? = 60, reps: Int? = 8, rpe: Double? = nil) -> LiftSetRow { + LiftSetRow(id: "s1", deviceId: "d", sessionId: "x", ord: 0, exercise: "Squat", + primaryMuscle: .quads, setIndex: 1, weightKg: weightKg, reps: reps, rpe: rpe, + isWarmup: false, startTs: 100, endTs: 140, restSec: 90, note: nil) + } + + /// Opening and saving an untouched pound value must not nudge the stored kilograms. + func testAnUntouchedFieldIsNotWrittenBack() { + let before = LiftSessionEditSheet.form(for: row(), system: .imperial) + var after = before + after.reps = "10" + let edited = LiftSessionEditSheet.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) + var after = before + after.weight = "135" + let edited = LiftSessionEditSheet.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) + var after = before + after.weight = "62,5" + after.rpe = "" + let edited = LiftSessionEditSheet.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) + var after = before + after.isWarmup = true + XCTAssertTrue(LiftSessionEditSheet.applying(after, over: before, to: row(), system: .metric).isWarmup) + } +} diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift new file mode 100644 index 0000000000..c1edd8837f --- /dev/null +++ b/StrandTests/LiftSessionEngineTests.swift @@ -0,0 +1,651 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The Lift Log session state machine. Pure, so a whole gym session can be driven through a known +/// timeline with no strap, no database and no simulator — which is the point of keeping it pure. +final class LiftSessionEngineTests: XCTestCase { + + private let t0 = 1_700_000_000 + + /// Two exercises: 2 sets then 1 set. Small enough to assert every transition by hand. + private func twoExercisePlan() -> [LiftPlanItem] { + [ + LiftPlanItem(exercise: "Incline dumbbell press", + primaryMuscle: .chest, secondaryMuscles: [.frontDelts, .triceps], + targetSets: 2, restSec: 90), + LiftPlanItem(exercise: "Lat pulldown", + primaryMuscle: .lats, secondaryMuscles: [.biceps], + targetSets: 1, restSec: 60), + ] + } + + /// Three exercises, so an EARLIER one can be left pending while a later one is worked. + private func threeExercisePlan() -> [LiftPlanItem] { + [ + LiftPlanItem(exercise: "Leg press", primaryMuscle: .quads, targetSets: 3, restSec: 90), + LiftPlanItem(exercise: "Lying leg curl", primaryMuscle: .hamstrings, targetSets: 3, restSec: 90), + LiftPlanItem(exercise: "Leg extension", primaryMuscle: .quads, targetSets: 2, restSec: 60), + ] + } + + /// One line carrying the targets a program actually plans. + private func targetedPlanItem() -> LiftPlanItem { + LiftPlanItem(exercise: "Leg press", primaryMuscle: .quads, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50) + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + // MARK: - The sheet + + func testTheSheetListsEverySetOfEveryExercise() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + XCTAssertEqual(e.slots(forExercise: 1), [slot(1, 1)]) + XCTAssertEqual(e.allSlots.count, 3) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + func testASessionStartsInTheWarmUpWithNothingCompleted() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) + XCTAssertFalse(e.canUndo) + XCTAssertEqual(e.nextPendingSlot, slot(0, 1)) + } + + // MARK: - The occupied-machine path (reported from a real session) + + /// Skipping an exercise because its machine is busy must not drag the session back to it after + /// every set. Reported from the gym: "I switched to a different move because the machine was + /// occupied. When I double-tap for the next set, it reverts to the first set of the exercise I + /// couldn't do earlier." + func testFinishingASetStaysOnTheSameExerciseEvenWithAnEarlierOneSkipped() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + + // Exercise 0's machine is busy — start exercise 2 instead. + e.start(slot(2, 1), now: t0 + 60) + e.advance(now: t0 + 100) // set done -> rest + XCTAssertEqual(e.stage, .resting(slot(2, 1), endsAt: t0 + 100 + 60)) + + e.advance(now: t0 + 160) // rest done -> next set + XCTAssertEqual(e.stage, .working(slot(2, 2)), + "must continue on the machine the user is standing at, not jump back to 0") + } + + /// And once that exercise IS finished, the skipped one is exactly what comes next — it was + /// deferred, not abandoned. + func testTheSkippedExerciseIsWhatComesNextOnceTheCurrentOneIsDone() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(2, 1), now: t0 + 60) + e.advance(now: t0 + 100); e.advance(now: t0 + 160) // set 1 done, on to set 2 + e.advance(now: t0 + 200) // set 2 done -> rest + e.advance(now: t0 + 260) // rest done -> exercise 2 finished + + XCTAssertEqual(e.stage, .working(slot(0, 1)), + "with exercise 2 complete, the deferred exercise 0 is next") + } + + func testSlotAfterPrefersTheSameExerciseThenFallsBackToPlanOrder() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + XCTAssertEqual(e.slotAfter(slot(2, 1)), slot(2, 1), "its own set is still pending") + + e.start(slot(2, 1), now: t0); e.advance(now: t0 + 40) + XCTAssertEqual(e.slotAfter(slot(2, 1)), slot(2, 2)) + + e.advance(now: t0 + 100); e.advance(now: t0 + 140) // finish exercise 2 entirely + XCTAssertEqual(e.slotAfter(slot(2, 2)), slot(0, 1), "exhausted -> first pending in plan order") + } + + // MARK: - Grey numbers + // + // A finished set records its timing only. Its numbers stay grey until typed, and what a set without + // typed numbers saves is decided when the session is finished (`LiftSessionFinishTests`). + + /// Finishing a set without typing writes nothing into it: the program's target stays a grey + /// suggestion the user can type straight over, and still counts as the numbers the sheet showed. + func testFinishingASetWithoutTypingLeavesItsNumbersGrey() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) // warm-up -> working set 1 + e.advance(now: t0 + 70) // set done + + let row = e.recordedSet(for: slot(0, 1)) + 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)) + } + + /// The second set follows what the FIRST set counts as — if you dropped to 45 kg, set 2 follows + /// you down rather than snapping back to the program. + func testGreyNumbersFollowWhatTheExerciseDidEarlierInTheSession() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 45, reps: 8, rpe: 9, isWarmup: false) + + XCTAssertEqual(e.carry(for: slot(0, 2), lastSession: [:]), LiftSetCarry(weightKg: 45, reps: 8), + "the session's own history outranks the program's plan") + } + + /// An untyped set 1 still leads set 2, and correcting set 1 later moves set 2's grey numbers with it: + /// nothing was written into set 2 that would have to be typed over. + func testCorrectingAnEarlierSetMovesTheGreyNumbersAfterIt() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) // set 1 done, untyped + e.advance(now: t0 + 130) + e.advance(now: t0 + 190) // set 2 done, untyped + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 50) + + e.updateSet(slot(0, 1), weightKg: 42.5, reps: nil, rpe: nil, isWarmup: false) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 42.5) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).reps, 10, + "a field left alone keeps following its own chain") + } + + /// The store's answer sits between this session and the program target. + func testLastSessionIsUsedWhenTheSessionHasNoEarlierSetForTheExercise() { + let e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + let last = [1: LiftSetCarry(weightKg: 52.5, reps: 9)] + XCTAssertEqual(e.carry(for: slot(0, 1), lastSession: last), LiftSetCarry(weightKg: 52.5, reps: 9), + "last session beats the program's target") + XCTAssertEqual(e.carry(for: slot(0, 3), lastSession: last), LiftSetCarry(weightKg: 50, reps: 10), + "a set number last session did not have falls through to the target") + } + + /// RPE is never carried: it is how hard a set FELT, which nothing can know in advance, and + /// inventing it would make the RPE card report full coverage for sets nobody rated. + func testRpeIsNeverCarried() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 50, reps: 10, rpe: 8.5, isWarmup: false) + e.advance(now: t0 + 130) + e.advance(now: t0 + 190) + + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 50, "weight carries") + XCTAssertNil(e.recordedSet(for: slot(0, 2))?.rpe, "the felt effort of a set does not") + } + + /// Nothing to carry stays nil rather than inventing a zero — a set with no plan, no history and + /// nothing typed genuinely has no measurement, and 0 kg would be a false one. + func testASetWithNothingToCarryStaysEmpty() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry.none) + } + + /// A typed value beats the grey one, including a 0 for a set that was planned but not performed. + func testTypingZeroOverAGreyValueSticks() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 0, reps: 0, rpe: nil, isWarmup: false) + 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() { + 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") + } + + // MARK: - The default in-order path + + func testTheFullTapThroughRecordsEverySetInOrder() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + + e.advance(now: t0 + 300) // warm-up → set 1 + XCTAssertEqual(e.stage, .working(slot(0, 1))) + + e.advance(now: t0 + 340) // done → rest (90s) + XCTAssertEqual(e.stage, .resting(slot(0, 1), endsAt: t0 + 430)) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + + e.advance(now: t0 + 440) // rest → set 2 + XCTAssertEqual(e.stage, .working(slot(0, 2))) + + e.advance(now: t0 + 480) + e.updateSet(slot(0, 2), weightKg: 30, reps: 8, rpe: 9, isWarmup: false) + + e.advance(now: t0 + 580) // rest → next exercise + XCTAssertEqual(e.stage, .working(slot(1, 1))) + + e.advance(now: t0 + 620) + e.updateSet(slot(1, 1), weightKg: 55, reps: 12, rpe: 7, isWarmup: false) + + XCTAssertTrue(e.allCompleted) + XCTAssertEqual(e.sets.count, 3) + XCTAssertEqual(e.sets.map(\.reps), [10, 8, 12]) + } + + func testFinishClosesTheRunningRestSoItsDurationIsNotLost() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) // resting from t0+40 + e.finish(now: t0 + 160) + XCTAssertEqual(e.stage, .finished) + XCTAssertEqual(e.sets[0].restSec, 120, "the rest that was running still happened") + } + + // MARK: - Out of order: the reason this model exists + + func testAnyPendingSetCanBeStartedWhenAMachineIsBusy() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(1, 1), now: t0 + 60) // skip straight to the second exercise + XCTAssertEqual(e.stage, .working(slot(1, 1))) + + e.advance(now: t0 + 100) + e.updateSet(slot(1, 1), weightKg: 55, reps: 12, rpe: nil, isWarmup: false) + XCTAssertTrue(e.isCompleted(slot(1, 1))) + XCTAssertEqual(e.nextPendingSlot, slot(0, 1), + "the skipped sets are still outstanding and come back round") + } + + func testAdvancingFromRestGoesToTheFirstOUTSTANDINGSetNotTheNextInLine() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) // did set 2 first + e.advance(now: t0 + 40) // → resting + e.advance(now: t0 + 140) // → next OUTSTANDING + XCTAssertEqual(e.stage, .working(slot(0, 1)), + "set 1 was never done, so it is what comes next") + } + + func testStartingAnAlreadyCompletedSetRedoesItRatherThanDoubleCounting() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + XCTAssertEqual(e.sets.count, 1) + + e.start(slot(0, 1), now: t0 + 200) // redo it + XCTAssertEqual(e.sets.count, 0, "the old record is dropped, not duplicated") + XCTAssertEqual(e.stage, .working(slot(0, 1))) + } + + func testStartingAnotherSetMidSetRecordsNothingForTheAbandonedOne() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // working set 1 + e.start(slot(1, 1), now: t0 + 20) // changed mind + XCTAssertTrue(e.sets.isEmpty, "an unfinished set is not a set") + XCTAssertEqual(e.stage, .working(slot(1, 1))) + } + + func testAdvancingWhenEverythingIsDoneDoesNotInventASet() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 30) // → resting, all done + e.advance(now: t0 + 120) // nothing left to start + XCTAssertEqual(e.sets.count, 1) + XCTAssertTrue(e.allCompleted) + } + + // MARK: - Time + + func testRestIsAnchoredToAnAbsoluteInstantNotACountdown() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10) // rest ends at t0+100 + + XCTAssertEqual(e.restRemaining(now: t0 + 10), 90) + XCTAssertEqual(e.restRemaining(now: t0 + 55), 45) + // A phone sleeping through a rest must not "pause" it — the answer depends only on the clock. + XCTAssertEqual(e.restRemaining(now: t0 + 100), 0) + } + + func testAnOverrunRestFloorsAtZeroAndNeverAutoAdvances() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10) + XCTAssertEqual(e.restRemaining(now: t0 + 5_000), 0, "an overrun rest reads 0:00, never negative") + XCTAssertEqual(e.stage, .resting(slot(0, 1), endsAt: t0 + 100), + "rest waits for the user; nothing starts a set on its own") + } + + func testRestRecordedIsWhatWasActuallyTakenNotWhatWasPlanned() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10) // planned 90s + e.advance(now: t0 + 210) // actually rested 200s + XCTAssertEqual(e.sets[0].restSec, 200, + "rest is measured from the taps, not assumed from the plan") + } + + func testASetCarriesTheDurationItWasPerformedOver() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) + XCTAssertEqual(e.sets[0].startTs, t0 + 300) + XCTAssertEqual(e.sets[0].endTs, t0 + 345) + } + + // MARK: - Entering what you lifted + + func testASetIsRecordedWithItsTimingBeforeAnyNumbersAreTyped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) + XCTAssertEqual(e.sets.count, 1) + XCTAssertNil(e.sets[0].weightKg, "numbers are typed during the rest, not while lifting") + XCTAssertNil(e.sets[0].reps) + } + + func testTypingEditsTheSetRatherThanAddingOne() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.updateSet(slot(0, 1), weightKg: 32.5, reps: 9, rpe: 8, isWarmup: false) + XCTAssertEqual(e.sets.count, 1) + XCTAssertEqual(e.sets[0].weightKg, 32.5) + XCTAssertEqual(e.sets[0].rpe, 8) + } + + func testTypingIntoASetThatWasNeverPerformedInventsNothing() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.updateSet(slot(0, 1), weightKg: 100, reps: 5, rpe: 10, isWarmup: false) + XCTAssertTrue(e.sets.isEmpty, "a number nobody performed must never become a set") + } + + func testAWarmUpSetIsRecordedButDoesNotCountAsAWorkingSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.sets.count, 1) + XCTAssertEqual(e.completedWorkingSets, 0, + "studies count working sets; a warm-up must not inflate the tally") + } + + // MARK: - Warm-ups + + func testAWarmUpMarkedBeforeTheSetIsPerformedSurvivesOntoIt() { + // You know a set is a warm-up on the way IN. The mark is held until the set exists, then + // applied — the regression this guards against silently counted every warm-up as a working + // set, inflating the one figure the whole feature rests on. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // working set 1 + e.advance(now: t0 + 40) // recorded + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + + XCTAssertTrue(e.sets[0].isWarmup) + XCTAssertEqual(e.completedWorkingSets, 0, + "a warm-up must not count toward the working-set tally") + } + + func testAWarmUpCanBeUnmarkedBackToAWorkingSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.completedWorkingSets, 0) + + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: false) + XCTAssertEqual(e.completedWorkingSets, 1, "un-marking restores it to a working set") + } + + func testAWarmUpStillCarriesItsWeightAndRepsForTheRecord() { + // Excluded from the COUNTS, but still logged: what you warmed up with is worth keeping. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.sets[0].weightKg, 20) + XCTAssertEqual(e.sets[0].reps, 12) + } + + // MARK: - Ghost values + + func testThePreviousSetInThisSessionIsWhatASetGhostsFrom() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + + let ghost = e.previousSetInSession(for: slot(0, 2)) + XCTAssertEqual(ghost?.weightKg, 30, "set 2 ghosts from set 1 of the same exercise") + XCTAssertEqual(ghost?.reps, 10) + } + + func testTheFirstSetOfAnExerciseHasNothingToGhostFrom() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + XCTAssertNil(e.previousSetInSession(for: slot(0, 1))) + XCTAssertNil(e.previousSetInSession(for: slot(1, 1)), + "a different exercise never ghosts from this one") + } + + // MARK: - Undo + + func testUndoRestoresTheStageAndRemovesTheRecordedSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + XCTAssertEqual(e.sets.count, 1) + e.undo() + XCTAssertEqual(e.stage, .working(slot(0, 1))) + XCTAssertTrue(e.sets.isEmpty, "undoing a mis-tap must take the set back with it") + } + + func testUndoWalksAllTheWayBackToTheWarmUp() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.advance(now: t0 + 140) + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) + } + + func testUndoIsGlobalAndWalksBackACROSSExercises() { + // Undo is one stack for the WHOLE session, not a per-exercise one: from the last set of the + // last exercise you can walk all the way back to the first set of the first. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // ex0 set1 + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.advance(now: t0 + 140) // ex0 set2 + e.advance(now: t0 + 180) + e.advance(now: t0 + 280) // ex1 set1 + e.advance(now: t0 + 320) + XCTAssertEqual(e.sets.count, 3) + XCTAssertEqual(e.currentSlot?.exerciseIndex, 1) + + // One undo steps back out of the SECOND exercise into the first — no boundary in the way. + e.undo() + XCTAssertEqual(e.sets.count, 2) + e.undo() + XCTAssertEqual(e.currentSlot?.exerciseIndex, 0, + "undo crosses from one exercise back into the previous one") + + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty, "the whole session unwinds, set by set, with no cap") + } + + func testUndoHasNoDepthLimit() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + for i in 0..<60 { e.advance(now: t0 + i * 10) } // far more steps than the plan has + var undone = 0 + while e.canUndo { e.undo(); undone += 1 } + XCTAssertGreaterThan(undone, 10, "undo depth is not capped") + XCTAssertEqual(e.stage, .warmup) + } + + func testUndoOnAFreshSessionIsHarmless() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.undo() + XCTAssertEqual(e.stage, .warmup) + } + + func testUndoTakesBackARedoRestoringTheSetItDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.start(slot(0, 1), now: t0 + 200) // redo drops it + XCTAssertTrue(e.sets.isEmpty) + e.undo() + XCTAssertEqual(e.sets.count, 1, "a redo started by accident is recoverable") + XCTAssertEqual(e.sets[0].weightKg, 30) + } + + // MARK: - Adding and dropping a set mid-session + // + // A program is what you INTENDED. Five sets when it says four is ordinary, and so is stopping at + // three — and before this the fifth set was performed and then simply lost, because the sheet + // drew exactly `1...targetSets`. + + func testAnAddedSetBecomesATappableRowAndCountsTowardThePlan() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.plannedWorkingSets, 3) + XCTAssertTrue(e.addSet(toExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2), slot(0, 3)]) + XCTAssertEqual(e.plannedWorkingSets, 4) + XCTAssertFalse(e.allCompleted) + } + + /// The whole point: the extra set has to be recordable, with its own numbers. + func testAnExtraSetIsWhereTheSessionGoesNextAndRecordsWhatItWasDoing() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1, restSec: 60)], + startTs: t0) + e.advance(now: t0) // set 1 + e.advance(now: t0 + 40) // set 1 done, resting + XCTAssertTrue(e.allCompleted, "the plan is finished, as written") + + e.addSet(toExercise: 0) + XCTAssertFalse(e.allCompleted, "and now it is not — there is one more to do") + e.advance(now: t0 + 160) // out of the rest, into set 2 + XCTAssertEqual(e.stage, .working(slot(0, 2))) + e.advance(now: t0 + 200) + XCTAssertEqual(e.sets.count, 2) + XCTAssertEqual(e.sets.last?.setIndex, 2) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [2: LiftSetCarry(weightKg: 20, reps: 12)]).reps, 12, + "an added set shows grey numbers like any other") + } + + func testAddingSetsStopsAtTheBound() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + while e.addSet(toExercise: 0) { } + XCTAssertEqual(e.slots(forExercise: 0).count, LiftSessionEngine.maxSetsPerExercise) + XCTAssertFalse(e.addSet(toExercise: 0), "a stuck finger cannot grow the sheet without end") + } + + func testAddingASetToALineThatIsNotThereDoesNothing() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertFalse(e.addSet(toExercise: 99)) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + func testDroppingTheLastSetTakesItOffTheSheet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertTrue(e.canRemoveSet(fromExercise: 0)) + XCTAssertTrue(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1)]) + XCTAssertEqual(e.plannedWorkingSets, 2) + } + + /// The minus edits a PLAN. A completed set is data — deleting it from here would throw away a + /// set that was actually performed. + func testACompletedSetIsNeverDroppedByTheMinus() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) + e.advance(now: t0 + 40) // set 2 recorded + // Move the session OFF that set, so the only thing protecting it is that it was performed. + e.start(slot(1, 1), now: t0 + 100) + XCTAssertNotEqual(e.currentSlot, slot(0, 2)) + XCTAssertTrue(e.isCompleted(slot(0, 2))) + + XCTAssertFalse(e.canRemoveSet(fromExercise: 0)) + XCTAssertFalse(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2, "what was logged stays on the sheet") + XCTAssertEqual(e.sets.count, 1, "and stays recorded") + } + + func testTheSetTheSessionIsStandingOnIsNeverDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) + XCTAssertEqual(e.stage, .working(slot(0, 2))) + XCTAssertFalse(e.canRemoveSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2) + } + + func testTheLastRemainingSetIsNeverDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertFalse(e.canRemoveSet(fromExercise: 1), "a line with one set has nothing to give up") + XCTAssertFalse(e.removeSet(fromExercise: 1)) + XCTAssertEqual(e.slots(forExercise: 1), [slot(1, 1)]) + } + + func testUndoTakesBackAnAddedSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.addSet(toExercise: 0) + XCTAssertEqual(e.plannedWorkingSets, 4) + e.undo() + XCTAssertEqual(e.plannedWorkingSets, 3, "undo takes back the plan change, not only sets") + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + } + + func testUndoPutsADroppedSetBack() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.removeSet(fromExercise: 0) + e.undo() + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + } + + /// The reason the plan travels in the undo snapshot at all. + /// + /// Start the last set, walk away to another exercise (which leaves it pending), drop it, then + /// undo back past the drop. Without the plan in the snapshot the stage would be restored onto a + /// slot the sheet no longer draws — and completing it would write a set nobody could see. + func testUndoingPastADroppedSetCannotStrandTheSessionOnASlotThatIsGone() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(0, 3), now: t0) // the last set of exercise 0 + e.start(slot(1, 1), now: t0 + 30) // machine busy: move on, 0/3 pending + XCTAssertTrue(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2) + + e.undo() // back past the drop + XCTAssertEqual(e.slots(forExercise: 0).count, 3, "the slot the stage refers to is back") + e.undo() // back onto that very slot + XCTAssertEqual(e.stage, .working(slot(0, 3))) + XCTAssertTrue(e.allSlots.contains(slot(0, 3)), + "the session is never left working a set the sheet does not draw") + } + + // MARK: - Degenerate plans + + func testALineWithNoTargetStillGetsOneTappableSet() { + XCTAssertEqual(LiftPlanItem(exercise: "Face pull", targetSets: nil).targetSets, 1) + } + + func testAMissingRestFallsBackToTheDefault() { + XCTAssertEqual(LiftPlanItem(exercise: "Face pull", restSec: nil).restSec, + LiftPlanItem.defaultRestSec) + } + + func testAnEmptyPlanHasNothingToStartAndCannotTrap() { + var e = LiftSessionEngine(plan: [], startTs: t0) + XCTAssertNil(e.nextPendingSlot) + e.advance(now: t0 + 10) + XCTAssertEqual(e.stage, .warmup, "with no sets there is nothing to advance into") + e.finish(now: t0 + 20) + XCTAssertEqual(e.stage, .finished, "and it can still be ended") + } + + func testStartingASlotOutsideThePlanIsIgnored() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(99, 1), now: t0) + XCTAssertEqual(e.stage, .warmup) + } +} diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift new file mode 100644 index 0000000000..65fa330ba8 --- /dev/null +++ b/StrandTests/LiftSessionFinishTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Finishing a session: what saves, and whether the program keeps a changed set count. +/// +/// Asked for after a real session, 15 Sep 2026: grey numbers stay grey during the session, finishing +/// asks once whether the sets without typed numbers are completed with them or left out ("the user +/// might not complete all the workout, just a couple of exercises"), and a set count changed with ⊕/⊖ +/// reaches the program only if the user says so. +@MainActor +final class LiftSessionFinishTests: XCTestCase { + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// Bench 3 sets and rows 2 sets, both with targets and program lines behind them. + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50, programItemId: "bench"), + LiftPlanItem(exercise: "Row", primaryMuscle: .lats, targetSets: 2, + restSec: 60, targetRepsLow: 12, targetWeightKg: 40, programItemId: "row")] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + /// Bench set 1 done with only its weight typed, bench set 2 done untyped, the rest never started. + private func halfDoneSession() -> LiftSessionController { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + c.advance() // bench 1 working + c.updateSet(slot(0, 1), weightKg: 55, reps: nil, rpe: nil, isWarmup: false) + c.advance() // bench 1 done, resting + c.advance() // bench 2 working + c.advance() // bench 2 done, untyped + 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() { + 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") + } + + func testUnfinishedSetsAreTheUntypedAndTheNeverStarted() { + XCTAssertEqual(halfDoneSession().unfinishedSlots, + [slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + } + + /// A set with anything typed always saves, and a blank field takes its grey number rather than + /// saving empty. + func testATypedSetSavesEitherWayWithItsBlanksFilled() { + let c = halfDoneSession() + for completing in [false, true] { + let bench1 = c.setsToSave(completingUnfinished: completing).first { $0.slot == slot(0, 1) } + XCTAssertEqual(bench1?.weightKg, 55) + XCTAssertEqual(bench1?.reps, 10, "the untyped reps take the grey target, not nil") + XCTAssertNotNil(bench1?.startTs) + } + } + + func testDiscardingLeavesEveryUnfinishedSetOut() { + let saved = halfDoneSession().setsToSave(completingUnfinished: false) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1)]) + } + + /// Completing saves the untyped and the never-started sets with the grey numbers the sheet showed: + /// bench follows bench set 1, rows take their target. Performed sets keep their order and timing; + /// the never-started ones follow, with no timing to invent. + func testCompletingSavesThemWithTheirGreyNumbers() { + let saved = halfDoneSession().setsToSave(completingUnfinished: true) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1), slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + + let bench2 = saved[1] + XCTAssertEqual(bench2.weightKg, 55, "set 2 follows what set 1 counts as") + XCTAssertEqual(bench2.reps, 10) + XCTAssertNotNil(bench2.endTs, "it was performed, so its timing is real") + + let row1 = saved[3] + XCTAssertEqual(row1.weightKg, 40) + XCTAssertEqual(row1.reps, 12) + XCTAssertNil(row1.startTs, "never started: no moment to record") + XCTAssertNil(row1.restSec) + XCTAssertNil(row1.rpe, "RPE is never invented") + } + + /// Numbers typed in advance and a warm-up mark still count for a set completed at finish. + func testCompletingUsesWhatWasTypedAndMarkedInAdvance() { + let c = halfDoneSession() + c.updateSet(slot(1, 1), weightKg: 42.5, reps: nil, rpe: 7, isWarmup: false) + c.setWarmup(slot(1, 2), true) + + let saved = c.setsToSave(completingUnfinished: true) + let row1 = saved.first { $0.slot == slot(1, 1) } + XCTAssertEqual(row1?.weightKg, 42.5) + XCTAssertEqual(row1?.reps, 12, "the field left alone is still grey") + XCTAssertEqual(row1?.rpe, 7) + XCTAssertEqual(saved.first { $0.slot == slot(1, 2) }?.isWarmup, true) + } + + /// Nothing unfinished means nothing to ask, and every set saves. + func testAFullyTypedSessionHasNothingUnfinished() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], programId: nil, programName: nil) + c.advance() + c.updateSet(slot(0, 1), weightKg: 12, reps: 12, rpe: nil, isWarmup: false) + c.advance() + XCTAssertTrue(c.unfinishedSlots.isEmpty) + XCTAssertEqual(c.setsToSave(completingUnfinished: false).count, 1) + } + + /// The minimised bar and the Lock Screen show a finished, untyped set's grey numbers, not a blank. + func testTheBarShowsGreyNumbersForAnUntypedSet() { + let c = halfDoneSession() + XCTAssertEqual(c.setNumbers(for: slot(0, 2), system: .metric), "10 x 55 kg") + } + + // MARK: - The program's set counts + + private func item(_ id: String, _ exercise: String, sets: Int?) -> LiftProgramItemRow { + LiftProgramItemRow(id: id, deviceId: "d", programId: "p", ord: 0, exercise: exercise, + targetSets: sets, targetRepsLow: 10, targetRepsHigh: nil, targetRpe: nil, + targetWeightKg: 50, restSec: 60, note: "keep me") + } + + func testOnlyLinesWhoseCountMovedAreOffered() { + var lines = plan() + lines[0].targetSets = 4 // bench 3 -> 4 + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + XCTAssertEqual(LiftSessionController.setCountChanges(plan: lines, program: rows), + [.init(itemId: "bench", exercise: "Bench press", from: 3, to: 4)]) + } + + /// A line with no count starts a session with one set, so one set is no change; a line deleted + /// from the program since the session began is never offered back. + func testAMissingCountIsOneAndADeletedLineIsSkipped() { + let lines = [LiftPlanItem(exercise: "Curl", targetSets: nil, programItemId: "curl"), + LiftPlanItem(exercise: "Gone", targetSets: 5, programItemId: "gone")] + XCTAssertTrue(LiftSessionController.setCountChanges( + plan: lines, program: [item("curl", "Curl", sets: nil)]).isEmpty) + } + + func testApplyingMovesOnlyTheSetCount() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let changed = LiftSessionController.applying( + [.init(itemId: "bench", exercise: "Bench press", from: 3, to: 5)], to: rows) + XCTAssertEqual(changed[0].targetSets, 5) + XCTAssertEqual(changed[0].note, "keep me") + XCTAssertEqual(changed[0].targetWeightKg, 50) + XCTAssertEqual(changed[1].targetSets, 2, "an unchanged line stays as it was") + } + + /// The hub reloads on this: a saved session must announce itself, and a discarded one must not. + func testSavingASessionIsAnnouncedButDiscardingIsNot() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: nil) + c.discard() + XCTAssertEqual(c.savedSessions, 0) + c.start(plan: plan(), programId: nil, programName: nil) + c.finishedSaving() + XCTAssertEqual(c.savedSessions, 1) + } +} diff --git a/StrandTests/LiftSessionPendingInputTests.swift b/StrandTests/LiftSessionPendingInputTests.swift new file mode 100644 index 0000000000..6db2ea7e2a --- /dev/null +++ b/StrandTests/LiftSessionPendingInputTests.swift @@ -0,0 +1,180 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Typing numbers into a set you are NOT currently doing. +/// +/// Reported from a real session, 11 Sep 2026: *"you can't edit the numbers of other sets while in an +/// active set — when I type something during an active set to other sets it refreshes to the empty."* +/// It did. `LiftSessionView.write` could only edit a set that already had a RECORD, so a keystroke +/// into a pending row went nowhere; the draft made the field look like it had taken until focus left. +/// +/// The engine rule underneath is not the bug and does not move: typing must never append a set, or a +/// set nobody performed becomes data. So the numbers are held in the controller and applied the +/// instant the set is recorded — the same mechanism a warm-up marked in advance already used. +@MainActor +final class LiftSessionPendingInputTests: XCTestCase { + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// Three sets, with targets, so there is a carried plan for a typed value to beat. + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50)] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + // MARK: - The reported bug + + /// The exact report: set 1 is active, the user types into set 3, and it must still be there. + func testTypingIntoAnotherSetWhileOneIsActiveIsKept() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() // set 1 is now active + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + + c.updateSet(slot(0, 3), weightKg: 62.5, reps: 6, rpe: nil, isWarmup: false) + + XCTAssertEqual(c.enteredValues(for: slot(0, 3)).weightKg, 62.5, + "a number typed into another set must not evaporate") + XCTAssertEqual(c.enteredValues(for: slot(0, 3)).reps, 6) + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1)), + "and typing elsewhere must not disturb the set being worked") + XCTAssertEqual(c.engine?.sets.count, 0, "nor invent a set nobody has performed") + } + + /// What was typed in advance is what gets recorded — it beats the carried plan, which is only + /// the sheet's guess (this exercise earlier, last session, the program's target). + func testWhatWasTypedInAdvanceIsWhatTheSetRecords() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.setLastSession(["Bench press": [1: LiftSetCarry(weightKg: 55, reps: 9)]]) + c.advance() // set 1 active + c.updateSet(slot(0, 1), weightKg: 70, reps: 5, rpe: nil, isWarmup: false) + c.advance() // "Set done" + + let recorded = c.engine?.recordedSet(for: slot(0, 1)) + XCTAssertEqual(recorded?.weightKg, 70, "the typed weight wins over the carried 55") + XCTAssertEqual(recorded?.reps, 5) + XCTAssertTrue(c.pendingValues.isEmpty, "and the held entry is consumed once applied") + } + + /// Typing only one field must not blank the others — they keep carrying. + func testAFieldLeftAloneStillCarries() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.setLastSession(["Bench press": [1: LiftSetCarry(weightKg: 55, reps: 9)]]) + c.advance() + c.updateSet(slot(0, 1), weightKg: 70, reps: nil, rpe: nil, isWarmup: false) + c.advance() + + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 70) + XCTAssertNil(c.engine?.recordedSet(for: slot(0, 1))?.reps, "reps nobody typed stay grey") + XCTAssertEqual(c.values(of: slot(0, 1)).reps, 9, + "and still count as the carried value, not as nothing") + } + + /// 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() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 2), weightKg: 80, reps: nil, rpe: nil, isWarmup: false) + XCTAssertFalse(c.pendingValues.isEmpty) + + c.updateSet(slot(0, 2), weightKg: nil, reps: nil, rpe: nil, isWarmup: false) + XCTAssertTrue(c.pendingValues.isEmpty) + XCTAssertNil(c.enteredValues(for: slot(0, 2)).weightKg) + } + + // MARK: - What must not change + + /// Editing a set that IS recorded still edits it in the engine, from any stage. + func testACompletedSetIsStillEditedInPlace() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.advance() // set 1 recorded, resting + c.advance() // set 2 active + + c.updateSet(slot(0, 1), weightKg: 47.5, reps: 12, rpe: 8, isWarmup: false) + + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 47.5) + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.rpe, 8) + XCTAssertTrue(c.pendingValues.isEmpty, + "a set with a record is edited, never shadowed by a held entry") + } + + /// The engine's rule is untouched: no amount of typing creates a set. + func testTypingNeverInventsASet() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 1), weightKg: 60, reps: 10, rpe: 9, isWarmup: false) + c.updateSet(slot(0, 3), weightKg: 60, reps: 10, rpe: 9, isWarmup: false) + XCTAssertEqual(c.engine?.sets.count, 0) + XCTAssertEqual(c.engine?.completedWorkingSets, 0) + } + + /// Typing into a slot the plan does not have is ignored rather than held forever. + func testTypingIntoASlotOutsideThePlanIsIgnored() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(9, 1), weightKg: 60, reps: 10, rpe: nil, isWarmup: false) + XCTAssertTrue(c.pendingValues.isEmpty) + } + + /// A warm-up marked in advance still lands, and now travels with the numbers. + func testAWarmUpMarkedInAdvanceStillApplies() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.setWarmup(slot(0, 1), true) + c.updateSet(slot(0, 1), weightKg: 20, reps: 15, rpe: nil, isWarmup: true) + c.advance() + + let recorded = c.engine?.recordedSet(for: slot(0, 1)) + XCTAssertEqual(recorded?.isWarmup, true) + XCTAssertEqual(recorded?.weightKg, 20) + XCTAssertEqual(c.engine?.completedWorkingSets, 0, "a warm-up is still not a working set") + } + + /// A redo drops the record and should show the ghosts again — the consumed entry must not come + /// back and re-fill the row with numbers the user is in the middle of redoing. + func testARedoDoesNotResurrectTheConsumedEntry() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.updateSet(slot(0, 1), weightKg: 70, reps: 5, rpe: nil, isWarmup: false) + c.advance() // recorded with 70 x 5 + c.start(slot(0, 1)) // redo: record dropped + + XCTAssertNil(c.engine?.recordedSet(for: slot(0, 1))) + XCTAssertNil(c.enteredValues(for: slot(0, 1)).weightKg, + "the row is back to its ghosts, as it was before this existed") + } + + /// Removing a set takes what was entered for it along: adding the set back starts it fresh + /// rather than returning numbers and a warm-up mark given to a set the user dropped. + func testRemovingASetDropsWhatWasEnteredForIt() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 3), weightKg: 80, reps: 4, rpe: nil, isWarmup: false) + c.setWarmup(slot(0, 3), true) + + XCTAssertTrue(c.removeSet(fromExercise: 0)) + XCTAssertTrue(c.addSet(toExercise: 0)) + + XCTAssertNil(c.enteredValues(for: slot(0, 3)).weightKg, + "the re-added set shows its ghosts, not the dropped set's numbers") + XCTAssertFalse(c.isWarmup(slot(0, 3))) + XCTAssertTrue(c.pendingValues.isEmpty) + } +} diff --git a/StrandTests/LiftSessionPersistenceTests.swift b/StrandTests/LiftSessionPersistenceTests.swift new file mode 100644 index 0000000000..2625df269a --- /dev/null +++ b/StrandTests/LiftSessionPersistenceTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The crash-safe snapshot of an in-flight session, and the one question that decides whether an +/// update can be installed OVER the previous build or needs a wipe: **does data written by the +/// previous build still read?** +/// +/// The session snapshot is the only thing this feature persists outside SQLite (UserDefaults, +/// `noop.activeLiftSession`), so it is the only place an app-layer change can strand something the +/// old build wrote. A schema change is the other half of that question and is answered by the +/// migration, not here. +final class LiftSessionPersistenceTests: XCTestCase { + + private let t0 = 1_700_000_000 + + /// A snapshot exactly as the PREVIOUS build wrote it: no `programItemId`, because the field did + /// not exist until the set count could be changed mid-session. + /// + /// Written as literal JSON on purpose. Encoding it with today's `Snapshot` would only prove that + /// today's build can read itself — the whole point is to read bytes the old build produced. + private func snapshotJSONWithoutProgramItemId() -> Data { + Data(""" + { + "startSec": \(t0), + "programId": "p1", + "programName": "Upper A", + "plan": [ + {"exercise": "Bench press", "secondaryMuscles": ["triceps"], "primaryMuscle": "chest", + "targetSets": 3, "restSec": 90, "targetRepsLow": 8, "targetWeightKg": 60} + ], + "stage": {"kind": "resting", "item": 0, "set": 1, "endsAt": \(t0 + 130)}, + "sets": [ + {"exerciseIndex": 0, "setIndex": 1, "weightKg": 60, "reps": 8, "isWarmup": false, + "startTs": \(t0 + 10), "endTs": \(t0 + 40)} + ], + "stageStartedAt": \(t0 + 40) + } + """.utf8) + } + + /// 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 { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId()), + "a snapshot from the previous build must not read as 'no session'") + let engine = LiftSessionPersistence.engine(from: decoded) + + XCTAssertEqual(engine.startTs, t0) + XCTAssertEqual(engine.plan.count, 1) + XCTAssertEqual(engine.plan[0].exercise, "Bench press") + XCTAssertEqual(engine.plan[0].targetSets, 3) + XCTAssertEqual(engine.plan[0].primaryMuscle, .chest) + XCTAssertEqual(engine.stage, .resting(LiftSlot(exerciseIndex: 0, setIndex: 1), + endsAt: t0 + 130)) + XCTAssertEqual(engine.sets.count, 1, "the set already logged survives the update") + XCTAssertEqual(engine.sets[0].weightKg, 60) + } + + /// The new field is simply absent, not garbage — so the write-back is skipped for that session + /// rather than aimed at a program line that was never identified. + func testAResumedOldSessionCarriesNoProgramLineAndIsStillFullyUsable() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + var engine = LiftSessionPersistence.engine(from: decoded) + XCTAssertNil(engine.plan[0].programItemId) + + // And the set count can still be changed — the session works, the program just is not rewritten. + XCTAssertTrue(engine.addSet(toExercise: 0)) + XCTAssertEqual(engine.plan[0].targetSets, 4) + } + + /// Numbers typed for a set that has not happened yet must survive a crash. Losing them to a + /// relaunch would be the same bug as losing them to a blur, with extra steps. + func testNumbersTypedForASetNotYetPerformedSurviveARelaunch() throws { + let plan = [LiftPlanItem(exercise: "Row", targetSets: 3, restSec: 60)] + let engine = LiftSessionEngine(plan: plan, startTs: t0) + let slot = LiftSlot(exerciseIndex: 0, setIndex: 3) + let typed = LiftSessionController.PendingSetValues(weightKg: 72.5, reps: 6, rpe: nil) + + let encoded = try XCTUnwrap(LiftSessionPersistence.encode( + LiftSessionPersistence.snapshot(engine: engine, programId: nil, programName: nil, + pendingValues: [slot: typed], + pendingWarmups: [LiftSlot(exerciseIndex: 0, setIndex: 1)]))) + let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) + + XCTAssertEqual(LiftSessionPersistence.pendingValues(from: back), [slot: typed]) + XCTAssertEqual(LiftSessionPersistence.pendingWarmups(from: back), + [LiftSlot(exerciseIndex: 0, setIndex: 1)]) + } + + /// A snapshot written before any of this existed carries neither, and must resume with nothing + /// pending rather than failing to read at all. + func testAnOldSnapshotResumesWithNothingPending() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + XCTAssertTrue(LiftSessionPersistence.pendingValues(from: decoded).isEmpty) + XCTAssertTrue(LiftSessionPersistence.pendingWarmups(from: decoded).isEmpty) + } + + /// The round trip today's build performs on itself, including the new field. + func testTodaysSnapshotRoundTripsWithTheProgramLine() throws { + let plan = [LiftPlanItem(exercise: "Lat pulldown", primaryMuscle: .lats, + targetSets: 2, restSec: 60, programItemId: "line-7")] + var engine = LiftSessionEngine(plan: plan, startTs: t0) + engine.advance(now: t0) + engine.advance(now: t0 + 30) + engine.addSet(toExercise: 0) + + let encoded = try XCTUnwrap(LiftSessionPersistence.encode( + LiftSessionPersistence.snapshot(engine: engine, programId: "p1", programName: "Pull", + pendingValues: [:], pendingWarmups: []))) + let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) + let rebuilt = LiftSessionPersistence.engine(from: back) + + XCTAssertEqual(rebuilt.plan[0].programItemId, "line-7", + "without this the added set could not be written back after a relaunch") + XCTAssertEqual(rebuilt.plan[0].targetSets, 3, "the added set survives a crash") + XCTAssertEqual(rebuilt.sets.count, 1) + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 943a49e484..b6f8a3182a 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -16,6 +16,8 @@ struct RootTabView: View { /// advertise a behaviour that never happens. Off until someone confirms it on an iOS 26 device. @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false + /// The live gym session, owned at the app root — see `LiftSessionController`. + @EnvironmentObject private var liftSession: LiftSessionController /// External entry points must wait until the mandatory first-run gates have completed. The root owns /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool @@ -241,6 +243,29 @@ struct RootTabView: View { .onChange(of: homeScreenQuickActionsEnabled) { _, _ in presentPendingHomeScreenQuickActionIfPossible() } + // The running gym session, reachable from ANY tab. It sits above the tab bar rather than + // inside the Lift Log screen, because a workout outlives whichever screen you wandered to — + // and because swiping the sheet away must minimise the session, not end it. + .safeAreaInset(edge: .bottom, spacing: 0) { + if liftSession.isActive { + LiftSessionBar() + .padding(.horizontal, 14) + // Clear the floating tab bar with the same constant every screen uses, or the + // session bar sits on top of the tab labels. + .padding(.bottom, NoopMetrics.tabBarClearance) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.25), value: liftSession.isActive) + .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 @@ -427,6 +452,7 @@ struct RootTabView: View { moreSection("Body") { MoreRow("Live", "waveform.path.ecg", .live) MoreRow("Workouts", "figure.run", .workouts) + MoreRow("Lift Log", "dumbbell.fill", .liftLog) MoreRow("Health", "heart.text.square.fill", .health) MoreRow("Lab Book", "books.vertical.fill", .labBook) MoreRow("Stress", "bolt.heart.fill", .stress) @@ -550,7 +576,7 @@ struct RootTabView: View { /// registration in `moreTab`. private enum MoreDestination: Hashable { case insightsHub, intelligence, coach, insights, explore, compare - case live, workouts, health, labBook, stress, breathe, intervals, rhythm + case live, workouts, liftLog, health, labBook, stress, breathe, intervals, rhythm case fusedRecord, appleHealth, miBand, dataSources, backupSync, shortcutsExport, noopLimitations case alarms, automations, testCentre, siriShortcuts, powerSaving, settings @@ -564,6 +590,7 @@ private enum MoreDestination: Hashable { case .compare: CompareView() case .live: LiveView() case .workouts: WorkoutsView() + case .liftLog: LiftLogView() case .health: HealthView() case .labBook: LabBookView() case .stress: StressView() diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 8f49e8653c..0931472057 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -25,6 +25,14 @@ struct StrandiOSApp: App { /// observes it and presents the Devices manager. @StateObject private var router: NavRouter @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() + /// 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`. + @StateObject private var liftSession: LiftSessionController @Environment(\.scenePhase) private var scenePhase /// Appearance preference (System/Light/Dark). Default follows the OS; the Settings picker writes it. @AppStorage(AppearanceMode.storageKey) private var appearanceRaw = AppearanceMode.system.rawValue @@ -36,6 +44,9 @@ struct StrandiOSApp: App { /// Effort's display scale is also embedded in the shared widget snapshot. Observe it here so a /// Settings change gets one accurate full rebuild instead of waiting for an unrelated repo refresh. @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue + /// kg vs lb for the Lift Log Live Activity's "8 x 30 kg" line — the app formats it, because the + /// unit preference lives here and not in the widget extension. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue init() { // #1008: pin the pre-change Overnight-only default for existing installs before @@ -72,6 +83,15 @@ struct StrandiOSApp: App { NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } let model = AppModel() _model = StateObject(wrappedValue: model) + // The buzz and the strap-gesture claim are injected, so the controller itself knows nothing + // about BLE and stays testable. + _liftSession = StateObject(wrappedValue: LiftSessionController( + buzz: { [weak model] loops in + model?.buzz(loops: loops, gate: HapticPrefs.liftRest) + }, + setStrapHandler: { [weak model] handler in + model?.strapDoubleTapOverride = handler + })) // #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 @@ -163,6 +183,7 @@ struct StrandiOSApp: App { .environmentObject(health) .environmentObject(router) .environmentObject(UpdateStore.shared) + .environmentObject(liftSession) // v5 L3: the shared stress check-in nudge surface, so the Breathe screen's passive // card observes the SAME instance the central detector (AppModel.evaluateStress) posts to. .environment(\.stressNudgeCenter, model.stressNudgeCenter) @@ -187,9 +208,10 @@ struct StrandiOSApp: App { liveActivity.update( bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, - connected: model.live.connected, + connected: model.live.connected && !liftSession.isActive, effort: day?.strain.map { Int($0.rounded()) } ) + pushLiftActivity() } // End the Live Activity the moment the link drops, even if no further HR tick arrives. .onReceive(model.live.$connected) { isConnected in @@ -200,10 +222,15 @@ struct StrandiOSApp: App { liveActivity.update( bpm: isConnected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, - connected: isConnected, + connected: isConnected && !liftSession.isActive, 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() } // #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 @@ -351,6 +378,32 @@ struct StrandiOSApp: App { } } } + + /// Map the running session onto the Lock Screen banner. + /// + /// 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. + @MainActor + private func pushLiftActivity() { + let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric + guard let p = liftSession.presentation(system: system) else { + liftActivity.update(programName: "", state: nil) + return + } + liftActivity.update( + programName: liftSession.programName ?? String(localized: "Session"), + 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"), + stageStartedAt: p.stageStartedAt, + restEndsAt: p.restEndsAt)) + } } /// iOS root — the `RootTabView` shell with the first-run onboarding/pairing wizard overlaid until @@ -540,5 +593,6 @@ private struct OuraOnboardingDemoHost: View { var body: some View { AddDeviceWizard(live: live, onClose: {}, startAt: (.oura, .prep)) } + } #endif diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift new file mode 100644 index 0000000000..a170f736a3 --- /dev/null +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -0,0 +1,99 @@ +#if os(iOS) +import Foundation +import ActivityKit + +/// Starts, updates and ends the Lift Log session Live Activity. +/// +/// Modelled on `LiveActivityController` (live HR) and deliberately separate from it: the two answer +/// different questions and have different lifetimes. While a gym session is open this one is the +/// useful banner — it carries the heart rate too — so the app suppresses the HR activity rather than +/// stacking two on the Lock Screen. +/// +/// PUSHES ARE CONTENT-DRIVEN, NOT CLOCK-DRIVEN. The widget's timers tick on their own from dates in +/// the content state, so this only sends an update when something a person would notice changes: +/// the stage, the exercise, the set, the numbers, or the heart rate. Without that, an activity that +/// merely shows a running clock would push once a second for a whole workout and be throttled by +/// ActivityKit for it. +@MainActor +final class LiftLiveActivityController { + private var activity: Activity? + private var lastPush: Date = .distantPast + private var lastSignature: String? + /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is + /// consulted on every session tick and its value only changes via Settings. + private let authInfo = ActivityAuthorizationInfo() + /// Guards against two ticks 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 + + /// 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 } + + // 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 } + + // 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 + } + + // 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.stageStartedAt.timeIntervalSince1970)", + "\(state.restEndsAt?.timeIntervalSince1970 ?? 0)", + ].joined(separator: "|") + + let contentChanged = signature != lastSignature + let heartRateDue = Date().timeIntervalSince(lastPush) >= Self.heartRateMinInterval + let content = ActivityContent(state: state, + staleDate: Date().addingTimeInterval(Self.staleAfter)) + + if let activity { + guard contentChanged || heartRateDue else { return } + lastSignature = signature + lastPush = Date() + Task { await activity.update(content) } + } else { + // Set synchronously before any await, so a second tick arriving while `Activity.request` + // is still in flight bails here instead of creating a duplicate activity. + guard !isStarting else { return } + isStarting = true + do { + activity = try Activity.request( + attributes: LiftActivityAttributes(programName: programName), + content: content, + pushType: nil) + lastSignature = signature + lastPush = Date() + } catch { + activity = nil + } + isStarting = false + } + } + + 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. + for act in Activity.activities { + await act.end(nil, dismissalPolicy: .immediate) + } + activity = nil + lastSignature = nil + } +} +#endif diff --git a/StrandiOSShared/LiftActivityAttributes.swift b/StrandiOSShared/LiftActivityAttributes.swift new file mode 100644 index 0000000000..f867ed1796 --- /dev/null +++ b/StrandiOSShared/LiftActivityAttributes.swift @@ -0,0 +1,60 @@ +#if os(iOS) +import Foundation +import ActivityKit + +/// Live Activity attributes for a running Lift Log session — the minimised session bar, on the Lock +/// Screen and in the Dynamic Island. +/// +/// Deliberately a SEPARATE activity type from `NOOPActivityAttributes` (live HR). They answer +/// different questions and have different lifetimes: the HR activity lives as long as the strap is +/// streaming, this one as long as a gym session is open. While a session is running this one is the +/// useful surface — it carries the heart rate too — so the app suppresses the HR activity rather +/// than stacking two banners on the Lock Screen. +/// +/// TIME IS CARRIED AS DATES, NOT AS A FORMATTED STRING. The widget renders them with +/// `Text(timerInterval:)`, which ticks on its own without the app pushing anything. Pushing a new +/// content state every second to animate a clock would burn the Live Activity update budget and +/// still look worse. +public struct LiftActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + /// Amber rest vs green working — the same colour language as the sheet and the bar. + public var isResting: Bool + /// The exercise being worked, or rested from. + public var exercise: String + /// "Set 2" / "Resting after set 2" — resolved app-side so the wording matches the bar. + public var status: String + /// "8 x 30 kg", already unit-converted, because the unit preference lives in the app. + /// 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 + /// 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, + stageStartedAt: Date, restEndsAt: Date?) { + self.isResting = isResting + self.exercise = exercise + self.status = status + self.detail = detail + self.bpm = bpm + self.progress = progress + 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 + } +} +#endif diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift new file mode 100644 index 0000000000..f419ebaaa4 --- /dev/null +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -0,0 +1,146 @@ +import WidgetKit +import SwiftUI +import ActivityKit +import StrandDesign + +/// Live Activity for a running Lift Log session — the minimised session bar, on the Lock Screen and +/// in the Dynamic Island. +/// +/// It carries the same four things the in-app bar does, in the same order, because it is answering +/// the same question from further away: what am I doing, on what, with what numbers, and how long. +/// The colour language matches too — green while a set is being worked, amber through the rest. +/// +/// THE CLOCK TICKS WITHOUT THE APP. Both timers are `Text(timerInterval:)`, driven by dates in the +/// content state, so the Lock Screen counts on its own between pushes. The app only sends a new +/// state when something actually changes (stage, set, heart rate), never once a second to animate a +/// number. +struct LiftLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LiftActivityAttributes.self) { context in + lockScreen(context.state, program: context.attributes.programName) + .activityBackgroundTint(StrandPalette.surfaceBase) + .activitySystemActionForegroundColor(StrandPalette.textPrimary) + } dynamicIsland: { context in + let tint = tint(context.state) + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label(context.state.exercise, systemImage: "dumbbell.fill") + .font(.caption).lineLimit(1) + .foregroundStyle(tint) + } + DynamicIslandExpandedRegion(.trailing) { + Label { + Text(context.state.bpm.map(String.init) ?? "—").monospacedDigit() + } icon: { + Image(systemName: "heart.fill") + } + .font(.caption) + .foregroundStyle(context.state.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.state.detail ?? context.state.status) + .font(.caption).lineLimit(1) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 8) + clock(context.state, tint: tint) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + } + } + } compactLeading: { + Image(systemName: "dumbbell.fill").foregroundStyle(tint) + } compactTrailing: { + clock(context.state, tint: tint) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } minimal: { + Image(systemName: "dumbbell.fill").foregroundStyle(tint) + } + } + } + + /// 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) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(tint(state)) + + VStack(alignment: .leading, spacing: 2) { + Text(state.exercise) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(state.detail.map { "\(state.status) — \($0)" } ?? state.status) + .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) + } + + Spacer(minLength: 8) + + // 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. + // + // 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) { + Label { + Text(state.bpm.map(String.init) ?? "—").monospacedDigit() + } icon: { + Image(systemName: "heart.fill") + } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(state.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + + clock(state, tint: tint(state)) + .font(.system(size: 22, weight: .bold, design: .rounded)) + } + } + .padding() + } + + /// Counts DOWN through a rest (the number you act on) and UP through a set, both self-ticking. + /// + /// Both branches use `Text(timerInterval:)`, which is the API widgets are given for a clock that + /// advances without the app pushing. `Text(date, style: .timer)` looks equivalent and is not: on + /// 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. + 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) + } + let from = state.restEndsAt ?? state.stageStartedAt + return Text(timerInterval: from...from.addingTimeInterval(86_400), countsDown: false) + }() + return counter + .monospacedDigit() + .foregroundStyle(tint) + } +} diff --git a/StrandiOSWidgets/NOOPWidgetBundle.swift b/StrandiOSWidgets/NOOPWidgetBundle.swift index c808f07c25..1ebec07f03 100644 --- a/StrandiOSWidgets/NOOPWidgetBundle.swift +++ b/StrandiOSWidgets/NOOPWidgetBundle.swift @@ -2,8 +2,9 @@ import WidgetKit import SwiftUI /// The widget extension entry point. Bundles the glanceable widget, the live-HR Live Activity, -/// the K10 Coach brief widget (stored morning brief on Lock Screen / Home Screen), and the -/// heart-rate trace widget (#1957), and the stress curve widget (#2040). +/// the K10 Coach brief widget (stored morning brief on Lock Screen / Home Screen), the +/// heart-rate trace widget (#1957), the stress curve widget (#2040), and the Lift Log session +/// Live Activity. @main struct NOOPWidgetBundle: WidgetBundle { var body: some Widget { @@ -12,5 +13,6 @@ struct NOOPWidgetBundle: WidgetBundle { CoachBriefWidget() HeartRateWidget() StressWidget() + LiftLiveActivity() } } diff --git a/Tools/make_lift_program_template.py b/Tools/make_lift_program_template.py new file mode 100644 index 0000000000..de2983a2f0 --- /dev/null +++ b/Tools/make_lift_program_template.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""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 +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 +rebuild it, and so the exact bytes are reproducible. An .xlsx is a ZIP of XML; only the parts that +matter here are emitted. + +WHAT MAKES IT "FILLABLE, NOT EDITABLE": + * the sheet is protected, and only the data cells are unlocked — headers cannot be renamed, + 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; + * a second sheet carries the instructions and a worked example, and is NOT read by the importer, + which always reads the first sheet. + +Run: python3 Tools/make_lift_program_template.py +Out: docs/lift-log-program-template.xlsx +""" +import os +import zipfile + +MUSCLES = [ + "Chest", "Front delts", "Side delts", "Rear delts", "Triceps", + "Lats", "Upper back", "Traps", "Biceps", "Forearms", + "Quads", "Hamstrings", "Glutes", "Adductors", "Abductors", "Calves", + "Abs", "Obliques", "Lower back", "Neck", +] + +HEADERS = [ + "Program", "Program note", "Exercise", "Primary muscle", "Secondary muscles", + "Sets", "Reps", "Weight kg", "Rest sec", "Note", +] + +# Generous, so a user can paste a long routine in without running out of validated rows. +DATA_ROWS = 200 + +HELP = [ + ("How to use this template", True), + ("", False), + ("1. Fill in one row per exercise, in the order you want to do them.", False), + ("2. Put the same name in 'Program' for every exercise that belongs to the same session.", False), + (" You can keep several programs in one file - Upper A, Lower A - just change the name.", False), + ("3. Save the file, put it on your phone, and open NOOP > Lift Log > Import a program.", False), + ("", False), + ("Only 'Exercise' is required. Every other column can be left blank and filled in later", False), + ("in the app, or during the session itself.", False), + ("", False), + ("Primary muscle / Secondary muscles use the dropdowns. Secondary accepts several,", False), + ("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), + ("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), + ("", False), + ("Muscle groups you can choose from", True), +] + [(m, False) for m in MUSCLES] + + +def esc(s): + return (s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def col_letter(i): + s = "" + i += 1 + while i: + i, r = divmod(i - 1, 26) + s = chr(65 + r) + s + return s + + +def cell(ref, text, style): + """An inline-string cell. Inline rather than shared strings keeps this generator single-pass.""" + if text == "": + return f'' + return (f'' + f'{esc(text)}') + + +def program_sheet(): + rows = [] + # Header row: style 1 (bold + locked). + cells = "".join(cell(f"{col_letter(i)}1", h, 1) for i, h in enumerate(HEADERS)) + rows.append(f'{cells}') + # Data rows: style 2 (unlocked), empty and ready to type into. + for r in range(2, DATA_ROWS + 2): + cells = "".join(f'' for i in range(len(HEADERS))) + rows.append(f'{cells}') + + widths = [16, 26, 30, 16, 26, 7, 7, 11, 10, 34] + cols = "".join( + f'' + for i, w in enumerate(widths)) + + listing = ",".join(MUSCLES) + validations = ( + f'' + f'"{esc(listing)}"' + f'"{esc(listing)}"' + f'') + + # Sheet protection, and the attribute semantics are the opposite of what they look like. + # + # In OOXML each of these flags answers "is this operation PREVENTED", and they default to TRUE + # once `sheet="1"`. So an attribute set to "0" ALLOWS the thing it names. An earlier version of + # this file carried `insertColumns="0" deleteColumns="0"`, which permitted exactly the one edit + # that breaks the importer: the column mapping is by header name and position, so a column + # inserted in the middle silently moves reps into the weight field. + # + # Left at their default (prevented): inserting and deleting COLUMNS. + # Explicitly allowed: selecting cells (or the sheet is unreadable), typing into the unlocked data + # cells, cosmetic formatting, sorting and filtering, and inserting or deleting ROWS — a user with + # a long routine needs more rows, and row count is nothing the importer depends on. + protection = ('') + + return ( + '' + '' + '' + '' + '' + f'{cols}' + f'{"".join(rows)}' + f'{protection}{validations}' + '') + + +def help_sheet(): + rows = [] + for i, (text, bold) in enumerate(HELP, start=1): + rows.append(f'{cell(f"A{i}", text, 1 if bold else 0)}') + return ( + '' + '' + '' + f'{"".join(rows)}' + '' + '') + + +STYLES = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + # 0: plain, locked + '' + # 1: header - bold, tinted, LOCKED so the structure cannot be edited + '' + # 2: data - UNLOCKED, the only cells a user can type into + '' + '' + '' + '' + '') + +CONTENT_TYPES = ( + '' + '' + '' + '' + '' + '' + '' + '' + '') + +ROOT_RELS = ( + '' + '' + '' + '') + +WORKBOOK = ( + '' + '' + '' + '' + '' + '') + +WORKBOOK_RELS = ( + '' + '' + '' + '' + '' + '') + + +def main(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + out = os.path.join(here, "docs", "lift-log-program-template.xlsx") + os.makedirs(os.path.dirname(out), exist_ok=True) + parts = { + "[Content_Types].xml": CONTENT_TYPES, + "_rels/.rels": ROOT_RELS, + "xl/workbook.xml": WORKBOOK, + "xl/_rels/workbook.xml.rels": WORKBOOK_RELS, + "xl/styles.xml": STYLES, + "xl/worksheets/sheet1.xml": program_sheet(), + "xl/worksheets/sheet2.xml": help_sheet(), + } + # Fixed timestamps: the file is a build artifact committed to the repo, and it should only change + # when its CONTENT does, not every time it is regenerated. + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for name, body in parts.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + z.writestr(info, body) + print(f"wrote {os.path.relpath(out, here)} ({os.path.getsize(out)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/docs/LIFT_LOG_PROGRAM_IMPORT.md b/docs/LIFT_LOG_PROGRAM_IMPORT.md new file mode 100644 index 0000000000..0e3f93c93b --- /dev/null +++ b/docs/LIFT_LOG_PROGRAM_IMPORT.md @@ -0,0 +1,62 @@ +# 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 +on a computer instead, in a couple of minutes, and bring the file across. + +## The template + +**[`lift-log-program-template.xlsx`](lift-log-program-template.xlsx)** — download it, open it in +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. + +## The columns + +| Column | Required | Notes | +|---|---|---| +| `Program` | no | The session name — "Upper A". Every row with the same name becomes one program, so several programs can live in one file. Defaults to "Imported program". | +| `Program note` | no | Taken from whichever row of that program carries it. | +| `Exercise` | **yes** | Whatever you call the movement. NOOP ships no exercise catalogue — your name is the name. | +| `Primary muscle` | no | Dropdown. | +| `Secondary muscles` | no | Dropdown, and several can be typed separated by commas: `Front delts, Triceps`. | +| `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. | +| `Rest sec` | no | **Seconds.** `120` is two minutes. | +| `Note` | no | Your technique cue, verbatim. | + +Only `Exercise` is required. Anything left blank can be filled in later in the app, or during the +session itself — a blank stays blank rather than becoming a zero. + +## Importing + +**Lift Log → Import → Choose a file.** Nothing is written until you have seen what it will create: +the programs, their exercises and targets, and anything that needs checking. Then press Import. + +Exercises are remembered with the muscle groups you gave them, so the picker offers them next time +and the per-muscle rollup resolves the same name the same way. + +## Formats and quirks + +- **`.xlsx`** (the template) and **`.csv`** both work. In a workbook, the first sheet in tab order that + has an `Exercise` column is imported, so an instructions tab in front of it is fine. +- **CSV delimiters** are sniffed — `,`, `;` or tab. Excel in most of Europe writes `;`, which is fine. +- **Decimal commas** are understood: `40,5` and `40.5` both mean 40.5. +- **Unit suffixes** are tolerated: `40,5 kg` reads as 40.5. +- **Blank rows** are skipped, wherever they are. +- **An unrecognised muscle** warns and leaves that line unclassified — it never guesses, and never + throws the row away. The muscle vocabulary is a stored-data contract, so deciding that "Shoulders" + means front delts would put sets in a bucket you did not choose. +- The template's dropdowns are in **English**, since the file is authored on a computer. The import + also accepts the stored tokens (`frontDelts`), and is insensitive to case, spaces and hyphens. + +## Rebuilding the template + +`python3 Tools/make_lift_program_template.py` — no third-party Python packages needed. The file is a +committed build artifact; regenerate it if the columns or the muscle vocabulary ever change, and note +that `LiftProgramSheetImporterTests` parses the shipped template and will fail if a column goes +missing. diff --git a/docs/lift-log-program-template.xlsx b/docs/lift-log-program-template.xlsx new file mode 100644 index 0000000000..ba1875f188 Binary files /dev/null and b/docs/lift-log-program-template.xlsx differ