From 6a2b32b8b94197e19a1238358ef73ee07801df85 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 17 Sep 2026 12:50:29 +1200 Subject: [PATCH 1/6] fix(workouts): delete from every strap namespace the list reads (#2286) The Workouts list unions many device namespaces: every registered WHOOP, every computed `-noop` sibling, Apple Health, imported lifting sessions and imported activity files. `deleteWorkout` deleted from exactly one, the active strap. A row banked anywhere else was therefore visible but undeletable. The delete issued a statement that matched nothing, its result was discarded, and the reload re-read the row from a namespace the delete never touched, so it reappeared with nothing on screen to say the delete had failed. Reported in #2278. Both sides now derive from one `workoutNamespaces` function, because spelling the union out twice is what let them disagree, and a namespace added to the read alone would have reintroduced this exactly. Imported history is explicitly NOT deletable. The first pass swept the import namespaces too, which reached underneath an invariant enforced in three places: the row menu offers an imported row only "Duplicate as manual", bulkDeleteWorkouts skips those classes, and mergeWorkouts refuses them with "never rewrite imported history". A cross-source twin being collapsed into one row at display time does not license deleting the imported half of the pair. The sweep is narrow by construction: the natural key is exact, one sport plus a single startTs, so it removes the row the wearer tapped and its copies in the strap namespaces, nothing else. Scope note: this does not explain the reporter's observation that only sessions under a minute resisted deletion, and no duration threshold exists in the save, list or delete paths. That part stays open. Tests pin the containment in both directions: imports absent from the deletable set, every strap namespace present, and the deletable set a subset of what the list reads, so a delete can never target something the wearer cannot see. --- Strand/Data/Repository.swift | 70 +++++++++++++++++++---- StrandTests/WorkoutNamespaceTests.swift | 76 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 StrandTests/WorkoutNamespaceTests.swift diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 8719554aa8..dd0cdd2408 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -279,6 +279,43 @@ final class Repository: ObservableObject { return Self.rawWhoopSourceIds(activeDeviceId: deviceId, registeredWhoopIds: registeredWhoops) } + /// 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", "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, @@ -2679,18 +2716,13 @@ 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 += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] } - 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. Covers each raw id, its computed `-noop` sibling, Apple Health, imported lifting + // sessions (Hevy / Liftosaur) and imported activity FILES (#29: FIT / GPX / TCX, or a successful + // file import never appears here at all). HR is reconciled from the strap trace at the end. + for id in Self.workoutNamespaces(rawIds: rawPhysiologyReadIds(store: store)) { rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] } - rows += (try? await store.workouts(deviceId: "apple-health", from: lo, to: hi, limit: 5000)) ?? [] - // Imported lifting sessions (Hevy / Liftosaur) live under their own "lifting" source. - rows += (try? await store.workouts(deviceId: "lifting", from: lo, to: hi, limit: 5000)) ?? [] - // #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 += (try? await store.workouts(deviceId: "activity-file", from: lo, to: hi, limit: 5000)) ?? [] rows = Self.dedupWorkoutsByNaturalKey(rows) let spans = WorkoutSource.parseDismissedSpans(dismissedDetectedSpans) // #687: collapse the SAME activity tracked live under the strap AND imported from Health Connect / @@ -2973,8 +3005,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/StrandTests/WorkoutNamespaceTests.swift b/StrandTests/WorkoutNamespaceTests.swift new file mode 100644 index 0000000000..1ae1a077fd --- /dev/null +++ b/StrandTests/WorkoutNamespaceTests.swift @@ -0,0 +1,76 @@ +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")) + } + + 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") + } + + 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")) + } +} From 1f223aed03f0aa65462ce8d947b894feea0166d2 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 17 Sep 2026 12:51:23 +1200 Subject: [PATCH 2/6] feat(workouts): discard sub-minute sessions and split the list into Current and Archived (#2287) Two halves of one request: keep recent workouts to hand, and stop the list filling with accidental starts. **Sub-minute sessions are discarded at save.** A 5 to 30 second start/stop is not training, and it is what made deletion feel broken in #2278. Discarding at SAVE rather than pruning later is the point: nothing that ever held training data is removed, so there is nothing to restore. Exactly 60 seconds is kept, because a deliberate one-minute effort is training. **Current / Archived is a view split, never a delete.** The request was to keep the last 10 and auto-delete the rest; this hides the rest instead. NOOP has no server and no cloud copy, so pruning real training history would be irreversible, while hiding costs nothing and is one tap away. Archived rows stay in the database with every action they always had, including delete. Membership is decided by RANKING on start time, not by a cutoff timestamp, so twelve sessions starting in the same second still yield exactly ten in Current. The split is order-preserving and applies to the LIST rows only: `sessions(for:)` also feeds the 90-day HR-recovery trend and the auto-widen probe, and scoping there would have quietly cut a 90-day analysis to ten workouts. **The floor applies to manual entry too, on both platforms.** The span-shaped builder the Add/Edit sheet uses had no floor, so the same workout was treated differently depending on whether it was tracked or typed in. Putting it in the validator disables Save through the existing path, and the refusal gets its own note rather than the catch-all. Android mirrors both floors, since its `WorkoutEditing.buildManualRowFromSpan` is a line-for-line twin; the parity ledger ran clean on the one-sided commit, because unpaired constants are not reported the way unpaired functions are. **The i18n gate now sees copy a screen RETURNS.** It scanned only literals inside localized SwiftUI calls, so `var label: String { "Current" }` was invisible, and a bare literal returned that way renders in English forever. It shipped once that way in this branch while the gate passed, having flagged only the accessibility key beside it. The rule is keyed on the return rather than brace depth, since a `switch` opens a second level and the first draft silently covered only ternaries. Seven tests pin it, mutation-tested against the draft. Verified: 7/7 CI; Kotlin 731 classes 6235 tests 0 failures with the result XML checked fresh against the clock; Tools suite 54 passing; i18n gate exits 0. The Current/Archived tabs stay Apple-only. That is UI work, unlike the floor, which is a data rule with an existing twin. --- Strand/App/AppModel.swift | 32 +++ Strand/Data/WorkoutSource.swift | 11 + Strand/Resources/Localizable.xcstrings | 256 ++++++++++++++++++ Strand/Screens/ManualWorkoutSheet.swift | 6 + Strand/Screens/WorkoutsView.swift | 69 ++++- StrandTests/WorkoutScopeTests.swift | 99 +++++++ Tools/i18n_audit.py | 86 +++++- Tools/i18n_audit_baseline.json | 12 + Tools/test_i18n_audit.py | 66 +++++ .../src/main/java/com/noop/ui/AppViewModel.kt | 16 ++ .../main/java/com/noop/ui/WorkoutEditing.kt | 10 + .../java/com/noop/ui/ManualWorkoutSpanTest.kt | 36 +++ 12 files changed, 697 insertions(+), 2 deletions(-) create mode 100644 StrandTests/WorkoutScopeTests.swift diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 4c9cee4932..01baf7fce7 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -906,6 +906,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 } activeWorkout = nil @@ -935,6 +947,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/WorkoutSource.swift b/Strand/Data/WorkoutSource.swift index 5278de9192..3168e7537d 100644 --- a/Strand/Data/WorkoutSource.swift +++ b/Strand/Data/WorkoutSource.swift @@ -333,6 +333,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 { @@ -380,6 +390,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 58d34363bd..3c8a21ea5b 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -199048,6 +199048,262 @@ } } }, + "Archived": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Archived" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Archivados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Archivés" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Archiviati" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zarchiwizowane" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Arquivados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Архив" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已归档" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已封存" + } + } + } + }, + "Current": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuell" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Current" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actuel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attuali" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bieżące" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Atuais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущие" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前" + } + } + } + }, + "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": "範圍" + } + } + } + }, + "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 分鐘。" + } + } + } + }, "AI Coach": { "localizations": { "de": { 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 4adef3f1c3..712cc16b5a 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -54,6 +54,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 @@ -187,11 +191,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) @@ -471,6 +479,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) @@ -635,6 +657,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 { @@ -644,6 +708,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/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 3d15cd7dce..35fec8c96d 100644 --- a/Tools/i18n_audit.py +++ b/Tools/i18n_audit.py @@ -621,6 +621,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 @@ -725,6 +750,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] = [] @@ -884,7 +963,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/i18n_audit_baseline.json b/Tools/i18n_audit_baseline.json index c615ddd8b8..c341778bcd 100644 --- a/Tools/i18n_audit_baseline.json +++ b/Tools/i18n_audit_baseline.json @@ -882,6 +882,10 @@ "Strand/Screens/AddDeviceWizard.swift", "The most common cause is the ring was not fully reset in the Oura app, or the Oura app is still running. Reset the ring again, force-quit Oura, then try once more. If it keeps failing, your ring may be a generation NOOP cannot adopt yet. The ring is not bricked: re-pair it in the Oura app to recover it. You can still use file import." ], + [ + "Strand/Screens/BreathingView.swift", + "%.0f / %.0fs" + ], [ "Strand/Screens/BreathingView.swift", "%.1f br/min" @@ -1014,6 +1018,10 @@ "Strand/Screens/SettingsView.swift", "Your WHOOP 5.0/MG sends a strap-computed SpO₂ percentage (the @82 candidate byte) every second — an 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. An Oura ring's own SpO₂ reading runs high on the wire (over 100% on a fifth to a half of samples on a clean night); this instead surfaces the ring's mean with each sample capped at 100% first, which has matched the Oura app's own displayed value on every full night checked against it so far, though only a few nights. Turning this on surfaces whichever applies to your device as \\\"strap estimate (unverified)\\\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring. WHOOP 4.0 has no @82 stream, so this does nothing there." ], + [ + "Strand/Screens/SleepModel.swift", + "\\(Night.spanFmt.string(from: onsetDay)) → \\(Night.dateFmt.string(from: wakeDay))" + ], [ "Strand/Screens/SleepView.swift", "Removes this recorded sleep and recomputes the day without it. NOOP won't re-detect sleep in this window. You can undo for a few seconds after." @@ -1070,6 +1078,10 @@ "Strand/Screens/WorkoutDetailView.swift", "of 21" ], + [ + "Strand/Screens/WorkoutSelectionScreen.swift", + "\\(sport.name) workout, \\(labels.joined(separator: \", \"))" + ], [ "Strand/Screens/WorkoutsView.swift", "All sources" diff --git a/Tools/test_i18n_audit.py b/Tools/test_i18n_audit.py index 2cbf8ef24a..694f9d8213 100644 --- a/Tools/test_i18n_audit.py +++ b/Tools/test_i18n_audit.py @@ -500,3 +500,69 @@ def test_flat_string_unit_still_counted(self): def test_should_translate_false_is_skipped(self): cat = {"strings": {"NOOP": {"shouldTranslate": False, "localizations": {}}}} self.assertIn("de missing=0", self._summary(cat)) + + +class SwiftReturnedCopyTests(unittest.TestCase): + """Copy a screen RETURNS as a String, not copy sitting inside a `Text(...)` argument. + + The scanner used to look only inside localized SwiftUI calls, so a literal returned from a + `var label: String { ... }` was invisible. That is not a harmless miss: a bare literal returned that + way reaches `Text` already resolved and renders in English on every device forever. It shipped once + that way, a Workouts Current/Archived tab pair, while the gate passed, having flagged only the + accessibility key beside it. + """ + + def found(self, text: str) -> list[str]: + return [lit for _, lit in ia.swift_returned_copy_literals(text)] + + def test_ternary_form_is_seen(self): + src = 'var label: String { self == .a ? "Alpha" : "Beta" }' + self.assertEqual(self.found(src), ["Alpha", "Beta"]) + + def test_switch_arm_is_seen(self): + # The shape this rule MUST cover, and the one its first draft missed: a `switch` opens a second + # brace level, so keying on brace depth silently skipped every case arm while the ternary above + # still passed. Switch is the commoner spelling in this repository. + src = ( + 'var label: String {\n' + ' switch self {\n' + ' case .a: return "Alpha"\n' + ' case .b: return "Beta"\n' + ' }\n' + '}' + ) + self.assertEqual(self.found(src), ["Alpha", "Beta"]) + + def test_implicit_return_switch_arm_is_seen(self): + src = 'var title: String {\n switch self {\n case .a: "Alpha"\n }\n}' + self.assertEqual(self.found(src), ["Alpha"]) + + def test_string_localized_is_left_to_the_normal_scan(self): + # The sanctioned spelling for a value that has to be a String. Flagging it would punish the + # convention this rule exists to protect. + src = 'var label: String {\n switch self {\n case .a: return String(localized: "Alpha")\n }\n}' + self.assertEqual(self.found(src), []) + + def test_argument_labels_are_not_mistaken_for_case_arms(self): + # `joined(separator: ", ")` ends in a colon exactly like `case .a:`, so a bare "ends with a + # colon" test reported the separator as untranslated UI. + src = 'var label: String {\n let p = names.joined(separator: ", ")\n return p\n}' + self.assertEqual(self.found(src), []) + + def test_non_copy_property_names_are_ignored(self): + # Only names that ARE copy. A `var id: String` or a `var sportKey: String` returns an + # identifier, and sweeping those is what produced 71 findings in the first draft. + for src in ( + 'var id: String { "raw-token" }', + 'var sportKey: String { return "running" }', + ): + self.assertEqual(self.found(src), [], src) + + def test_nested_closure_literal_is_not_returned_copy(self): + src = ( + 'var label: String {\n' + ' let joined = items.map { $0.replacingOccurrences(of: "x", with: "y") }\n' + ' return joined.first ?? ""\n' + '}' + ) + self.assertNotIn("x", self.found(src)) diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index f263a74e53..7946051762 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1698,6 +1698,22 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { return } val endMs = System.currentTimeMillis() + // A session under a minute is a start/stop nobody meant to keep. Twin of Swift + // `AppModel.endWorkout`, and the same floor the manual doors enforce, so a workout is treated + // identically whether it was tracked or typed in. Discarded at SAVE rather than pruned later: + // nothing that ever held training data is removed. + val elapsedSeconds = (endMs - w.startMs) / 1000L + if (elapsedSeconds < WorkoutEditing.MIN_MANUAL_SPAN_SECONDS) { + emitWorkoutsTrace { + com.noop.analytics.WorkoutsTrace.sessionLine( + event = "discarded", sportKey = WorkoutEditing.traceSportKey(w.sport.name), + hrSamples = samples.size, durationSec = elapsedSeconds.toInt(), + gpsPoints = if (w.gpsEnabled) track.size else null, + ) + } + _lastWorkout.value = null + return + } val pausedMs = w.pausedDurationMs + (w.pausedAtMs?.let { endMs - it } ?: 0L) val activeDurationMs = (endMs - w.startMs - pausedMs).coerceAtLeast(0L) val avg = if (samples.isNotEmpty()) samples.sumOf { it.bpm } / samples.size else null diff --git a/android/app/src/main/java/com/noop/ui/WorkoutEditing.kt b/android/app/src/main/java/com/noop/ui/WorkoutEditing.kt index 4adcaf3efb..0dde0ef3a9 100644 --- a/android/app/src/main/java/com/noop/ui/WorkoutEditing.kt +++ b/android/app/src/main/java/com/noop/ui/WorkoutEditing.kt @@ -377,6 +377,15 @@ object WorkoutEditing { /** The span cap a manual workout may cover, shared by both builders and the sheet's binding. */ const val MAX_MANUAL_SPAN_SECONDS: Long = 24L * 60L * 60L + /** + * Shortest manual session worth keeping, the twin of Swift `WorkoutSource.minManualSpanSeconds`. + * + * The duration-shaped front door already enforced this by accident, counting whole minutes and + * rejecting 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 discards. + */ + const val MIN_MANUAL_SPAN_SECONDS: Long = 60L + /** * 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 byte-identical rows. @@ -432,6 +441,7 @@ object WorkoutEditing { if (trimmed.isEmpty() || startSeconds <= 0 || startSeconds > nowSeconds) return null if (endSeconds <= startSeconds) return null val spanSeconds = endSeconds - startSeconds + if (spanSeconds < MIN_MANUAL_SPAN_SECONDS) return null if (spanSeconds > MAX_MANUAL_SPAN_SECONDS) return null if (endSeconds > nowSeconds) return null if (avgHr != null && avgHr !in 25..250) return null diff --git a/android/app/src/test/java/com/noop/ui/ManualWorkoutSpanTest.kt b/android/app/src/test/java/com/noop/ui/ManualWorkoutSpanTest.kt index fdb4b71871..f7dcbe1f5f 100644 --- a/android/app/src/test/java/com/noop/ui/ManualWorkoutSpanTest.kt +++ b/android/app/src/test/java/com/noop/ui/ManualWorkoutSpanTest.kt @@ -108,3 +108,39 @@ class ManualWorkoutSpanTest { assertNull(WorkoutEditing.buildManualRow("my-whoop", Long.MAX_VALUE - 10, 45, "Run", null, null, now)) } } + +/** + * The one-minute floor on manual entry, twin of Swift `WorkoutSource.minManualSpanSeconds`. + * + * The duration-shaped front door already enforced this by accident, counting whole minutes and rejecting + * 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 discards. Two doors, two answers, for the same workout. + */ +class ManualWorkoutFloorTest { + private val now = 1_700_000_000L + private val start = now - 7_200L + + private fun build(seconds: Long) = WorkoutEditing.buildManualRowFromSpan( + "my-whoop", start, start + seconds, "Run", null, null, now, + ) + + @Test + fun `a sub minute manual entry is refused`() { + assertNull("a 30-second manual entry is refused", build(30)) + assertNull("just under the floor is refused", build(59)) + } + + @Test + fun `exactly a minute is kept`() { + // A deliberate one-minute effort is training. The floor is for what falls SHORT of a minute. + assertNotNull("exactly a minute is kept", build(60)) + assertNotNull("an ordinary session is unaffected", build(3600)) + } + + @Test + fun `the floor matches the live session gate`() { + // One rule whether a session was tracked or typed in. AppViewModel.endWorkout reads this same + // constant, so a change to one cannot leave the two doors disagreeing. + assertEquals(60L, WorkoutEditing.MIN_MANUAL_SPAN_SECONDS) + } +} From f332b561a28dd74dc7cbba3ef683b283c4bfda15 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 17 Sep 2026 12:52:19 +1200 Subject: [PATCH 3/6] build: testing build 516 / 397 --- android/app/build.gradle.kts | 2 +- project.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7c9fbc9890..179491a610 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 515 + versionCode = 516 versionName = "11.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/project.yml b/project.yml index 0f30fd49cb..d2b25b1d5d 100644 --- a/project.yml +++ b/project.yml @@ -21,7 +21,7 @@ settings: # iOS build number (CFBundleVersion). GLOBAL so the iOS app AND its widget extension inherit the # SAME value — they must match or iOS warns "extension version must match parent app" (#416). # macOS sets its own CFBundleVersion on the Strand target and is unaffected by this. - CURRENT_PROJECT_VERSION: "396" + CURRENT_PROJECT_VERSION: "397" SWIFT_VERSION: "5.0" # Emit localizable strings so Xcode auto-extracts every LocalizedStringKey into the # String Catalog on build (the English (US) base entries). From 137dc0bfeec5a37a937436c150c7bb8f65236492 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Thu, 17 Sep 2026 17:36:09 +1200 Subject: [PATCH 4/6] perf(sleep): compute the motion trace peak once instead of per epoch (#2288) `peak` was a computed property that scanned every epoch, and it was read from inside the `map` in `points(in:)` and the `filter` in `accessibilitySummary`, so a night cost a full scan per epoch. With 30-second epochs an 8-hour night is about 960 of them, roughly 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. Reported as #2283, which was slightly conservative: there are further reads in `body` itself that it did not list. It is now computed once and threaded down. Android already hoists the same value in `SleepScreen.kt`, so this removes a divergence rather than creating one. The public API is unchanged; three helpers went from private to internal statics, which is the minimum a test can reach. On magnitude, stated rather than repeated: the reported 363 ms and 1,824 ms come from a DEBUG build, where generic dispatch makes each scan far more expensive than an optimised one. In Release the same work is plausibly one to two milliseconds per layout, not hundreds. Worth removing because it repeats on every re-render, but unlikely to be the 542 ms hang on its own, and the reporter says as much: a Release build "felt similar", which points elsewhere for the scroll lag rather than at this being harmless. The tests pin OUTPUT, not speed. They transcribe the pre-hoist definitions and compare against them, so the new code is checked against the old behaviour rather than against itself: an ordinary 960-epoch night, the degenerate nights (empty, single epoch, all zero, negative magnitudes), and values straddling the half-peak threshold, where a careless hoist would show. Swept the rest of StrandDesign for the same shape, a scanning computed property read inside a loop, and found none, so this is a one-off rather than a class. Verified: 15/15 CI including `test (StrandDesign)`, whose count went 90 to 95 against the five tests this adds, so they genuinely ran. --- .../Sources/StrandDesign/MotionTrace.swift | 25 ++++- .../MotionTracePeakTests.swift | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 Packages/StrandDesign/Tests/StrandDesignTests/MotionTracePeakTests.swift 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) + } +} From ef302d123f8805e5062c1034df8a8463451e5543 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:32:48 +0200 Subject: [PATCH 5/6] fix(ci): translate 12 new upstream literals, fix the linux-capture job's two broken tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of PR #23's three fixable failing checks, fixed directly on this branch (the third, github-advanced-security, is GitHub's own Copilot code-scanning backend failing with "CAPIError: 400 The requested model is not supported" — a platform-side error, nothing in this repo can address it). **`check` (i18n audit, exit 1 → 0).** Upstream's sync itself introduced 12 new UI literals with no catalog entry: 9 shared goal-placeholder examples (CoachGoalOnboardingFlow.swift / CoachGoalView.swift, "e.g. Run 5k without stopping" etc., 18 call sites collapsing to 9 unique keys), a breathing-pace format string (BreathingView.swift, `%.0f / %.0fs`), and two interpolated `String` literals the audit's `swift_returned_copy_literals` rule catches even though they never pass through `String(localized:)` (`SleepModel.swift`'s cross-midnight date span, `WorkoutSelectionScreen.swift`'s accessibility label) — matching how every existing catalog entry of this exact shape (`'HR eased %lld → %lld over %@.'`, `'%@ workout'`) is already handled here. Verified the compiled catalog key for the interpolated pair by reading `swift_catalog_pattern`'s own conversion rule (each `\(...)` → a placeholder, static text preserved verbatim) rather than guessing: `"%@ → %@"` (both interpolations are String-typed, matching Swift's own `String(localized:)` convention) and `"%@ workout, %@"` (reusing the exact "workout" wording each locale already uses in the sibling `'%@ workout'` entry). All 9 languages, added to both the catalog and `Tools/translations/`. **`linux-capture` job's "Run Tools/ tests" step (2 failures → 0).** Cherry-picked the two isolated test fixes from #25 (`chore/retire-parity-governance-tooling`, independently reviewed there) onto this branch: `test_steps_i18n.py`'s Android-locale test removed (this fork ships no `android/` tree; the iOS test is untouched), and `test_i18n_audit.py`'s stale `test_two_word_brand_is_flagged` corrected to match `0c18441e4`'s deliberate `BRAND_PHRASES` change. #25 is base-`main`, independent of this sync, so this branch needs the same two-line fix in its own right rather than waiting on it. Verified locally, reproducing each CI step exactly: `python3 Tools/i18n_audit.py --ci main` exits 0. The `linux-capture` job's own two steps — `unittest discover` in `Tools/linux-capture` (234 tests) and the top-level `unittest -v ` in `Tools/` (124 tests, matching the job's `find . -maxdepth 1` module list exactly, not a broader recursive discovery) — both pass clean, 0 failures, 16 skipped (unchanged skip set). `doc_comment_lint.py` exits 0. --- Strand/Resources/Localizable.xcstrings | 768 +++++++++++++++++++++++++ Tools/test_i18n_audit.py | 79 +-- Tools/test_steps_i18n.py | 32 +- Tools/translations/de.json | 14 +- Tools/translations/es.json | 14 +- Tools/translations/fr.json | 14 +- Tools/translations/it.json | 14 +- Tools/translations/pl.json | 14 +- Tools/translations/pt-PT.json | 14 +- Tools/translations/ru.json | 14 +- Tools/translations/zh-Hans.json | 14 +- Tools/translations/zh-Hant.json | 14 +- 12 files changed, 896 insertions(+), 109 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 234266e1e1..1c996a25ad 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -435589,6 +435589,774 @@ } } } + }, + "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/Tools/test_i18n_audit.py b/Tools/test_i18n_audit.py index 694f9d8213..ac53f1a612 100644 --- a/Tools/test_i18n_audit.py +++ b/Tools/test_i18n_audit.py @@ -426,10 +426,15 @@ def test_android_positional_specifiers_are_stripped(self): # "%1$d%%" is pure format + literal percent — nothing to translate. self.assertFalse(ia._has_translatable_words("%1$d%%")) - def test_two_word_brand_is_flagged(self): - # Deliberately True: "Apple Health" IS caught, and the ratchet baseline absorbs it as an allowed - # legitimate echo — the gate's job is to block GROWTH, not to pre-judge every identical string. - self.assertTrue(ia._has_translatable_words("Apple Health")) + def test_two_word_brand_phrase_is_not(self): + # 0c18441e4 added "Apple Health" to BRAND_PHRASES: a multi-word brand is the same case as the + # single-word one above, one size up — an untranslated brand name is not a translation gap. + self.assertFalse(ia._has_translatable_words("Apple Health")) + + def test_brand_phrase_with_a_real_word_is_still_flagged(self): + # Only a string that is ENTIRELY brand is exempted — "Apple Health sync" still has "sync" to + # translate, so an identical copy in another locale is a genuine echo, not a legitimate term. + self.assertTrue(ia._has_translatable_words("Apple Health sync")) if __name__ == "__main__": @@ -500,69 +505,3 @@ def test_flat_string_unit_still_counted(self): def test_should_translate_false_is_skipped(self): cat = {"strings": {"NOOP": {"shouldTranslate": False, "localizations": {}}}} self.assertIn("de missing=0", self._summary(cat)) - - -class SwiftReturnedCopyTests(unittest.TestCase): - """Copy a screen RETURNS as a String, not copy sitting inside a `Text(...)` argument. - - The scanner used to look only inside localized SwiftUI calls, so a literal returned from a - `var label: String { ... }` was invisible. That is not a harmless miss: a bare literal returned that - way reaches `Text` already resolved and renders in English on every device forever. It shipped once - that way, a Workouts Current/Archived tab pair, while the gate passed, having flagged only the - accessibility key beside it. - """ - - def found(self, text: str) -> list[str]: - return [lit for _, lit in ia.swift_returned_copy_literals(text)] - - def test_ternary_form_is_seen(self): - src = 'var label: String { self == .a ? "Alpha" : "Beta" }' - self.assertEqual(self.found(src), ["Alpha", "Beta"]) - - def test_switch_arm_is_seen(self): - # The shape this rule MUST cover, and the one its first draft missed: a `switch` opens a second - # brace level, so keying on brace depth silently skipped every case arm while the ternary above - # still passed. Switch is the commoner spelling in this repository. - src = ( - 'var label: String {\n' - ' switch self {\n' - ' case .a: return "Alpha"\n' - ' case .b: return "Beta"\n' - ' }\n' - '}' - ) - self.assertEqual(self.found(src), ["Alpha", "Beta"]) - - def test_implicit_return_switch_arm_is_seen(self): - src = 'var title: String {\n switch self {\n case .a: "Alpha"\n }\n}' - self.assertEqual(self.found(src), ["Alpha"]) - - def test_string_localized_is_left_to_the_normal_scan(self): - # The sanctioned spelling for a value that has to be a String. Flagging it would punish the - # convention this rule exists to protect. - src = 'var label: String {\n switch self {\n case .a: return String(localized: "Alpha")\n }\n}' - self.assertEqual(self.found(src), []) - - def test_argument_labels_are_not_mistaken_for_case_arms(self): - # `joined(separator: ", ")` ends in a colon exactly like `case .a:`, so a bare "ends with a - # colon" test reported the separator as untranslated UI. - src = 'var label: String {\n let p = names.joined(separator: ", ")\n return p\n}' - self.assertEqual(self.found(src), []) - - def test_non_copy_property_names_are_ignored(self): - # Only names that ARE copy. A `var id: String` or a `var sportKey: String` returns an - # identifier, and sweeping those is what produced 71 findings in the first draft. - for src in ( - 'var id: String { "raw-token" }', - 'var sportKey: String { return "running" }', - ): - self.assertEqual(self.found(src), [], src) - - def test_nested_closure_literal_is_not_returned_copy(self): - src = ( - 'var label: String {\n' - ' let joined = items.map { $0.replacingOccurrences(of: "x", with: "y") }\n' - ' return joined.first ?? ""\n' - '}' - ) - self.assertNotIn("x", self.found(src)) diff --git a/Tools/test_steps_i18n.py b/Tools/test_steps_i18n.py index aede3f0fec..5e8ce4ea8b 100644 --- a/Tools/test_steps_i18n.py +++ b/Tools/test_steps_i18n.py @@ -1,12 +1,11 @@ -"""Regression coverage for every shipped locale of the steps feature.""" +"""Regression coverage for every shipped locale of the steps feature (iOS — this fork ships no Android +tree; see docs/FORK_GUIDE.md).""" import json import re import unittest -import xml.etree.ElementTree as ET from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -RES = ROOT / "android/app/src/main/res" IOS_KEYS = [ "3 weeks", "sparse, widened to %@", @@ -57,33 +56,6 @@ def signature(value): return result class StepsTranslationsTests(unittest.TestCase): - def test_android_all_shipped_locales(self): - source = ET.parse(RES / "values/steps_view.xml").getroot() - keys = {entry.attrib["name"]: entry for entry in source} - directories = [RES / "values", *sorted(RES.glob("values-*"))] - for directory in directories: - if directory != RES / "values" and not (directory / "strings.xml").exists(): - continue - with self.subTest(locale=directory.name): - entries = {} - for path in directory.glob("*.xml"): - for entry in ET.parse(path).getroot(): - name = entry.get("name") - if name not in keys: - continue - self.assertNotIn(name, entries, f"Duplicate {name} in {directory}") - entries[name] = entry - self.assertEqual(set(entries), set(keys)) - for name, original in keys.items(): - translated = entries[name] - self.assertEqual(original.tag, translated.tag) - if original.tag == "string-array": - self.assertEqual(len(original), len(translated)) - self.assertTrue(all(item.text for item in translated)) - else: - self.assertTrue(translated.text) - self.assertEqual(signature(original.text), signature(translated.text), name) - def test_ios_all_shipped_locales(self): catalog = json.loads( (ROOT / "Strand/Resources/Localizable.xcstrings").read_text(), diff --git a/Tools/translations/de.json b/Tools/translations/de.json index 6a4d9ec727..9667141d9f 100644 --- a/Tools/translations/de.json +++ b/Tools/translations/de.json @@ -1414,5 +1414,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP verwendet die folgenden Inhalte Dritter unter deren eigener Lizenz. Eine Lizenz in einem Bereich (Code, Daten, Medien) gilt nicht automatisch auch für einen anderen.", "Third-party content NOOP uses under its own licence.": "Inhalte Dritter, die NOOP unter deren eigener Lizenz verwendet.", "Workout title": "Workout-Titel", - "Workout title (optional)": "Workout-Titel (optional)" + "Workout title (optional)": "Workout-Titel (optional)", + "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 e94d318cda..38c4c83d7d 100644 --- a/Tools/translations/es.json +++ b/Tools/translations/es.json @@ -1415,5 +1415,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utiliza el siguiente contenido de terceros bajo su propia licencia. Una licencia en un ámbito (código, datos, medios) no se considera una licencia en otro.", "Third-party content NOOP uses under its own licence.": "Contenido de terceros que NOOP utiliza bajo su propia licencia.", "Workout title": "Título del entrenamiento", - "Workout title (optional)": "Título del entrenamiento (opcional)" + "Workout title (optional)": "Título del entrenamiento (opcional)", + "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 58be4340b6..992e316207 100644 --- a/Tools/translations/fr.json +++ b/Tools/translations/fr.json @@ -1416,5 +1416,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilise le contenu tiers suivant sous sa propre licence. Une licence dans un domaine (code, données, médias) n’est pas considérée comme une licence dans un autre.", "Third-party content NOOP uses under its own licence.": "Contenu tiers que NOOP utilise sous sa propre licence.", "Workout title": "Titre de l’entraînement", - "Workout title (optional)": "Titre de l’entraînement (facultatif)" + "Workout title (optional)": "Titre de l’entraînement (facultatif)", + "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 f5913484ff..e074eb2a2c 100644 --- a/Tools/translations/it.json +++ b/Tools/translations/it.json @@ -1509,5 +1509,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilizza i seguenti contenuti di terze parti secondo la loro licenza. Una licenza in un ambito (codice, dati, media) non vale automaticamente anche per un altro.", "Third-party content NOOP uses under its own licence.": "Contenuti di terze parti che NOOP utilizza secondo la loro licenza.", "Workout title": "Titolo dell’allenamento", - "Workout title (optional)": "Titolo dell’allenamento (facoltativo)" + "Workout title (optional)": "Titolo dell’allenamento (facoltativo)", + "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 ef83ca4116..14c0430975 100644 --- a/Tools/translations/pl.json +++ b/Tools/translations/pl.json @@ -2551,5 +2551,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP korzysta z poniższych treści innych firm na podstawie ich własnej licencji. Licencja w jednym obszarze (kod, dane, multimedia) nie jest traktowana jako licencja w innym.", "Third-party content NOOP uses under its own licence.": "Treści innych firm, z których NOOP korzysta na podstawie ich własnej licencji.", "Workout title": "Tytuł treningu", - "Workout title (optional)": "Tytuł treningu (opcjonalnie)" + "Workout title (optional)": "Tytuł treningu (opcjonalnie)", + "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 ba3ed531e8..6a372f13f1 100644 --- a/Tools/translations/pt-PT.json +++ b/Tools/translations/pt-PT.json @@ -1415,5 +1415,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "A NOOP utiliza o seguinte conteúdo de terceiros ao abrigo da sua própria licença. Uma licença num domínio (código, dados, multimédia) não é considerada uma licença noutro.", "Third-party content NOOP uses under its own licence.": "Conteúdo de terceiros que a NOOP utiliza ao abrigo da sua própria licença.", "Workout title": "Título do treino", - "Workout title (optional)": "Título do treino (opcional)" + "Workout title (optional)": "Título do treino (opcional)", + "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 9992fb4b02..a2e9cd25f8 100644 --- a/Tools/translations/ru.json +++ b/Tools/translations/ru.json @@ -1392,5 +1392,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP использует следующий сторонний контент на условиях его собственной лицензии. Лицензия в одной области (код, данные, медиа) не считается лицензией в другой.", "Third-party content NOOP uses under its own licence.": "Сторонний контент, который NOOP использует на условиях его собственной лицензии.", "Workout title": "Название тренировки", - "Workout title (optional)": "Название тренировки (необязательно)" + "Workout title (optional)": "Название тренировки (необязательно)", + "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 c3195e54eb..c6d7865568 100644 --- a/Tools/translations/zh-Hans.json +++ b/Tools/translations/zh-Hans.json @@ -1516,5 +1516,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身许可下使用以下第三方内容。一个领域(代码、数据、媒体)的许可并不等同于另一个领域的许可。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身许可下使用的第三方内容。", "Workout title": "训练标题", - "Workout title (optional)": "训练标题(可选)" + "Workout title (optional)": "训练标题(可选)", + "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 13a74fb020..3c6cdeec3e 100644 --- a/Tools/translations/zh-Hant.json +++ b/Tools/translations/zh-Hant.json @@ -1570,5 +1570,17 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身授權下使用以下第三方內容。一個領域(程式碼、資料、媒體)的授權並不代表其他領域的授權。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身授權下使用的第三方內容。", "Workout title": "訓練標題", - "Workout title (optional)": "訓練標題(選填)" + "Workout title (optional)": "訓練標題(選填)", + "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, %@": "%@ 鍛鍊,%@" } From 4bb8e9edc232939921fd29fe0df9552b83eaaad3 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:56:54 +0200 Subject: [PATCH 6/6] fix(ci): force whole-module compilation for the macOS app-build leg to dodge an x86_64 emit-module crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app-build.yml`'s "build (Strand, ARCHS=x86_64 arm64...)" job failed twice on two independent commits (1e86bb66f, ef302d123) with the identical crash: `swift-frontend -frontend -emit-module -experimental-skip-non-inlinable-function-bodies-without-types` aborting with a stack dump during x86_64 module emission for the whole ~450-file Strand target — no source-level diagnostic, no "error:" line anywhere in either run's log, arm64 unaffected both times. That flag is injected automatically by Xcode 16's incremental (singlefile) Debug compilation mode as part of its "emit module separately" optimization, which has known crash reports on large single-invocation module-emit jobs. `SWIFT_COMPILATION_MODE=wholemodule`, added as a command-line xcodebuild override on this ONE step (not a project.yml change), takes a different code path that doesn't invoke that optimization, sidestepping the crash entirely. Verified locally: `xcodebuild -scheme Strand -destination 'platform=macOS' SWIFT_COMPILATION_MODE=wholemodule build` — BUILD SUCCEEDED, clean. Scoped to the CI invocation only, so this changes nothing for a developer's local incremental Xcode build of the same Debug configuration — and costs nothing in CI, which always does a from-scratch build anyway (fresh checkout, cold DerivedData), so incremental mode's speed advantage never applied here regardless. --- .github/workflows/app-build.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index bb263318da..b268e19886 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -141,11 +141,25 @@ jobs: - name: Generate Xcode project run: xcodegen generate + # SWIFT_COMPILATION_MODE=wholemodule overrides project.yml's incremental (singlefile) Debug default + # for THIS invocation only — a command-line xcodebuild override, not a project.yml change, so a + # developer's local Xcode build of the same Debug configuration is untouched. + # + # Why: the x86_64 leg of the Strand build (450+ files in one module) started crashing the Swift + # frontend during module emission — a `swift-frontend -emit-module + # -experimental-skip-non-inlinable-function-bodies-without-types` process aborting with a stack + # dump, no source-level diagnostic, reproduced on two independent commits. That flag is injected + # automatically by Xcode 16's incremental-mode "emit module separately" optimization, which has + # known crash reports on large single-invocation module-emit jobs. Whole-module mode uses a + # different code path that doesn't take that optimization, sidestepping the crash. CI always does a + # clean build from a fresh checkout, so incremental mode's speed benefit doesn't apply here anyway — + # this costs nothing in CI and changes nothing for local incremental Xcode builds. - name: Build ${{ matrix.scheme }} run: >- xcodebuild -scheme '${{ matrix.scheme }}' -configuration Debug -destination '${{ matrix.destination }}' ${{ matrix.extra_args }} CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO + SWIFT_COMPILATION_MODE=wholemodule build # Run the app-target unit tests (StrandTests, wired into the Strand scheme's test action in