diff --git a/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift b/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift index 0cd8839c1f..e41234f3ee 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift @@ -33,12 +33,27 @@ public struct MotionTrace: View { /// The peak magnitude used to normalise the fill height. A non-positive peak (all-zero / empty) maps /// everything to the baseline so the strip is flat rather than dividing by zero. - private var peak: Double { max(epochs.max() ?? 0, 0) } + /// + /// Computed ONCE where it is needed and threaded down, never read inside a per-epoch loop. As a + /// computed property it rescanned every epoch on each read, and it was read from inside the `map` in + /// `points(in:)` and the `filter` in `accessibilitySummary`, so a night cost a scan per epoch. With + /// 30-second epochs an 8-hour night is ~960 of them, about 1.8 million comparisons every time the + /// strip is laid out, and SwiftUI re-runs `body` on hover, animation and the 1 Hz HR tick (#2283). + /// + /// Android already hoists the same value (`SleepScreen.kt`), so this removes a divergence rather than + /// creating one. + static func peak(of epochs: [Double]) -> Double { max(epochs.max() ?? 0, 0) } public var body: some View { GeometryReader { geo in let w = geo.size.width let h = geo.size.height + // ONE scan, threaded into everything below, rather than a scan per epoch. See `peak(of:)`. + // + // Sits inside the GeometryReader, so it is recomputed per LAYOUT pass rather than per body + // evaluation. That distinction does not matter here (both are O(n) against the O(n^2) this + // replaces) and keeping it here avoids restructuring `body` around the ViewBuilder. + let peak = Self.peak(of: epochs) ZStack { // Faint baseline so the strip reads as a grounded trace even on a calm night. Path { p in @@ -49,7 +64,7 @@ public struct MotionTrace: View { // Filled area under the per-epoch magnitude, normalised to the night's own peak. if epochs.count >= 2, peak > 0 { - let pts = points(in: geo.size) + let pts = Self.points(in: geo.size, epochs: epochs, peak: peak) Path { p in p.move(to: CGPoint(x: 0, y: h)) for pt in pts { p.addLine(to: pt) } @@ -77,14 +92,14 @@ public struct MotionTrace: View { } .accessibilityElement() .accessibilityLabel(Text("Movement during sleep")) - .accessibilityValue(Text(accessibilitySummary)) + .accessibilityValue(Text(Self.accessibilitySummary(epochs: epochs, peak: peak))) } .frame(height: height) } /// One screen point per epoch: x spread evenly across the width (matching the hypnogram's left→right /// time mapping), y the magnitude normalised to the night's peak (0 at the baseline, full at the top). - private func points(in size: CGSize) -> [CGPoint] { + static func points(in size: CGSize, epochs: [Double], peak: Double) -> [CGPoint] { let n = epochs.count guard n >= 2, peak > 0 else { return [] } let h = size.height @@ -98,7 +113,7 @@ public struct MotionTrace: View { /// A coarse VoiceOver summary — the share of epochs with above-half-peak movement — since a per-epoch /// trace can't be voiced point by point. "Calm" when nothing crosses the threshold. - private var accessibilitySummary: String { + static func accessibilitySummary(epochs: [Double], peak: Double) -> String { guard peak > 0, !epochs.isEmpty else { return "no movement data" } let restless = epochs.filter { $0 >= peak * 0.5 }.count if restless == 0 { return "calm throughout" } diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/MotionTracePeakTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/MotionTracePeakTests.swift new file mode 100644 index 0000000000..8ab7c0a65c --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/MotionTracePeakTests.swift @@ -0,0 +1,97 @@ +import XCTest +import CoreGraphics +@testable import StrandDesign + +/// Hoisting `peak` out of the per-epoch loops must not change a single pixel or a single word (#2283). +/// +/// `peak` was a computed property that rescanned every epoch, read from inside the `map` in `points` and +/// the `filter` in `accessibilitySummary`. That is a scan per epoch: with 30-second epochs an 8-hour +/// night is ~960 of them, about 1.8 million comparisons every time the strip is laid out, repeated +/// because SwiftUI re-runs `body` on hover, animation and the 1 Hz HR tick. +/// +/// These pin the OUTPUT rather than the speed. A performance change that alters what is drawn is not a +/// performance change, it is a regression, and the normalisation, the half-peak threshold and the +/// degenerate cases are exactly where a careless hoist would show it. +final class MotionTracePeakTests: XCTestCase { + + private let size = CGSize(width: 100, height: 40) + + /// The pre-hoist definitions, transcribed, so the new code is compared against the old behaviour + /// rather than against itself. + private func referencePoints(_ epochs: [Double]) -> [CGPoint] { + let peak = max(epochs.max() ?? 0, 0) + let n = epochs.count + guard n >= 2, peak > 0 else { return [] } + let h = size.height + let usable = h - 2 + return epochs.enumerated().map { i, v in + let x = CGFloat(i) / CGFloat(n - 1) * size.width + let frac = CGFloat(max(0, min(v / peak, 1))) + return CGPoint(x: x, y: h - frac * usable) + } + } + + private func referenceSummary(_ epochs: [Double]) -> String { + let peak = max(epochs.max() ?? 0, 0) + guard peak > 0, !epochs.isEmpty else { return "no movement data" } + let restless = epochs.filter { $0 >= peak * 0.5 }.count + if restless == 0 { return "calm throughout" } + let pct = Int((Double(restless) / Double(epochs.count) * 100).rounded()) + return "\(pct)% of the night had elevated movement" + } + + private func assertMatches(_ epochs: [Double], _ label: String, + file: StaticString = #filePath, line: UInt = #line) { + let peak = MotionTrace.peak(of: epochs) + XCTAssertEqual(MotionTrace.points(in: size, epochs: epochs, peak: peak), + referencePoints(epochs), label, file: file, line: line) + XCTAssertEqual(MotionTrace.accessibilitySummary(epochs: epochs, peak: peak), + referenceSummary(epochs), label, file: file, line: line) + } + + func testAnOrdinaryNightIsUnchanged() { + // 30-second epochs across eight hours, the real shape this draws. + let epochs = (0..<960).map { i in Double((i * 37) % 100) / 10.0 } + assertMatches(epochs, "ordinary night") + } + + func testDegenerateNightsAreUnchanged() { + // The cases where a careless hoist changes behaviour: dividing by a zero peak, or losing the + // guard that keeps an empty strip flat rather than crashing. + assertMatches([], "empty") + assertMatches([0], "single zero epoch") + assertMatches([4.2], "single non-zero epoch") + assertMatches([0, 0, 0, 0], "all zero") + assertMatches([-1, -2], "negative magnitudes clamp to a flat strip") + } + + func testHalfPeakThresholdIsUnchanged() { + // `accessibilitySummary` counts epochs at or above half the peak, so a value exactly on the + // boundary is the one that would move if the peak were computed differently. + assertMatches([10, 5, 4.999, 0], "values straddling half peak") + assertMatches([1, 1, 1], "every epoch at the peak") + } + + func testTheStripsOwnHelpersAgreeOnTheSamePeak() { + // The gap these tests cannot close: nothing here checks that `body` passes the peak it computed + // into both helpers. Pin the next best thing, that the helpers agree when handed the peak the + // hoisted accessor produces, so a caller threading a DIFFERENT value is the only way to break it. + let epochs: [Double] = [0, 3, 9, 4.5, 4.4, 0] + let peak = MotionTrace.peak(of: epochs) + XCTAssertEqual(peak, 9) + let pts = MotionTrace.points(in: size, epochs: epochs, peak: peak) + XCTAssertEqual(pts.count, epochs.count) + // 9 is the peak, so it must land at the very top of the usable band, and 0 at the baseline. + XCTAssertEqual(pts[2].y, size.height - (size.height - 2), accuracy: 0.0001) + XCTAssertEqual(pts[0].y, size.height, accuracy: 0.0001) + // 4.5 is exactly half the peak and counts as restless; 4.4 does not. Two of six is 33%. + XCTAssertEqual(MotionTrace.accessibilitySummary(epochs: epochs, peak: peak), + "33% of the night had elevated movement") + } + + func testPeakIgnoresNegativesAndEmpties() { + XCTAssertEqual(MotionTrace.peak(of: []), 0) + XCTAssertEqual(MotionTrace.peak(of: [-5, -1]), 0, "a negative peak clamps to zero") + XCTAssertEqual(MotionTrace.peak(of: [1, 9, 3]), 9) + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index b11c2ba688..ae1fd094be 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1082,6 +1082,18 @@ final class AppModel: ObservableObject { /// Finish the active workout: finalize the GPS route (#524), score the captured HR window, and save it /// as a `WorkoutRow`. A session with no HR window AND no real GPS route is discarded quietly (parity /// with Android) , but a GPS-only walk with HR not streaming still saves. Double-buzz confirms. + /// Shortest live session worth keeping. Below this a start/stop is an accident, not training (#2278). + static let minimumWorkoutSeconds: TimeInterval = 60 + + /// Whether a finished live session is too short to save. + /// + /// A named predicate rather than an inline comparison so the boundary is pinned by a test and so the + /// Android twin has one thing to mirror. Exactly `minimumWorkoutSeconds` is KEPT: a wearer who logs a + /// deliberate one-minute effort gets to keep it, and the discard is for what falls short of that. + nonisolated static func isTooShortToSave(elapsedSeconds: TimeInterval) -> Bool { + elapsedSeconds < minimumWorkoutSeconds + } + func endWorkout() { guard let w = activeWorkout else { return } endZoneTraining() @@ -1113,6 +1125,26 @@ final class AppModel: ObservableObject { return } let end = Date() + // A session under a minute is a start/stop the wearer did not mean to keep, and it was the thing + // that made deletion feel broken: the list filled with 5-30 second entries (#2278). Discarded HERE, + // at save, rather than retained and pruned later, which is the whole difference between dropping + // something that never had training data in it and deleting a wearer's history. NOOP has no server + // and no cloud copy, so a later prune would be irreversible; this is not, because nothing with real + // data is ever removed. + // + // Sits after the sample/route gate above so that gate's meaning is unchanged: a 30-second session + // can easily carry two HR samples and would otherwise have been saved. + let elapsed = w.elapsed(at: end) + if Self.isTooShortToSave(elapsedSeconds: elapsed) { + emitWorkoutsTrace(WorkoutsTrace.sessionLine( + event: "discarded", sportKey: WorkoutSource.traceSportKey(w.sport), + hrSamples: samples.count, durationSec: Int(elapsed), + gpsPoints: wasGps ? gpsRecorder.pointCount : nil)) + // Drop the route too: keeping a polyline for a session that was never saved would orphan it in + // RouteStore under a natural key no row claims. + lastWorkout = nil + return + } let avg = samples.isEmpty ? nil : Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) let peak = samples.map(\.bpm).max() diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 563c220c3e..29f9fbbdb6 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -362,6 +362,43 @@ final class Repository: ObservableObject { return Self.rawWhoopSourceIds(activeDeviceId: deviceId, registeredWhoopIds: registeredWhoopIds) } + /// Every namespace the Workouts list reads a row out of, in read order, duplicates collapsed. + /// + /// Why this exists: the list unions many device ids while `deleteWorkout` deleted from exactly ONE, + /// the active strap. A row banked under any other namespace (a retained strap, a computed `-noop` + /// sibling, Apple Health, an imported lifting session or activity file) was therefore VISIBLE BUT + /// UNDELETABLE: the delete reported nothing, the reload re-read the row from the namespace the delete + /// never touched, and it came straight back. Reported as workouts that ignore the delete button + /// (#2278). + /// + /// Deriving both sides from one function is the point. Spelling the union out twice is what let them + /// disagree, and a future namespace added to the read alone would reintroduce exactly this bug. + nonisolated static func workoutNamespaces(rawIds: [String]) -> [String] { + deletableWorkoutNamespaces(rawIds: rawIds) + + [WorkoutSource.appleHealthSource, "lifting", HevySource.id, "activity-file"] + } + + /// The subset of [workoutNamespaces] a DELETE may touch: the strap namespaces only. + /// + /// Imported history is read-only, and that is enforced everywhere else: the row menu offers only + /// "Duplicate as manual…" for an imported row, `bulkDeleteWorkouts` skips those classes outright, and + /// `mergeWorkouts` refuses them with "never rewrite imported history". A delete that swept the import + /// namespaces would reach underneath all three guards and destroy a wearer's imported Apple Health, + /// Hevy/Liftosaur or FIT/GPX/TCX row, which nothing in the UI ever offers to remove. + /// + /// That a cross-source twin is COLLAPSED into one row at display time does not license deleting the + /// imported half of the pair: the dedup is a presentation decision, and the surviving import is + /// exactly the history this repository promises not to rewrite. + /// + /// A `.manual` row, the only class the delete button is offered for, is written under a strap id, so + /// this set is what a delete actually needs. + nonisolated static func deletableWorkoutNamespaces(rawIds: [String]) -> [String] { + (rawIds + rawIds.map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }) + .reduce(into: [String]()) { acc, id in + if !acc.contains(id) { acc.append(id) } + } + } + /// Pure ordering contract shared with Android's parity guard: current active source first, every other /// registered WHOOP in stable registry order, canonical history last; duplicates collapse. nonisolated static func rawWhoopSourceIds(activeDeviceId: String, @@ -3433,25 +3470,18 @@ final class Repository: ObservableObject { // De-dup identical same-source rows that appear under both union ids by natural key (the cross-SOURCE // dedup below only collapses strap-vs-Apple twins, not a row present in two strap namespaces). var rows: [WorkoutRow] = [] - let rawIds = rawPhysiologyReadIds(store: store) - for id in rawIds { rows += await pagedWorkoutRows(store: store, deviceId: id, from: lo, to: hi) } - for id in rawIds.map({ $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }) { + // Every namespace in one list, shared with `deleteWorkout` so the two cannot disagree about where a + // row lives (#2278). File-imported lifting sessions (Hevy / Liftosaur exports) live under "lifting"; + // API-synced Hevy sessions deliberately use their own source so an export and its API twin can be + // fused instead of one silently overwriting the other. #29: imported activity FILES (FIT / GPX / + // TCX) live under "activity-file", or a successful file import never appears here at all. + for id in Self.workoutNamespaces(rawIds: rawPhysiologyReadIds(store: store)) { rows += await pagedWorkoutRows(store: store, deviceId: id, from: lo, to: hi) } - rows += await pagedWorkoutRows(store: store, deviceId: "apple-health", from: lo, to: hi) - // File-imported lifting sessions (Hevy / Liftosaur exports) live under "lifting". API-synced - // Hevy sessions deliberately use their own source so an export and its API twin can be fused - // instead of one silently overwriting the other. - rows += await pagedWorkoutRows(store: store, deviceId: "lifting", from: lo, to: hi) - rows += await pagedWorkoutRows(store: store, deviceId: "hevy", from: lo, to: hi) // Native sessions remain normalized in the training tables. Their read-time envelope makes // them visible to Workouts and session fusion without persisting a duplicate workout row. let native = await pagedNativeWorkoutRows(store: store, from: lo, to: hi) rows += native.map(NativeTrainingProjection.workoutRow) - // #29: imported activity FILES (FIT / GPX / TCX) live under their own "activity-file" source — read - // them too, or a successful file import never appears in the Workouts list (Data Sources counts it, - // the load didn't). HR is reconciled from the strap trace at the end like every other row. - rows += await pagedWorkoutRows(store: store, deviceId: "activity-file", from: lo, to: hi) rows = Self.dedupWorkoutsByNaturalKey(rows) let lifecycleLinks = ((try? await store.trainingSessionLinks()) ?? []).filter { $0.origin == "native-lifecycle" } rows = Self.hidingLegacyStrengthRecordings(rows, links: lifecycleLinks) @@ -3784,8 +3814,22 @@ final class Repository: ObservableObject { func deleteWorkout(_ row: WorkoutRow) async { if WorkoutSource.classify(row.source) == .detected { await dismissDetected(row); return } guard let store = await ensureStore() else { return } - _ = try? await store.deleteWorkouts(deviceId: deviceId, sport: row.sport, - from: row.startTs, to: row.startTs) + // Sweep every STRAP namespace, not just the active one. A manual row banked under a retained + // strap or a computed sibling is shown by `workoutRows` and was previously undeletable: the + // delete touched one namespace, the reload re-read the row from another, and it reappeared + // (#2278). + // + // Import namespaces are deliberately excluded, see `deletableWorkoutNamespaces`: imported + // history is read-only and no UI offers to remove it. + // + // Narrow by construction. The natural key is exact (`sport` plus a single `startTs`), so this + // removes the row the wearer tapped and its copies in the strap namespaces, nothing else. An + // overlapping-but-differently-keyed session is NOT touched; collapsing those is the dedup's job + // at display time, not a delete's. + for id in Self.deletableWorkoutNamespaces(rawIds: rawPhysiologyReadIds(store: store)) { + _ = try? await store.deleteWorkouts(deviceId: id, sport: row.sport, + from: row.startTs, to: row.startTs) + } } /// #64: merge two-or-more overlapping / adjacent MANUAL or DETECTED sessions into ONE manual session diff --git a/Strand/Data/WorkoutSource.swift b/Strand/Data/WorkoutSource.swift index 1b4e4b44c6..05102c15f5 100644 --- a/Strand/Data/WorkoutSource.swift +++ b/Strand/Data/WorkoutSource.swift @@ -400,6 +400,16 @@ enum WorkoutSource: Equatable { /// The span cap a manual workout may cover, shared by both builders and the sheet's binding. static let maxManualSpanSeconds = 24 * 60 * 60 + /// Shortest manual session worth keeping, matching the live-session floor in `AppModel.endWorkout`. + /// + /// The duration-shaped front door already enforced this by accident, since it counts whole minutes and + /// rejects zero. The SPAN-shaped door did not, and that is the one the Add/Edit sheet uses, so a + /// start and end thirty seconds apart made a row the live path would have discarded. + /// + /// Enforcing it here rather than at the sheet means the Save button disables itself and the sheet's + /// existing validation note explains why, with no new UI and no new string. + static let minManualSpanSeconds = 60 + /// The end a given duration implies. The sheet uses this when the user types a duration, so a typed /// duration and a picked end produce identical rows. static func endForDuration(start: Date, durationMin: Int) -> Date { @@ -447,6 +457,7 @@ enum WorkoutSource: Equatable { let e = Int(end.timeIntervalSince1970) guard s > 0, e > s else { return nil } let spanSeconds = e - s + guard spanSeconds >= minManualSpanSeconds else { return nil } guard spanSeconds <= maxManualSpanSeconds else { return nil } guard e <= Int(now.timeIntervalSince1970) else { return nil } if let hr = avgHr, !(25...250).contains(hr) { return nil } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index e3c08aa6b4..f7336b2311 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -437189,6 +437189,902 @@ } } } + }, + "A workout must be at least 1 minute." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Workout muss mindestens 1 Minute dauern." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A workout must be at least 1 minute." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un entrenamiento debe durar al menos 1 minuto." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une séance doit durer au moins 1 minute." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un allenamento deve durare almeno 1 minuto." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trening musi trwać co najmniej 1 minutę." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Um treino tem de durar pelo menos 1 minuto." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тренировка должна длиться не менее 1 минуты." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锻炼时长至少为 1 分钟。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動時長至少為 1 分鐘。" + } + } + } + }, + "Scope" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scope" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ámbito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ambito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakres" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Âmbito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Область" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "范围" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "範圍" + } + } + } + }, + "e.g. Run 5k without stopping" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. 5 km am Stück laufen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Correr 5 km sin parar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Courir 5 km sans s'arrêter" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Correre 5 km senza fermarsi" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Przebiec 5 km bez zatrzymywania się" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Correr 5 km sem parar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., пробежать 5 км без остановки" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:不停跑完5公里" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:不停跑完5公里" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Run 5k without stopping" + } + } + } + }, + "e.g. Train three times a week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. Dreimal pro Woche trainieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Entrenar tres veces por semana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. S'entraîner trois fois par semaine" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Allenarsi tre volte a settimana" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Trenować trzy razy w tygodniu" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Treinar três vezes por semana" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., тренироваться три раза в неделю" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每周训练三次" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每週訓練三次" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Train three times a week" + } + } + } + }, + "e.g. Sleep 7.5 hours a night" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. 7,5 Stunden pro Nacht schlafen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Dormir 7,5 horas por noche" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Dormir 7,5 heures par nuit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Dormire 7,5 ore a notte" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Spać 7,5 godziny na dobę" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Dormir 7,5 horas por noite" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., спать 7,5 часов за ночь" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每晚睡7.5小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每晚睡7.5小時" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Sleep 7.5 hours a night" + } + } + } + }, + "e.g. Get back to full-body strength work" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. Wieder Ganzkörper-Krafttraining machen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Retomar el entrenamiento de fuerza de cuerpo completo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Reprendre la musculation full-body" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Riprendere l'allenamento di forza total body" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Wrócić do treningu siłowego całego ciała" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Retomar o treino de força de corpo inteiro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., вернуться к силовым тренировкам всего тела" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:恢复全身力量训练" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:恢復全身力量訓練" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Get back to full-body strength work" + } + } + } + }, + "e.g. 18 hard sets a week for legs" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. 18 harte Sätze pro Woche für die Beine" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. 18 series duras a la semana para piernas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. 18 séries intenses par semaine pour les jambes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. 18 serie dure a settimana per le gambe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. 18 ciężkich serii na nogi tygodniowo" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. 18 séries intensas por semana para pernas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., 18 тяжёлых подходов в неделю на ноги" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每周18组高强度腿部训练" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每週18組高強度腿部訓練" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. 18 hard sets a week for legs" + } + } + } + }, + "e.g. Get to 78 kg" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. 78 kg erreichen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Llegar a 78 kg" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Atteindre 78 kg" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Raggiungere i 78 kg" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Osiągnąć 78 kg" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Chegar aos 78 kg" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., достичь 78 кг" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:达到78公斤" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:達到78公斤" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Get to 78 kg" + } + } + } + }, + "e.g. Fewer high-stress days each week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. Weniger stressreiche Tage pro Woche" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Menos días de mucho estrés a la semana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Moins de jours très stressants par semaine" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Meno giorni ad alto stress a settimana" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Mniej dni z dużym stresem w tygodniu" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Menos dias de grande stress por semana" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., меньше дней с высоким стрессом в неделю" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每周减少高压力的天数" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:每週減少高壓力的天數" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Fewer high-stress days each week" + } + } + } + }, + "e.g. Wake up feeling more recovered" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. Erholter aufwachen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Despertar sintiéndote más recuperado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Se réveiller plus récupéré" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Svegliarsi più riposato" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Budzić się bardziej wypoczętym" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Acordar mais recuperado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., просыпаться более отдохнувшим" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:醒来时感觉恢复得更好" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:醒來時感覺恢復得更好" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Wake up feeling more recovered" + } + } + } + }, + "e.g. Feel good on the hills again" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "z. B. Bei Anstiegen wieder ein gutes Gefühl haben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ej. Volver a sentirte bien en las cuestas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Se sentir bien en côte à nouveau" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "es. Sentirsi di nuovo bene in salita" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "np. Znów dobrze czuć się na podbiegach" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "p. ex. Voltar a sentir-me bem nas subidas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напр., снова хорошо чувствовать себя на подъёмах" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:在爬坡时重新感觉良好" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:在爬坡時重新感覺良好" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Feel good on the hills again" + } + } + } + }, + "%.0f / %.0fs" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fс" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f / %.0fs" + } + } + } + }, + "%@ → %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ → %@" + } + } + } + }, + "%@ workout, %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-Workout, %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "entrenamiento %@, %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "entraînement %@, %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "allenamento %@, %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trening %@, %@" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "treino %@, %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "тренировка %@, %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 锻炼,%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 鍛鍊,%@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ workout, %@" + } + } + } } }, "version" : "1.0" diff --git a/Strand/Screens/ManualWorkoutSheet.swift b/Strand/Screens/ManualWorkoutSheet.swift index 52e83a215c..a765a1bca3 100644 --- a/Strand/Screens/ManualWorkoutSheet.swift +++ b/Strand/Screens/ManualWorkoutSheet.swift @@ -471,6 +471,12 @@ struct ManualWorkoutSheet: View { // The failure this feature introduces, so it gets its own line rather than the catch-all below. if end <= start { return String(localized: "End must be after the start.") } if end > Date() { return String(localized: "End can't be in the future.") } + // Its own line rather than the catch-all below: "Check the values and try again." gives a wearer + // no way to know a 30-second entry is the thing being refused. Matches the live-session floor, so + // the same session is treated the same whether it was tracked or typed in. + if Int(end.timeIntervalSince1970) - Int(start.timeIntervalSince1970) < WorkoutSource.minManualSpanSeconds { + return String(localized: "A workout must be at least 1 minute.") + } if !avgHrText.trimmingCharacters(in: .whitespaces).isEmpty, avgHr == nil || !(25...250).contains(avgHr ?? -1) { return String(localized: "Average HR must be 25-250 bpm.") } diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index 667b3c3a98..1c8a905360 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -56,6 +56,10 @@ struct WorkoutsView: View { @State private var allRows: [WorkoutRow] @State private var loaded: Bool @State private var seededInitialRange = false + /// Current (the most recent sessions) or Archived (everything older). A view split only: archived rows + /// stay in the database and are one tap away. + @State private var scope: Scope = .current + @State private var range: Range = .all /// #797: how many trailing days of workouts are currently LOADED into `allRows`. First paint loads /// `Self.firstPaintWindowDays`; picking "All" (or a range wider than this) pages the full history in on @@ -203,11 +207,15 @@ struct WorkoutsView: View { // sportGroups → rows → …) rebuilt the same filters/aggregations // several times per render. Same windowing, same results. let resolved = effectiveRange - let windowRows = sessions(for: resolved) + // Current / Archived applies to what the LIST and its summaries show. `sessions(for:)` + // itself stays unscoped so the HR-recovery trend and the auto-widen probe keep seeing the + // whole window. + let windowRows = Self.scopedRows(sessions(for: resolved), scope: scope) let groups = sportGroups(from: windowRows) let zonesSummary = WorkoutZones.summary(from: windowRows) workoutActionRow + scopeBar rangeBar(rows: windowRows, effectiveRange: resolved) if let postLogNote { postLogBanner(postLogNote) } effortHero(rows: windowRows, effectiveRange: resolved, groups: groups) @@ -505,6 +513,20 @@ struct WorkoutsView: View { // MARK: - Range control + /// Current / Archived. Sits above the range bar because it is the coarser cut: it decides WHICH rows + /// the range then narrows. + /// + /// Always shown, including when everything still fits in Current. A segment that appeared only once a + /// wearer crossed ten sessions would shift the whole screen down the first time it did, and an empty + /// Archived tab answers "where did my older workouts go" plainly: nothing is hidden yet. + private var scopeBar: some View { + Picker("Scope", selection: $scope) { + ForEach(Scope.allCases) { s in Text(s.label).tag(s) } + } + .pickerStyle(.segmented) + .padding(.horizontal, 2) + } + private func rangeBar(rows: [WorkoutRow], effectiveRange: Range) -> some View { let fellBack = effectiveRange != range let caption = rangeCaption(rows: rows, effectiveRange: effectiveRange, fellBack: fellBack) @@ -670,6 +692,48 @@ struct WorkoutsView: View { /// Sessions inside a given range, RELATIVE TO THE LATEST session, then passed through the active /// filter. `.all` = all. The window anchor (`latestTs`) is the newest of ALL loaded rows so the /// window doesn't shift when a filter narrows the set. + /// Which slice of the history the list is showing. + /// + /// A VIEW split, never a delete. Archived rows stay in the database untouched and are one tap away, + /// which is the whole reason the request for "keep the last 10 and auto-delete the rest" is answered + /// this way instead: NOOP has no server and no cloud copy, so pruning real training history would be + /// irreversible, and hiding it costs nothing. + enum Scope: String, CaseIterable, Identifiable { + case current, archived + var id: String { rawValue } + /// `LocalizedStringKey` rather than `String`, because this is only ever handed to `Text`, so the + /// resolution belongs to the view environment. + /// + /// The sibling enums on other screens return `String(localized:)` instead, which is equally correct + /// for a value that has to be a String. What is NOT correct, and is what this property shipped as + /// first, is a BARE literal returned as a String: it renders in English forever, and the i18n gate + /// does not catch it, because a literal in that position is not somewhere the scanner looks. The + /// gate flagged the Picker's "Scope" key and said nothing about these two, which are the words + /// actually printed on the tabs. + var label: LocalizedStringKey { self == .current ? "Current" : "Archived" } + } + + /// How many of the most recent sessions "Current" holds. + static let currentScopeCount = 10 + + /// Split rows into the most recent `currentCount` and everything older. + /// + /// Pure and order-preserving: membership is decided by ranking on `startTs`, but the rows come back in + /// the order they arrived, so the caller's sort still decides what the screen shows. Ranking rather + /// than comparing against a cutoff timestamp is what makes ties safe: two sessions that start in the + /// same second cannot both sneak past a threshold and hand "Current" an eleventh row. + /// + /// Applied AFTER the range and sport filters, so each tab means "the 10 most recent of what you are + /// currently looking at" rather than silently showing an empty Current when a filter excludes the + /// newest sessions. + nonisolated static func scopedRows(_ rows: [WorkoutRow], scope: Scope, + currentCount: Int = currentScopeCount) -> [WorkoutRow] { + guard rows.count > currentCount else { return scope == .current ? rows : [] } + let key: (WorkoutRow) -> String = { "\($0.startTs)|\($0.sport)" } + let newest = Set(rows.sorted { $0.startTs > $1.startTs }.prefix(currentCount).map(key)) + return rows.filter { scope == .current ? newest.contains(key($0)) : !newest.contains(key($0)) } + } + private func sessions(for r: Range) -> [WorkoutRow] { let windowed: [WorkoutRow] if let days = r.days { @@ -679,6 +743,9 @@ struct WorkoutsView: View { } else { windowed = allRows } + // Deliberately NOT scoped. This feeds the HR-recovery trend (a 90-day analysis) and the + // auto-widen probe as well as the list, and cutting those to the ten most recent sessions would + // quietly change what they measure. The Current/Archived split is applied to the LIST rows only. return filter.apply(windowed) } diff --git a/StrandTests/WorkoutNamespaceTests.swift b/StrandTests/WorkoutNamespaceTests.swift new file mode 100644 index 0000000000..707d625252 --- /dev/null +++ b/StrandTests/WorkoutNamespaceTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import Strand + +/// The Workouts list and the Workouts delete must agree about where a row lives (#2278). +/// +/// Why this exists: the list unioned many device namespaces while the delete touched exactly one, the +/// active strap. A row banked anywhere else was visible but undeletable, because the delete reported +/// nothing and the reload re-read the row from a namespace the delete never went near. Both sides now +/// derive from `workoutNamespaces`, and these tests pin what that list must contain. +final class WorkoutNamespaceTests: XCTestCase { + + func testEveryRawIdAndItsComputedSiblingAreIncluded() { + let ids = Repository.workoutNamespaces(rawIds: ["strap-a", "my-whoop"]) + XCTAssertTrue(ids.contains("strap-a")) + XCTAssertTrue(ids.contains("my-whoop")) + XCTAssertTrue(ids.contains("strap-a-noop"), "the computed sibling holds detected bouts") + XCTAssertTrue(ids.contains("my-whoop-noop")) + } + + func testAnIdThatIsAlreadyComputedIsNotDoubleSuffixed() { + let ids = Repository.workoutNamespaces(rawIds: ["my-whoop-noop"]) + XCTAssertTrue(ids.contains("my-whoop-noop")) + XCTAssertFalse(ids.contains("my-whoop-noop-noop"), "suffixing must be idempotent") + } + + func testTheReadIncludesImportNamespaces() { + // A workout imported from Apple Health, Hevy/Liftosaur or a FIT/GPX/TCX file is SHOWN by the list. + let ids = Repository.workoutNamespaces(rawIds: ["strap-a"]) + XCTAssertTrue(ids.contains("apple-health")) + XCTAssertTrue(ids.contains("lifting")) + XCTAssertTrue(ids.contains("activity-file")) + XCTAssertTrue(ids.contains("hevy"), "API-synced Hevy sessions are listed in their own lane") + } + + func testDeleteNeverReachesImportNamespaces() { + // Imported history is read-only, enforced in the row menu (imported rows are offered only + // "Duplicate as manual…"), in bulkDeleteWorkouts and in mergeWorkouts ("never rewrite imported + // history"). A delete sweeping the import namespaces would reach underneath all three and destroy + // a row nothing in the UI ever offers to remove, so the deletable set must stay strap-only. + let ids = Repository.deletableWorkoutNamespaces(rawIds: ["strap-a"]) + XCTAssertFalse(ids.contains("apple-health"), "imported Apple Health history must survive a delete") + XCTAssertFalse(ids.contains("lifting"), "imported Hevy / Liftosaur history must survive a delete") + XCTAssertFalse(ids.contains("activity-file"), "imported FIT / GPX / TCX history must survive") + XCTAssertFalse(ids.contains("hevy"), "API-synced Hevy history must survive a delete") + } + + func testDeleteStillCoversEveryStrapNamespace() { + // The actual bug: a manual row under a retained strap or a computed sibling was undeletable. + let ids = Repository.deletableWorkoutNamespaces(rawIds: ["active", "retained"]) + XCTAssertEqual(ids, ["active", "retained", "active-noop", "retained-noop"]) + } + + func testTheDeletableSetIsASubsetOfWhatTheListReads() { + // If a delete could target a namespace the list never reads, it would be deleting something the + // wearer cannot see. Pin the containment rather than the two lists separately. + let raw = ["active", "retained", "my-whoop-noop"] + let readable = Set(Repository.workoutNamespaces(rawIds: raw)) + for id in Repository.deletableWorkoutNamespaces(rawIds: raw) { + XCTAssertTrue(readable.contains(id), "\(id) is deletable but never read") + } + } + + func testNoDuplicatesAndReadOrderIsStable() { + // Duplicates would make the delete issue the same statement twice and the read return the same row + // twice, which the natural-key dedup would then have to clean up. + let ids = Repository.deletableWorkoutNamespaces(rawIds: ["a", "a", "b"]) + XCTAssertEqual(ids.count, Set(ids).count, "duplicates must collapse") + XCTAssertEqual(ids.firstIndex(of: "a"), 0, "the active id stays first, preserving read order") + } + + func testTheActiveStrapAloneIsNotEnough() { + // The regression in one line: the old delete used only the active id. If that were still the whole + // namespace set, every import and every retained strap would remain undeletable. + let ids = Repository.workoutNamespaces(rawIds: ["active"]) + XCTAssertGreaterThan(ids.count, 1, "delete must reach more than the active strap") + XCTAssertTrue(ids.contains("active")) + } +} diff --git a/StrandTests/WorkoutScopeTests.swift b/StrandTests/WorkoutScopeTests.swift new file mode 100644 index 0000000000..16c7fbaa40 --- /dev/null +++ b/StrandTests/WorkoutScopeTests.swift @@ -0,0 +1,99 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// Current / Archived is a VIEW split, and the sub-minute discard is a save-time gate. +/// +/// Why these are tested together: they are the two halves of one request, "keep the last 10 workouts then +/// auto delete". Auto-deleting real training history would be irreversible on a device with no server and +/// no cloud copy, so the split hides rather than removes, and the only thing actually discarded is a +/// session too short to contain training data. +final class WorkoutScopeTests: XCTestCase { + + private func row(_ startTs: Int, sport: String = "Running") -> WorkoutRow { + WorkoutRow(startTs: startTs, endTs: startTs + 600, sport: sport, source: "manual", + durationS: 600, energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: nil, steps: nil) + } + + // MARK: - The split + + func testFewerThanTheLimitPutsEverythingInCurrentAndNothingInArchived() { + let rows = (0..<4).map { row(1_700_000_000 + $0 * 3600) } + XCTAssertEqual(WorkoutsView.scopedRows(rows, scope: .current, currentCount: 10).count, 4) + XCTAssertTrue(WorkoutsView.scopedRows(rows, scope: .archived, currentCount: 10).isEmpty) + } + + func testCurrentHoldsTheMostRecentAndArchivedHoldsTheRest() { + let rows = (0..<25).map { row(1_700_000_000 + $0 * 3600) } // ascending + let current = WorkoutsView.scopedRows(rows, scope: .current, currentCount: 10) + let archived = WorkoutsView.scopedRows(rows, scope: .archived, currentCount: 10) + XCTAssertEqual(current.count, 10) + XCTAssertEqual(archived.count, 15) + let newestCurrent = current.map(\.startTs).min() ?? 0 + let newestArchived = archived.map(\.startTs).max() ?? 0 + XCTAssertGreaterThan(newestCurrent, newestArchived, "every Current row is newer than every Archived one") + } + + func testTheTwoScopesPartitionTheInputExactly() { + // Nothing may be lost or duplicated by the split: a row the wearer cannot find in either tab has + // effectively been deleted by the UI, which is the outcome this design exists to avoid. + let rows = (0..<23).map { row(1_700_000_000 + $0 * 3600) } + let combined = WorkoutsView.scopedRows(rows, scope: .current) + + WorkoutsView.scopedRows(rows, scope: .archived) + XCTAssertEqual(combined.count, rows.count) + XCTAssertEqual(Set(combined.map(\.startTs)), Set(rows.map(\.startTs))) + } + + func testTiedStartTimesCannotOverfillCurrent() { + // Ranking, not a cutoff timestamp: twelve sessions that all start in the same second must still + // yield exactly ten in Current. A threshold comparison would hand back all twelve. + let rows = (0..<12).map { row(1_700_000_000, sport: "Sport\($0)") } + XCTAssertEqual(WorkoutsView.scopedRows(rows, scope: .current, currentCount: 10).count, 10) + XCTAssertEqual(WorkoutsView.scopedRows(rows, scope: .archived, currentCount: 10).count, 2) + } + + func testOrderIsPreserved() { + // The caller's sort decides what the screen shows; the split only chooses membership. + let rows = (0..<25).map { row(1_700_000_000 + $0 * 3600) }.sorted { $0.startTs > $1.startTs } + let current = WorkoutsView.scopedRows(rows, scope: .current) + XCTAssertEqual(current.map(\.startTs), current.map(\.startTs).sorted(by: >)) + } + + // MARK: - The discard gate + + func testSessionsUnderAMinuteAreDiscarded() { + XCTAssertTrue(AppModel.isTooShortToSave(elapsedSeconds: 5)) + XCTAssertTrue(AppModel.isTooShortToSave(elapsedSeconds: 30)) + XCTAssertTrue(AppModel.isTooShortToSave(elapsedSeconds: 59.9)) + } + + func testManualEntryHonoursTheSameFloor() { + // The span-shaped builder is the one the Add/Edit sheet uses, and it had no floor: a start and end + // thirty seconds apart made a row the live path would have discarded. The duration-shaped builder + // enforced it only by accident, counting whole minutes. + let start = Date(timeIntervalSince1970: 1_700_000_000) + let now = start.addingTimeInterval(86_400) + func build(_ seconds: TimeInterval) -> WorkoutRow? { + WorkoutSource.buildManualRowFromSpan(start: start, end: start.addingTimeInterval(seconds), + sport: "Running", avgHr: nil, energyKcal: nil, now: now) + } + XCTAssertNil(build(30), "a 30-second manual entry is refused") + XCTAssertNil(build(59), "just under the floor is refused") + XCTAssertNotNil(build(60), "exactly a minute is kept, matching the live-session floor") + XCTAssertNotNil(build(3600), "an ordinary session is unaffected") + } + + func testTheTwoFloorsAgree() { + // One rule, whether a session was tracked or typed in. If these ever diverge, the same workout + // would be accepted by one door and refused by the other. + XCTAssertEqual(Double(WorkoutSource.minManualSpanSeconds), AppModel.minimumWorkoutSeconds) + } + + func testExactlyAMinuteIsKept() { + // A deliberate one-minute effort is training. The gate is for what falls SHORT of a minute. + XCTAssertFalse(AppModel.isTooShortToSave(elapsedSeconds: 60)) + XCTAssertFalse(AppModel.isTooShortToSave(elapsedSeconds: 61)) + XCTAssertFalse(AppModel.isTooShortToSave(elapsedSeconds: 3600)) + } +} diff --git a/Tools/i18n_audit.py b/Tools/i18n_audit.py index 5e760e62d2..d84561cdae 100644 --- a/Tools/i18n_audit.py +++ b/Tools/i18n_audit.py @@ -631,6 +631,31 @@ def signature(value: str) -> list[str]: r"\.(?:navigationTitle|confirmationDialog|alert|accessibilityLabel|help)\s*\(" ) +# A computed property that RETURNS user-facing copy as a `String`, e.g. +# `var label: String { ... }` on a screen's scope/mode enum. +# +# These are invisible to SWIFT_CALL_START_PATTERN above, because the literal sits +# in a `return`, not inside a `Text(`/`Picker(` argument. That is not a harmless +# miss: a bare literal returned as a String reaches `Text` already resolved, so it +# renders in English on every device forever, and nothing flags it. It shipped +# exactly once that way (a Workouts "Current"/"Archived" tab pair) while the gate +# passed, having caught only the accessibility label beside it. +# +# The repository's own convention already avoids this, either `String(localized:)` +# for a value that must be a String, or `LocalizedStringKey` when the value only +# ever reaches `Text`. Both are recognised: the first because the literal sits in +# a `localized:` argument, the second because `LocalizedStringKey` resolves in the +# view environment. So this rule has no pre-existing findings to baseline; it +# exists to keep it that way. +# +# Deliberately narrow. It matches only property names that ARE copy (label, title, +# caption, subtitle) and only literals that are returned, so the many String +# helpers that build keys, symbol names, trace tokens and log lines stay out. +SWIFT_COPY_PROPERTY_PATTERN = re.compile( + r"\bvar\s+\w*(?:label|title|caption|subtitle)\w*\s*:\s*String\s*\{", + re.IGNORECASE, +) + # A placeholder generated by Swift's LocalizedStringKey interpolation. The # precise conversion depends on the interpolated value's static type, so the # source-side audit deliberately accepts any valid String Catalog placeholder @@ -735,6 +760,60 @@ def swift_string_literals(text: str): i += 1 +def swift_returned_copy_literals(text: str): + """Yield (offset, literal) for copy RETURNED as a String from a `var label: String { ... }`-shaped + property, e.g. `case .current: return "Current"`. + + Separate from `swift_string_literals`, and applied to SCREEN files only, because the same shape means + something else elsewhere: `Commands.swift` names BLE opcodes through a `var label: String`, and those + are diagnostics rather than copy a wearer reads. That directory restriction is what keeps this rule at + zero pre-existing findings instead of 71. + + Only literals at the property's own brace level are yielded, so one nested inside a closure or a + helper call stays out and string building is not flagged. + """ + for match in SWIFT_COPY_PROPERTY_PATTERN.finditer(text): + body_start = match.end() - 1 + depth = 0 + i = body_start + while i < len(text): + ch = text[i] + if ch == '"': + literal_end = _skip_swift_string_literal(text, i) + prefix = text[max(body_start, i - 60):i] + line = prefix.rsplit("\n", 1)[-1] + # Returned copy, in the spellings a label property actually uses: a `switch` arm + # (`case .a: return "Alpha"`, or the implicit-return form), or a ternary. + # + # A bare "ends with a colon" test is NOT enough to spot a case arm: every argument label + # ends the same way, so `joined(separator: ", ")` looked like returned copy and the + # separator was reported as untranslated UI. + # + # Keyed on the RETURN, not on brace depth. Depth alone looked right and silently missed + # the commoner shape: a `switch` opens a second brace level, so every `case ... return` + # arm sat a level deeper than the ternary this rule was first written against, and the + # dominant form in this repository went unchecked. + stripped = line.lstrip() + returned = ( + "return" in line # `return "Alpha"` + or "?" in line # `cond ? "Alpha" : "Beta"` + or stripped.startswith(("case ", "default")) # `case .a: "Alpha"` (implicit return) + ) + # `String(localized: "...")` is the sanctioned spelling for a String-typed value: the + # literal already sits in a localized position and the normal scan handles it. + if returned and "localized:" not in prefix: + yield i, text[i + 1:literal_end - 1] + i = literal_end + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + break + i += 1 + + def swift_catalog_pattern(literal: str) -> re.Pattern[str] | None: """Turn a Swift source literal into a regex for its compiled catalog key.""" parts: list[str] = [] @@ -894,7 +973,12 @@ def scan_ios() -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]: continue for path in sorted(base.rglob("*.swift")): text = path.read_text(encoding="utf-8", errors="replace") - for offset, literal in swift_string_literals(text): + literals = list(swift_string_literals(text)) + # Screen files only: see `swift_returned_copy_literals` for why the same shape + # elsewhere (BLE opcode names, design-system internals) is not copy. + if "/Screens/" in path.as_posix() or "/Liquid/" in path.as_posix(): + literals += list(swift_returned_copy_literals(text)) + for offset, literal in literals: if not is_probably_ui_text(literal): continue entry = swift_catalog_lookup(cat, literal) diff --git a/Tools/translations/de.json b/Tools/translations/de.json index 031bc8a8f3..4a567eb5cc 100644 --- a/Tools/translations/de.json +++ b/Tools/translations/de.json @@ -1441,5 +1441,17 @@ "The recovery budget both share": "Das Erholungsbudget, das beide teilen", "Why lifting and heart rate stay apart": "Warum Krafttraining und Herzfrequenz getrennt bleiben", "Working sets weighted by how close they went to failure": "Arbeitssätze, gewichtet nach Nähe zum Muskelversagen", - "weighted sets": "gewichtete Sätze" + "weighted sets": "gewichtete Sätze", + "e.g. Run 5k without stopping": "z. B. 5 km am Stück laufen", + "e.g. Train three times a week": "z. B. Dreimal pro Woche trainieren", + "e.g. Sleep 7.5 hours a night": "z. B. 7,5 Stunden pro Nacht schlafen", + "e.g. Get back to full-body strength work": "z. B. Wieder Ganzkörper-Krafttraining machen", + "e.g. 18 hard sets a week for legs": "z. B. 18 harte Sätze pro Woche für die Beine", + "e.g. Get to 78 kg": "z. B. 78 kg erreichen", + "e.g. Fewer high-stress days each week": "z. B. Weniger stressreiche Tage pro Woche", + "e.g. Wake up feeling more recovered": "z. B. Erholter aufwachen", + "e.g. Feel good on the hills again": "z. B. Bei Anstiegen wieder ein gutes Gefühl haben", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "%@-Workout, %@" } diff --git a/Tools/translations/es.json b/Tools/translations/es.json index 400102a7c8..47d25f82b9 100644 --- a/Tools/translations/es.json +++ b/Tools/translations/es.json @@ -1442,5 +1442,17 @@ "The recovery budget both share": "El margen de recuperación que ambos comparten", "Why lifting and heart rate stay apart": "Por qué la fuerza y la frecuencia cardiaca van por separado", "Working sets weighted by how close they went to failure": "Series efectivas ponderadas por su cercanía al fallo", - "weighted sets": "series ponderadas" + "weighted sets": "series ponderadas", + "e.g. Run 5k without stopping": "p. ej. Correr 5 km sin parar", + "e.g. Train three times a week": "p. ej. Entrenar tres veces por semana", + "e.g. Sleep 7.5 hours a night": "p. ej. Dormir 7,5 horas por noche", + "e.g. Get back to full-body strength work": "p. ej. Retomar el entrenamiento de fuerza de cuerpo completo", + "e.g. 18 hard sets a week for legs": "p. ej. 18 series duras a la semana para piernas", + "e.g. Get to 78 kg": "p. ej. Llegar a 78 kg", + "e.g. Fewer high-stress days each week": "p. ej. Menos días de mucho estrés a la semana", + "e.g. Wake up feeling more recovered": "p. ej. Despertar sintiéndote más recuperado", + "e.g. Feel good on the hills again": "p. ej. Volver a sentirte bien en las cuestas", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "entrenamiento %@, %@" } diff --git a/Tools/translations/fr.json b/Tools/translations/fr.json index 8baec07862..8306a35181 100644 --- a/Tools/translations/fr.json +++ b/Tools/translations/fr.json @@ -1443,5 +1443,17 @@ "The recovery budget both share": "Le budget de récupération qu’ils partagent", "Why lifting and heart rate stay apart": "Pourquoi musculation et fréquence cardiaque restent séparées", "Working sets weighted by how close they went to failure": "Séries de travail pondérées par leur proximité de l’échec", - "weighted sets": "séries pondérées" + "weighted sets": "séries pondérées", + "e.g. Run 5k without stopping": "p. ex. Courir 5 km sans s'arrêter", + "e.g. Train three times a week": "p. ex. S'entraîner trois fois par semaine", + "e.g. Sleep 7.5 hours a night": "p. ex. Dormir 7,5 heures par nuit", + "e.g. Get back to full-body strength work": "p. ex. Reprendre la musculation full-body", + "e.g. 18 hard sets a week for legs": "p. ex. 18 séries intenses par semaine pour les jambes", + "e.g. Get to 78 kg": "p. ex. Atteindre 78 kg", + "e.g. Fewer high-stress days each week": "p. ex. Moins de jours très stressants par semaine", + "e.g. Wake up feeling more recovered": "p. ex. Se réveiller plus récupéré", + "e.g. Feel good on the hills again": "p. ex. Se sentir bien en côte à nouveau", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "entraînement %@, %@" } diff --git a/Tools/translations/it.json b/Tools/translations/it.json index 51616c1618..f8ec5c29a5 100644 --- a/Tools/translations/it.json +++ b/Tools/translations/it.json @@ -1536,5 +1536,17 @@ "The recovery budget both share": "Il margine di recupero che condividono", "Why lifting and heart rate stay apart": "Perché forza e frequenza cardiaca restano separate", "Working sets weighted by how close they went to failure": "Serie allenanti pesate in base alla vicinanza al cedimento", - "weighted sets": "serie pesate" + "weighted sets": "serie pesate", + "e.g. Run 5k without stopping": "es. Correre 5 km senza fermarsi", + "e.g. Train three times a week": "es. Allenarsi tre volte a settimana", + "e.g. Sleep 7.5 hours a night": "es. Dormire 7,5 ore a notte", + "e.g. Get back to full-body strength work": "es. Riprendere l'allenamento di forza total body", + "e.g. 18 hard sets a week for legs": "es. 18 serie dure a settimana per le gambe", + "e.g. Get to 78 kg": "es. Raggiungere i 78 kg", + "e.g. Fewer high-stress days each week": "es. Meno giorni ad alto stress a settimana", + "e.g. Wake up feeling more recovered": "es. Svegliarsi più riposato", + "e.g. Feel good on the hills again": "es. Sentirsi di nuovo bene in salita", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "allenamento %@, %@" } diff --git a/Tools/translations/pl.json b/Tools/translations/pl.json index a8dc814de0..1be801e427 100644 --- a/Tools/translations/pl.json +++ b/Tools/translations/pl.json @@ -2578,5 +2578,17 @@ "The recovery budget both share": "Wspólny budżet regeneracji", "Why lifting and heart rate stay apart": "Dlaczego siła i tętno są liczone osobno", "Working sets weighted by how close they went to failure": "Serie robocze ważone bliskością upadku mięśniowego", - "weighted sets": "serie ważone" + "weighted sets": "serie ważone", + "e.g. Run 5k without stopping": "np. Przebiec 5 km bez zatrzymywania się", + "e.g. Train three times a week": "np. Trenować trzy razy w tygodniu", + "e.g. Sleep 7.5 hours a night": "np. Spać 7,5 godziny na dobę", + "e.g. Get back to full-body strength work": "np. Wrócić do treningu siłowego całego ciała", + "e.g. 18 hard sets a week for legs": "np. 18 ciężkich serii na nogi tygodniowo", + "e.g. Get to 78 kg": "np. Osiągnąć 78 kg", + "e.g. Fewer high-stress days each week": "np. Mniej dni z dużym stresem w tygodniu", + "e.g. Wake up feeling more recovered": "np. Budzić się bardziej wypoczętym", + "e.g. Feel good on the hills again": "np. Znów dobrze czuć się na podbiegach", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "Trening %@, %@" } diff --git a/Tools/translations/pt-PT.json b/Tools/translations/pt-PT.json index 274910153b..51e453303d 100644 --- a/Tools/translations/pt-PT.json +++ b/Tools/translations/pt-PT.json @@ -1442,5 +1442,17 @@ "The recovery budget both share": "A margem de recuperação que ambos partilham", "Why lifting and heart rate stay apart": "Porque é que força e frequência cardíaca ficam separadas", "Working sets weighted by how close they went to failure": "Séries de trabalho ponderadas pela proximidade da falha", - "weighted sets": "séries ponderadas" + "weighted sets": "séries ponderadas", + "e.g. Run 5k without stopping": "p. ex. Correr 5 km sem parar", + "e.g. Train three times a week": "p. ex. Treinar três vezes por semana", + "e.g. Sleep 7.5 hours a night": "p. ex. Dormir 7,5 horas por noite", + "e.g. Get back to full-body strength work": "p. ex. Retomar o treino de força de corpo inteiro", + "e.g. 18 hard sets a week for legs": "p. ex. 18 séries intensas por semana para pernas", + "e.g. Get to 78 kg": "p. ex. Chegar aos 78 kg", + "e.g. Fewer high-stress days each week": "p. ex. Menos dias de grande stress por semana", + "e.g. Wake up feeling more recovered": "p. ex. Acordar mais recuperado", + "e.g. Feel good on the hills again": "p. ex. Voltar a sentir-me bem nas subidas", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "treino %@, %@" } diff --git a/Tools/translations/ru.json b/Tools/translations/ru.json index 344ab185ee..cceb0e5e96 100644 --- a/Tools/translations/ru.json +++ b/Tools/translations/ru.json @@ -1419,5 +1419,17 @@ "The recovery budget both share": "Общий ресурс восстановления", "Why lifting and heart rate stay apart": "Почему силовые и пульс учитываются отдельно", "Working sets weighted by how close they went to failure": "Рабочие подходы, взвешенные по близости к отказу", - "weighted sets": "взвешенные подходы" + "weighted sets": "взвешенные подходы", + "e.g. Run 5k without stopping": "напр., пробежать 5 км без остановки", + "e.g. Train three times a week": "напр., тренироваться три раза в неделю", + "e.g. Sleep 7.5 hours a night": "напр., спать 7,5 часов за ночь", + "e.g. Get back to full-body strength work": "напр., вернуться к силовым тренировкам всего тела", + "e.g. 18 hard sets a week for legs": "напр., 18 тяжёлых подходов в неделю на ноги", + "e.g. Get to 78 kg": "напр., достичь 78 кг", + "e.g. Fewer high-stress days each week": "напр., меньше дней с высоким стрессом в неделю", + "e.g. Wake up feeling more recovered": "напр., просыпаться более отдохнувшим", + "e.g. Feel good on the hills again": "напр., снова хорошо чувствовать себя на подъёмах", + "%.0f / %.0fs": "%.0f / %.0fс", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "тренировка %@, %@" } diff --git a/Tools/translations/zh-Hans.json b/Tools/translations/zh-Hans.json index eb513b12f9..5dd214c4a5 100644 --- a/Tools/translations/zh-Hans.json +++ b/Tools/translations/zh-Hans.json @@ -1543,5 +1543,17 @@ "The recovery budget both share": "两者共享的恢复预算", "Why lifting and heart rate stay apart": "为什么力量训练与心率分开计算", "Working sets weighted by how close they went to failure": "按接近力竭程度加权的正式组", - "weighted sets": "加权组数" + "weighted sets": "加权组数", + "e.g. Run 5k without stopping": "例如:不停跑完5公里", + "e.g. Train three times a week": "例如:每周训练三次", + "e.g. Sleep 7.5 hours a night": "例如:每晚睡7.5小时", + "e.g. Get back to full-body strength work": "例如:恢复全身力量训练", + "e.g. 18 hard sets a week for legs": "例如:每周18组高强度腿部训练", + "e.g. Get to 78 kg": "例如:达到78公斤", + "e.g. Fewer high-stress days each week": "例如:每周减少高压力的天数", + "e.g. Wake up feeling more recovered": "例如:醒来时感觉恢复得更好", + "e.g. Feel good on the hills again": "例如:在爬坡时重新感觉良好", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "%@ 锻炼,%@" } diff --git a/Tools/translations/zh-Hant.json b/Tools/translations/zh-Hant.json index 804d682544..4cf718c1e0 100644 --- a/Tools/translations/zh-Hant.json +++ b/Tools/translations/zh-Hant.json @@ -1597,5 +1597,17 @@ "The recovery budget both share": "兩者共享的恢復預算", "Why lifting and heart rate stay apart": "為什麼力量訓練與心率分開計算", "Working sets weighted by how close they went to failure": "按接近力竭程度加權的正式組", - "weighted sets": "加權組數" + "weighted sets": "加權組數", + "e.g. Run 5k without stopping": "例如:不停跑完5公里", + "e.g. Train three times a week": "例如:每週訓練三次", + "e.g. Sleep 7.5 hours a night": "例如:每晚睡7.5小時", + "e.g. Get back to full-body strength work": "例如:恢復全身力量訓練", + "e.g. 18 hard sets a week for legs": "例如:每週18組高強度腿部訓練", + "e.g. Get to 78 kg": "例如:達到78公斤", + "e.g. Fewer high-stress days each week": "例如:每週減少高壓力的天數", + "e.g. Wake up feeling more recovered": "例如:醒來時感覺恢復得更好", + "e.g. Feel good on the hills again": "例如:在爬坡時重新感覺良好", + "%.0f / %.0fs": "%.0f / %.0fs", + "%@ → %@": "%@ → %@", + "%@ workout, %@": "%@ 鍛鍊,%@" }