From a95291b0930bb7d710478d903d56945706ace987 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:57:18 +0200 Subject: [PATCH 01/31] lift log: programs, the exercise vocabulary and muscle classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase one of the Lift Log UI: build a program once and reuse it. Adds a "Lift Log" row to More → Body, next to Workouts, on the v40 schema. Screens: LiftLogView the hub — saved programs, and a header stating plainly that lifting never moves Effort LiftProgramEditorSheet name, note and an ordered list of exercise lines LiftProgramItemSheet one line: the exercise, its muscles, and the targets NOOP still ships no exercise catalogue. The user types whatever they call a movement; it is remembered in `liftExercise` with the muscle group they gave it and offered back next time, with its classification adopted automatically. A shipped exercise→muscle mapping would be both a permanent maintenance burden and a correctness claim NOOP has no business making about someone else's technique. Classification is a one-time action per exercise: one primary muscle (a direct set) and any number of secondaries (indirect, counted at half). Selecting a primary removes it from the secondary list, so one muscle can never be credited twice for the same set. Weight display units are the app's EXISTING metric/imperial preference (`UnitPrefs.systemKey`) rather than a second setting of the Lift Log's own — a pounds user gets pounds for free, and two settings can never disagree. `LiftFormat` converts through one constant in both directions so a typed weight round-trips instead of drifting a tenth on the way back. All 30 new strings are translated into the nine shipped locales. `Effort` uses the catalog's existing per-locale metric name (Belastung / Esfuerzo / Sforzo / Wysiłek / Усилие / 消耗 …) rather than a fresh translation, so the screen agrees with the rest of the app. Verification: `xcodebuild` clean for BOTH targets — NOOPiOS (iOS Simulator) and Strand (macOS) — since files under Strand/ compile into both and no CI covers either. `Tools/i18n_audit.py --ci main` and `Tools/doc_comment_lint.py` both pass. Exercised end-to-end in the iPhone 17 Pro simulator: created a program, typed a new exercise, classified it Chest + front delts/triceps, set targets, saved, reopened — name, line, targets and classification all restored from SQLite, and the primary correctly absent from the secondary list. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftFormat.swift | 67 ++++ Strand/Data/LiftMuscleNames.swift | 81 +++++ Strand/Resources/Localizable.xcstrings | 90 +++++ Strand/Screens/LiftLogView.swift | 160 +++++++++ Strand/Screens/LiftProgramEditorSheet.swift | 332 +++++++++++++++++ Strand/Screens/LiftProgramItemSheet.swift | 379 ++++++++++++++++++++ StrandiOS/App/RootTabView.swift | 4 +- 7 files changed, 1112 insertions(+), 1 deletion(-) create mode 100644 Strand/Data/LiftFormat.swift create mode 100644 Strand/Data/LiftMuscleNames.swift create mode 100644 Strand/Screens/LiftLogView.swift create mode 100644 Strand/Screens/LiftProgramEditorSheet.swift create mode 100644 Strand/Screens/LiftProgramItemSheet.swift diff --git a/Strand/Data/LiftFormat.swift b/Strand/Data/LiftFormat.swift new file mode 100644 index 0000000000..793843ebb5 --- /dev/null +++ b/Strand/Data/LiftFormat.swift @@ -0,0 +1,67 @@ +import Foundation + +// Number and unit formatting for the Lift Log. +// +// WEIGHT IS ALWAYS STORED IN KILOGRAMS. Only display and typed input are converted, using the +// unit system the user already picked for the whole app (`UnitPrefs.systemKey`) — the Lift Log +// deliberately does not add a second weight-unit setting of its own, so a pounds user gets pounds +// here for free and never has two settings that can disagree. +// +// Conversion goes through ONE constant in BOTH directions (`UnitFormatter.poundsPerKilogram`), so a typed +// value round-trips: enter 225 lb, store 102.058… kg, read it back and it renders 225 lb again. +// Using the exact 0.45359237 for input while displaying with the rounded 2.20462 would drift the +// number by a tenth on the way back and make the log look like it had edited itself. + +enum LiftFormat { + + // MARK: - Weight + + /// Convert a typed weight in the user's display unit to the kilograms that get stored. + static func kilograms(fromDisplay value: Double, system: UnitSystem) -> Double { + system == .imperial ? value / UnitFormatter.poundsPerKilogram : value + } + + /// Convert stored kilograms to the user's display unit, as a number (not a string) so callers + /// can put it straight into an editable text field. + static func display(fromKilograms kg: Double, system: UnitSystem) -> Double { + system == .imperial ? kg * UnitFormatter.poundsPerKilogram : kg + } + + /// A stored weight rendered for display with its unit: "60 kg", "132.5 lb". + static func weight(_ kg: Double?, system: UnitSystem) -> String { + guard let kg else { return "—" } + return "\(trim(display(fromKilograms: kg, system: system))) \(UnitFormatter.massUnit(system))" + } + + /// The bare unit label for a field suffix. + static func weightUnit(_ system: UnitSystem) -> String { UnitFormatter.massUnit(system) } + + // MARK: - Numbers + + /// Drop a trailing ".0" so a whole number reads as one: 8.0 → "8", 7.5 → "7.5". + /// + /// Weights and RPE are both entered as decimals but are usually whole, and "8.0 × 10" in a + /// summary line reads like a precision the user did not type. + static func trim(_ value: Double) -> String { + if value == value.rounded() && abs(value) < 1e9 { + return String(Int(value.rounded())) + } + return String(format: "%.1f", value) + } + + /// Parse a typed number, accepting both "7.5" and the comma decimal separator "7,5" that most of + /// NOOP's shipped locales use on their keyboards. Returns nil for anything else. + static func number(_ text: String) -> Double? { + let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".") + guard !cleaned.isEmpty else { return nil } + return Double(cleaned) + } + + // MARK: - Durations + + /// A rest period as "2:00" / "45s" — minutes and seconds, which is how rest is spoken about. + static func duration(_ seconds: Int) -> String { + guard seconds >= 60 else { return "\(seconds)s" } + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} diff --git a/Strand/Data/LiftMuscleNames.swift b/Strand/Data/LiftMuscleNames.swift new file mode 100644 index 0000000000..78569f2f85 --- /dev/null +++ b/Strand/Data/LiftMuscleNames.swift @@ -0,0 +1,81 @@ +import Foundation +import WhoopStore + +// Display names for the stored `LiftMuscle` vocabulary. +// +// `WhoopStore` deliberately holds no UI strings: `LiftMuscle`'s raw values are a stored-data +// contract (an Android twin must one day write byte-identical tokens), so the human-readable name +// has to live at the app layer, where it can be localized without touching what is written to the +// database. Renaming a label here is free; renaming a token is not — see the header of +// `LiftMuscle.swift`. +// +// Names are the ones lifters use, not the anatomical Latin: "Lats", not "latissimus dorsi". The +// vocabulary is meant to be picked from in a gym in a few seconds, and the granularity is already +// justified in the enum — the label's only job is to be recognised instantly. + +extension LiftMuscle { + /// Localized display name, e.g. "Front delts". + /// + /// A computed `String` rather than a `LocalizedStringKey` because callers interpolate it — into + /// a picker row, a summary line, an accessibility label — and an interpolated + /// `LocalizedStringKey` would look up the *interpolated* result as a key and miss. + var displayName: String { + switch self { + // Push + case .chest: return String(localized: "Chest") + case .frontDelts: return String(localized: "Front delts") + case .sideDelts: return String(localized: "Side delts") + case .rearDelts: return String(localized: "Rear delts") + case .triceps: return String(localized: "Triceps") + // Pull + case .lats: return String(localized: "Lats") + case .upperBack: return String(localized: "Upper back") + case .traps: return String(localized: "Traps") + case .biceps: return String(localized: "Biceps") + case .forearms: return String(localized: "Forearms") + // Legs + case .quads: return String(localized: "Quads") + case .hamstrings: return String(localized: "Hamstrings") + case .glutes: return String(localized: "Glutes") + case .adductors: return String(localized: "Adductors") + case .abductors: return String(localized: "Abductors") + case .calves: return String(localized: "Calves") + // Trunk + case .abs: return String(localized: "Abs") + case .obliques: return String(localized: "Obliques") + case .lowerBack: return String(localized: "Lower back") + case .neck: return String(localized: "Neck") + } + } +} + +extension LiftMuscle.Region { + /// Localized section title for the muscle picker. Presentation-only, like the region itself. + var displayName: String { + switch self { + case .push: return String(localized: "Push") + case .pull: return String(localized: "Pull") + case .legs: return String(localized: "Legs") + case .trunk: return String(localized: "Trunk") + } + } +} + +// MARK: - Summarising a classification + +enum LiftMuscleSummary { + + /// One line describing how an exercise is classified, for a program row or a picker subtitle: + /// "Chest · Front delts, Triceps", or "Not classified" when it has no primary. + /// + /// The primary is listed first and separated from the secondaries, because the direct/indirect + /// split is what makes the per-muscle counts computable at all — collapsing them into one list + /// would hide the distinction the whole rollup rests on. + static func line(primary: LiftMuscle?, secondaries: [LiftMuscle]) -> String { + guard let primary else { return String(localized: "Not classified") } + let secondary = secondaries.filter { $0 != primary } + guard !secondary.isEmpty else { return primary.displayName } + let joined = secondary.map(\.displayName).joined(separator: ", ") + return "\(primary.displayName) · \(joined)" + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 1a6758dc5d..f84a282e4e 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -67,6 +67,96 @@ "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Die Synchronisierung ist abgeschlossen, aber NOOP konnte kein Schlafzeitfenster sicher erkennen. Lass den Strap verbunden und synchronisiere erneut; die ältere Nacht unten ist weiterhin dein zuletzt erkannter Schlaf." } }, "en": { "stringUnit": { "state": "translated", "value": "Sync finished, but NOOP couldn't confidently identify a sleep window. Keep the strap connected and try Sync again; the older night below is still your latest detected sleep." } }, "es": { "stringUnit": { "state": "translated", "value": "La sincronización terminó, pero NOOP no pudo identificar con confianza un periodo de sueño. Mantén la pulsera conectada y vuelve a sincronizar; la noche anterior que aparece abajo sigue siendo tu último sueño detectado." } }, "fr": { "stringUnit": { "state": "translated", "value": "La synchronisation est terminée, mais NOOP n’a pas pu identifier avec certitude une période de sommeil. Gardez le bracelet connecté et relancez la synchronisation ; la nuit plus ancienne ci-dessous reste votre dernier sommeil détecté." } }, "it": { "stringUnit": { "state": "translated", "value": "La sincronizzazione è terminata, ma NOOP non ha identificato con certezza un intervallo di sonno. Tieni la fascia collegata e sincronizza di nuovo; la notte precedente qui sotto resta l’ultimo sonno rilevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja zakończyła się, ale NOOP nie zdołał wiarygodnie określić okna snu. Pozostaw opaskę połączoną i zsynchronizuj ponownie; starsza noc poniżej nadal jest ostatnim wykrytym snem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A sincronização terminou, mas a NOOP não conseguiu identificar com confiança um período de sono. Mantém a bracelete ligada e sincroniza novamente; a noite anterior abaixo continua a ser o teu último sono detetado." } }, "ru": { "stringUnit": { "state": "translated", "value": "Синхронизация завершена, но NOOP не смог уверенно определить окно сна. Оставьте браслет подключённым и повторите синхронизацию; более ранняя ночь ниже остаётся последним обнаруженным сном." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 无法可靠识别睡眠时段。请保持手环连接并再次同步;下方较早的一夜仍是最近一次检测到的睡眠。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同步已完成,但 NOOP 無法可靠識別睡眠時段。請保持手環連接並再次同步;下方較早的一夜仍是最近一次偵測到的睡眠。" } } } }, + "Save exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer l'exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存動作" } } + } }, + "Your log book": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Logbuch" } }, "en": { "stringUnit": { "state": "translated", "value": "Your log book" } }, "es": { "stringUnit": { "state": "translated", "value": "Tu cuaderno" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ton carnet" } }, "it": { "stringUnit": { "state": "translated", "value": "Il tuo diario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Twój dziennik" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu diário" } }, "ru": { "stringUnit": { "state": "translated", "value": "Твой дневник" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你的训练日志" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你的訓練日誌" } } + } }, + "Programs, sessions and per-set history": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programme, Sitzungen und Satz-für-Satz-Verlauf" } }, "en": { "stringUnit": { "state": "translated", "value": "Programs, sessions and per-set history" } }, "es": { "stringUnit": { "state": "translated", "value": "Programas, sesiones e historial por serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programmes, séances et historique par série" } }, "it": { "stringUnit": { "state": "translated", "value": "Programmi, sessioni e storico per serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Programy, sesje i historia serii" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programas, sessões e histórico por série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программы, сессии и история по подходам" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划、训练与每组历史" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫、訓練與每組歷史" } } + } }, + "Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Krafttraining ergänzt Volumen und Satzzahlen. Deine Belastung ändert es nie – die wird weiterhin aus der Herzfrequenz gemessen." } }, "en": { "stringUnit": { "state": "translated", "value": "Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate." } }, "es": { "stringUnit": { "state": "translated", "value": "Las pesas añaden volumen y recuento de series. Nunca cambian tu Esfuerzo, que se sigue midiendo por la frecuencia cardíaca." } }, "fr": { "stringUnit": { "state": "translated", "value": "La musculation ajoute du volume et un décompte de séries. Elle ne change jamais ton Effort, toujours mesuré à partir de la fréquence cardiaque." } }, "it": { "stringUnit": { "state": "translated", "value": "I pesi aggiungono volume e conteggio delle serie. Non cambiano mai il tuo Sforzo, che resta misurato dalla frequenza cardiaca." } }, "pl": { "stringUnit": { "state": "translated", "value": "Trening siłowy dodaje objętość i liczbę serii. Nigdy nie zmienia Twojego Wysiłku, który nadal jest mierzony tętnem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Os pesos acrescentam volume e contagem de séries. Nunca alteram o teu Esforço, que continua medido pela frequência cardíaca." } }, "ru": { "stringUnit": { "state": "translated", "value": "Силовые добавляют объём и число подходов. Они никогда не меняют твоё Усилие — оно по-прежнему измеряется по пульсу." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "力量训练会增加容量和组数统计,但绝不会改变你的消耗——消耗始终由心率测得。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "力量訓練會增加容量和組數統計,但絕不會改變你的消耗——消耗始終由心率測得。" } } + } }, + "Programs": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "Programs" } }, "es": { "stringUnit": { "state": "translated", "value": "Programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programmes" } }, "it": { "stringUnit": { "state": "translated", "value": "Programmi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Programy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } + } }, + "New program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Neues Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "New program" } }, "es": { "stringUnit": { "state": "translated", "value": "Nuevo programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Nouveau programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Nuovo programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Nowy program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Novo programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Новая программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "新建计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "新增計畫" } } + } }, + "No programs yet": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "No programs yet" } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun programme pour l'instant" } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak programów" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программ пока нет" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有計畫" } } + } }, + "A program is a name and an ordered list of exercises with your targets — working sets, rep range, target RPE, rest and your own technique note.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Ein Programm ist ein Name und eine geordnete Liste von Übungen mit deinen Zielen – Arbeitssätze, Wiederholungsbereich, Ziel-RPE, Pause und deine eigene Technik-Notiz." } }, "en": { "stringUnit": { "state": "translated", "value": "A program is a name and an ordered list of exercises with your targets — working sets, rep range, target RPE, rest and your own technique note." } }, "es": { "stringUnit": { "state": "translated", "value": "Un programa es un nombre y una lista ordenada de ejercicios con tus objetivos: series efectivas, rango de repeticiones, RPE objetivo, descanso y tu propia nota de técnica." } }, "fr": { "stringUnit": { "state": "translated", "value": "Un programme, c'est un nom et une liste ordonnée d'exercices avec tes objectifs : séries de travail, fourchette de répétitions, RPE visé, repos et ta propre note de technique." } }, "it": { "stringUnit": { "state": "translated", "value": "Un programma è un nome e un elenco ordinato di esercizi con i tuoi obiettivi: serie di lavoro, intervallo di ripetizioni, RPE target, recupero e la tua nota sulla tecnica." } }, "pl": { "stringUnit": { "state": "translated", "value": "Program to nazwa i uporządkowana lista ćwiczeń z Twoimi celami – serie robocze, zakres powtórzeń, docelowe RPE, przerwa i Twoja własna notatka o technice." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Um programa é um nome e uma lista ordenada de exercícios com os teus objetivos: séries de trabalho, intervalo de repetições, RPE alvo, descanso e a tua própria nota de técnica." } }, "ru": { "stringUnit": { "state": "translated", "value": "Программа — это название и упорядоченный список упражнений с твоими целями: рабочие подходы, диапазон повторений, целевой RPE, отдых и твоя заметка о технике." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "一个计划就是一个名称加上一份有序的动作列表,附带你的目标:正式组、次数区间、目标 RPE、休息时间,以及你自己的技术笔记。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "一個計畫就是一個名稱加上一份有序的動作列表,附帶你的目標:正式組、次數區間、目標 RPE、休息時間,以及你自己的技術筆記。" } } + } }, + "Program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "Program" } }, "es": { "stringUnit": { "state": "translated", "value": "Programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } + } }, + "Exercises": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übungen" } }, "en": { "stringUnit": { "state": "translated", "value": "Exercises" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejercicios" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exercices" } }, "it": { "stringUnit": { "state": "translated", "value": "Esercizi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ćwiczenia" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Exercícios" } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнения" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "動作" } } + } }, + "No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine Übungen. Füge unten die erste hinzu – du kannst jeden Namen eingeben, NOOP merkt ihn sich fürs nächste Mal." } }, "en": { "stringUnit": { "state": "translated", "value": "No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time." } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay ejercicios. Añade el primero abajo: puedes escribir el nombre que quieras y NOOP lo recordará para la próxima vez." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun exercice pour l'instant. Ajoute le premier ci-dessous : tu peux saisir le nom que tu veux, NOOP le retient pour la prochaine fois." } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun esercizio. Aggiungi il primo qui sotto: puoi scrivere il nome che preferisci e NOOP lo ricorda per la prossima volta." } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak ćwiczeń. Dodaj pierwsze poniżej – możesz wpisać dowolną nazwę, a NOOP zapamięta ją na następny raz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem exercícios. Adiciona o primeiro abaixo: podes escrever o nome que quiseres e o NOOP guarda-o para a próxima vez." } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнений пока нет. Добавь первое ниже — можешь ввести любое название, NOOP запомнит его на следующий раз." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有动作。在下面添加第一个——名称随你怎么写,NOOP 会记住它,下次直接用。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有動作。在下面新增第一個——名稱隨你怎麼寫,NOOP 會記住它,下次直接用。" } } + } }, + "Add exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung hinzufügen" } }, "en": { "stringUnit": { "state": "translated", "value": "Add exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Añadir ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ajouter un exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Aggiungi esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dodaj ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Adicionar exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Добавить упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "添加动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "新增動作" } } + } }, + "Move up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nach oben" } }, "en": { "stringUnit": { "state": "translated", "value": "Move up" } }, "es": { "stringUnit": { "state": "translated", "value": "Subir" } }, "fr": { "stringUnit": { "state": "translated", "value": "Monter" } }, "it": { "stringUnit": { "state": "translated", "value": "Sposta su" } }, "pl": { "stringUnit": { "state": "translated", "value": "W górę" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mover para cima" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вверх" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "上移" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "上移" } } + } }, + "Move down": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nach unten" } }, "en": { "stringUnit": { "state": "translated", "value": "Move down" } }, "es": { "stringUnit": { "state": "translated", "value": "Bajar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Descendre" } }, "it": { "stringUnit": { "state": "translated", "value": "Sposta giù" } }, "pl": { "stringUnit": { "state": "translated", "value": "W dół" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mover para baixo" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вниз" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下移" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下移" } } + } }, + "Remove exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung entfernen" } }, "en": { "stringUnit": { "state": "translated", "value": "Remove exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Quitar ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Retirer l'exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Rimuovi esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usuń ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Remover exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Убрать упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移除動作" } } + } }, + "Delete program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm löschen" } }, "en": { "stringUnit": { "state": "translated", "value": "Delete program" } }, "es": { "stringUnit": { "state": "translated", "value": "Eliminar programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Supprimer le programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Elimina programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usuń program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Удалить программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除計畫" } } + } }, + "Delete this program?": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieses Programm löschen?" } }, "en": { "stringUnit": { "state": "translated", "value": "Delete this program?" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Eliminar este programa?" } }, "fr": { "stringUnit": { "state": "translated", "value": "Supprimer ce programme ?" } }, "it": { "stringUnit": { "state": "translated", "value": "Eliminare questo programma?" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usunąć ten program?" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar este programa?" } }, "ru": { "stringUnit": { "state": "translated", "value": "Удалить эту программу?" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除这个计划?" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除這個計畫?" } } + } }, + "Sessions you already logged from it are kept.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereits damit aufgezeichnete Sitzungen bleiben erhalten." } }, "en": { "stringUnit": { "state": "translated", "value": "Sessions you already logged from it are kept." } }, "es": { "stringUnit": { "state": "translated", "value": "Las sesiones que ya registraste con él se conservan." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séances déjà enregistrées avec lui sont conservées." } }, "it": { "stringUnit": { "state": "translated", "value": "Le sessioni già registrate con esso vengono mantenute." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisane już sesje pozostaną." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As sessões que já registaste com ele são mantidas." } }, "ru": { "stringUnit": { "state": "translated", "value": "Уже записанные по ней сессии сохранятся." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经用它记录的训练会保留。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已經用它記錄的訓練會保留。" } } + } }, + "Save program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save program" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer le programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存計畫" } } + } }, + "Exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung" } }, "en": { "stringUnit": { "state": "translated", "value": "Exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "動作" } } + } }, + "Used before": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Schon verwendet" } }, "en": { "stringUnit": { "state": "translated", "value": "Used before" } }, "es": { "stringUnit": { "state": "translated", "value": "Ya usados" } }, "fr": { "stringUnit": { "state": "translated", "value": "Déjà utilisés" } }, "it": { "stringUnit": { "state": "translated", "value": "Già usati" } }, "pl": { "stringUnit": { "state": "translated", "value": "Już używane" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Já usados" } }, "ru": { "stringUnit": { "state": "translated", "value": "Уже использованные" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "用过的" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "用過的" } } + } }, + "Muscles": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Muskeln" } }, "en": { "stringUnit": { "state": "translated", "value": "Muscles" } }, "es": { "stringUnit": { "state": "translated", "value": "Músculos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Muscles" } }, "it": { "stringUnit": { "state": "translated", "value": "Muscoli" } }, "pl": { "stringUnit": { "state": "translated", "value": "Mięśnie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Músculos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мышцы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "肌群" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "肌群" } } + } }, + "Primary": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Primär" } }, "en": { "stringUnit": { "state": "translated", "value": "Primary" } }, "es": { "stringUnit": { "state": "translated", "value": "Principal" } }, "fr": { "stringUnit": { "state": "translated", "value": "Principal" } }, "it": { "stringUnit": { "state": "translated", "value": "Primario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Główny" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Principal" } }, "ru": { "stringUnit": { "state": "translated", "value": "Основная" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "主要" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "主要" } } + } }, + "Not classified": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nicht zugeordnet" } }, "en": { "stringUnit": { "state": "translated", "value": "Not classified" } }, "es": { "stringUnit": { "state": "translated", "value": "Sin clasificar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Non classé" } }, "it": { "stringUnit": { "state": "translated", "value": "Non classificato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Bez przypisania" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Sem classificação" } }, "ru": { "stringUnit": { "state": "translated", "value": "Без категории" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未分类" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未分類" } } + } }, + "Primary muscle": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Primärer Muskel" } }, "en": { "stringUnit": { "state": "translated", "value": "Primary muscle" } }, "es": { "stringUnit": { "state": "translated", "value": "Músculo principal" } }, "fr": { "stringUnit": { "state": "translated", "value": "Muscle principal" } }, "it": { "stringUnit": { "state": "translated", "value": "Muscolo primario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Główny mięsień" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Músculo principal" } }, "ru": { "stringUnit": { "state": "translated", "value": "Основная мышца" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "主要肌群" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "主要肌群" } } + } }, + "Also works (counted as half a set)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trainiert außerdem (zählt als halber Satz)" } }, "en": { "stringUnit": { "state": "translated", "value": "Also works (counted as half a set)" } }, "es": { "stringUnit": { "state": "translated", "value": "También trabaja (cuenta como media serie)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Sollicite aussi (compté comme une demi-série)" } }, "it": { "stringUnit": { "state": "translated", "value": "Coinvolge anche (conta come mezza serie)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Angażuje też (liczy się jako pół serii)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Também trabalha (conta como meia série)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Также задействует (считается за полподхода)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同时练到(计为半组)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同時練到(計為半組)" } } + } }, + "Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Direkte Sätze zählen einfach, indirekte als halber Satz. Erst diese Trennung macht die wöchentlichen Zahlen pro Muskel aussagekräftig." } }, "en": { "stringUnit": { "state": "translated", "value": "Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything." } }, "es": { "stringUnit": { "state": "translated", "value": "Las series directas cuentan una vez; las indirectas, media. Esa distinción es lo que da sentido a las cifras semanales por músculo." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séries directes comptent pour une, les indirectes pour une demie. C'est cette distinction qui donne un sens aux chiffres hebdomadaires par muscle." } }, "it": { "stringUnit": { "state": "translated", "value": "Le serie dirette contano una volta, quelle indirette una metà. È questa distinzione a dare senso ai numeri settimanali per muscolo." } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie bezpośrednie liczą się raz, pośrednie jako pół. To właśnie ten podział nadaje sens tygodniowym liczbom dla każdego mięśnia." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As séries diretas contam uma vez, as indiretas contam meia. É essa distinção que dá sentido aos números semanais por músculo." } }, "ru": { "stringUnit": { "state": "translated", "value": "Прямые подходы считаются за один, косвенные — за половину. Именно это разделение делает недельные цифры по мышцам осмысленными." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "直接组计为一组,间接组计为半组。正是这个区分,才让每周的各肌群数字有意义。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "直接組計為一組,間接組計為半組。正是這個區分,才讓每週的各肌群數字有意義。" } } + } }, + "Targets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Ziele" } }, "en": { "stringUnit": { "state": "translated", "value": "Targets" } }, "es": { "stringUnit": { "state": "translated", "value": "Objetivos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Objectifs" } }, "it": { "stringUnit": { "state": "translated", "value": "Obiettivi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Cele" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Objetivos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Цели" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "目标" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "目標" } } + } }, + "Every target is optional — fill in what you actually plan against.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – trag ein, wonach du dich tatsächlich richtest." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — fill in what you actually plan against." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: rellena solo aquello con lo que de verdad planificas." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : remplis seulement ce sur quoi tu te bases vraiment." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: compila solo ciò su cui pianifichi davvero." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – wpisz to, według czego naprawdę planujesz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: preenche apenas aquilo com que planeias de facto." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — заполняй то, на что действительно ориентируешься." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——只填你真正会照着做的那些。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——只填你真正會照著做的那些。" } } + } }, + "Technique note": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Technik-Notiz" } }, "en": { "stringUnit": { "state": "translated", "value": "Technique note" } }, "es": { "stringUnit": { "state": "translated", "value": "Nota de técnica" } }, "fr": { "stringUnit": { "state": "translated", "value": "Note de technique" } }, "it": { "stringUnit": { "state": "translated", "value": "Nota sulla tecnica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Notatka o technice" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nota de técnica" } }, "ru": { "stringUnit": { "state": "translated", "value": "Заметка о технике" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "技术笔记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "技術筆記" } } + } }, "RHR +%lld": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Ruhepuls +%lld" } }, "en": { "stringUnit": { "state": "translated", "value": "RHR +%lld" } }, "es": { "stringUnit": { "state": "translated", "value": "FC en reposo +%lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "FC au repos +%lld" } }, "it": { "stringUnit": { "state": "translated", "value": "FC a riposo +%lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno spocz. +%lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "FC em repouso +%lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс в покое +%lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "静息心率 +%lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "靜息心率 +%lld" } } } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift new file mode 100644 index 0000000000..17b66d4f53 --- /dev/null +++ b/Strand/Screens/LiftLogView.swift @@ -0,0 +1,160 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// The Lift Log: build a program once, then run it in the gym by tapping through it. +// +// This screen is the front door — it lists the saved programs and (from a later phase) the sessions +// run from them. It lives in the Effort colour world, like Workouts, because a finished session +// lands in the `workout` table beside every other workout. +// +// EFFORT IS NEVER MODIFIED HERE (load-bearing). NOOP's Effort is computed from heart rate alone +// (Karvonen %HRR → Edwards TRIMP, `StrainScorer`), and there is no validated public path from typed +// sets/reps/weight to a cardiovascular-strain equivalent — WHOOP's own muscular load runs +// velocity-based algorithms over strap accelerometer/gyroscope data under an unpublished model. +// So the lifting figures are shown BESIDE Effort and never folded into it, matching the choice the +// imported-lifting path already made (`strain: nil, // never a fabricated cardiovascular strain`). + +struct LiftLogView: View { + @EnvironmentObject var repo: Repository + + /// Saved programs, most-recently-touched first. Loaded off the store on appear/refresh. + @State private var programs: [LiftProgramRow] = [] + @State private var loaded = false + + /// The program being created or edited (nil = the editor is closed). + @State private var editing: ProgramEditTarget? + + var body: some View { + ScreenScaffold( + title: "Lift Log", + subtitle: "Build a program once, then tap through it at the gym. Kept on \(Platform.deviceNounPhrase).", + onRefresh: { await load() } + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + headerCard + programsSection + } + } + .task(id: repo.refreshSeq) { await load() } + .sheet(item: $editing) { target in + LiftProgramEditorSheet(program: target.program) { + await load() + } + } + } + + // MARK: - Header + + private var headerCard: some View { + NoopCard(tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(StrandPalette.effortColor) + .frame(width: 30, height: 30) + .background(StrandPalette.effortColor.opacity(0.14), + in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text("Your log book") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Programs, sessions and per-set history") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + } + Text("Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // MARK: - Programs + + private var programsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Programs", overline: "Saved") + + if !loaded { + ComingSoon(what: "Reading your programs…", symbol: "dumbbell") + } else if programs.isEmpty { + emptyState + } else { + ForEach(programs, id: \.id) { program in + programRow(program) + } + } + + Button { + editing = ProgramEditTarget(id: "new", program: nil) + } label: { + Label("New program", systemImage: "plus") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + private var emptyState: some View { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + Text("No programs yet") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("A program is a name and an ordered list of exercises with your targets — working sets, rep range, target RPE, rest and your own technique note.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func programRow(_ program: LiftProgramRow) -> some View { + Button { + editing = ProgramEditTarget(id: program.id, program: program) + } label: { + NoopCard { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(program.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let note = program.note, !note.isEmpty { + Text(note) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(2) + } + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .accessibilityHidden(true) + } + } + } + .buttonStyle(.plain) + } + + // MARK: - Load + + private func load() async { + guard let store = await repo.storeHandle() else { return } + programs = (try? await store.liftPrograms(deviceId: repo.deviceId)) ?? [] + loaded = true + } +} + +/// Identifies what the editor sheet is editing. A wrapper rather than a retroactive `Identifiable` +/// on `LiftProgramRow`, so the store's row types stay free of app-layer conformances — and so +/// "new program" has an identity of its own to present on. +private struct ProgramEditTarget: Identifiable { + let id: String + let program: LiftProgramRow? +} diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift new file mode 100644 index 0000000000..6fe841f282 --- /dev/null +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -0,0 +1,332 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Build or edit a program: a name and an ordered list of exercise lines carrying the TARGETS — +// working sets, rep range, target RPE, rest and the user's own technique note. +// +// Lines are edited as local drafts and written in one go on Save, through +// `replaceLiftProgramItems`, which swaps the whole list transactionally. Editing a program never +// rewrites history: a session snapshots the program's NAME when it runs, so renaming "Upper A" or +// deleting it entirely leaves every past session reading exactly as it did. + +struct LiftProgramEditorSheet: View { + /// The program being edited, or nil to create a new one. + let program: LiftProgramRow? + /// Called after a successful save or delete, so the caller can reload. + let onSaved: () async -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var note: String = "" + /// The exercise lines, in display order. `ord` is assigned from the array index on save, so + /// reordering is just moving an element. + @State private var items: [LiftProgramItemRow] = [] + @State private var loaded = false + @State private var saving = false + + /// The line being added or edited (nil = that sheet is closed). + @State private var editingItem: ItemEditTarget? + @State private var confirmingDelete = false + + @FocusState private var focused: Field? + private enum Field: Hashable { case name, note } + + private var isNew: Bool { program == nil } + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !saving + } + + var body: some View { + ScreenScaffold( + title: isNew ? "New program" : "Edit program", + subtitle: "Your targets for each exercise. What you actually lift is recorded when you run it." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + detailsSection + exercisesSection + if !isNew { deleteSection } + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + // A fixed frame, for the reason the other editor sheets document: a macOS sheet hosting a + // ScrollView needs a definite height or every row collapses to the top. + .frame(width: 520, height: 720) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .task { await loadIfNeeded() } + .sheet(item: $editingItem) { target in + LiftProgramItemSheet(item: target.item) { saved in + apply(saved, replacing: target.item) + } + } + } + + // MARK: - Name + note + + private var detailsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Program", overline: "Details") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + field("Name") { + TextField("Upper A", text: $name) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .name) + } + field("Note (optional)") { + TextField("Anything you want to remember", text: $note) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .note) + } + } + } + } + } + + // MARK: - Exercise lines + + private var exercisesSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercises", overline: "In order") + + if items.isEmpty { + NoopCard { + Text("No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + itemRow(item, index: index) + } + } + + Button { + editingItem = ItemEditTarget(id: "new", item: nil) + } label: { + Label("Add exercise", systemImage: "plus") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + private func itemRow(_ item: LiftProgramItemRow, index: Int) -> some View { + NoopCard { + HStack(alignment: .top, spacing: 12) { + Button { + editingItem = ItemEditTarget(id: item.id, item: item) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(item.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(targetSummary(item)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + if let note = item.note, !note.isEmpty { + Text(note) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + VStack(spacing: 10) { + Button { + move(from: index, by: -1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(index == 0) + .accessibilityLabel("Move up") + + Button { + move(from: index, by: 1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(index == items.count - 1) + .accessibilityLabel("Move down") + + Button(role: .destructive) { + items.removeAll { $0.id == item.id } + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("Remove exercise") + } + .font(.system(size: 12, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + + /// "4 × 8–10 · RPE 8 · 2:00 rest" — only the parts that were actually filled in. + private func targetSummary(_ item: LiftProgramItemRow) -> String { + var parts: [String] = [] + if let sets = item.targetSets { + if let lo = item.targetRepsLow, let hi = item.targetRepsHigh, lo != hi { + parts.append("\(sets) × \(lo)–\(hi)") + } else if let lo = item.targetRepsLow { + parts.append("\(sets) × \(lo)") + } else { + parts.append(String(localized: "\(sets) sets")) + } + } + if let rpe = item.targetRpe { + parts.append("RPE \(LiftFormat.trim(rpe))") + } + if let rest = item.restSec { + parts.append(String(localized: "\(LiftFormat.duration(rest)) rest")) + } + return parts.isEmpty ? String(localized: "No targets set") : parts.joined(separator: " · ") + } + + // MARK: - Delete + + private var deleteSection: some View { + VStack(alignment: .leading, spacing: 8) { + Button(role: .destructive) { + confirmingDelete = true + } label: { + Label("Delete program", systemImage: "trash") + } + .buttonStyle(NoopButtonStyle(.secondary)) + .confirmationDialog("Delete this program?", + isPresented: $confirmingDelete, + titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await deleteProgram() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("Sessions you already logged from it are kept.") + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + .disabled(!canSave) + .accessibilityLabel("Save program") + } + } + + // MARK: - Helpers + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func move(from index: Int, by offset: Int) { + let target = index + offset + guard items.indices.contains(index), items.indices.contains(target) else { return } + items.swapAt(index, target) + } + + /// Insert a new line, or replace an edited one in place so its position is kept. + private func apply(_ saved: LiftProgramItemRow, replacing old: LiftProgramItemRow?) { + guard let old, let index = items.firstIndex(where: { $0.id == old.id }) else { + items.append(saved) + return + } + items[index] = saved + } + + // MARK: - Load / save + + private func loadIfNeeded() async { + guard !loaded else { return } + loaded = true + guard let program else { return } + name = program.name + note = program.note ?? "" + guard let store = await repo.storeHandle() else { return } + items = (try? await store.liftProgramItems(programId: program.id)) ?? [] + } + + private func save() async { + guard canSave, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + let now = Int(Date().timeIntervalSince1970) + let id = program?.id ?? UUID().uuidString + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + + let row = LiftProgramRow( + id: id, + deviceId: repo.deviceId, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + note: trimmedNote.isEmpty ? nil : trimmedNote, + createdAt: program?.createdAt ?? now, + updatedAt: now, + archived: program?.archived ?? false + ) + _ = try? await store.upsertLiftPrograms([row]) + + // `ord` is the array index: reordering the list is all it takes to reorder the program. + let ordered = items.enumerated().map { index, item in + LiftProgramItemRow( + id: item.id, + deviceId: repo.deviceId, + programId: id, + ord: index, + exercise: item.exercise, + targetSets: item.targetSets, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + restSec: item.restSec, + note: item.note + ) + } + _ = try? await store.replaceLiftProgramItems(programId: id, items: ordered) + + await onSaved() + dismiss() + } + + private func deleteProgram() async { + guard let program, let store = await repo.storeHandle() else { return } + _ = try? await store.deleteLiftProgram(id: program.id) + await onSaved() + dismiss() + } +} + +/// What the line editor is editing — a wrapper so "new line" has an identity to present on. +private struct ItemEditTarget: Identifiable { + let id: String + let item: LiftProgramItemRow? +} diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift new file mode 100644 index 0000000000..8e54b895f7 --- /dev/null +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -0,0 +1,379 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Edit ONE exercise line of a program: which exercise, and the targets for it. +// +// NOOP SHIPS NO EXERCISE CATALOGUE — deliberately. The user types whatever they call the movement +// and it is remembered in `liftExercise` with the muscle group they gave it, then offered back next +// time. A shipped mapping of common exercises to muscles would be both a permanent maintenance +// burden and a correctness claim NOOP has no business making about someone else's technique. +// +// Classification is therefore a one-time, few-second action per exercise: pick the primary muscle +// (a direct set) and any secondaries (indirect, counted at half). It is asked once, on first use, +// and remembered thereafter. Leaving it unset is allowed — an unclassified exercise still counts +// toward volume and session load, it simply claims no muscle it was never assigned. + +struct LiftProgramItemSheet: View { + /// The line being edited, or nil to add a new one. + let item: LiftProgramItemRow? + /// Handed the finished line. The parent owns ordering and persistence. + let onSave: (LiftProgramItemRow) -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var exercise: String = "" + @State private var primary: LiftMuscle? + @State private var secondaries: Set = [] + + @State private var setsText: String = "" + @State private var repsLowText: String = "" + @State private var repsHighText: String = "" + @State private var rpeText: String = "" + @State private var restText: String = "" + @State private var note: String = "" + + /// The user's own exercise vocabulary, for suggestions and for adopting a known classification. + @State private var vocabulary: [LiftExerciseRow] = [] + @State private var loaded = false + + @FocusState private var focused: Field? + private enum Field: Hashable { case exercise, sets, repsLow, repsHigh, rpe, rest, note } + + private var trimmedExercise: String { + exercise.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canSave: Bool { !trimmedExercise.isEmpty } + + /// Vocabulary entries matching what has been typed so far, minus an exact match (no point + /// suggesting the thing already in the box). Capped — this is a hint, not a browser. + private var suggestions: [LiftExerciseRow] { + let query = trimmedExercise.lowercased() + guard !query.isEmpty else { return Array(vocabulary.prefix(6)) } + return vocabulary + .filter { $0.name.lowercased().contains(query) && $0.name.lowercased() != query } + .prefix(6) + .map { $0 } + } + + var body: some View { + ScreenScaffold( + title: item == nil ? "Add exercise" : "Edit exercise", + subtitle: "Type any name you like. NOOP remembers it, with the muscles you give it." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + exerciseSection + muscleSection + targetsSection + noteSection + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 520, height: 720) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .task { await loadIfNeeded() } + } + + // MARK: - Exercise name + suggestions + + private var exerciseSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercise", overline: "Movement") + NoopCard { + VStack(alignment: .leading, spacing: 12) { + TextField("Incline dumbbell press", text: $exercise) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .exercise) + + if !suggestions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Used before").strandOverline() + ForEach(suggestions, id: \.id) { row in + Button { + adopt(row) + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.up.left") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + VStack(alignment: .leading, spacing: 1) { + Text(row.name) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Text(LiftMuscleSummary.line(primary: row.primaryMuscle, + secondaries: row.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + } + } + } + + // MARK: - Muscle classification + + private var muscleSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Muscles", overline: "Counted once per exercise") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("Primary").strandOverline() + Menu { + Button("Not classified") { primary = nil } + ForEach(LiftMuscle.Region.allCases, id: \.self) { region in + Section(region.displayName) { + ForEach(LiftMuscle.inRegion(region), id: \.self) { muscle in + Button(muscle.displayName) { select(primary: muscle) } + } + } + } + } label: { + HStack { + Text(primary?.displayName ?? String(localized: "Not classified")) + .font(StrandFont.body) + .foregroundStyle(primary == nil + ? StrandPalette.textTertiary + : StrandPalette.textPrimary) + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .contentShape(Rectangle()) + } + .accessibilityLabel("Primary muscle") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Also works (counted as half a set)").strandOverline() + LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 8)], + alignment: .leading, spacing: 8) { + ForEach(LiftMuscle.allCases, id: \.self) { muscle in + if muscle != primary { + secondaryChip(muscle) + } + } + } + } + + Text("Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private func secondaryChip(_ muscle: LiftMuscle) -> some View { + let on = secondaries.contains(muscle) + return Button { + if on { secondaries.remove(muscle) } else { secondaries.insert(muscle) } + } label: { + Text(muscle.displayName) + .font(StrandFont.caption) + .foregroundStyle(on ? StrandPalette.effortColor : StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(on ? StrandPalette.effortColor.opacity(0.14) : StrandPalette.surfaceRaised) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(on ? [.isSelected] : []) + } + + // MARK: - Targets + + private var targetsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Targets", overline: "What you're aiming for") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + field("Working sets") { + numberInput("4", text: $setsText, field: .sets) + } + field("Target RPE") { + numberInput("8", text: $rpeText, field: .rpe) + } + } + HStack(spacing: 12) { + field("Reps from") { + numberInput("8", text: $repsLowText, field: .repsLow) + } + field("Reps to") { + numberInput("10", text: $repsHighText, field: .repsHigh) + } + } + field("Rest (seconds)") { + numberInput("120", text: $restText, field: .rest) + } + Text("Every target is optional — fill in what you actually plan against.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + } + + private var noteSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Technique note", overline: "In your words") + NoopCard { + TextField("Slow eccentric, pause at the bottom", text: $note, axis: .vertical) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1...4) + .focused($focused, equals: .note) + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + .disabled(!canSave) + .accessibilityLabel("Save exercise") + } + } + + // MARK: - Field helpers (the house form idiom) + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func numberInput(_ placeholder: LocalizedStringKey, + text: Binding, + field: Field) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + } + + // MARK: - Behaviour + + /// Take a known exercise from the vocabulary, including the classification it already carries — + /// so a movement is classified once and never asked about again. + private func adopt(_ row: LiftExerciseRow) { + exercise = row.name + primary = row.primaryMuscle + secondaries = Set(row.secondaryMuscles) + focused = nil + } + + /// Setting a primary that is also ticked as a secondary drops it from the secondaries: one + /// muscle can never be credited twice for the same set. + private func select(primary muscle: LiftMuscle) { + primary = muscle + secondaries.remove(muscle) + } + + private func loadIfNeeded() async { + guard !loaded else { return } + loaded = true + if let item { + exercise = item.exercise + setsText = item.targetSets.map(String.init) ?? "" + repsLowText = item.targetRepsLow.map(String.init) ?? "" + repsHighText = item.targetRepsHigh.map(String.init) ?? "" + rpeText = item.targetRpe.map { LiftFormat.trim($0) } ?? "" + restText = item.restSec.map(String.init) ?? "" + note = item.note ?? "" + } + guard let store = await repo.storeHandle() else { return } + vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + // An existing line adopts whatever classification its exercise already carries, so editing a + // line shows the muscles the exercise is known by rather than an empty picker. + if let item, let known = vocabulary.first(where: { $0.name == item.exercise }) { + primary = known.primaryMuscle + secondaries = Set(known.secondaryMuscles) + } + } + + private func save() async { + guard canSave else { return } + let name = trimmedExercise + + // Remember the exercise (and its classification) in the vocabulary, so it is offered back + // next time. `upsertLiftExercises` is keyed on (deviceId, name), so re-saving updates rather + // than duplicating. + if let store = await repo.storeHandle() { + let now = Int(Date().timeIntervalSince1970) + let existing = vocabulary.first { $0.name == name } + let row = LiftExerciseRow( + id: existing?.id ?? UUID().uuidString, + deviceId: repo.deviceId, + name: name, + primaryMuscle: primary, + secondaryMuscles: orderedSecondaries, + createdAt: existing?.createdAt ?? now, + lastUsedTs: now + ) + _ = try? await store.upsertLiftExercises([row]) + } + + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(LiftProgramItemRow( + id: item?.id ?? UUID().uuidString, + deviceId: repo.deviceId, + // Assigned properly by the parent on save; a placeholder here would be a second source + // of truth for ordering. + programId: item?.programId ?? "", + ord: item?.ord ?? 0, + exercise: name, + targetSets: Int(setsText.trimmingCharacters(in: .whitespaces)), + targetRepsLow: Int(repsLowText.trimmingCharacters(in: .whitespaces)), + targetRepsHigh: Int(repsHighText.trimmingCharacters(in: .whitespaces)), + targetRpe: LiftFormat.number(rpeText), + restSec: Int(restText.trimmingCharacters(in: .whitespaces)), + note: trimmedNote.isEmpty ? nil : trimmedNote + )) + dismiss() + } + + /// Secondaries in the vocabulary's canonical order rather than `Set` iteration order, so the + /// stored list is stable between saves instead of reshuffling on every edit. + private var orderedSecondaries: [LiftMuscle] { + LiftMuscle.ordered.filter { secondaries.contains($0) && $0 != primary } + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 943a49e484..3d158e951e 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -427,6 +427,7 @@ struct RootTabView: View { moreSection("Body") { MoreRow("Live", "waveform.path.ecg", .live) MoreRow("Workouts", "figure.run", .workouts) + MoreRow("Lift Log", "dumbbell.fill", .liftLog) MoreRow("Health", "heart.text.square.fill", .health) MoreRow("Lab Book", "books.vertical.fill", .labBook) MoreRow("Stress", "bolt.heart.fill", .stress) @@ -550,7 +551,7 @@ struct RootTabView: View { /// registration in `moreTab`. private enum MoreDestination: Hashable { case insightsHub, intelligence, coach, insights, explore, compare - case live, workouts, health, labBook, stress, breathe, intervals, rhythm + case live, workouts, liftLog, health, labBook, stress, breathe, intervals, rhythm case fusedRecord, appleHealth, miBand, dataSources, backupSync, shortcutsExport, noopLimitations case alarms, automations, testCentre, siriShortcuts, powerSaving, settings @@ -564,6 +565,7 @@ private enum MoreDestination: Hashable { case .compare: CompareView() case .live: LiveView() case .workouts: WorkoutsView() + case .liftLog: LiftLogView() case .health: HealthView() case .labBook: LabBookView() case .stress: StressView() From bf9956049f115f6b612830e09a6c8cb6ed1b74cd Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:27:28 +0200 Subject: [PATCH 02/31] lift log: the session loop, the rest timer and strap double-tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase two: run a program at the gym. Warm-up → set → rest → set → … → cool-down → save, one action per transition, and the finished session lands in the workout table like any other workout. THREE WAYS TO ADVANCE, all identical: • a double-tap on the WHOOP strap — the one cue that works with the phone face-down on a bench; • a tap anywhere on the screen; • the explicit button. The last two were asked for by name. They are ordinary single taps: the double-tap is the STRAP gesture only, because a strap takes knocks against bars all session while a phone screen in your hand does not. `LiftSessionEngine` is the state machine, kept pure — no timers, no store, no SwiftUI — because it is the piece most likely to be wrong in a way that costs someone a logged set, and the only part of the feature testable without a strap, a database or a simulator. Time enters as a parameter, so a whole session can be driven through a known timeline. 17 tests. Rest is anchored to an ABSOLUTE instant, never a decrementing counter: `IntervalTimerView` decrements and loses time when the phone suspends, and a rest timer that quietly runs long is worse than none. Verified by killing the app mid-rest — it read 1:55 at death and 1:00 on resume, having kept counting while the process was gone. Rest never auto-advances. When the countdown reaches zero the stage stays resting and waits: a timer that starts logging while you are still racking the bar attributes time to work that was not work. The strap buzz fires five seconds before the rest ends, gated behind a new `HapticPrefs.liftRest` key rather than sharing `intervals` — a silent interval timer at home is a different want from a silent rack. A running session claims `AppModel.strapDoubleTapOverride` for its lifetime and hands the gesture back untouched when it ends. `LiftSessionPersistence` writes the in-flight session on every tap, so a crash, a call or a flat battery costs nothing. The stage is persisted as a flat record rather than an encoded enum, so adding a case later cannot strand a session. ON SAVE the session is written through the SAME path a manual workout takes, so it inherits overlap dedup, `rescoreManualWorkouts` and delete/merge. `strain` is left nil deliberately: the engine fills it from the heart rate the strap actually MEASURED over that window (`ManualWorkoutRescore.scored` → `StrainScorer`). It is never derived from the typed sets/reps/weight — there is no validated public path from those to a strain equivalent. One i18n trap avoided: the catalog's existing "Rest" key is NOOP's SLEEP metric ("Erholung", "Riposo", "Odpoczynek"). The rest timer uses its own "Rest period" string, or every non-English user would see the word for overnight recovery on a rest between sets. 27 new strings, all nine locales. Verification: `xcodebuild` clean for NOOPiOS and Strand (macOS). Full StrandTests run: 1271 tests, the only 2 failures being TodayCarryOverTests, which fail identically on a clean checkout of the branch point and are unrelated to this work. i18n and doc-comment gates pass. Driven end-to-end in the simulator: started "Upper A", logged 30 kg × 10 (volume read 300 kg), reached the rest countdown, killed the app, relaunched, resumed — set count, volume and the still-running countdown all intact. Not yet done: the strap buzz and the double-tap gesture need validating on real hardware. BLE behaviour cannot be CI- or simulator-tested. Co-Authored-By: Claude Opus 5 --- Strand/App/AppModel.swift | 14 + Strand/Data/HapticPrefs.swift | 4 + Strand/Data/LiftSessionEngine.swift | 233 +++++++++ Strand/Data/LiftSessionPersistence.swift | 186 ++++++++ Strand/Resources/Localizable.xcstrings | 81 ++++ Strand/Screens/LiftLogView.swift | 113 ++++- Strand/Screens/LiftSessionView.swift | 575 +++++++++++++++++++++++ StrandTests/LiftSessionEngineTests.swift | 205 ++++++++ 8 files changed, 1400 insertions(+), 11 deletions(-) create mode 100644 Strand/Data/LiftSessionEngine.swift create mode 100644 Strand/Data/LiftSessionPersistence.swift create mode 100644 Strand/Screens/LiftSessionView.swift create mode 100644 StrandTests/LiftSessionEngineTests.swift diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index c8c533b22c..54a4116940 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1634,10 +1634,24 @@ final class AppModel: ObservableObject { // MARK: - Physical inputs / wear automation + /// Set by a running Lift Log session to CLAIM the strap's double-tap for the duration of that + /// session, so a set can be logged without picking the phone up — the one cue that works with the + /// phone face-down on a bench. Cleared when the session ends, handing the gesture straight back to + /// whatever the user has configured; nothing about their setting is read or written. + /// + /// A double tap rather than a single one because a strap takes knocks against bars and benches all + /// session, and two deliberate taps are not something a rack does by accident. + var strapDoubleTapOverride: (() -> Void)? + private func handleDoubleTap() { let now = Date() guard now.timeIntervalSince(lastDoubleTapAt) > 1.2 else { return } // debounce repeats lastDoubleTapAt = now + if let override = strapDoubleTapOverride { + live.append(log: "Double-tap → Lift Log: next") + override() + return + } live.append(log: "Double-tap → \(behavior.doubleTapAction.label)") runMacAction(behavior.doubleTapAction, shortcut: behavior.doubleTapShortcut) } diff --git a/Strand/Data/HapticPrefs.swift b/Strand/Data/HapticPrefs.swift index 8788184185..833c1a8678 100644 --- a/Strand/Data/HapticPrefs.swift +++ b/Strand/Data/HapticPrefs.swift @@ -13,6 +13,10 @@ enum HapticPrefs { // Double-tap is deliberately NOT here: it's already opt-in via the DoubleTapAction picker. static let breathing = "haptics.breathing" static let intervals = "haptics.intervals" + /// The Lift Log rest timer's strap buzz, five seconds before the rest ends. Its own key rather + /// than sharing `intervals`: someone who wants a silent interval timer at home may well still + /// want the buzz at the rack, where the phone is face-down on a bench. + static let liftRest = "haptics.liftRest" static let liveSession = "haptics.liveSession" static let workout = "haptics.workout" diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift new file mode 100644 index 0000000000..fd666357c2 --- /dev/null +++ b/Strand/Data/LiftSessionEngine.swift @@ -0,0 +1,233 @@ +import Foundation +import WhoopStore + +// The session state machine: the heart of the Lift Log. +// +// One action advances everything — warm-up → set → rest → set → … → cool-down → save. It is kept +// PURE (no timers, no store, no SwiftUI) for two reasons: it is the piece most likely to be wrong in +// a way that costs someone a logged set, and it is the only part of the feature that can be tested +// without a strap, a database or a simulator. +// +// TIME ENTERS ONLY AS A PARAMETER. `advance(now:)` is told what time it is rather than reading the +// clock, so a test can drive a whole session through a known timeline. The rest period is stored as +// an ABSOLUTE end instant, never a decrementing counter: `IntervalTimerView` decrements and loses +// time whenever the phone suspends, and a rest timer that quietly runs long is worse than none. +// +// REST NEVER AUTO-ADVANCES. When the countdown reaches zero the stage stays `.resting` and waits for +// the user. That is a deliberate product decision: a timer that starts logging a set while you are +// still racking the bar attributes time to work that was not work. + +/// One planned exercise line, flattened from a program (or built freehand) for the session to run. +struct LiftPlanItem: Equatable { + var exercise: String + var primaryMuscle: LiftMuscle? + var secondaryMuscles: [LiftMuscle] + /// How many working sets are planned. Always ≥ 1 — a line with no target still gets one set, + /// because a plan that schedules zero sets of an exercise cannot be tapped through at all. + var targetSets: Int + /// Intended rest after each set, in seconds. + var restSec: Int + var targetRepsLow: Int? + var targetRepsHigh: Int? + var targetRpe: Double? + var note: String? + + /// The rest period used when a program line does not specify one. Two minutes is the middle of + /// the range the hypertrophy literature uses for compound work, and it is only a starting value: + /// what is actually rested is measured from the taps, not assumed from this. + static let defaultRestSec = 120 + + init(exercise: String, + primaryMuscle: LiftMuscle? = nil, + secondaryMuscles: [LiftMuscle] = [], + targetSets: Int? = nil, + restSec: Int? = nil, + targetRepsLow: Int? = nil, + targetRepsHigh: Int? = nil, + targetRpe: Double? = nil, + note: String? = nil) { + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = secondaryMuscles + self.targetSets = max(1, targetSets ?? 1) + self.restSec = max(0, restSec ?? LiftPlanItem.defaultRestSec) + self.targetRepsLow = targetRepsLow + self.targetRepsHigh = targetRepsHigh + self.targetRpe = targetRpe + self.note = note + } +} + +/// One set as actually performed. Becomes a `LiftSetRow` on save; kept separate so the engine has no +/// opinion about ids or device scoping. +struct LiftRecordedSet: Equatable { + var exerciseIndex: Int + /// 1-based within its exercise, so "set 3 of 4" survives into the stored row. + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int + var endTs: Int + /// Rest actually taken after this set, filled in when the rest ends. Nil for the final set (no + /// rest follows it) or a set whose rest is still running. + var restSec: Int? +} + +struct LiftSessionEngine: Equatable { + + enum Stage: Equatable { + /// Before the first set — the warm-up, derived from timestamps rather than stored as a flag. + case warmup + /// Performing a set. `set` is 1-based within the exercise. + case working(item: Int, set: Int) + /// Resting after (item, set). `endsAt` is an absolute unix second. + case resting(item: Int, set: Int, endsAt: Int) + /// After the last set — the cool-down. + case cooldown + /// Tapped through the cool-down; ready to save. + case finished + } + + let plan: [LiftPlanItem] + /// When the session began (unix seconds) — the start of the warm-up. + let startTs: Int + private(set) var stage: Stage + private(set) var sets: [LiftRecordedSet] + /// When the CURRENT stage began, so a set's duration is measurable. + private(set) var stageStartedAt: Int + + /// Undo stack. Whole-state snapshots rather than inverse operations: a gym is a bad place to be + /// one tap ahead of yourself, and restoring a snapshot cannot get the arithmetic wrong the way a + /// hand-written inverse can. + private var history: [Snapshot] = [] + + private struct Snapshot: Equatable { + var stage: Stage + var sets: [LiftRecordedSet] + var stageStartedAt: Int + } + + init(plan: [LiftPlanItem], startTs: Int) { + self.plan = plan + self.startTs = startTs + self.stage = plan.isEmpty ? .cooldown : .warmup + self.sets = [] + self.stageStartedAt = startTs + } + + /// Rebuild a session that was interrupted — see `LiftSessionPersistence`. + /// + /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and an undo + /// stack that survives a relaunch invites someone to reach back past a save boundary into state + /// the store has already been told about. + init(restoring plan: [LiftPlanItem], startTs: Int, stage: Stage, + sets: [LiftRecordedSet], stageStartedAt: Int) { + self.plan = plan + self.startTs = startTs + self.stage = stage + self.sets = sets + self.stageStartedAt = stageStartedAt + } + + // MARK: - Queries the UI needs + + var isFinished: Bool { stage == .finished } + var canUndo: Bool { !history.isEmpty } + + /// The exercise currently being worked or rested from, if any. + var currentItem: LiftPlanItem? { + switch stage { + case .working(let i, _), .resting(let i, _, _): return plan.indices.contains(i) ? plan[i] : nil + case .warmup, .cooldown, .finished: return nil + } + } + + /// Seconds left in the current rest, floored at zero. Nil when not resting. + /// + /// Floored rather than allowed to go negative so the UI shows "0:00" and waits, which is what a + /// rest that has run over actually means — the user has not tapped yet. + func restRemaining(now: Int) -> Int? { + guard case .resting(_, _, let endsAt) = stage else { return nil } + return max(0, endsAt - now) + } + + /// Total working sets planned across the session, for a progress read-out. + var plannedWorkingSets: Int { plan.reduce(0) { $0 + $1.targetSets } } + + /// Working sets recorded so far (warm-ups excluded, matching how volume and set counts treat them). + var completedWorkingSets: Int { sets.filter { !$0.isWarmup }.count } + + // MARK: - The one action + + /// Advance the machine. `weight`/`reps`/`rpe`/`isWarmup` are only read when a set is being + /// closed out (i.e. the current stage is `.working`); every other transition ignores them. + mutating func advance(now: Int, + weightKg: Double? = nil, + reps: Int? = nil, + rpe: Double? = nil, + isWarmup: Bool = false) { + history.append(Snapshot(stage: stage, sets: sets, stageStartedAt: stageStartedAt)) + + switch stage { + case .warmup: + // The warm-up ends and the first set begins. + stage = plan.isEmpty ? .cooldown : .working(item: 0, set: 1) + + case .working(let i, let s): + // Close out the set that was being performed. + sets.append(LiftRecordedSet( + exerciseIndex: i, setIndex: s, + weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup, + startTs: stageStartedAt, endTs: now, restSec: nil)) + // No rest after the very last set of the very last exercise — that is the cool-down. + if isLastSetOfSession(item: i, set: s) { + stage = .cooldown + } else { + let rest = plan.indices.contains(i) ? plan[i].restSec : LiftPlanItem.defaultRestSec + stage = .resting(item: i, set: s, endsAt: now + rest) + } + + case .resting(let i, let s, _): + // Record what was ACTUALLY rested, not what was planned — this is the figure the + // work-vs-rest split is built from, and it is the one thing only the taps can know. + if let last = sets.indices.last { + sets[last].restSec = max(0, now - stageStartedAt) + } + if s < setsPlanned(for: i) { + stage = .working(item: i, set: s + 1) + } else { + stage = .working(item: i + 1, set: 1) + } + + case .cooldown: + stage = .finished + + case .finished: + // Terminal. Drop the snapshot we just pushed so a stray tap cannot fill the undo stack + // with no-ops. + history.removeLast() + return + } + stageStartedAt = now + } + + /// Undo the last advance. Restores the whole prior state, including a set that was recorded. + mutating func undo() { + guard let previous = history.popLast() else { return } + stage = previous.stage + sets = previous.sets + stageStartedAt = previous.stageStartedAt + } + + // MARK: - Plan arithmetic + + private func setsPlanned(for item: Int) -> Int { + plan.indices.contains(item) ? plan[item].targetSets : 0 + } + + private func isLastSetOfSession(item: Int, set: Int) -> Bool { + item >= plan.count - 1 && set >= setsPlanned(for: item) + } +} diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift new file mode 100644 index 0000000000..1eaf69517d --- /dev/null +++ b/Strand/Data/LiftSessionPersistence.swift @@ -0,0 +1,186 @@ +import Foundation +import WhoopStore + +// Crash-safety for an in-flight gym session. +// +// A session is written to UserDefaults on every tap and rehydrated on launch, so a crash, a phone +// call, a flat battery or simply swiping the app away mid-workout costs nothing. This mirrors +// `ActiveWorkoutPersistence` exactly — a small `Codable` snapshot, unix-second anchors, and an +// encode/decode pair that is pure (no UserDefaults dependency of its own) so the round-trip is +// testable without touching the defaults database. +// +// Unix seconds rather than `Date` for the same reason the store uses them: an absolute instant +// survives suspension, timezone changes and a relaunch, and it is the only anchor that keeps a rest +// countdown honest. + +enum LiftSessionPersistence { + + /// The durable shape of an in-flight session — the minimum needed to rebuild the engine and still + /// finish and save the session after a relaunch. + struct Snapshot: Codable, Equatable { + var startSec: Int + var programId: String? + var programName: String? + /// The plan, flattened at start. Stored rather than re-read from the program so that editing + /// or deleting the program mid-session cannot change what is being run. + var plan: [PlanItem] + var stage: StageBox + var sets: [RecordedSet] + var stageStartedAt: Int + + struct PlanItem: Codable, Equatable { + var exercise: String + var primaryMuscle: String? + var secondaryMuscles: [String] + var targetSets: Int + var restSec: Int + var targetRepsLow: Int? + var targetRepsHigh: Int? + var targetRpe: Double? + var note: String? + } + + /// The stage as a flat, forward-compatible record rather than an encoded enum: a persisted + /// enum with associated values is a migration hazard the moment a case is added, and this + /// shape decodes to "warm-up" rather than to garbage if it is ever read by an older build. + struct StageBox: Codable, Equatable { + var kind: String // warmup | working | resting | cooldown | finished + var item: Int? + var set: Int? + var endsAt: Int? + } + + struct RecordedSet: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int + var endTs: Int + var restSec: Int? + } + } + + /// The single UserDefaults key (JSON-encoded `Snapshot`), namespaced like `noop.activeWorkout`. + static let defaultsKey = "noop.activeLiftSession" + + // MARK: - Codec + + static func encode(_ snapshot: Snapshot) -> Data? { + try? JSONEncoder().encode(snapshot) + } + + /// Decode a snapshot, bound-checking the untrusted persisted values. Returns nil for + /// nil/garbage/empty input or an implausible start time, so a corrupt write is treated as "no + /// session in flight" rather than reviving a broken screen the user cannot get out of. + static func decode(_ data: Data?) -> Snapshot? { + guard let data, !data.isEmpty, + let raw = try? JSONDecoder().decode(Snapshot.self, from: data), + raw.startSec > 1_000_000_000, // after 2001; not a zero/garbage anchor + raw.startSec < Int(Date().timeIntervalSince1970) + 86_400, + !raw.plan.isEmpty + else { return nil } + return raw + } + + // MARK: - UserDefaults + + static func store(_ snapshot: Snapshot?, into d: UserDefaults = .standard) { + guard let snapshot, let data = encode(snapshot) else { + d.removeObject(forKey: defaultsKey) + return + } + d.set(data, forKey: defaultsKey) + } + + static func load(from d: UserDefaults = .standard) -> Snapshot? { + decode(d.data(forKey: defaultsKey)) + } + + static func clear(_ d: UserDefaults = .standard) { + d.removeObject(forKey: defaultsKey) + } + + // MARK: - Engine bridge + + static func snapshot(engine: LiftSessionEngine, + programId: String?, + programName: String?) -> Snapshot { + Snapshot( + startSec: engine.startTs, + programId: programId, + programName: programName, + plan: engine.plan.map { + Snapshot.PlanItem(exercise: $0.exercise, + primaryMuscle: $0.primaryMuscle?.rawValue, + secondaryMuscles: $0.secondaryMuscles.map(\.rawValue), + targetSets: $0.targetSets, + restSec: $0.restSec, + targetRepsLow: $0.targetRepsLow, + targetRepsHigh: $0.targetRepsHigh, + targetRpe: $0.targetRpe, + note: $0.note) + }, + stage: box(engine.stage), + sets: engine.sets.map { + Snapshot.RecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, + weightKg: $0.weightKg, reps: $0.reps, rpe: $0.rpe, + isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, + restSec: $0.restSec) + }, + stageStartedAt: engine.stageStartedAt) + } + + /// Rebuild an engine from a snapshot. Unknown muscle tokens are dropped rather than failing the + /// read, matching `LiftMuscle.decodeList`: a snapshot written by a newer build must not strand a + /// session in an older one. + static func engine(from s: Snapshot) -> LiftSessionEngine { + let plan = s.plan.map { + LiftPlanItem(exercise: $0.exercise, + primaryMuscle: $0.primaryMuscle.flatMap(LiftMuscle.init(rawValue:)), + secondaryMuscles: $0.secondaryMuscles.compactMap(LiftMuscle.init(rawValue:)), + targetSets: $0.targetSets, + restSec: $0.restSec, + targetRepsLow: $0.targetRepsLow, + targetRepsHigh: $0.targetRepsHigh, + targetRpe: $0.targetRpe, + note: $0.note) + } + let sets = s.sets.map { + LiftRecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, + weightKg: $0.weightKg, reps: $0.reps, rpe: $0.rpe, + isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, + restSec: $0.restSec) + } + return LiftSessionEngine(restoring: plan, startTs: s.startSec, + stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt) + } + + private static func box(_ stage: LiftSessionEngine.Stage) -> Snapshot.StageBox { + switch stage { + case .warmup: return .init(kind: "warmup", item: nil, set: nil, endsAt: nil) + case .working(let i, let s): return .init(kind: "working", item: i, set: s, endsAt: nil) + case .resting(let i, let s, let e): return .init(kind: "resting", item: i, set: s, endsAt: e) + case .cooldown: return .init(kind: "cooldown", item: nil, set: nil, endsAt: nil) + case .finished: return .init(kind: "finished", item: nil, set: nil, endsAt: nil) + } + } + + /// Anything unrecognised (or a `working`/`resting` box missing its indices) falls back to the + /// warm-up: the session is still recoverable and still saveable, which beats refusing to load. + private static func unbox(_ box: Snapshot.StageBox) -> LiftSessionEngine.Stage { + switch box.kind { + case "working": + guard let i = box.item, let s = box.set else { return .warmup } + return .working(item: i, set: s) + case "resting": + guard let i = box.item, let s = box.set, let e = box.endsAt else { return .warmup } + return .resting(item: i, set: s, endsAt: e) + case "cooldown": return .cooldown + case "finished": return .finished + default: return .warmup + } + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f84a282e4e..94214dae6b 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,87 @@ { "sourceLanguage": "en", "strings": { + "That's the last set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Das war der letzte Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "That's the last set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esa fue la última serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "C'était la dernière série" } }, "it": { "stringUnit": { "state": "translated", "value": "Quella era l'ultima serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "To była ostatnia seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Essa foi a última série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Это был последний подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是最后一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是最後一組" } } + } }, + "Pre-filled with what you did last time. Change anything that's different today.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vorausgefüllt mit dem, was du letztes Mal gemacht hast. Ändere alles, was heute anders ist." } }, "en": { "stringUnit": { "state": "translated", "value": "Pre-filled with what you did last time. Change anything that's different today." } }, "es": { "stringUnit": { "state": "translated", "value": "Rellenado con lo que hiciste la última vez. Cambia lo que hoy sea distinto." } }, "fr": { "stringUnit": { "state": "translated", "value": "Pré-rempli avec ce que tu as fait la dernière fois. Modifie ce qui change aujourd'hui." } }, "it": { "stringUnit": { "state": "translated", "value": "Precompilato con quello che hai fatto l'ultima volta. Cambia ciò che oggi è diverso." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wypełnione tym, co robiłeś ostatnio. Zmień to, co dziś jest inne." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Preenchido com o que fizeste da última vez. Muda o que hoje for diferente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Заполнено тем, что ты делал в прошлый раз. Измени всё, что сегодня иначе." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已填入你上次的数据。今天有变化的地方改一下就行。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已填入你上次的資料。今天有變化的地方改一下就行。" } } + } }, + "This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Das ist der Sitzungs-RPE. Mal der Dauer der Sitzung ergibt er die Sitzungsbelastung – die eine Zahl, die sich über völlig verschiedene Trainingsarten hinweg vergleichen lässt." } }, "en": { "stringUnit": { "state": "translated", "value": "This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training." } }, "es": { "stringUnit": { "state": "translated", "value": "Esto es el RPE de sesión. Multiplicado por la duración da la carga de sesión: la única cifra comparable entre entrenamientos completamente distintos." } }, "fr": { "stringUnit": { "state": "translated", "value": "C'est le RPE de séance. Multiplié par la durée, il donne la charge de séance — le seul chiffre comparable entre des entraînements totalement différents." } }, "it": { "stringUnit": { "state": "translated", "value": "Questo è l'RPE della sessione. Moltiplicato per la durata dà il carico della sessione: l'unico numero confrontabile fra allenamenti completamente diversi." } }, "pl": { "stringUnit": { "state": "translated", "value": "To jest RPE sesji. Pomnożone przez czas trwania daje obciążenie sesji – jedyną liczbę porównywalną między zupełnie różnymi treningami." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Isto é o RPE da sessão. Multiplicado pela duração dá a carga da sessão — o único número comparável entre treinos completamente diferentes." } }, "ru": { "stringUnit": { "state": "translated", "value": "Это RPE сессии. Умноженный на её длительность, он даёт нагрузку сессии — единственную величину, сравнимую между совершенно разными тренировками." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是整场 RPE。乘以训练时长就得到训练负荷——唯一能在完全不同的训练之间比较的数字。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是整場 RPE。乘以訓練時長就得到訓練負荷——唯一能在完全不同的訓練之間比較的數字。" } } + } }, + "Discard": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Verwerfen" } }, "en": { "stringUnit": { "state": "translated", "value": "Discard" } }, "es": { "stringUnit": { "state": "translated", "value": "Descartar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Abandonner" } }, "it": { "stringUnit": { "state": "translated", "value": "Scarta" } }, "pl": { "stringUnit": { "state": "translated", "value": "Odrzuć" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Descartar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отбросить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "放弃" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "放棄" } } + } }, + "Next": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Weiter" } }, "en": { "stringUnit": { "state": "translated", "value": "Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Siguiente" } }, "fr": { "stringUnit": { "state": "translated", "value": "Suivant" } }, "it": { "stringUnit": { "state": "translated", "value": "Avanti" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dalej" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Seguinte" } }, "ru": { "stringUnit": { "state": "translated", "value": "Далее" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下一步" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下一步" } } + } }, + "Skip": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Überspringen" } }, "en": { "stringUnit": { "state": "translated", "value": "Skip" } }, "es": { "stringUnit": { "state": "translated", "value": "Omitir" } }, "fr": { "stringUnit": { "state": "translated", "value": "Passer" } }, "it": { "stringUnit": { "state": "translated", "value": "Salta" } }, "pl": { "stringUnit": { "state": "translated", "value": "Pomiń" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ignorar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пропустить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "跳过" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "跳過" } } + } }, + "Rest period": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause" } }, "en": { "stringUnit": { "state": "translated", "value": "Rest period" } }, "es": { "stringUnit": { "state": "translated", "value": "Descanso entre series" } }, "fr": { "stringUnit": { "state": "translated", "value": "Temps de repos" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Descanso entre séries" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых между подходами" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组间休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組間休息" } } + } }, + "Resting": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "In recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдыхаешь" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "休息中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "休息中" } } + } }, + "Ready when you are": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereit, wenn du es bist" } }, "en": { "stringUnit": { "state": "translated", "value": "Ready when you are" } }, "es": { "stringUnit": { "state": "translated", "value": "Cuando quieras" } }, "fr": { "stringUnit": { "state": "translated", "value": "Quand tu veux" } }, "it": { "stringUnit": { "state": "translated", "value": "Quando vuoi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Gotowe, gdy będziesz gotowy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quando quiseres" } }, "ru": { "stringUnit": { "state": "translated", "value": "Готово, когда будешь готов" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "准备好就开始" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "準備好就開始" } } + } }, + "Warming up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmen" } }, "en": { "stringUnit": { "state": "translated", "value": "Warming up" } }, "es": { "stringUnit": { "state": "translated", "value": "Calentando" } }, "fr": { "stringUnit": { "state": "translated", "value": "Échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozgrzewka" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A aquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминка" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身中" } } + } }, + "Warm-up set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmsatz" } }, "en": { "stringUnit": { "state": "translated", "value": "Warm-up set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie de calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série d'échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie di riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria rozgrzewkowa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série de aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминочный подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身組" } } + } }, + "Everything before your first set counts as the warm-up. Nothing is being recorded yet.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Alles vor deinem ersten Satz zählt als Aufwärmen. Es wird noch nichts aufgezeichnet." } }, "en": { "stringUnit": { "state": "translated", "value": "Everything before your first set counts as the warm-up. Nothing is being recorded yet." } }, "es": { "stringUnit": { "state": "translated", "value": "Todo lo anterior a tu primera serie cuenta como calentamiento. Aún no se registra nada." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tout ce qui précède ta première série compte comme échauffement. Rien n'est encore enregistré." } }, "it": { "stringUnit": { "state": "translated", "value": "Tutto ciò che precede la prima serie conta come riscaldamento. Non viene ancora registrato nulla." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wszystko przed pierwszą serią liczy się jako rozgrzewka. Nic nie jest jeszcze zapisywane." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Tudo antes da tua primeira série conta como aquecimento. Ainda não está a ser registado nada." } }, "ru": { "stringUnit": { "state": "translated", "value": "Всё до первого подхода считается разминкой. Пока ничего не записывается." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第一组之前的一切都算热身。现在还没有开始记录。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第一組之前的一切都算熱身。現在還沒有開始記錄。" } } + } }, + "Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippe zum Abschließen. Die Sitzung wird als Workout gesichert und taucht wie jedes andere in Workouts und Heute auf." } }, "en": { "stringUnit": { "state": "translated", "value": "Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other." } }, "es": { "stringUnit": { "state": "translated", "value": "Toca para terminar. La sesión se guarda como un entrenamiento, así que aparece en Entrenamientos y Hoy como cualquier otro." } }, "fr": { "stringUnit": { "state": "translated", "value": "Touche pour terminer. La séance est enregistrée comme un entraînement et apparaît dans Entraînements et Aujourd'hui comme les autres." } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca per concludere. La sessione viene salvata come allenamento, quindi compare in Allenamenti e Oggi come tutti gli altri." } }, "pl": { "stringUnit": { "state": "translated", "value": "Dotknij, aby zakończyć. Sesja zapisuje się jako trening, więc pojawi się w Treningach i Dzisiaj jak każdy inny." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca para terminar. A sessão é guardada como treino, por isso aparece em Treinos e Hoje como qualquer outro." } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми, чтобы завершить. Сессия сохраняется как тренировка и появится в Тренировках и Сегодня, как любая другая." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "点按结束。这次训练会作为一次锻炼保存,和其他锻炼一样出现在「锻炼」和「今天」里。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "點按結束。這次訓練會作為一次鍛鍊儲存,和其他鍛鍊一樣出現在「鍛鍊」和「今天」裡。" } } + } }, + "Double-tap your strap to log a set without picking the phone up.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippe zweimal auf dein Band, um einen Satz zu speichern, ohne zum Telefon zu greifen." } }, "en": { "stringUnit": { "state": "translated", "value": "Double-tap your strap to log a set without picking the phone up." } }, "es": { "stringUnit": { "state": "translated", "value": "Toca dos veces tu banda para registrar una serie sin coger el teléfono." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tape deux fois sur ton bracelet pour enregistrer une série sans toucher au téléphone." } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca due volte la fascia per registrare una serie senza prendere il telefono." } }, "pl": { "stringUnit": { "state": "translated", "value": "Stuknij dwukrotnie w opaskę, aby zapisać serię bez sięgania po telefon." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca duas vezes na tua banda para registar uma série sem pegar no telemóvel." } }, "ru": { "stringUnit": { "state": "translated", "value": "Дважды коснись браслета, чтобы записать подход, не беря телефон в руки." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "双击手环即可记录一组,不用拿起手机。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輕點兩下手環即可記錄一組,不用拿起手機。" } } + } }, + "Session in progress": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Session in progress" } }, "es": { "stringUnit": { "state": "translated", "value": "Sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sesja w toku" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сессия идёт" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "训练进行中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "訓練進行中" } } + } }, + "You left a session running. Nothing was lost — pick it up where you stopped.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast eine Sitzung laufen lassen. Nichts ist verloren – mach da weiter, wo du aufgehört hast." } }, "en": { "stringUnit": { "state": "translated", "value": "You left a session running. Nothing was lost — pick it up where you stopped." } }, "es": { "stringUnit": { "state": "translated", "value": "Dejaste una sesión en marcha. No se ha perdido nada: retómala donde la dejaste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tu as laissé une séance en cours. Rien n'est perdu : reprends là où tu t'es arrêté." } }, "it": { "stringUnit": { "state": "translated", "value": "Hai lasciato una sessione in corso. Non è andato perso nulla: riprendi da dove eri." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zostawiłeś rozpoczętą sesję. Nic nie przepadło – wróć tam, gdzie skończyłeś." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Deixaste uma sessão a decorrer. Não se perdeu nada: retoma onde paraste." } }, "ru": { "stringUnit": { "state": "translated", "value": "У тебя осталась незавершённая сессия. Ничего не потеряно — продолжи с того места, где остановился." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你有一次训练还没结束。什么都没丢——从停下的地方继续吧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你有一次訓練還沒結束。什麼都沒丟——從停下的地方繼續吧。" } } + } }, + "Save session": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save session" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar sesión" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer la séance" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva sessione" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz sesję" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar sessão" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить сессию" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存訓練" } } + } }, + "Start this program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieses Programm starten" } }, "en": { "stringUnit": { "state": "translated", "value": "Start this program" } }, "es": { "stringUnit": { "state": "translated", "value": "Empezar este programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Démarrer ce programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Avvia questo programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij ten program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Iniciar este programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Начать эту программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "开始这个计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開始這個計畫" } } + } }, + "Tap to edit": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zum Bearbeiten tippen" } }, "en": { "stringUnit": { "state": "translated", "value": "Tap to edit" } }, "es": { "stringUnit": { "state": "translated", "value": "Toca para editar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Touche pour modifier" } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca per modificare" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dotknij, aby edytować" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca para editar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми, чтобы изменить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "点按编辑" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "點按編輯" } } + } }, + "Sets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets" } }, "es": { "stringUnit": { "state": "translated", "value": "Series" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組數" } } + } }, + "Elapsed": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergangen" } }, "en": { "stringUnit": { "state": "translated", "value": "Elapsed" } }, "es": { "stringUnit": { "state": "translated", "value": "Transcurrido" } }, "fr": { "stringUnit": { "state": "translated", "value": "Écoulé" } }, "it": { "stringUnit": { "state": "translated", "value": "Trascorso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Upłynęło" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Decorrido" } }, "ru": { "stringUnit": { "state": "translated", "value": "Прошло" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已用时" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已用時" } } + } }, + "Volume": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Volumen" } }, "en": { "stringUnit": { "state": "translated", "value": "Volume" } }, "es": { "stringUnit": { "state": "translated", "value": "Volumen" } }, "fr": { "stringUnit": { "state": "translated", "value": "Volume" } }, "it": { "stringUnit": { "state": "translated", "value": "Volume" } }, "pl": { "stringUnit": { "state": "translated", "value": "Objętość" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Volume" } }, "ru": { "stringUnit": { "state": "translated", "value": "Объём" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "容量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "容量" } } + } }, + "Set %lld of %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %1$lld von %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld of %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %1$lld de %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %1$lld sur %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %1$lld di %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %1$lld z %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %1$lld de %2$lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %1$lld из %2$lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %1$lld 组,共 %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %1$lld 組,共 %2$lld 組" } } + } }, + "%lld reps": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld Wdh." } }, "en": { "stringUnit": { "state": "translated", "value": "%lld reps" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld reps" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld répétitions" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld ripetizioni" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld powtórzeń" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld repetições" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld повторений" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 次" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 次" } } + } }, + "session RPE %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzungs-RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "session RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE de sesión %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE de séance %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE della sessione %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE sesji %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE da sessão %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE сессии %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场 RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場 RPE %@" } } + } }, + "Weight (kg)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Gewicht (kg)" } }, "en": { "stringUnit": { "state": "translated", "value": "Weight (kg)" } }, "es": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Poids (kg)" } }, "it": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ciężar (kg)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вес (кг)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重量(公斤)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重量(公斤)" } } + } }, + "Weight (lb)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Gewicht (lb)" } }, "en": { "stringUnit": { "state": "translated", "value": "Weight (lb)" } }, "es": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Poids (lb)" } }, "it": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ciężar (lb)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вес (фунты)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重量(磅)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重量(磅)" } } + } }, "Heart rate, no reading": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Herzfrequenz, keine Messung" } }, "en": { "stringUnit": { "state": "translated", "value": "Heart rate, no reading" } }, "es": { "stringUnit": { "state": "translated", "value": "Frecuencia cardíaca, sin lectura" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fréquence cardiaque, aucune mesure" } }, "it": { "stringUnit": { "state": "translated", "value": "Frequenza cardiaca, nessuna lettura" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno, brak odczytu" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Frequência cardíaca, sem leitura" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс, нет данных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "心率,无读数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "心率,無讀數" } } } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 17b66d4f53..63a5a7f53f 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -24,6 +24,10 @@ struct LiftLogView: View { /// The program being created or edited (nil = the editor is closed). @State private var editing: ProgramEditTarget? + /// The session being run (nil = not in a session). + @State private var running: SessionStart? + /// An interrupted session found on disk, offered for resume. + @State private var interrupted: LiftSessionPersistence.Snapshot? var body: some View { ScreenScaffold( @@ -33,6 +37,7 @@ struct LiftLogView: View { ) { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { headerCard + if interrupted != nil { resumeCard } programsSection } } @@ -42,6 +47,49 @@ struct LiftLogView: View { await load() } } + .sheet(item: $running) { start in + if let snapshot = start.resuming { + LiftSessionView(resuming: snapshot) { await load() } + } else { + LiftSessionView(plan: start.plan, + programId: start.programId, + programName: start.programName) { await load() } + } + } + } + + // MARK: - Resume an interrupted session + + private var resumeCard: some View { + NoopCard(tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: 10) { + Text("Session in progress") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("You left a session running. Nothing was lost — pick it up where you stopped.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Button("Resume") { + if let interrupted { + running = SessionStart(id: "resume", plan: [], programId: nil, + programName: nil, resuming: interrupted) + } + } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + Spacer() + Button(role: .destructive) { + LiftSessionPersistence.clear() + interrupted = nil + } label: { + Text("Discard") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + } } // MARK: - Header @@ -115,11 +163,11 @@ struct LiftLogView: View { } private func programRow(_ program: LiftProgramRow) -> some View { - Button { - editing = ProgramEditTarget(id: program.id, program: program) - } label: { - NoopCard { - HStack(spacing: 12) { + NoopCard { + HStack(spacing: 12) { + Button { + editing = ProgramEditTarget(id: program.id, program: program) + } label: { VStack(alignment: .leading, spacing: 3) { Text(program.name) .font(StrandFont.headline) @@ -130,27 +178,70 @@ struct LiftLogView: View { .foregroundStyle(StrandPalette.textSecondary) .lineLimit(2) } + Text("Tap to edit") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) } - Spacer(minLength: 0) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(StrandPalette.textSecondary) - .accessibilityHidden(true) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + + Button("Start") { Task { await start(program) } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 110) + .accessibilityLabel("Start this program") } } - .buttonStyle(.plain) + } + + // MARK: - Start a session + + /// Flatten a program into the plan the session runs. The plan is SNAPSHOT at start: editing or + /// deleting the program mid-session cannot change what is being tapped through. + private func start(_ program: LiftProgramRow) async { + guard let store = await repo.storeHandle() else { return } + let items = (try? await store.liftProgramItems(programId: program.id)) ?? [] + guard !items.isEmpty else { return } + let vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + + let plan = items.map { item -> LiftPlanItem in + // The classification comes from the exercise vocabulary, which is the one place that owns + // it — the program line deliberately stores no muscle of its own to drift from. + let known = vocabulary.first { $0.name == item.exercise } + return LiftPlanItem(exercise: item.exercise, + primaryMuscle: known?.primaryMuscle, + secondaryMuscles: known?.secondaryMuscles ?? [], + targetSets: item.targetSets, + restSec: item.restSec, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + note: item.note) + } + running = SessionStart(id: program.id, plan: plan, + programId: program.id, programName: program.name, resuming: nil) } // MARK: - Load private func load() async { + interrupted = LiftSessionPersistence.load() guard let store = await repo.storeHandle() else { return } programs = (try? await store.liftPrograms(deviceId: repo.deviceId)) ?? [] loaded = true } } +/// What the session sheet is presenting — a fresh run of a program, or a resumed snapshot. +private struct SessionStart: Identifiable { + let id: String + let plan: [LiftPlanItem] + let programId: String? + let programName: String? + let resuming: LiftSessionPersistence.Snapshot? +} + /// Identifies what the editor sheet is editing. A wrapper rather than a retroactive `Identifiable` /// on `LiftProgramRow`, so the store's row types stay free of app-layer conformances — and so /// "new program" has an identity of its own to present on. diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift new file mode 100644 index 0000000000..4a7e845e17 --- /dev/null +++ b/Strand/Screens/LiftSessionView.swift @@ -0,0 +1,575 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Running a session: the screen you actually use at the rack. +// +// THREE WAYS TO ADVANCE, all doing exactly the same thing: +// 1. A double-tap on the WHOOP strap — the one that works with the phone face-down on a bench. +// 2. Tapping anywhere on the screen. +// 3. The explicit button. +// Both (2) and (3) were asked for by name; shipping only one of them is not the same feature. They +// are ordinary single taps — the double-tap is the STRAP gesture only, because a strap takes knocks +// against bars all session while a phone screen in your hand does not. +// +// The countdown is read from `LiftSessionEngine`, which anchors rest to an absolute instant, so a +// phone that sleeps through a rest still shows the truth when it wakes. Nothing auto-advances: when +// the rest hits zero the screen says so and waits. + +struct LiftSessionView: View { + let programId: String? + let programName: String? + /// Called once the session has been written, so the hub can reload. + let onFinished: () async -> Void + + @EnvironmentObject var repo: Repository + @EnvironmentObject var live: LiveState + @EnvironmentObject var model: AppModel + @Environment(\.dismiss) private var dismiss + + @State var engine: LiftSessionEngine + + /// What the user is entering for the set in progress. Pre-filled from last time. + @State private var weightText = "" + @State private var repsText = "" + @State private var rpeText = "" + @State private var isWarmup = false + + /// Drives the countdown redraw and the 5-second cue. One second is plenty: the timer is read + /// from the clock, so the tick only decides how often the label is refreshed. + @State private var now = Int(Date().timeIntervalSince1970) + /// The rest period the 5-second cue has already fired for, so it fires once per rest and not + /// once per tick. + @State private var buzzedFor: Int? + + @State private var saving = false + @State private var showingFinish = false + @State private var sessionRpeText = "" + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + /// Transition cues, mirrored to the phone's Taptic Engine on iOS. + private enum Cue { case next, rest, ready, done } + #if os(iOS) + @State private var lastCue: Cue = .next + @State private var cueTick = 0 + #endif + + @FocusState private var focused: Field? + private enum Field: Hashable { case weight, reps, rpe, sessionRpe } + + private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + init(plan: [LiftPlanItem], programId: String?, programName: String?, + onFinished: @escaping () async -> Void) { + self.programId = programId + self.programName = programName + self.onFinished = onFinished + _engine = State(initialValue: LiftSessionEngine(plan: plan, + startTs: Int(Date().timeIntervalSince1970))) + } + + /// Resume an interrupted session. + init(resuming snapshot: LiftSessionPersistence.Snapshot, onFinished: @escaping () async -> Void) { + self.programId = snapshot.programId + self.programName = snapshot.programName + self.onFinished = onFinished + _engine = State(initialValue: LiftSessionPersistence.engine(from: snapshot)) + } + + var body: some View { + ScreenScaffold(title: sessionTitle, subtitle: sessionSubtitle) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + stageCard + if case .working = engine.stage { entryCard } + progressCard + controls + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 520, height: 760) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + // The whole screen advances the session. `.contentShape` so the empty space between cards + // counts too — at the rack you should not have to aim. + .contentShape(Rectangle()) + .onTapGesture { advance() } + #if os(iOS) + .sensoryFeedback(trigger: cueTick) { _, _ in + switch lastCue { + case .next: return .impact(weight: .heavy) + case .rest: return .impact(weight: .light) + case .ready: return .success + case .done: return .success + } + } + #endif + .onReceive(tick) { instant in + now = Int(instant.timeIntervalSince1970) + fireRestCueIfDue() + } + .task { + // Claim the strap's double-tap for as long as this session is on screen. + model.strapDoubleTapOverride = { advance() } + await prefillFromLastTime() + persist() + } + .onDisappear { + model.strapDoubleTapOverride = nil + } + .sheet(isPresented: $showingFinish) { finishSheet } + } + + // MARK: - Header + + private var sessionTitle: LocalizedStringKey { + switch engine.stage { + case .warmup: return "Warm-up" + case .working: return "Working" + // NOT the bare "Rest": that key already exists in the catalog as NOOP's SLEEP metric + // ("Erholung", "Riposo", "Odpoczynek"). Reusing it would label a rest between sets with the + // word for overnight recovery in every non-English locale. + case .resting: return "Rest period" + case .cooldown: return "Cool-down" + case .finished: return "Done" + } + } + + private var sessionSubtitle: LocalizedStringKey { + switch engine.stage { + case .warmup: return "Tap when you start your first set." + case .working: return "Tap when the set is done." + case .resting: return "Tap when you're ready for the next set." + case .cooldown: return "Tap to finish and save." + case .finished: return "Saving…" + } + } + + // MARK: - The big stage card + + private var stageCard: some View { + NoopCard(tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: 10) { + if let item = engine.currentItem { + Text(item.exercise) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Text(LiftMuscleSummary.line(primary: item.primaryMuscle, + secondaries: item.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + + switch engine.stage { + case .working(_, let s): + Text(setLabel(s)) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.effortColor) + if let target = targetLine { Text(target).font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) } + if let note = engine.currentItem?.note, !note.isEmpty { + Text(note).font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + case .resting: + Text(restLabel) + .font(.system(size: 52, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(restRemaining == 0 + ? StrandPalette.statusPositive : StrandPalette.effortColor) + Text(restRemaining == 0 ? "Ready when you are" : "Resting") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + + case .warmup: + Text("Warming up") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Everything before your first set counts as the warm-up. Nothing is being recorded yet.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + case .cooldown, .finished: + Text("That's the last set") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Set entry + + private var entryCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + field(weightLabel) { numberInput("0", text: $weightText, field: .weight) } + field("Reps") { numberInput("0", text: $repsText, field: .reps) } + field("RPE") { numberInput("—", text: $rpeText, field: .rpe) } + } + Toggle(isOn: $isWarmup) { + Text("Warm-up set") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Text("Pre-filled with what you did last time. Change anything that's different today.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + // The entry card must NOT swallow taps into the advance gesture while someone is typing a + // weight, so it takes its own (empty) tap and stops propagation. + .contentShape(Rectangle()) + .onTapGesture { } + } + + // MARK: - Progress + controls + + private var progressCard: some View { + NoopCard { + HStack(spacing: 14) { + stat(String(localized: "Sets"), + "\(engine.completedWorkingSets)/\(engine.plannedWorkingSets)") + stat(String(localized: "Elapsed"), LiftFormat.duration(max(0, now - engine.startTs))) + stat(String(localized: "Volume"), LiftFormat.weight(volumeKg, system: unitSystem)) + } + } + } + + private func stat(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label).strandOverline() + Text(value) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var controls: some View { + VStack(spacing: 10) { + Button { advance() } label: { + Text(buttonLabel).frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) + .accessibilityLabel("Next") + + HStack { + Button { + engine.undo() + persist() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .buttonStyle(NoopButtonStyle(.secondary)) + .disabled(!engine.canUndo) + + Spacer() + + Button(role: .destructive) { + LiftSessionPersistence.clear() + model.strapDoubleTapOverride = nil + dismiss() + } label: { + Text("Discard") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + + Text("Double-tap your strap to log a set without picking the phone up.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var buttonLabel: LocalizedStringKey { + switch engine.stage { + case .warmup: return "Start first set" + case .working: return "Set done" + case .resting: return "Start next set" + case .cooldown: return "Finish & save" + case .finished: return "Saving…" + } + } + + // MARK: - Finish + + private var finishSheet: some View { + ScreenScaffold(title: "Finish session", + subtitle: "One number for the whole session, so a leg day can be compared with a run.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + NoopCard { + VStack(alignment: .leading, spacing: 12) { + field("How hard was the whole session? (1–10)") { + numberInput("7", text: $sessionRpeText, field: .sessionRpe) + } + Text("This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + HStack { + Button("Skip") { Task { await save() } } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save session") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 180) + .disabled(saving) + } + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 460, height: 420) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + } + + // MARK: - Behaviour + + private func advance() { + let stamp = Int(Date().timeIntervalSince1970) + let wasWorking: Bool + if case .working = engine.stage { wasWorking = true } else { wasWorking = false } + + if case .cooldown = engine.stage { + showingFinish = true + return + } + + engine.advance(now: stamp, + weightKg: wasWorking ? enteredWeightKg : nil, + reps: wasWorking ? Int(repsText.trimmingCharacters(in: .whitespaces)) : nil, + rpe: wasWorking ? LiftFormat.number(rpeText) : nil, + isWarmup: wasWorking ? isWarmup : false) + + buzzedFor = nil + isWarmup = false + cue(for: engine.stage) + persist() + + if case .working = engine.stage { + Task { await prefillFromLastTime() } + } + } + + /// The strap buzz five seconds before the rest ends — the cue that reaches you with the phone + /// face-down. Fires once per rest period, and only while a strap is actually bonded. + private func fireRestCueIfDue() { + guard case .resting(_, _, let endsAt) = engine.stage else { return } + guard buzzedFor != endsAt else { return } + let remaining = endsAt - now + guard remaining <= 5 else { return } + buzzedFor = endsAt + if live.bonded { + model.buzz(loops: 2, gate: HapticPrefs.liftRest) + } + cue(.ready) + } + + private func cue(for stage: LiftSessionEngine.Stage) { + switch stage { + case .working: cue(.next) + case .resting: cue(.rest) + case .finished: cue(.done) + default: break + } + } + + /// Fire an iPhone haptic alongside the strap buzz, so the transition is felt even with no strap + /// bonded. Bumping the token re-triggers `.sensoryFeedback` even when the same cue repeats. + /// A no-op on macOS, which has no Taptic Engine. + private func cue(_ c: Cue) { + #if os(iOS) + lastCue = c + cueTick &+= 1 + #endif + } + + private func persist() { + LiftSessionPersistence.store( + LiftSessionPersistence.snapshot(engine: engine, + programId: programId, + programName: programName)) + } + + /// Fill the entry boxes with what was actually lifted for this exercise last time — the read the + /// whole feature exists for, and the reason sets are stored as rows rather than a blob. + private func prefillFromLastTime() async { + guard case .working(_, let setNumber) = engine.stage, + let item = engine.currentItem, + let store = await repo.storeHandle() else { return } + let previous = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: item.exercise, + before: engine.startTs)) ?? [] + // Prefer the matching set number from last time, else the last set performed. + let match = previous.first { $0.setIndex == setNumber && !$0.isWarmup } ?? previous.last + guard let match else { + weightText = ""; repsText = ""; rpeText = "" + return + } + weightText = match.weightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) + } ?? "" + repsText = match.reps.map(String.init) ?? "" + rpeText = match.rpe.map { LiftFormat.trim($0) } ?? "" + } + + private func save() async { + guard !saving else { return } + saving = true + defer { saving = false } + + var finished = engine + if !finished.isFinished { + finished.advance(now: Int(Date().timeIntervalSince1970)) + } + let endTs = Int(Date().timeIntervalSince1970) + + guard let store = await repo.storeHandle() else { return } + let sessionId = UUID().uuidString + + // The session row, pinned to its workout row by the workout table's own natural key. + let session = LiftSessionRow( + id: sessionId, deviceId: repo.deviceId, + startTs: finished.startTs, endTs: endTs, + sport: LiftSessionView.sport, + programId: programId, + // Snapshot the name: renaming or deleting the program never rewrites this session. + programName: programName, + note: sessionNote) + _ = try? await store.upsertLiftSessions([session]) + + let rows = finished.sets.enumerated().map { ord, s -> LiftSetRow in + let item = finished.plan.indices.contains(s.exerciseIndex) + ? finished.plan[s.exerciseIndex] : nil + return LiftSetRow( + id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, + ord: ord, exercise: item?.exercise ?? "", + // Snapshot the classification AS IT WAS, so reclassifying later never silently + // rewrites what past weeks were counted as. + primaryMuscle: item?.primaryMuscle, + secondaryMuscles: item?.secondaryMuscles ?? [], + setIndex: s.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, + isWarmup: s.isWarmup, startTs: s.startTs, endTs: s.endTs, + restSec: s.restSec, note: nil) + } + _ = try? await store.upsertLiftSets(rows) + + // And the workout row itself, through the SAME path a manual workout takes — so it inherits + // overlap dedup, the engine's HR-derived strain fill (`rescoreManualWorkouts`), and + // delete/merge. `strain` is left nil deliberately: the engine fills it from the heart rate + // the strap actually measured over this window. It is never derived from the typed + // sets/reps/weight, because there is no validated path from those to a strain equivalent. + let workout = WorkoutRow( + startTs: finished.startTs, endTs: endTs, sport: LiftSessionView.sport, + source: "manual", durationS: Double(max(0, endTs - finished.startTs)), + energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: sessionNote, steps: nil) + await repo.saveManualWorkout(workout) + + LiftSessionPersistence.clear() + model.strapDoubleTapOverride = nil + await repo.refresh() + await onFinished() + showingFinish = false + dismiss() + } + + // MARK: - Derived + + /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer + /// uses, so a typed session and an imported one land in one bucket with one icon. + static let sport = "Strength Training" + + private var sessionNote: String? { + var parts: [String] = [] + if let programName, !programName.isEmpty { parts.append(programName) } + if let rpe = LiftFormat.number(sessionRpeText) { + parts.append(String(localized: "session RPE \(LiftFormat.trim(rpe))")) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private var enteredWeightKg: Double? { + LiftFormat.number(weightText).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + } + } + + private var volumeKg: Double? { + let total = engine.sets.filter { !$0.isWarmup }.reduce(0.0) { sum, s in + guard let w = s.weightKg, let r = s.reps else { return sum } + return sum + w * Double(r) + } + return total > 0 ? total : nil + } + + private var restRemaining: Int { engine.restRemaining(now: now) ?? 0 } + + private var restLabel: String { LiftFormat.duration(restRemaining) } + + private func setLabel(_ s: Int) -> String { + let total = engine.currentItem?.targetSets ?? s + return String(localized: "Set \(s) of \(total)") + } + + private var targetLine: String? { + guard let item = engine.currentItem else { return nil } + var parts: [String] = [] + if let lo = item.targetRepsLow, let hi = item.targetRepsHigh, lo != hi { + parts.append("\(lo)–\(hi) reps") + } else if let lo = item.targetRepsLow { + parts.append(String(localized: "\(lo) reps")) + } + if let rpe = item.targetRpe { parts.append("RPE \(LiftFormat.trim(rpe))") } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private var weightLabel: LocalizedStringKey { + unitSystem == .imperial ? "Weight (lb)" : "Weight (kg)" + } + + // MARK: - Field helpers + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func numberInput(_ placeholder: LocalizedStringKey, + text: Binding, field: Field) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + } +} diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift new file mode 100644 index 0000000000..e650603d56 --- /dev/null +++ b/StrandTests/LiftSessionEngineTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The Lift Log session state machine. Pure, so a whole gym session can be driven through a known +/// timeline with no strap, no database and no simulator — which is the point of keeping it pure. +final class LiftSessionEngineTests: XCTestCase { + + private let t0 = 1_700_000_000 + + /// Two exercises: 2 sets then 1 set. Small enough to assert every transition by hand. + private func twoExercisePlan() -> [LiftPlanItem] { + [ + LiftPlanItem(exercise: "Incline dumbbell press", + primaryMuscle: .chest, secondaryMuscles: [.frontDelts, .triceps], + targetSets: 2, restSec: 90), + LiftPlanItem(exercise: "Lat pulldown", + primaryMuscle: .lats, secondaryMuscles: [.biceps], + targetSets: 1, restSec: 60), + ] + } + + // MARK: - The loop + + func testASessionStartsInTheWarmUp() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) + XCTAssertFalse(e.canUndo) + } + + func testTheFullTapThroughReachesFinishedAndRecordsEverySet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + + e.advance(now: t0 + 300) // warm-up → set 1 + XCTAssertEqual(e.stage, .working(item: 0, set: 1)) + + e.advance(now: t0 + 340, weightKg: 30, reps: 10, rpe: 8) // set 1 done → rest + XCTAssertEqual(e.stage, .resting(item: 0, set: 1, endsAt: t0 + 340 + 90)) + + e.advance(now: t0 + 440) // rest → set 2 + XCTAssertEqual(e.stage, .working(item: 0, set: 2)) + + e.advance(now: t0 + 480, weightKg: 30, reps: 8, rpe: 9) // set 2 done → rest + XCTAssertEqual(e.stage, .resting(item: 0, set: 2, endsAt: t0 + 480 + 90)) + + e.advance(now: t0 + 580) // rest → next exercise, set 1 + XCTAssertEqual(e.stage, .working(item: 1, set: 1)) + + e.advance(now: t0 + 620, weightKg: 55, reps: 12, rpe: 7) // last set → cool-down, no rest + XCTAssertEqual(e.stage, .cooldown) + + e.advance(now: t0 + 700) // cool-down → finished + XCTAssertEqual(e.stage, .finished) + XCTAssertTrue(e.isFinished) + + XCTAssertEqual(e.sets.count, 3) + XCTAssertEqual(e.sets.map(\.exerciseIndex), [0, 0, 1]) + XCTAssertEqual(e.sets.map(\.setIndex), [1, 2, 1]) + XCTAssertEqual(e.sets.map(\.reps), [10, 8, 12]) + } + + func testNoRestFollowsTheFinalSet() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0 + 60) // → set 1 + e.advance(now: t0 + 100, weightKg: 20, reps: 12) // final set → cool-down + XCTAssertEqual(e.stage, .cooldown, "the last set is followed by the cool-down, not a rest") + XCTAssertNil(e.sets[0].restSec, "no rest was taken after the final set, so none is recorded") + } + + // MARK: - Time + + func testRestIsAnchoredToAnAbsoluteInstantNotACountdown() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10, weightKg: 30, reps: 10) // rest ends at t0+100 (90s) + + XCTAssertEqual(e.restRemaining(now: t0 + 10), 90) + XCTAssertEqual(e.restRemaining(now: t0 + 55), 45) + // The phone sleeping for a minute must not "pause" the rest: the answer depends only on the + // clock, which is the whole reason the end instant is stored rather than a counter. + XCTAssertEqual(e.restRemaining(now: t0 + 100), 0) + } + + func testAnOverrunRestFloorsAtZeroAndNeverAutoAdvances() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10, weightKg: 30, reps: 10) + + XCTAssertEqual(e.restRemaining(now: t0 + 5_000), 0, "an overrun rest reads 0:00, never negative") + XCTAssertEqual(e.stage, .resting(item: 0, set: 1, endsAt: t0 + 100), + "rest waits for the user; nothing starts a set on its own") + } + + func testRestRecordedIsWhatWasActuallyTakenNotWhatWasPlanned() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10, weightKg: 30, reps: 10) // planned rest 90s + e.advance(now: t0 + 210) // actually rested 200s + + XCTAssertEqual(e.sets[0].restSec, 200, + "the work-vs-rest split is measured from the taps, not assumed from the plan") + } + + func testASetCarriesTheDurationItWasPerformedOver() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0 + 300) // set 1 begins + e.advance(now: t0 + 345, weightKg: 30, reps: 10) // set 1 ends + XCTAssertEqual(e.sets[0].startTs, t0 + 300) + XCTAssertEqual(e.sets[0].endTs, t0 + 345) + } + + // MARK: - Undo + + func testUndoRestoresTheStageAndRemovesTheRecordedSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40, weightKg: 30, reps: 10, rpe: 8) + XCTAssertEqual(e.sets.count, 1) + + e.undo() + XCTAssertEqual(e.stage, .working(item: 0, set: 1)) + XCTAssertTrue(e.sets.isEmpty, "undoing a mis-tap must take the set back with it") + } + + func testUndoWalksAllTheWayBackToTheWarmUp() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40, weightKg: 30, reps: 10) + e.advance(now: t0 + 140) + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) + } + + func testUndoOnAFreshSessionIsHarmless() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.undo() + XCTAssertEqual(e.stage, .warmup) + } + + func testTappingPastTheEndDoesNothingAndCannotFillTheUndoStack() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 30, weightKg: 20, reps: 10) + e.advance(now: t0 + 60) + XCTAssertEqual(e.stage, .finished) + + e.advance(now: t0 + 90) + e.advance(now: t0 + 120) + XCTAssertEqual(e.stage, .finished, "finished is terminal") + + e.undo() + XCTAssertEqual(e.stage, .cooldown, "a stray tap after the end must not consume the undo history") + } + + // MARK: - Warm-ups and counting + + func testAWarmUpSetIsRecordedButDoesNotCountAsAWorkingSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40, weightKg: 20, reps: 12, isWarmup: true) + + XCTAssertEqual(e.sets.count, 1) + XCTAssertTrue(e.sets[0].isWarmup) + XCTAssertEqual(e.completedWorkingSets, 0, + "studies count working sets; a warm-up must not inflate the tally") + } + + func testPlannedWorkingSetsSumsTheWholePlan() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + // MARK: - Degenerate plans + + func testAnEmptyPlanGoesStraightToTheCoolDownRatherThanTrapping() { + var e = LiftSessionEngine(plan: [], startTs: t0) + XCTAssertEqual(e.stage, .cooldown) + e.advance(now: t0 + 10) + XCTAssertEqual(e.stage, .finished) + } + + func testALineWithNoTargetStillGetsOneTappableSet() { + let item = LiftPlanItem(exercise: "Face pull", targetSets: nil) + XCTAssertEqual(item.targetSets, 1, "a plan that schedules zero sets could not be tapped through") + } + + func testAMissingRestFallsBackToTheDefault() { + let item = LiftPlanItem(exercise: "Face pull", restSec: nil) + XCTAssertEqual(item.restSec, LiftPlanItem.defaultRestSec) + } + + func testCurrentItemTracksTheExerciseBeingWorked() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertNil(e.currentItem, "there is no exercise during the warm-up") + e.advance(now: t0) + XCTAssertEqual(e.currentItem?.exercise, "Incline dumbbell press") + e.advance(now: t0 + 40, weightKg: 30, reps: 10) + e.advance(now: t0 + 140) + e.advance(now: t0 + 180, weightKg: 30, reps: 8) + e.advance(now: t0 + 280) + XCTAssertEqual(e.currentItem?.exercise, "Lat pulldown") + } +} From 87688bd250a2cb758da23d864e421b50062c68cc Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:35:20 +0200 Subject: [PATCH 03/31] lift log: fix the tap-anywhere mistake, and move set entry into the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real session with a strap, and four things were wrong. TAP-ANYWHERE IS GONE. The screen advanced the session on a tap anywhere on it. In use that fires while scrolling, while typing a weight, or just holding the phone — and a stray advance costs a logged set. The session now moves on exactly two deliberate inputs: the button, or a double-tap on the strap. The inherited brief asked for tap-anywhere by name; using it settled the question. SET ENTRY MOVED INTO THE REST. You cannot type a weight with the bar in your hands. `advance` now takes no set values at all: the set is recorded the instant it ends, carrying its timing, and the numbers are filled in during the rest that follows (`updateLastSet`). The final set — which no rest follows — is filled in during the cool-down. Every keystroke goes to the engine and to disk, so a crash mid-rest keeps what was typed. TARGETS ARE A PLAN, NOT A RECORD. A program line now plans ONE rep count and a WEIGHT. The first draft carried a rep RANGE and a target RPE — a literal reading of one planning spreadsheet — but no weight, which is the number actually written on a program. Target RPE is gone from the UI on principle: RPE is how hard a set FELT, knowable only after doing it; planning one means guessing at your own effort and reading the guess back as data. `liftSession.sessionRpe` is a real column, created by `v42-lift-log` itself rather than added by a follow-up migration, so the schema commit stands alone. The rating was being appended to the session's free-text note, which a human can read and nothing can compute with. Foster's session load is sRPE x duration, so the rating has to be a number or the metric cannot be derived at all. Nullable — a skipped rating must not read as an effortless 0. TWO CLOCKS AND TWO BUZZ PATTERNS. The session total answers "how long have I been here"; a second clock answers "how long has THIS set/rest been running", which is the number you act on between sets. And the strap now says two different things: ONE pulse confirms a double-tap registered (with the phone face-down there is otherwise no way to know), THREE means the rest is nearly up. Two cues that felt identical answered "did it just buzz?" badly. Verification: `swift test` in WhoopStore — 457 tests, 0 failures, including 4 new ones pinning the target-weight and session-RPE columns and that a skipped rating stays nil. Full StrandTests: 1276 tests, the only 2 failures being TodayCarryOverTests, which fail identically on a clean checkout under a non-US region (they compare against a US date format) and are unrelated to this work. Both app targets build — the macOS one caught a `onChange(of:)` that is macOS 14+ and would have shipped broken from an iOS-only build. i18n and doc-comment gates pass; 12 new strings across all nine locales. Co-Authored-By: Claude Opus 5 --- .../Sources/WhoopStore/LiftLogStore.swift | 26 +- .../WhoopStoreTests/LiftLogStoreTests.swift | 47 +++- Strand/Data/LiftSessionEngine.swift | 46 +++- Strand/Data/LiftSessionPersistence.swift | 3 + Strand/Resources/Localizable.xcstrings | 30 +++ Strand/Screens/LiftLogView.swift | 1 + Strand/Screens/LiftProgramEditorSheet.swift | 16 +- Strand/Screens/LiftProgramItemSheet.swift | 55 +++-- Strand/Screens/LiftSessionView.swift | 225 +++++++++++++----- StrandTests/LiftSessionEngineTests.swift | 88 +++++-- 10 files changed, 418 insertions(+), 119 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index b618be49d4..138ba15819 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -129,6 +129,8 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { public var targetRepsHigh: Int? /// Target RPE on the user's own 1-10 scale. public var targetRpe: Double? + /// Planned working weight in kilograms (v41). A program line plans a weight, not only reps. + public var targetWeightKg: Double? /// Intended rest after each set, seconds. public var restSec: Int? /// The user's own technique cue, stored and shown back verbatim. @@ -144,6 +146,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { targetRepsLow: Int?, targetRepsHigh: Int?, targetRpe: Double?, + targetWeightKg: Double?, restSec: Int?, note: String? ) { @@ -156,6 +159,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { self.targetRepsLow = targetRepsLow self.targetRepsHigh = targetRepsHigh self.targetRpe = targetRpe + self.targetWeightKg = targetWeightKg self.restSec = restSec self.note = note } @@ -171,6 +175,7 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable { targetRepsLow: row["targetRepsLow"], targetRepsHigh: row["targetRepsHigh"], targetRpe: row["targetRpe"], + targetWeightKg: row["targetWeightKg"], restSec: row["restSec"], note: row["note"] ) @@ -191,6 +196,9 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { public var programId: String? /// The program's name AS IT WAS when the session ran, so a later rename never rewrites history. public var programName: String? + /// Session RPE, 0-10 Borg CR10 (v41). A NUMBER, not a note: Foster's session load is sRPE x + /// duration, so the rating has to be computable. Nil when the user skipped rating the session. + public var sessionRpe: Double? public var note: String? public init( @@ -201,6 +209,7 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { sport: String, programId: String?, programName: String?, + sessionRpe: Double?, note: String? ) { self.id = id @@ -210,6 +219,7 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { self.sport = sport self.programId = programId self.programName = programName + self.sessionRpe = sessionRpe self.note = note } @@ -222,6 +232,7 @@ public struct LiftSessionRow: Equatable, Codable, Sendable { sport: row["sport"], programId: row["programId"], programName: row["programName"], + sessionRpe: row["sessionRpe"], note: row["note"] ) } @@ -450,11 +461,12 @@ extension WhoopStore { try db.execute(sql: """ INSERT INTO liftProgramItem (id, deviceId, programId, ord, exercise, targetSets, - targetRepsLow, targetRepsHigh, targetRpe, restSec, note) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + targetRepsLow, targetRepsHigh, targetRpe, targetWeightKg, restSec, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ r.id, r.deviceId, r.programId, r.ord, r.exercise, r.targetSets, - r.targetRepsLow, r.targetRepsHigh, r.targetRpe, r.restSec, r.note, + r.targetRepsLow, r.targetRepsHigh, r.targetRpe, r.targetWeightKg, + r.restSec, r.note, ]) n += db.changesCount } @@ -486,16 +498,18 @@ extension WhoopStore { for r in rows { try db.execute(sql: """ INSERT INTO liftSession - (id, deviceId, startTs, endTs, sport, programId, programName, note) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (id, deviceId, startTs, endTs, sport, programId, programName, + sessionRpe, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(deviceId, startTs, sport) DO UPDATE SET endTs = excluded.endTs, programId = excluded.programId, programName = excluded.programName, + sessionRpe = excluded.sessionRpe, note = excluded.note """, arguments: [ r.id, r.deviceId, r.startTs, r.endTs, r.sport, - r.programId, r.programName, r.note, + r.programId, r.programName, r.sessionRpe, r.note, ]) n += db.changesCount } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index f4c88a3047..50e572f07b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -82,6 +82,50 @@ final class LiftLogStoreTests: XCTestCase { } } + // MARK: - Targets and the session rating, stored as numbers + + func testTargetWeightRoundTripsOnAProgramLine() async throws { + let store = try await WhoopStore.inMemory() + let programId = UUID().uuidString + let item = LiftProgramItemRow( + id: UUID().uuidString, deviceId: "dev", programId: programId, ord: 0, + exercise: "Back squat", targetSets: 5, targetRepsLow: 5, targetRepsHigh: nil, + targetRpe: nil, targetWeightKg: 102.5, restSec: 180, note: nil) + _ = try await store.replaceLiftProgramItems(programId: programId, items: [item]) + + let back = try await store.liftProgramItems(programId: programId) + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].targetWeightKg, 102.5) + XCTAssertEqual(back[0].targetRepsLow, 5) + } + + func testSessionRpeRoundTripsAsANumber() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftSessionRow( + id: UUID().uuidString, deviceId: "dev", startTs: 1_700_000_000, endTs: 1_700_003_600, + sport: "Strength Training", programId: nil, programName: "Upper A", + sessionRpe: 7.5, note: nil) + _ = try await store.upsertLiftSessions([row]) + + let back = try await store.liftSession(deviceId: "dev", startTs: 1_700_000_000, + sport: "Strength Training") + XCTAssertEqual(back?.sessionRpe, 7.5) + } + + func testSessionRpeIsOptionalSoASkippedRatingIsNotAZero() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftSessionRow( + id: UUID().uuidString, deviceId: "dev", startTs: 1_700_000_500, endTs: nil, + sport: "Strength Training", programId: nil, programName: nil, + sessionRpe: nil, note: nil) + _ = try await store.upsertLiftSessions([row]) + + let back = try await store.liftSession(deviceId: "dev", startTs: 1_700_000_500, + sport: "Strength Training") + XCTAssertNil(back?.sessionRpe, + "a skipped rating must stay nil — a 0 would read as 'effortless' and corrupt the load") + } + // MARK: - The user's own exercise vocabulary /// Anything the user types becomes an exercise they can reuse, with the muscle group they gave @@ -494,13 +538,14 @@ final class LiftLogStoreTests: XCTestCase { LiftProgramItemRow(id: id, deviceId: dev, programId: programId, ord: ord, exercise: exercise, targetSets: 3, targetRepsLow: 8, targetRepsHigh: 10, targetRpe: 7.5, + targetWeightKg: nil, restSec: 180, note: "Lower slowly.") } private func mkSession(id: String, startTs: Int, endTs: Int? = nil, programId: String? = "p1") -> LiftSessionRow { LiftSessionRow(id: id, deviceId: dev, startTs: startTs, endTs: endTs, sport: sport, - programId: programId, programName: "Upper A", note: nil) + programId: programId, programName: "Upper A", sessionRpe: nil, note: nil) } private func mkSet(id: String, sessionId: String, ord: Int, setIndex: Int, diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index fd666357c2..cc1c78ad9a 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -30,6 +30,9 @@ struct LiftPlanItem: Equatable { var targetRepsLow: Int? var targetRepsHigh: Int? var targetRpe: Double? + /// The weight the program plans for this line, in kilograms. Seeds the entry box so the common + /// case is a glance and a tap rather than typing. + var targetWeightKg: Double? var note: String? /// The rest period used when a program line does not specify one. Two minutes is the middle of @@ -45,6 +48,7 @@ struct LiftPlanItem: Equatable { targetRepsLow: Int? = nil, targetRepsHigh: Int? = nil, targetRpe: Double? = nil, + targetWeightKg: Double? = nil, note: String? = nil) { self.exercise = exercise self.primaryMuscle = primaryMuscle @@ -54,6 +58,7 @@ struct LiftPlanItem: Equatable { self.targetRepsLow = targetRepsLow self.targetRepsHigh = targetRepsHigh self.targetRpe = targetRpe + self.targetWeightKg = targetWeightKg self.note = note } } @@ -161,13 +166,13 @@ struct LiftSessionEngine: Equatable { // MARK: - The one action - /// Advance the machine. `weight`/`reps`/`rpe`/`isWarmup` are only read when a set is being - /// closed out (i.e. the current stage is `.working`); every other transition ignores them. - mutating func advance(now: Int, - weightKg: Double? = nil, - reps: Int? = nil, - rpe: Double? = nil, - isWarmup: Bool = false) { + /// Advance the machine. + /// + /// Deliberately takes NO set values. What was lifted is entered AFTERWARDS, during the rest that + /// follows (see `updateLastSet`) — you cannot type a weight while the bar is still in your hands, + /// and asking for it mid-set means either stopping to type or guessing later. The set is recorded + /// the instant you finish it, with its timing; the numbers are filled in while you recover. + mutating func advance(now: Int) { history.append(Snapshot(stage: stage, sets: sets, stageStartedAt: stageStartedAt)) switch stage { @@ -176,10 +181,10 @@ struct LiftSessionEngine: Equatable { stage = plan.isEmpty ? .cooldown : .working(item: 0, set: 1) case .working(let i, let s): - // Close out the set that was being performed. + // Close out the set that was being performed — timing now, numbers during the rest. sets.append(LiftRecordedSet( exerciseIndex: i, setIndex: s, - weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup, + weightKg: nil, reps: nil, rpe: nil, isWarmup: false, startTs: stageStartedAt, endTs: now, restSec: nil)) // No rest after the very last set of the very last exercise — that is the cool-down. if isLastSetOfSession(item: i, set: s) { @@ -213,6 +218,29 @@ struct LiftSessionEngine: Equatable { stageStartedAt = now } + /// Fill in what the set just finished actually was. Called while resting (or during the + /// cool-down, for the final set, which no rest follows). + /// + /// Editing rather than appending, so typing a weight can never create a phantom set — and so the + /// user can keep correcting it for the whole rest period without anything being double-counted. + /// No-op when no set has been recorded yet. + mutating func updateLastSet(weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { + guard let last = sets.indices.last else { return } + sets[last].weightKg = weightKg + sets[last].reps = reps + sets[last].rpe = rpe + sets[last].isWarmup = isWarmup + } + + /// The set awaiting its numbers — the one just performed, while resting or cooling down. Nil in + /// any stage where there is nothing to fill in. + var setAwaitingEntry: LiftRecordedSet? { + switch stage { + case .resting, .cooldown: return sets.last + case .warmup, .working, .finished: return nil + } + } + /// Undo the last advance. Restores the whole prior state, including a set that was recorded. mutating func undo() { guard let previous = history.popLast() else { return } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index 1eaf69517d..8b51a25808 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -37,6 +37,7 @@ enum LiftSessionPersistence { var targetRepsLow: Int? var targetRepsHigh: Int? var targetRpe: Double? + var targetWeightKg: Double? var note: String? } @@ -121,6 +122,7 @@ enum LiftSessionPersistence { targetRepsLow: $0.targetRepsLow, targetRepsHigh: $0.targetRepsHigh, targetRpe: $0.targetRpe, + targetWeightKg: $0.targetWeightKg, note: $0.note) }, stage: box(engine.stage), @@ -146,6 +148,7 @@ enum LiftSessionPersistence { targetRepsLow: $0.targetRepsLow, targetRepsHigh: $0.targetRepsHigh, targetRpe: $0.targetRpe, + targetWeightKg: $0.targetWeightKg, note: $0.note) } let sets = s.sets.map { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 94214dae6b..7282673f55 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,36 @@ { "sourceLanguage": "en", "strings": { + "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – das ist der Plan, nicht das Protokoll. Was du tatsächlich hebst, trägst du während der Sitzung Satz für Satz ein." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: esto es el plan, no el registro. Lo que realmente levantas se introduce serie a serie durante la sesión." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : ceci est le plan, pas le relevé. Ce que tu soulèves vraiment se saisit série par série pendant la séance." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: questo è il piano, non il registro. Quello che sollevi davvero lo inserisci serie per serie durante la sessione." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – to plan, nie zapis. To, co naprawdę podniesiesz, wpisujesz seria po serii w trakcie sesji." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: isto é o plano, não o registo. O que levantas de facto é introduzido série a série durante a sessão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — это план, а не запись. То, что ты действительно поднял, вводится подход за подходом во время сессии." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——这是计划,不是记录。你实际举起的重量,在训练中一组一组地输入。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——這是計畫,不是記錄。你實際舉起的重量,在訓練中一組一組地輸入。" } } + } }, + "Pre-filled with your target, or what you did last time. Correct it to what you actually lifted.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vorausgefüllt mit deinem Ziel oder dem, was du letztes Mal gemacht hast. Korrigiere es auf das, was du tatsächlich gehoben hast." } }, "en": { "stringUnit": { "state": "translated", "value": "Pre-filled with your target, or what you did last time. Correct it to what you actually lifted." } }, "es": { "stringUnit": { "state": "translated", "value": "Rellenado con tu objetivo o con lo que hiciste la última vez. Corrígelo con lo que de verdad levantaste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Pré-rempli avec ton objectif ou ce que tu as fait la dernière fois. Corrige-le avec ce que tu as vraiment soulevé." } }, "it": { "stringUnit": { "state": "translated", "value": "Precompilato con il tuo obiettivo o con quello che hai fatto l'ultima volta. Correggilo con ciò che hai davvero sollevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wypełnione Twoim celem lub tym, co robiłeś ostatnio. Popraw na to, co naprawdę podniosłeś." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Preenchido com o teu objetivo ou com o que fizeste da última vez. Corrige para o que levantaste realmente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Заполнено твоей целью или тем, что ты делал в прошлый раз. Исправь на то, что действительно поднял." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已填入你的目标,或你上次的数据。改成你实际举起的重量。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已填入你的目標,或你上次的資料。改成你實際舉起的重量。" } } + } }, + "What you just did — %@, set %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Was du gerade gemacht hast – %1$@, Satz %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "What you just did — %@, set %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Lo que acabas de hacer: %1$@, serie %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ce que tu viens de faire — %1$@, série %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Quello che hai appena fatto — %1$@, serie %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "To, co przed chwilą zrobiłeś — %1$@, seria %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O que acabaste de fazer — %1$@, série %2$lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Что ты только что сделал — %1$@, подход %2$lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你刚做的——%1$@,第 %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你剛做的——%1$@,第 %2$lld 組" } } + } }, + "This set %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieser Satz %@" } }, "en": { "stringUnit": { "state": "translated", "value": "This set %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Esta serie %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Cette série %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Questa serie %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ta seria %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esta série %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Этот подход %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "本组 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "本組 %@" } } + } }, + "Resting %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "休息 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "休息 %@" } } + } }, + "Warming up %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmen %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Warming up %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Calentando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Échauffement %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Riscaldamento %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozgrzewka %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A aquecer %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминка %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身 %@" } } + } }, + "Cooling down %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Abwärmen %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Cooling down %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Enfriando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Retour au calme %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Defaticamento %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Schłodzenie %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A arrefecer %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Заминка %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "放松 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "放鬆 %@" } } + } }, + "Enter the set you just did, then start the next one.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trag den Satz ein, den du gerade gemacht hast, und starte dann den nächsten." } }, "en": { "stringUnit": { "state": "translated", "value": "Enter the set you just did, then start the next one." } }, "es": { "stringUnit": { "state": "translated", "value": "Introduce la serie que acabas de hacer y luego empieza la siguiente." } }, "fr": { "stringUnit": { "state": "translated", "value": "Saisis la série que tu viens de faire, puis lance la suivante." } }, "it": { "stringUnit": { "state": "translated", "value": "Inserisci la serie che hai appena fatto, poi inizia la successiva." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wpisz serię, którą przed chwilą zrobiłeś, a potem zacznij następną." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Introduz a série que acabaste de fazer e depois começa a seguinte." } }, "ru": { "stringUnit": { "state": "translated", "value": "Введи подход, который только что сделал, затем начни следующий." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入你刚做完的那组,然后开始下一组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輸入你剛做完的那組,然後開始下一組。" } } + } }, + "Enter your last set, then finish and save.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trag deinen letzten Satz ein, dann abschließen und sichern." } }, "en": { "stringUnit": { "state": "translated", "value": "Enter your last set, then finish and save." } }, "es": { "stringUnit": { "state": "translated", "value": "Introduce tu última serie, luego termina y guarda." } }, "fr": { "stringUnit": { "state": "translated", "value": "Saisis ta dernière série, puis termine et enregistre." } }, "it": { "stringUnit": { "state": "translated", "value": "Inserisci l'ultima serie, poi concludi e salva." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wpisz ostatnią serię, a potem zakończ i zapisz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Introduz a tua última série, depois termina e guarda." } }, "ru": { "stringUnit": { "state": "translated", "value": "Введи последний подход, затем заверши и сохрани." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入最后一组,然后结束并保存。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輸入最後一組,然後結束並儲存。" } } + } }, + "Press the button, or double-tap your strap, when the set is done.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Drück den Knopf oder tippe zweimal auf dein Band, wenn der Satz fertig ist." } }, "en": { "stringUnit": { "state": "translated", "value": "Press the button, or double-tap your strap, when the set is done." } }, "es": { "stringUnit": { "state": "translated", "value": "Pulsa el botón, o toca dos veces tu banda, cuando termines la serie." } }, "fr": { "stringUnit": { "state": "translated", "value": "Appuie sur le bouton, ou tape deux fois sur ton bracelet, quand la série est finie." } }, "it": { "stringUnit": { "state": "translated", "value": "Premi il pulsante, o tocca due volte la fascia, quando la serie è finita." } }, "pl": { "stringUnit": { "state": "translated", "value": "Naciśnij przycisk albo stuknij dwukrotnie w opaskę, gdy skończysz serię." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Carrega no botão, ou toca duas vezes na tua banda, quando acabares a série." } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми кнопку или дважды коснись браслета, когда закончишь подход." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "做完这组后,按下按钮,或双击手环。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "做完這組後,按下按鈕,或輕點兩下手環。" } } + } }, "That's the last set": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Das war der letzte Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "That's the last set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esa fue la última serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "C'était la dernière série" } }, "it": { "stringUnit": { "state": "translated", "value": "Quella era l'ultima serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "To była ostatnia seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Essa foi a última série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Это был последний подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是最后一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是最後一組" } } } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 63a5a7f53f..6ca03eadfc 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -217,6 +217,7 @@ struct LiftLogView: View { targetRepsLow: item.targetRepsLow, targetRepsHigh: item.targetRepsHigh, targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, note: item.note) } running = SessionStart(id: program.id, plan: plan, diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index 6fe841f282..5b31a4c8c3 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -31,6 +31,9 @@ struct LiftProgramEditorSheet: View { @State private var editingItem: ItemEditTarget? @State private var confirmingDelete = false + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + @FocusState private var focused: Field? private enum Field: Hashable { case name, note } @@ -178,20 +181,18 @@ struct LiftProgramEditorSheet: View { } } - /// "4 × 8–10 · RPE 8 · 2:00 rest" — only the parts that were actually filled in. + /// "4 × 8 · 60 kg · 2:00 rest" — only the parts that were actually filled in. private func targetSummary(_ item: LiftProgramItemRow) -> String { var parts: [String] = [] if let sets = item.targetSets { - if let lo = item.targetRepsLow, let hi = item.targetRepsHigh, lo != hi { - parts.append("\(sets) × \(lo)–\(hi)") - } else if let lo = item.targetRepsLow { - parts.append("\(sets) × \(lo)") + if let reps = item.targetRepsLow { + parts.append("\(sets) × \(reps)") } else { parts.append(String(localized: "\(sets) sets")) } } - if let rpe = item.targetRpe { - parts.append("RPE \(LiftFormat.trim(rpe))") + if let kg = item.targetWeightKg { + parts.append(LiftFormat.weight(kg, system: unitSystem)) } if let rest = item.restSec { parts.append(String(localized: "\(LiftFormat.duration(rest)) rest")) @@ -307,6 +308,7 @@ struct LiftProgramEditorSheet: View { targetRepsLow: item.targetRepsLow, targetRepsHigh: item.targetRepsHigh, targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, restSec: item.restSec, note: item.note ) diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index 8e54b895f7..2ed418f0c2 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -28,9 +28,8 @@ struct LiftProgramItemSheet: View { @State private var secondaries: Set = [] @State private var setsText: String = "" - @State private var repsLowText: String = "" - @State private var repsHighText: String = "" - @State private var rpeText: String = "" + @State private var repsText: String = "" + @State private var weightText: String = "" @State private var restText: String = "" @State private var note: String = "" @@ -38,8 +37,16 @@ struct LiftProgramItemSheet: View { @State private var vocabulary: [LiftExerciseRow] = [] @State private var loaded = false + /// The app's existing metric/imperial preference — the Lift Log never adds a second weight unit + /// setting of its own, so the plan is typed in the same unit the session records in. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + private var weightLabel: LocalizedStringKey { + unitSystem == .imperial ? "Weight (lb)" : "Weight (kg)" + } + @FocusState private var focused: Field? - private enum Field: Hashable { case exercise, sets, repsLow, repsHigh, rpe, rest, note } + private enum Field: Hashable { case exercise, sets, reps, weight, rest, note } private var trimmedExercise: String { exercise.trimmingCharacters(in: .whitespacesAndNewlines) @@ -214,24 +221,26 @@ struct LiftProgramItemSheet: View { field("Working sets") { numberInput("4", text: $setsText, field: .sets) } - field("Target RPE") { - numberInput("8", text: $rpeText, field: .rpe) + field("Reps") { + numberInput("8", text: $repsText, field: .reps) } } HStack(spacing: 12) { - field("Reps from") { - numberInput("8", text: $repsLowText, field: .repsLow) + field(weightLabel) { + numberInput("60", text: $weightText, field: .weight) } - field("Reps to") { - numberInput("10", text: $repsHighText, field: .repsHigh) + field("Rest (seconds)") { + numberInput("120", text: $restText, field: .rest) } } - field("Rest (seconds)") { - numberInput("120", text: $restText, field: .rest) - } - Text("Every target is optional — fill in what you actually plan against.") + // No target RPE here on purpose. RPE is how hard a set FELT, which you can only + // know once you have done it — planning one means guessing at your own effort in + // advance and then reading the guess back as if it were data. It is recorded per + // set during the session instead. + Text("Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } } @@ -314,9 +323,10 @@ struct LiftProgramItemSheet: View { if let item { exercise = item.exercise setsText = item.targetSets.map(String.init) ?? "" - repsLowText = item.targetRepsLow.map(String.init) ?? "" - repsHighText = item.targetRepsHigh.map(String.init) ?? "" - rpeText = item.targetRpe.map { LiftFormat.trim($0) } ?? "" + repsText = item.targetRepsLow.map(String.init) ?? "" + weightText = item.targetWeightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) + } ?? "" restText = item.restSec.map(String.init) ?? "" note = item.note ?? "" } @@ -362,9 +372,14 @@ struct LiftProgramItemSheet: View { ord: item?.ord ?? 0, exercise: name, targetSets: Int(setsText.trimmingCharacters(in: .whitespaces)), - targetRepsLow: Int(repsLowText.trimmingCharacters(in: .whitespaces)), - targetRepsHigh: Int(repsHighText.trimmingCharacters(in: .whitespaces)), - targetRpe: LiftFormat.number(rpeText), + // ONE rep count. `targetRepsHigh`/`targetRpe` stay nil: they are v40 columns kept for + // compatibility, not part of the plan any more. + targetRepsLow: Int(repsText.trimmingCharacters(in: .whitespaces)), + targetRepsHigh: nil, + targetRpe: nil, + targetWeightKg: LiftFormat.number(weightText).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + }, restSec: Int(restText.trimmingCharacters(in: .whitespaces)), note: trimmedNote.isEmpty ? nil : trimmedNote )) diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 4a7e845e17..eb8b4dcd4d 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -4,13 +4,20 @@ import WhoopStore // Running a session: the screen you actually use at the rack. // -// THREE WAYS TO ADVANCE, all doing exactly the same thing: +// EXACTLY TWO WAYS TO ADVANCE, both deliberate: // 1. A double-tap on the WHOOP strap — the one that works with the phone face-down on a bench. -// 2. Tapping anywhere on the screen. -// 3. The explicit button. -// Both (2) and (3) were asked for by name; shipping only one of them is not the same feature. They -// are ordinary single taps — the double-tap is the STRAP gesture only, because a strap takes knocks -// against bars all session while a phone screen in your hand does not. +// 2. The explicit button. +// An earlier build also advanced on a tap ANYWHERE on screen. First real session killed that: it +// fires while you scroll, while you type a weight, while you just hold the phone — and a stray +// advance costs a logged set. Do not reintroduce it. +// +// WHAT YOU LIFTED IS ENTERED DURING THE REST, not during the set. You cannot type a weight with the +// bar in your hands. The set is recorded the instant it ends (timing and all); the numbers are +// filled in while you recover, and the final set — which no rest follows — is filled in during the +// cool-down. +// +// Two buzz patterns, deliberately distinguishable on a wrist that has been knocked about all +// session: ONE pulse confirms a strap double-tap registered; THREE means the rest is nearly up. // // The countdown is read from `LiftSessionEngine`, which anchors rest to an absolute instant, so a // phone that sleeps through a rest still shows the truth when it wakes. Nothing auto-advances: when @@ -82,7 +89,10 @@ struct LiftSessionView: View { ScreenScaffold(title: sessionTitle, subtitle: sessionSubtitle) { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { stageCard - if case .working = engine.stage { entryCard } + // The entry card belongs to the REST, not the set. You cannot type a weight with the + // bar in your hands; you can while you recover. The final set has no rest after it, + // so the cool-down is its entry window. + if engine.setAwaitingEntry != nil { entryCard } progressCard controls } @@ -94,10 +104,10 @@ struct LiftSessionView: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) - // The whole screen advances the session. `.contentShape` so the empty space between cards - // counts too — at the rack you should not have to aim. - .contentShape(Rectangle()) - .onTapGesture { advance() } + // DELIBERATELY NOT tap-anywhere. An earlier build advanced the session on a tap anywhere on + // screen; in real use that fires while you are scrolling, typing a weight or just holding the + // phone, and a stray advance costs a logged set. The session now moves on exactly two + // deliberate inputs: the button below, or a double-tap on the strap. #if os(iOS) .sensoryFeedback(trigger: cueTick) { _, _ in switch lastCue { @@ -114,8 +124,7 @@ struct LiftSessionView: View { } .task { // Claim the strap's double-tap for as long as this session is on screen. - model.strapDoubleTapOverride = { advance() } - await prefillFromLastTime() + model.strapDoubleTapOverride = { advance(fromStrap: true) } persist() } .onDisappear { @@ -142,9 +151,9 @@ struct LiftSessionView: View { private var sessionSubtitle: LocalizedStringKey { switch engine.stage { case .warmup: return "Tap when you start your first set." - case .working: return "Tap when the set is done." - case .resting: return "Tap when you're ready for the next set." - case .cooldown: return "Tap to finish and save." + case .working: return "Press the button, or double-tap your strap, when the set is done." + case .resting: return "Enter the set you just did, then start the next one." + case .cooldown: return "Enter your last set, then finish and save." case .finished: return "Saving…" } } @@ -216,6 +225,9 @@ struct LiftSessionView: View { private var entryCard: some View { NoopCard { VStack(alignment: .leading, spacing: 14) { + Text(entryHeading) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) HStack(spacing: 12) { field(weightLabel) { numberInput("0", text: $weightText, field: .weight) } field("Reps") { numberInput("0", text: $repsText, field: .reps) } @@ -226,31 +238,92 @@ struct LiftSessionView: View { .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) } - Text("Pre-filled with what you did last time. Change anything that's different today.") + Text("Pre-filled with your target, or what you did last time. Correct it to what you actually lifted.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) } } - // The entry card must NOT swallow taps into the advance gesture while someone is typing a - // weight, so it takes its own (empty) tap and stops propagation. - .contentShape(Rectangle()) - .onTapGesture { } + // Every keystroke goes straight into the engine and to disk, so the numbers survive a crash + // mid-rest exactly like the rest of the session does. + // The two-argument closure form deliberately: the zero-argument `onChange(of:)` is + // macOS 14+, and NOOP still targets macOS 13. iOS compiled it happily — only the Mac build + // catches this, which is why both targets are built for every change. + .onChange(of: weightText) { _ in commitEntry() } + .onChange(of: repsText) { _ in commitEntry() } + .onChange(of: rpeText) { _ in commitEntry() } + .onChange(of: isWarmup) { _ in commitEntry() } + } + + /// Names the set being filled in, so it is never ambiguous which one the numbers belong to. + private var entryHeading: String { + guard let s = engine.setAwaitingEntry else { return "" } + let name = engine.plan.indices.contains(s.exerciseIndex) + ? engine.plan[s.exerciseIndex].exercise : "" + return String(localized: "What you just did — \(name), set \(s.setIndex)") + } + + /// Push the typed values into the engine and persist. Editing, never appending: the set already + /// exists (it was recorded the moment it ended), so typing can't create a phantom. + private func commitEntry() { + engine.updateLastSet(weightKg: enteredWeightKg, + reps: Int(repsText.trimmingCharacters(in: .whitespaces)), + rpe: LiftFormat.number(rpeText), + isWarmup: isWarmup) + persist() } // MARK: - Progress + controls private var progressCard: some View { NoopCard { - HStack(spacing: 14) { - stat(String(localized: "Sets"), - "\(engine.completedWorkingSets)/\(engine.plannedWorkingSets)") - stat(String(localized: "Elapsed"), LiftFormat.duration(max(0, now - engine.startTs))) - stat(String(localized: "Volume"), LiftFormat.weight(volumeKg, system: unitSystem)) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 14) { + stat(String(localized: "Sets"), + "\(engine.completedWorkingSets)/\(engine.plannedWorkingSets)") + stat(String(localized: "Session"), + LiftFormat.duration(max(0, now - engine.startTs))) + stat(String(localized: "Volume"), LiftFormat.weight(volumeKg, system: unitSystem)) + } + // TWO clocks, deliberately. The session total above answers "how long have I been + // here"; this one answers "how long has THIS set/rest been running", which is the + // number you actually act on between sets. + HStack(spacing: 8) { + Image(systemName: stageClockSymbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + .accessibilityHidden(true) + Text(stageClockLabel) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + } } } } + /// The current stage's own elapsed clock — how long this set has been under way, or how long + /// you have been resting (which keeps counting past zero, because an overrun rest is worth + /// seeing rather than hiding). + private var stageClockLabel: String { + let elapsed = max(0, now - engine.stageStartedAt) + switch engine.stage { + case .working: return String(localized: "This set \(LiftFormat.duration(elapsed))") + case .resting: return String(localized: "Resting \(LiftFormat.duration(elapsed))") + case .warmup: return String(localized: "Warming up \(LiftFormat.duration(elapsed))") + case .cooldown: return String(localized: "Cooling down \(LiftFormat.duration(elapsed))") + case .finished: return "" + } + } + + private var stageClockSymbol: String { + switch engine.stage { + case .working: return "figure.strengthtraining.traditional" + case .resting: return "hourglass" + default: return "clock" + } + } + private func stat(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 3) { Text(label).strandOverline() @@ -349,34 +422,43 @@ struct LiftSessionView: View { // MARK: - Behaviour - private func advance() { - let stamp = Int(Date().timeIntervalSince1970) - let wasWorking: Bool - if case .working = engine.stage { wasWorking = true } else { wasWorking = false } + /// The one action. `fromStrap` is true when a WHOOP double-tap drove it, which earns a single + /// confirming buzz — with the phone face-down you otherwise have no way to know it registered. + /// A button press needs no such confirmation: you watched it happen. + private func advance(fromStrap: Bool = false) { + // Anything typed during the rest is already in the engine via `commitEntry`, but commit once + // more here so a value still being typed as the user advances is not lost. + if engine.setAwaitingEntry != nil { commitEntry() } if case .cooldown = engine.stage { showingFinish = true return } - engine.advance(now: stamp, - weightKg: wasWorking ? enteredWeightKg : nil, - reps: wasWorking ? Int(repsText.trimmingCharacters(in: .whitespaces)) : nil, - rpe: wasWorking ? LiftFormat.number(rpeText) : nil, - isWarmup: wasWorking ? isWarmup : false) + if fromStrap, live.bonded { + model.buzz(loops: LiftSessionView.advanceConfirmBuzzes, gate: HapticPrefs.liftRest) + } + + engine.advance(now: Int(Date().timeIntervalSince1970)) buzzedFor = nil - isWarmup = false cue(for: engine.stage) persist() - if case .working = engine.stage { - Task { await prefillFromLastTime() } + // Entering a rest: seed the boxes for the set just finished, so the common case is a glance + // and a tap rather than typing three numbers. + if engine.setAwaitingEntry != nil { + Task { await seedEntryFields() } } } /// The strap buzz five seconds before the rest ends — the cue that reaches you with the phone /// face-down. Fires once per rest period, and only while a strap is actually bonded. + /// + /// THREE buzzes, deliberately distinct from the single confirmation buzz an advance gives. On a + /// wrist that has been knocked around a gym all session, "did it just buzz?" is a real question, + /// and two cues that feel identical answer it badly — one pulse means "I heard you", three means + /// "your rest is nearly up". private func fireRestCueIfDue() { guard case .resting(_, _, let endsAt) = engine.stage else { return } guard buzzedFor != endsAt else { return } @@ -384,11 +466,16 @@ struct LiftSessionView: View { guard remaining <= 5 else { return } buzzedFor = endsAt if live.bonded { - model.buzz(loops: 2, gate: HapticPrefs.liftRest) + model.buzz(loops: LiftSessionView.restWarningBuzzes, gate: HapticPrefs.liftRest) } cue(.ready) } + /// Rest is nearly over: three pulses. + static let restWarningBuzzes: UInt8 = 3 + /// A strap double-tap registered: one pulse, so the gesture is confirmed without ambiguity. + static let advanceConfirmBuzzes: UInt8 = 1 + private func cue(for stage: LiftSessionEngine.Stage) { switch stage { case .working: cue(.next) @@ -415,26 +502,40 @@ struct LiftSessionView: View { programName: programName)) } - /// Fill the entry boxes with what was actually lifted for this exercise last time — the read the - /// whole feature exists for, and the reason sets are stored as rows rather than a blob. - private func prefillFromLastTime() async { - guard case .working(_, let setNumber) = engine.stage, - let item = engine.currentItem, - let store = await repo.storeHandle() else { return } - let previous = (try? await store.lastLiftSets(deviceId: repo.deviceId, - exercise: item.exercise, - before: engine.startTs)) ?? [] - // Prefer the matching set number from last time, else the last set performed. - let match = previous.first { $0.setIndex == setNumber && !$0.isWarmup } ?? previous.last - guard let match else { - weightText = ""; repsText = ""; rpeText = "" - return + /// Seed the entry boxes for the set just performed. + /// + /// Two sources, in order: what you actually lifted for this exercise LAST TIME (the read the + /// whole feature exists for, and the reason sets are stored as rows rather than a blob), falling + /// back to the weight and reps the program PLANNED. Last time beats the plan because the plan is + /// an intention and last time is evidence. + private func seedEntryFields() async { + guard let awaiting = engine.setAwaitingEntry, + engine.plan.indices.contains(awaiting.exerciseIndex) else { return } + let item = engine.plan[awaiting.exerciseIndex] + isWarmup = awaiting.isWarmup + + var seededWeight: Double? = item.targetWeightKg + var seededReps: Int? = item.targetRepsLow + var seededRpe: Double? + + if let store = await repo.storeHandle() { + let previous = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: item.exercise, + before: engine.startTs)) ?? [] + if let match = previous.first(where: { $0.setIndex == awaiting.setIndex && !$0.isWarmup }) + ?? previous.last { + seededWeight = match.weightKg ?? seededWeight + seededReps = match.reps ?? seededReps + seededRpe = match.rpe + } } - weightText = match.weightKg.map { + + weightText = seededWeight.map { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) } ?? "" - repsText = match.reps.map(String.init) ?? "" - rpeText = match.rpe.map { LiftFormat.trim($0) } ?? "" + repsText = seededReps.map(String.init) ?? "" + rpeText = seededRpe.map { LiftFormat.trim($0) } ?? "" + commitEntry() } private func save() async { @@ -459,6 +560,8 @@ struct LiftSessionView: View { programId: programId, // Snapshot the name: renaming or deleting the program never rewrites this session. programName: programName, + // A NUMBER, so session load (sRPE x duration) is computable rather than buried in prose. + sessionRpe: LiftFormat.number(sessionRpeText), note: sessionNote) _ = try? await store.upsertLiftSessions([session]) @@ -504,13 +607,11 @@ struct LiftSessionView: View { /// uses, so a typed session and an imported one land in one bucket with one icon. static let sport = "Strength Training" + /// The workout row's human-readable note. The session RPE is NOT repeated here — it has its own + /// column now, and duplicating it invites the two spellings to disagree. private var sessionNote: String? { - var parts: [String] = [] - if let programName, !programName.isEmpty { parts.append(programName) } - if let rpe = LiftFormat.number(sessionRpeText) { - parts.append(String(localized: "session RPE \(LiftFormat.trim(rpe))")) - } - return parts.isEmpty ? nil : parts.joined(separator: " · ") + guard let programName, !programName.isEmpty else { return nil } + return programName } private var enteredWeightKg: Double? { diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index e650603d56..33a475258d 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -35,20 +35,25 @@ final class LiftSessionEngineTests: XCTestCase { e.advance(now: t0 + 300) // warm-up → set 1 XCTAssertEqual(e.stage, .working(item: 0, set: 1)) - e.advance(now: t0 + 340, weightKg: 30, reps: 10, rpe: 8) // set 1 done → rest + e.advance(now: t0 + 340) // set 1 done → rest XCTAssertEqual(e.stage, .resting(item: 0, set: 1, endsAt: t0 + 340 + 90)) + // The numbers are typed DURING the rest that follows the set, not while holding the bar. + e.updateLastSet(weightKg: 30, reps: 10, rpe: 8, isWarmup: false) e.advance(now: t0 + 440) // rest → set 2 XCTAssertEqual(e.stage, .working(item: 0, set: 2)) - e.advance(now: t0 + 480, weightKg: 30, reps: 8, rpe: 9) // set 2 done → rest + e.advance(now: t0 + 480) // set 2 done → rest XCTAssertEqual(e.stage, .resting(item: 0, set: 2, endsAt: t0 + 480 + 90)) + e.updateLastSet(weightKg: 30, reps: 8, rpe: 9, isWarmup: false) e.advance(now: t0 + 580) // rest → next exercise, set 1 XCTAssertEqual(e.stage, .working(item: 1, set: 1)) - e.advance(now: t0 + 620, weightKg: 55, reps: 12, rpe: 7) // last set → cool-down, no rest + e.advance(now: t0 + 620) // last set → cool-down, no rest XCTAssertEqual(e.stage, .cooldown) + // The final set has no rest after it, so its numbers are entered during the cool-down. + e.updateLastSet(weightKg: 55, reps: 12, rpe: 7, isWarmup: false) e.advance(now: t0 + 700) // cool-down → finished XCTAssertEqual(e.stage, .finished) @@ -63,7 +68,7 @@ final class LiftSessionEngineTests: XCTestCase { func testNoRestFollowsTheFinalSet() { var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) e.advance(now: t0 + 60) // → set 1 - e.advance(now: t0 + 100, weightKg: 20, reps: 12) // final set → cool-down + e.advance(now: t0 + 100) // final set → cool-down XCTAssertEqual(e.stage, .cooldown, "the last set is followed by the cool-down, not a rest") XCTAssertNil(e.sets[0].restSec, "no rest was taken after the final set, so none is recorded") } @@ -73,7 +78,7 @@ final class LiftSessionEngineTests: XCTestCase { func testRestIsAnchoredToAnAbsoluteInstantNotACountdown() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 10, weightKg: 30, reps: 10) // rest ends at t0+100 (90s) + e.advance(now: t0 + 10) // rest ends at t0+100 (90s) XCTAssertEqual(e.restRemaining(now: t0 + 10), 90) XCTAssertEqual(e.restRemaining(now: t0 + 55), 45) @@ -85,7 +90,7 @@ final class LiftSessionEngineTests: XCTestCase { func testAnOverrunRestFloorsAtZeroAndNeverAutoAdvances() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 10, weightKg: 30, reps: 10) + e.advance(now: t0 + 10) XCTAssertEqual(e.restRemaining(now: t0 + 5_000), 0, "an overrun rest reads 0:00, never negative") XCTAssertEqual(e.stage, .resting(item: 0, set: 1, endsAt: t0 + 100), @@ -95,7 +100,7 @@ final class LiftSessionEngineTests: XCTestCase { func testRestRecordedIsWhatWasActuallyTakenNotWhatWasPlanned() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 10, weightKg: 30, reps: 10) // planned rest 90s + e.advance(now: t0 + 10) // planned rest 90s e.advance(now: t0 + 210) // actually rested 200s XCTAssertEqual(e.sets[0].restSec, 200, @@ -105,7 +110,7 @@ final class LiftSessionEngineTests: XCTestCase { func testASetCarriesTheDurationItWasPerformedOver() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0 + 300) // set 1 begins - e.advance(now: t0 + 345, weightKg: 30, reps: 10) // set 1 ends + e.advance(now: t0 + 345) // set 1 ends XCTAssertEqual(e.sets[0].startTs, t0 + 300) XCTAssertEqual(e.sets[0].endTs, t0 + 345) } @@ -115,7 +120,7 @@ final class LiftSessionEngineTests: XCTestCase { func testUndoRestoresTheStageAndRemovesTheRecordedSet() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 40, weightKg: 30, reps: 10, rpe: 8) + e.advance(now: t0 + 40) XCTAssertEqual(e.sets.count, 1) e.undo() @@ -126,7 +131,7 @@ final class LiftSessionEngineTests: XCTestCase { func testUndoWalksAllTheWayBackToTheWarmUp() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 40, weightKg: 30, reps: 10) + e.advance(now: t0 + 40) e.advance(now: t0 + 140) while e.canUndo { e.undo() } XCTAssertEqual(e.stage, .warmup) @@ -142,7 +147,7 @@ final class LiftSessionEngineTests: XCTestCase { func testTappingPastTheEndDoesNothingAndCannotFillTheUndoStack() { var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 30, weightKg: 20, reps: 10) + e.advance(now: t0 + 30) e.advance(now: t0 + 60) XCTAssertEqual(e.stage, .finished) @@ -159,7 +164,8 @@ final class LiftSessionEngineTests: XCTestCase { func testAWarmUpSetIsRecordedButDoesNotCountAsAWorkingSet() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 40, weightKg: 20, reps: 12, isWarmup: true) + e.advance(now: t0 + 40) + e.updateLastSet(weightKg: 20, reps: 12, rpe: nil, isWarmup: true) XCTAssertEqual(e.sets.count, 1) XCTAssertTrue(e.sets[0].isWarmup) @@ -167,6 +173,60 @@ final class LiftSessionEngineTests: XCTestCase { "studies count working sets; a warm-up must not inflate the tally") } + // MARK: - Entering the set during the rest that follows it + + func testASetIsRecordedWithItsTimingBeforeAnyNumbersAreTyped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) + + XCTAssertEqual(e.sets.count, 1, "the set exists the moment it ends") + XCTAssertEqual(e.sets[0].startTs, t0 + 300) + XCTAssertEqual(e.sets[0].endTs, t0 + 345) + XCTAssertNil(e.sets[0].weightKg, "numbers are typed during the rest, not while lifting") + XCTAssertNil(e.sets[0].reps) + } + + func testTheSetAwaitingEntryIsTheOneJustPerformed() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertNil(e.setAwaitingEntry, "nothing to fill in during the warm-up") + e.advance(now: t0) + XCTAssertNil(e.setAwaitingEntry, "nothing to fill in while the set is being performed") + e.advance(now: t0 + 40) + XCTAssertEqual(e.setAwaitingEntry?.setIndex, 1, "resting → the set just done is editable") + } + + func testTypingDuringRestEditsTheSetRatherThanAddingOne() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + + // Someone correcting themselves mid-rest must not accumulate sets. + e.updateLastSet(weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.updateLastSet(weightKg: 32.5, reps: 9, rpe: 8, isWarmup: false) + + XCTAssertEqual(e.sets.count, 1) + XCTAssertEqual(e.sets[0].weightKg, 32.5) + XCTAssertEqual(e.sets[0].reps, 9) + XCTAssertEqual(e.sets[0].rpe, 8) + } + + func testTheFinalSetIsEditableDuringTheCoolDown() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + XCTAssertEqual(e.stage, .cooldown) + XCTAssertNotNil(e.setAwaitingEntry, "the last set has no rest after it, so the cool-down is its entry window") + e.updateLastSet(weightKg: 20, reps: 12, rpe: 9, isWarmup: false) + XCTAssertEqual(e.sets[0].reps, 12) + } + + func testUpdatingWithNoSetRecordedIsHarmless() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.updateLastSet(weightKg: 100, reps: 5, rpe: 10, isWarmup: false) + XCTAssertTrue(e.sets.isEmpty, "typing before any set exists must not invent one") + } + func testPlannedWorkingSetsSumsTheWholePlan() { let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) XCTAssertEqual(e.plannedWorkingSets, 3) @@ -196,9 +256,9 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertNil(e.currentItem, "there is no exercise during the warm-up") e.advance(now: t0) XCTAssertEqual(e.currentItem?.exercise, "Incline dumbbell press") - e.advance(now: t0 + 40, weightKg: 30, reps: 10) + e.advance(now: t0 + 40) e.advance(now: t0 + 140) - e.advance(now: t0 + 180, weightKg: 30, reps: 8) + e.advance(now: t0 + 180) e.advance(now: t0 + 280) XCTAssertEqual(e.currentItem?.exercise, "Lat pulldown") } From d852d3c89dca300c2ed9b721f0742a22372e2a6c Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:10:12 +0200 Subject: [PATCH 04/31] lift log: the metrics, and the session detail screen that shows them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "where can I see what I lifted, and where are the numbers" — the last substantial gap in the feature. `LiftMetrics` (StrandAnalytics, pure, 29 tests) computes six figures, every one of them arithmetic the user can redo by hand from the sets listed on the same screen. That is the design constraint: six honest numbers rather than one invented composite score, because a "workout score out of 100" feels satisfying and tells you nothing about what to change. volume load Σ(weight × reps), working sets only sets per muscle direct×1.0 + indirect×0.5 session load sRPE × minutes (Foster) work vs rest measured from the taps est. 1RM Epley, ≤12 reps, always labelled estimated RPE profile mean · count at/above 8 · unrated THE 0.5 IS NOT A HOUSE CONVENTION. The 2025 Sports Medicine dose-response meta-regression compared crediting a secondary mover's set as 1.0, 0.5 and 0.0, found the evidence strongest for fractional, and used it in its primary models. The reference doses shown against the weekly counts were derived under that same operationalisation, so the credit and the doses have to move together or the comparison silently stops meaning anything. Pinned by a test. Set counts are NOT filtered by RPE, deliberately and against the obvious instinct: the reference doses come from unfiltered working-set counts, so filtering to "hard" sets would compare a smaller number against a scale built from a larger one. Warm-ups are excluded; nothing else is. The muscle vocabulary stays at 20 in 4 regions. Coarser hides an untrained hamstring inside a full "Legs" bucket — the gap invisible precisely because the bucket is full. Finer splits a triceps into three counts of ~1.3 and goes past the resolution the evidence is measured at. The weekly view rolls up to regions; the data stays at 20. One display bug caught by checking the screen's own arithmetic by hand: session load read 21 under a caption saying "RPE 8 × 2 min", because the caption rounded to whole minutes while the figure was computed from 2.67. Anyone verifying it would have got 16 and concluded the app invents numbers. The caption now shows the minutes the calculation actually used. Also refused, and documented in the source so it stays refused: anything feeding `workout.strain` from typed sets/reps/weight; per-exercise muscle weightings (no published table exists, and inventing one makes every downstream figure fiction wearing the costume of precision); and acute:chronic workload ratios or injury-risk warnings. Verification: StrandAnalytics 1627 tests, WhoopStore 457, both 0 failures. Both app targets build. i18n and doc-comment gates pass; 28 new strings across nine locales. Driven end-to-end in the simulator: logged a 4-set session and checked every figure by hand — volume 500 kg = 30×8 + 32.5×8, e1RM 41.2 = 32.5×(1+8/30), chest 4.0 direct with front delts and triceps at 2.0 each from four indirect sets. Co-Authored-By: Claude Opus 5 --- .../Sources/StrandAnalytics/LiftMetrics.swift | 283 +++++++++++++ .../LiftMetricsTests.swift | 226 +++++++++++ Strand/Resources/Localizable.xcstrings | 84 ++++ Strand/Screens/LiftLogView.swift | 134 ++++++ Strand/Screens/LiftSessionDetailSheet.swift | 382 ++++++++++++++++++ 5 files changed, 1109 insertions(+) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift create mode 100644 Strand/Screens/LiftSessionDetailSheet.swift diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift new file mode 100644 index 0000000000..11a7f491af --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -0,0 +1,283 @@ +import Foundation +import WhoopStore + +// Training metrics for the Lift Log. +// +// Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the +// whole design constraint: NOOP shows six honest numbers rather than one invented score, because a +// composite "workout score out of 100" feels satisfying and tells you nothing about what to change. +// +// PURE. No store, no clock, no UI — the inputs are rows and the outputs are numbers, so the whole +// surface is unit-testable with no strap, no database and no simulator. +// +// WHAT IS DELIBERATELY ABSENT, and must stay absent: +// +// • Anything that feeds `workout.strain` or daily Effort. NOOP's strain is HR-measured (Karvonen +// %HRR -> Edwards TRIMP). There is no validated public path from typed sets/reps/weight to a +// cardiovascular-strain equivalent — WHOOP's own muscular load runs velocity-based algorithms +// over strap accelerometer/gyroscope under an unpublished model — and deriving one here is the +// exact case `CLAUDE.md` warns about after the withdrawn PPG->HR estimate (#194). +// +// • Per-exercise muscle weightings ("bench press = 0.7 triceps"). There is no published table to +// take them from. Inventing one would make every downstream per-muscle figure fiction wearing +// the costume of precision. The direct/indirect split is the resolution the evidence supports. +// +// • Acute:chronic workload ratios or any injury-risk / overtraining warning. The construct's +// validity is actively disputed, and a health warning from a non-medical app is either ignored +// or believed — both bad. + +public enum LiftMetrics { + + // MARK: - Volume load (tonnage) + + /// Σ (weight × reps) over WORKING sets, in kilograms. Nil when nothing countable was logged. + /// + /// Warm-ups are excluded because the training literature counts working sets, and a warm-up + /// double counted as volume would flatter every session. A set missing either its weight or its + /// reps contributes nothing rather than a guess. + /// + /// Good for: tracking progression WITHIN one exercise across weeks. Not comparable between + /// exercises — 100 kg of leg press is not 100 kg of squat — and not comparable between people. + public static func volumeLoadKg(_ sets: [LiftSetRow]) -> Double? { + let total = sets.reduce(into: 0.0) { sum, s in + guard !s.isWarmup, let w = s.weightKg, let r = s.reps, w > 0, r > 0 else { return } + sum += w * Double(r) + } + return total > 0 ? total : nil + } + + // MARK: - Session load (Foster sRPE-TL) + + /// Session RPE × duration in minutes. + /// + /// Foster's session-RPE training load. The reason it earns a place next to volume: it is + /// validated across BOTH resistance and endurance training, which makes it the only figure in + /// the app that puts a leg day and a run on one comparable scale. + /// + /// Nil when the session was not rated — a skipped rating must never be read as an effortless 0. + public static func sessionLoad(sessionRpe: Double?, durationSec: Int) -> Double? { + guard let rpe = sessionRpe, rpe > 0, durationSec > 0 else { return nil } + return rpe * (Double(durationSec) / 60.0) + } + + // MARK: - Work versus rest + + public struct WorkRest: Equatable { + /// Seconds actually spent performing sets (warm-ups included — a warm-up is still time under + /// load, even though it is not counted as training volume). + public let workSec: Int + /// Seconds spent resting between sets, as MEASURED from the taps rather than as planned. + public let restSec: Int + /// Rest ÷ work. Nil when no work was recorded. A leg day at 1:4 and a circuit at 1:1 are + /// different training even at identical volume, and only a tap-through log can know it. + public let restToWorkRatio: Double? + + public init(workSec: Int, restSec: Int) { + self.workSec = workSec + self.restSec = restSec + self.restToWorkRatio = workSec > 0 ? Double(restSec) / Double(workSec) : nil + } + } + + public static func workRest(_ sets: [LiftSetRow]) -> WorkRest { + var work = 0, rest = 0 + for s in sets { + if let start = s.startTs, let end = s.endTs, end > start { work += end - start } + if let r = s.restSec, r > 0 { rest += r } + } + return WorkRest(workSec: work, restSec: rest) + } + + // MARK: - Estimated one-rep max (Epley) + + /// The rep ceiling above which a 1RM estimate stops being worth showing. + /// + /// Every 1RM formula is a straight-line fit to a curved relationship, and the error grows with + /// reps: a 20-rep set says far more about endurance than about maximal strength. Twelve is the + /// conventional upper bound where the formulas are least unreliable. + public static let oneRepMaxRepCeiling = 12 + + /// Epley: `w × (1 + reps/30)`. Nil for a set that cannot support an estimate. + /// + /// A single rep returns the weight itself — the formula's own +3.3% at one rep is an artefact of + /// the fit, not a claim that a single you just completed was really 3% heavier. + public static func estimatedOneRepMaxKg(weightKg: Double?, reps: Int?) -> Double? { + guard let w = weightKg, let r = reps, w > 0, r > 0, r <= oneRepMaxRepCeiling else { return nil } + guard r > 1 else { return w } + return w * (1.0 + Double(r) / 30.0) + } + + // MARK: - Per-exercise summary + + public struct ExerciseSummary: Equatable { + public let exercise: String + /// Working sets only — the tally the dose-response literature is built on. + public let workingSets: Int + public let warmupSets: Int + public let volumeKg: Double? + /// The session's best set for this exercise, ranked by ESTIMATED 1RM rather than by raw + /// weight: 90 kg × 10 is a better set than 100 kg × 5, and ranking by weight alone would + /// hide that. Falls back to the heaviest set when no set supports an estimate. + public let bestWeightKg: Double? + public let bestReps: Int? + public let bestEstimatedOneRepMaxKg: Double? + + public init(exercise: String, workingSets: Int, warmupSets: Int, volumeKg: Double?, + bestWeightKg: Double?, bestReps: Int?, bestEstimatedOneRepMaxKg: Double?) { + self.exercise = exercise + self.workingSets = workingSets + self.warmupSets = warmupSets + self.volumeKg = volumeKg + self.bestWeightKg = bestWeightKg + self.bestReps = bestReps + self.bestEstimatedOneRepMaxKg = bestEstimatedOneRepMaxKg + } + } + + /// One summary per exercise, in the order the exercises were first performed — which is the + /// order they were done in, not alphabetical, because that is how a session reads back. + public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] { + var order: [String] = [] + var grouped: [String: [LiftSetRow]] = [:] + for s in sets.sorted(by: { $0.ord < $1.ord }) { + if grouped[s.exercise] == nil { order.append(s.exercise) } + grouped[s.exercise, default: []].append(s) + } + return order.map { name in + let rows = grouped[name] ?? [] + let working = rows.filter { !$0.isWarmup } + + // Rank by estimated 1RM where possible; otherwise by raw weight, so an exercise logged + // only at high reps still reports a best set rather than nothing. + let best = working.max { a, b in + let ea = estimatedOneRepMaxKg(weightKg: a.weightKg, reps: a.reps) + let eb = estimatedOneRepMaxKg(weightKg: b.weightKg, reps: b.reps) + if let ea, let eb { return ea < eb } + if ea != nil { return false } + if eb != nil { return true } + return (a.weightKg ?? 0) < (b.weightKg ?? 0) + } + return ExerciseSummary( + exercise: name, + workingSets: working.count, + warmupSets: rows.count - working.count, + volumeKg: volumeLoadKg(rows), + bestWeightKg: best?.weightKg, + bestReps: best?.reps, + bestEstimatedOneRepMaxKg: estimatedOneRepMaxKg(weightKg: best?.weightKg, + reps: best?.reps)) + } + } + + // MARK: - RPE profile + + public struct RpeProfile: Equatable { + public let mean: Double? + public let ratedSets: Int + public let unratedSets: Int + public let setsAtOrAboveThreshold: Int + public let threshold: Double + + public init(mean: Double?, ratedSets: Int, unratedSets: Int, + setsAtOrAboveThreshold: Int, threshold: Double) { + self.mean = mean + self.ratedSets = ratedSets + self.unratedSets = unratedSets + self.setsAtOrAboveThreshold = setsAtOrAboveThreshold + self.threshold = threshold + } + } + + /// The default "this set was close to failure" line. Informational only. + public static let hardSetRpeThreshold = 8.0 + + /// How close to failure the working sets were. + /// + /// Reported SEPARATELY from the set counts and never as a filter on them. The tempting move is + /// to count only sets at RPE >= 7 toward a muscle's weekly total, since proximity to failure is + /// what makes a set count biologically. Doing that would compare a smaller number against + /// reference doses derived from UNFILTERED working-set counts — quietly changing the scale. + /// `unratedSets` is surfaced so a mean computed from three of twelve sets is visibly thin. + public static func rpeProfile(_ sets: [LiftSetRow], + threshold: Double = hardSetRpeThreshold) -> RpeProfile { + let working = sets.filter { !$0.isWarmup } + let rated = working.compactMap(\.rpe) + let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) + return RpeProfile(mean: mean, + ratedSets: rated.count, + unratedSets: working.count - rated.count, + setsAtOrAboveThreshold: rated.filter { $0 >= threshold }.count, + threshold: threshold) + } + + // MARK: - Sets per muscle + + public struct MuscleCounts: Equatable { + /// direct × 1.0 + indirect × 0.5 — the published fractional method. + public let fractional: [LiftMuscle: Double] + public let direct: [LiftMuscle: Int] + public let indirect: [LiftMuscle: Int] + + public init(fractional: [LiftMuscle: Double], direct: [LiftMuscle: Int], + indirect: [LiftMuscle: Int]) { + self.fractional = fractional + self.direct = direct + self.indirect = indirect + } + } + + /// Fractional set counts per muscle over the given sets. + /// + /// The 0.5 for an indirect set is NOT a house convention: the 2025 Sports Medicine dose-response + /// meta-regression compared counting a secondary mover's set as 1.0 ("total"), 0.5 + /// ("fractional") and 0.0 ("direct"), found the evidence strongest for fractional, and used it + /// in its primary models. The reference doses in `ReferenceDose` were derived under that same + /// operationalisation, so the credit and the doses have to move together or the comparison + /// silently stops meaning anything. + /// + /// Warm-ups are excluded; nothing else is. An unclassified exercise (nil primary) contributes to + /// volume and session load but claims no muscle it was never assigned. + public static func muscleCounts(_ sets: [LiftSetRow]) -> MuscleCounts { + var fractional: [LiftMuscle: Double] = [:] + var direct: [LiftMuscle: Int] = [:] + var indirect: [LiftMuscle: Int] = [:] + for s in sets where !s.isWarmup { + if let p = s.primaryMuscle { + direct[p, default: 0] += 1 + fractional[p, default: 0] += LiftMuscle.directSetCredit + } + for m in s.secondaryMuscles where m != s.primaryMuscle { + indirect[m, default: 0] += 1 + fractional[m, default: 0] += LiftMuscle.indirectSetCredit + } + } + return MuscleCounts(fractional: fractional, direct: direct, indirect: indirect) + } + + // MARK: - The reference band + + /// Weekly fractional sets per muscle, from the same dose-response meta-regression the 0.5 + /// credit comes from. + /// + /// PRESENTED AS A BAND WITH ITS SOURCE NAMED, NEVER AS A PERSONAL PRESCRIPTION. NOOP is not a + /// medical device and does not tell anyone what their body needs; it says what the research + /// associates with growth and leaves the conclusion to the reader. + public enum ReferenceDose { + /// Below roughly this, hypertrophy is not reliably detectable. + public static let hypertrophyMinimumSetsPerWeek = 4.0 + /// Strength keeps improving from a single weekly set. + public static let strengthMinimumSetsPerWeek = 1.0 + /// Beyond roughly this, added volume stops reliably beating the smallest detectable effect + /// FOR STRENGTH. Hypertrophy has no identified ceiling — gains continue with strongly + /// diminishing returns, and the uncertainty widens as volume rises. + public static let strengthPlateauSetsPerWeek = 4.0 + + /// Where a weekly count sits relative to the hypertrophy band, as a 0...1 fraction of the + /// minimum effective dose, clamped. Deliberately NOT a percentage score: a muscle at 9 sets + /// is not "225% complete", it is simply past the point where the evidence thins out. + public static func fractionOfHypertrophyMinimum(_ weeklySets: Double) -> Double { + guard hypertrophyMinimumSetsPerWeek > 0 else { return 0 } + return min(1.0, max(0.0, weeklySets / hypertrophyMinimumSetsPerWeek)) + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift new file mode 100644 index 0000000000..6e41ca78e2 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift @@ -0,0 +1,226 @@ +import XCTest +@testable import StrandAnalytics +import WhoopStore + +/// The Lift Log's training metrics. Pure arithmetic, so every figure the user is shown can be +/// checked here against a number worked out by hand. +final class LiftMetricsTests: XCTestCase { + + private var ord = 0 + + /// A working set. Defaults chosen so a test only states what it is actually about. + private func set(_ exercise: String = "Bench press", + weight: Double? = 100, reps: Int? = 5, rpe: Double? = nil, + warmup: Bool = false, + start: Int? = nil, end: Int? = nil, rest: Int? = nil, + primary: LiftMuscle? = .chest, + secondary: [LiftMuscle] = []) -> LiftSetRow { + ord += 1 + return LiftSetRow(id: UUID().uuidString, deviceId: "dev", sessionId: "s", + ord: ord, exercise: exercise, + primaryMuscle: primary, secondaryMuscles: secondary, + setIndex: ord, weightKg: weight, reps: reps, rpe: rpe, + isWarmup: warmup, startTs: start, endTs: end, restSec: rest, note: nil) + } + + // MARK: - Volume load + + func testVolumeIsWeightTimesRepsSummed() { + let v = LiftMetrics.volumeLoadKg([set(weight: 100, reps: 5), set(weight: 80, reps: 10)]) + XCTAssertEqual(v!, 100 * 5 + 80 * 10, accuracy: 0.001) // 1300 + } + + func testWarmUpsAreExcludedFromVolume() { + let v = LiftMetrics.volumeLoadKg([set(weight: 100, reps: 5), + set(weight: 40, reps: 10, warmup: true)]) + XCTAssertEqual(v!, 500, accuracy: 0.001, + "the literature counts working sets; a warm-up would flatter every session") + } + + func testASetMissingWeightOrRepsContributesNothingRatherThanAGuess() { + XCTAssertNil(LiftMetrics.volumeLoadKg([set(weight: nil, reps: 5)])) + XCTAssertNil(LiftMetrics.volumeLoadKg([set(weight: 100, reps: nil)])) + XCTAssertNil(LiftMetrics.volumeLoadKg([])) + } + + func testBodyweightWorkLoggedWithNoWeightIsNotCountedAsVolume() { + // Pull-ups logged as reps only: real training, but no tonnage figure is honest for it. + XCTAssertNil(LiftMetrics.volumeLoadKg([set("Pull-up", weight: nil, reps: 12)])) + } + + // MARK: - Session load (Foster sRPE-TL) + + func testSessionLoadIsRpeTimesMinutes() { + XCTAssertEqual(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 3600)!, 420, accuracy: 0.001) + XCTAssertEqual(LiftMetrics.sessionLoad(sessionRpe: 8, durationSec: 1800)!, 240, accuracy: 0.001) + } + + func testAnUnratedSessionHasNoLoadRatherThanZero() { + XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: nil, durationSec: 3600), + "a skipped rating must never read as an effortless session") + XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 0)) + } + + // MARK: - Work versus rest + + func testWorkAndRestAreSummedFromTheTaps() { + let wr = LiftMetrics.workRest([ + set(start: 100, end: 140, rest: 120), // 40s work, 120s rest + set(start: 260, end: 290, rest: 90), // 30s work, 90s rest + ]) + XCTAssertEqual(wr.workSec, 70) + XCTAssertEqual(wr.restSec, 210) + XCTAssertEqual(wr.restToWorkRatio!, 3.0, accuracy: 0.001) + } + + func testWarmUpTimeCountsAsWorkEvenThoughItIsNotVolume() { + let wr = LiftMetrics.workRest([set(warmup: true, start: 0, end: 60, rest: 30)]) + XCTAssertEqual(wr.workSec, 60, "a warm-up is still time under load") + } + + func testNoWorkMeansNoRatioRatherThanADivideByZero() { + XCTAssertNil(LiftMetrics.workRest([]).restToWorkRatio) + XCTAssertNil(LiftMetrics.workRest([set(start: nil, end: nil, rest: 90)]).restToWorkRatio) + } + + // MARK: - Estimated 1RM (Epley) + + func testEpleyMatchesTheFormula() { + // 100 × (1 + 5/30) = 116.67 + XCTAssertEqual(LiftMetrics.estimatedOneRepMaxKg(weightKg: 100, reps: 5)!, 116.6667, accuracy: 0.001) + } + + func testASingleReturnsTheWeightItself() { + XCTAssertEqual(LiftMetrics.estimatedOneRepMaxKg(weightKg: 140, reps: 1)!, 140, accuracy: 0.001, + "the formula's +3.3% at one rep is an artefact of the fit, not a heavier single") + } + + func testHighRepSetsGetNoEstimate() { + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 60, reps: 20), + "a 20-rep set describes endurance, not a maximum") + XCTAssertNotNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 60, reps: 12), "12 is the ceiling") + } + + func testNoEstimateWithoutBothNumbers() { + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: nil, reps: 5)) + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 100, reps: nil)) + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 0, reps: 5)) + } + + // MARK: - Per exercise + + func testExercisesComeBackInTheOrderTheyWerePerformed() { + let s = [set("Squat"), set("Bench press"), set("Squat")] + XCTAssertEqual(LiftMetrics.perExercise(s).map(\.exercise), ["Squat", "Bench press"], + "a session reads back in the order it happened, not alphabetically") + } + + func testBestSetIsRankedByEstimatedOneRepMaxNotRawWeight() { + // 100×5 → 116.7 ; 90×10 → 120.0. The lighter set is the better one. + let summaries = LiftMetrics.perExercise([set(weight: 100, reps: 5), set(weight: 90, reps: 10)]) + XCTAssertEqual(summaries.count, 1) + XCTAssertEqual(summaries[0].bestWeightKg!, 90, accuracy: 0.001) + XCTAssertEqual(summaries[0].bestReps, 10) + XCTAssertEqual(summaries[0].bestEstimatedOneRepMaxKg!, 120, accuracy: 0.001) + } + + func testBestSetFallsBackToHeaviestWhenNoSetSupportsAnEstimate() { + let summaries = LiftMetrics.perExercise([set(weight: 60, reps: 20), set(weight: 70, reps: 25)]) + XCTAssertEqual(summaries[0].bestWeightKg!, 70, accuracy: 0.001) + XCTAssertNil(summaries[0].bestEstimatedOneRepMaxKg, "still no 1RM claim from a 25-rep set") + } + + func testWorkingAndWarmUpSetsAreCountedSeparately() { + let summaries = LiftMetrics.perExercise([ + set(warmup: true), set(warmup: true), set(), set(), set(), + ]) + XCTAssertEqual(summaries[0].workingSets, 3) + XCTAssertEqual(summaries[0].warmupSets, 2) + } + + // MARK: - RPE profile + + func testRpeProfileAveragesOnlyRatedWorkingSets() { + let p = LiftMetrics.rpeProfile([set(rpe: 7), set(rpe: 9), set(rpe: nil)]) + XCTAssertEqual(p.mean!, 8.0, accuracy: 0.001) + XCTAssertEqual(p.ratedSets, 2) + XCTAssertEqual(p.unratedSets, 1, "an unrated set is surfaced, so a thin mean is visibly thin") + } + + func testHardSetsAreCountedAtOrAboveTheThreshold() { + let p = LiftMetrics.rpeProfile([set(rpe: 7.5), set(rpe: 8), set(rpe: 9.5)]) + XCTAssertEqual(p.setsAtOrAboveThreshold, 2, "at the threshold counts, not just above it") + } + + func testAWarmUpNeverEntersTheRpeProfile() { + let p = LiftMetrics.rpeProfile([set(rpe: 3, warmup: true), set(rpe: 9)]) + XCTAssertEqual(p.mean!, 9.0, accuracy: 0.001) + XCTAssertEqual(p.ratedSets, 1) + } + + func testNothingRatedMeansNoMean() { + XCTAssertNil(LiftMetrics.rpeProfile([set(rpe: nil), set(rpe: nil)]).mean) + } + + // MARK: - Sets per muscle + + func testDirectSetsCountOnceAndIndirectSetsCountHalf() { + let s = [set(primary: .chest, secondary: [.triceps, .frontDelts])] + let c = LiftMetrics.muscleCounts(s) + XCTAssertEqual(c.fractional[.chest]!, 1.0, accuracy: 0.001) + XCTAssertEqual(c.fractional[.triceps]!, 0.5, accuracy: 0.001) + XCTAssertEqual(c.fractional[.frontDelts]!, 0.5, accuracy: 0.001) + XCTAssertEqual(c.direct[.chest], 1) + XCTAssertEqual(c.indirect[.triceps], 1) + } + + func testTheCreditsMatchThePublishedMethod() { + // Pinned deliberately: these constants and the reference doses were derived under the SAME + // operationalisation, so changing one without the other silently invalidates the comparison. + XCTAssertEqual(LiftMuscle.directSetCredit, 1.0) + XCTAssertEqual(LiftMuscle.indirectSetCredit, 0.5) + } + + func testAMuscleIsNeverCreditedTwiceForOneSet() { + // A malformed row listing the primary among its secondaries must not double-count. + let s = [set(primary: .chest, secondary: [.chest, .triceps])] + let c = LiftMetrics.muscleCounts(s) + XCTAssertEqual(c.fractional[.chest]!, 1.0, accuracy: 0.001) + XCTAssertNil(c.indirect[.chest]) + } + + func testWarmUpsDoNotCountTowardAnyMuscle() { + let c = LiftMetrics.muscleCounts([set(warmup: true, primary: .chest, secondary: [.triceps])]) + XCTAssertTrue(c.fractional.isEmpty) + } + + func testSetCountsAreNotFilteredByRpe() { + // Load-bearing: the reference doses come from UNFILTERED working-set counts. Filtering to + // "hard" sets would compare a smaller number against a scale built from a larger one. + let c = LiftMetrics.muscleCounts([set(rpe: 5), set(rpe: nil), set(rpe: 10)]) + XCTAssertEqual(c.fractional[.chest]!, 3.0, accuracy: 0.001, + "an easy set and an unrated set still count toward the dose") + } + + func testAnUnclassifiedExerciseClaimsNoMuscle() { + let s = [set(primary: nil, secondary: [])] + XCTAssertTrue(LiftMetrics.muscleCounts(s).fractional.isEmpty) + XCTAssertNotNil(LiftMetrics.volumeLoadKg(s), "but it still counts toward volume") + } + + // MARK: - The reference band + + func testTheBandIsAFractionOfTheMinimumEffectiveDoseAndClamps() { + XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(2), 0.5, accuracy: 0.001) + XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(4), 1.0, accuracy: 0.001) + XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(20), 1.0, accuracy: 0.001, + "9 sets is not '225% complete' — past the minimum the evidence just thins out") + XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(0), 0.0, accuracy: 0.001) + } + + func testTheReferenceDosesAreTheOnesTheCreditsWereDerivedUnder() { + XCTAssertEqual(LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek, 4.0) + XCTAssertEqual(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek, 1.0) + XCTAssertEqual(LiftMetrics.ReferenceDose.strengthPlateauSetsPerWeek, 4.0) + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7282673f55..068c80b579 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,90 @@ { "sourceLanguage": "en", "strings": { + "RPE %@ × %@ min": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %@ min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分鐘" } } + } }, + "Best set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bester Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Best set" } }, "es": { "stringUnit": { "state": "translated", "value": "Mejor serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Meilleure série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie migliore" } }, "pl": { "stringUnit": { "state": "translated", "value": "Najlepsza seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Melhor série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Лучший подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最佳组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最佳組" } } + } }, + "Mean RPE": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Mittlerer RPE" } }, "en": { "stringUnit": { "state": "translated", "value": "Mean RPE" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE medio" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE moyen" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE medio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Średnie RPE" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE médio" } }, "ru": { "stringUnit": { "state": "translated", "value": "Средний RPE" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "平均 RPE" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "平均 RPE" } } + } }, + "How hard it felt": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie schwer es sich anfühlte" } }, "en": { "stringUnit": { "state": "translated", "value": "How hard it felt" } }, "es": { "stringUnit": { "state": "translated", "value": "Lo duro que se sintió" } }, "fr": { "stringUnit": { "state": "translated", "value": "À quel point c'était dur" } }, "it": { "stringUnit": { "state": "translated", "value": "Quanto è sembrato duro" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak ciężko było" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quão difícil pareceu" } }, "ru": { "stringUnit": { "state": "translated", "value": "Насколько было тяжело" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "感觉有多吃力" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "感覺有多吃力" } } + } }, + "Sets per muscle": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze pro Muskel" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets per muscle" } }, "es": { "stringUnit": { "state": "translated", "value": "Series por músculo" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries par muscle" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie per muscolo" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie na mięsień" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries por músculo" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы на мышцу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "每个肌群的组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "每個肌群的組數" } } + } }, + "Session load": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzungsbelastung" } }, "en": { "stringUnit": { "state": "translated", "value": "Session load" } }, "es": { "stringUnit": { "state": "translated", "value": "Carga de sesión" } }, "fr": { "stringUnit": { "state": "translated", "value": "Charge de séance" } }, "it": { "stringUnit": { "state": "translated", "value": "Carico della sessione" } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie sesji" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Carga da sessão" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нагрузка сессии" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "训练负荷" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "訓練負荷" } } + } }, + "Work vs rest": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Arbeit vs. Pause" } }, "en": { "stringUnit": { "state": "translated", "value": "Work vs rest" } }, "es": { "stringUnit": { "state": "translated", "value": "Trabajo vs descanso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Travail vs repos" } }, "it": { "stringUnit": { "state": "translated", "value": "Lavoro vs recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Praca vs przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Trabalho vs descanso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Работа и отдых" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "做功与休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "做功與休息" } } + } }, + "Figures": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zahlen" } }, "en": { "stringUnit": { "state": "translated", "value": "Figures" } }, "es": { "stringUnit": { "state": "translated", "value": "Cifras" } }, "fr": { "stringUnit": { "state": "translated", "value": "Chiffres" } }, "it": { "stringUnit": { "state": "translated", "value": "Numeri" } }, "pl": { "stringUnit": { "state": "translated", "value": "Liczby" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Números" } }, "ru": { "stringUnit": { "state": "translated", "value": "Показатели" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "数据" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "數據" } } + } }, + "As performed": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie ausgeführt" } }, "en": { "stringUnit": { "state": "translated", "value": "As performed" } }, "es": { "stringUnit": { "state": "translated", "value": "Tal como se hizo" } }, "fr": { "stringUnit": { "state": "translated", "value": "Tel qu'exécuté" } }, "it": { "stringUnit": { "state": "translated", "value": "Come eseguito" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak wykonano" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Tal como feito" } }, "ru": { "stringUnit": { "state": "translated", "value": "Как выполнено" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "实际完成" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "實際完成" } } + } }, + "Reading the session…": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung wird gelesen…" } }, "en": { "stringUnit": { "state": "translated", "value": "Reading the session…" } }, "es": { "stringUnit": { "state": "translated", "value": "Leyendo la sesión…" } }, "fr": { "stringUnit": { "state": "translated", "value": "Lecture de la séance…" } }, "it": { "stringUnit": { "state": "translated", "value": "Lettura della sessione…" } }, "pl": { "stringUnit": { "state": "translated", "value": "Wczytywanie sesji…" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A ler a sessão…" } }, "ru": { "stringUnit": { "state": "translated", "value": "Читаем сессию…" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在读取训练…" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在讀取訓練…" } } + } }, + "not rated": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "nicht bewertet" } }, "en": { "stringUnit": { "state": "translated", "value": "not rated" } }, "es": { "stringUnit": { "state": "translated", "value": "sin valorar" } }, "fr": { "stringUnit": { "state": "translated", "value": "non évaluée" } }, "it": { "stringUnit": { "state": "translated", "value": "non valutata" } }, "pl": { "stringUnit": { "state": "translated", "value": "bez oceny" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "sem classificação" } }, "ru": { "stringUnit": { "state": "translated", "value": "без оценки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未评分" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未評分" } } + } }, + "same as last time": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "wie letztes Mal" } }, "en": { "stringUnit": { "state": "translated", "value": "same as last time" } }, "es": { "stringUnit": { "state": "translated", "value": "igual que la última vez" } }, "fr": { "stringUnit": { "state": "translated", "value": "comme la dernière fois" } }, "it": { "stringUnit": { "state": "translated", "value": "come l'ultima volta" } }, "pl": { "stringUnit": { "state": "translated", "value": "tak jak ostatnio" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "igual à última vez" } }, "ru": { "stringUnit": { "state": "translated", "value": "как в прошлый раз" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "与上次相同" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "與上次相同" } } + } }, + "RPE %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %@" } } + } }, + "Sets at RPE %@ or above": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze bei RPE %@ oder höher" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets at RPE %@ or above" } }, "es": { "stringUnit": { "state": "translated", "value": "Series con RPE %@ o más" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries à RPE %@ ou plus" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie a RPE %@ o superiore" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie przy RPE %@ lub wyżej" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries com RPE %@ ou mais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы с RPE %@ и выше" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %@ 及以上的组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %@ 及以上的組數" } } + } }, + "%lld working sets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld Arbeitssätze" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld working sets" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld series efectivas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld séries de travail" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld serie di lavoro" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld serii roboczych" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld séries de trabalho" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld рабочих подходов" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个正式组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 個正式組" } } + } }, + "%@ under load": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%@ unter Last" } }, "en": { "stringUnit": { "state": "translated", "value": "%@ under load" } }, "es": { "stringUnit": { "state": "translated", "value": "%@ bajo carga" } }, "fr": { "stringUnit": { "state": "translated", "value": "%@ sous charge" } }, "it": { "stringUnit": { "state": "translated", "value": "%@ sotto carico" } }, "pl": { "stringUnit": { "state": "translated", "value": "%@ pod obciążeniem" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%@ sob carga" } }, "ru": { "stringUnit": { "state": "translated", "value": "%@ под нагрузкой" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "负重 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "負重 %@" } } + } }, + "RPE %@ × %lld min": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %lld min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld 分鐘" } } + } }, + "%lld direct · %lld indirect": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld direkt · %2$lld indirekt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld direct · %lld indirect" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld directas · %2$lld indirectas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld directes · %2$lld indirectes" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld dirette · %2$lld indirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld bezpośrednich · %2$lld pośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld diretas · %2$lld indiretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld прямых · %2$lld косвенных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$lld 直接 · %2$lld 间接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%1$lld 直接 · %2$lld 間接" } } + } }, + "%lld direct": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld direkt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld direct" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld directas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld directes" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld dirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld bezpośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld diretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld прямых" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 直接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 直接" } } + } }, + "%lld indirect": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld indirekt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld indirect" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld indirectas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld indirectes" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld indirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld pośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld indiretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld косвенных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 间接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 間接" } } + } }, + "≈ %@ estimated 1RM": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "≈ %@ geschätztes 1RM" } }, "en": { "stringUnit": { "state": "translated", "value": "≈ %@ estimated 1RM" } }, "es": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimado" } }, "fr": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimé" } }, "it": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM stimato" } }, "pl": { "stringUnit": { "state": "translated", "value": "≈ %@ szacowane 1RM" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimado" } }, "ru": { "stringUnit": { "state": "translated", "value": "≈ %@ расчётный 1ПМ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "≈ %@ 估算 1RM" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "≈ %@ 估算 1RM" } } + } }, + "%lld working sets weren't rated, so the mean is drawn from %lld.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld Arbeitssätze wurden nicht bewertet, der Mittelwert stammt also aus %2$lld." } }, "en": { "stringUnit": { "state": "translated", "value": "%lld working sets weren't rated, so the mean is drawn from %lld." } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld series efectivas no se valoraron, así que la media sale de %2$lld." } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld séries de travail n'ont pas été évaluées ; la moyenne porte donc sur %2$lld." } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld serie di lavoro non sono state valutate, quindi la media si basa su %2$lld." } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld serii roboczych nie oceniono, więc średnia pochodzi z %2$lld." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld séries de trabalho não foram classificadas, por isso a média vem de %2$lld." } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld рабочих подходов не оценены, поэтому среднее посчитано по %2$lld." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "有 %1$lld 个正式组未评分,因此平均值来自 %2$lld 组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "有 %1$lld 個正式組未評分,因此平均值來自 %2$lld 組。" } } + } }, + "Direct sets count once, indirect sets count as a half — the method the reference figures were derived under.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Direkte Sätze zählen einfach, indirekte als halber Satz – die Methode, unter der die Referenzwerte ermittelt wurden." } }, "en": { "stringUnit": { "state": "translated", "value": "Direct sets count once, indirect sets count as a half — the method the reference figures were derived under." } }, "es": { "stringUnit": { "state": "translated", "value": "Las series directas cuentan una vez y las indirectas media: el método con el que se obtuvieron las cifras de referencia." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séries directes comptent pour une, les indirectes pour une demie — la méthode sous laquelle les valeurs de référence ont été établies." } }, "it": { "stringUnit": { "state": "translated", "value": "Le serie dirette contano una volta, quelle indirette una metà: il metodo con cui sono stati ricavati i valori di riferimento." } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie bezpośrednie liczą się raz, pośrednie jako pół – metoda, według której wyznaczono wartości odniesienia." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As séries diretas contam uma vez e as indiretas meia — o método com que os valores de referência foram obtidos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Прямые подходы считаются за один, косвенные — за половину: именно так были получены эталонные значения." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "直接组计为一组,间接组计为半组——参考数值正是在这个方法下得出的。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "直接組計為一組,間接組計為半組——參考數值正是在這個方法下得出的。" } } + } }, + "Finished sessions land here, with every set you logged.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Abgeschlossene Sitzungen landen hier – mit jedem Satz, den du aufgezeichnet hast." } }, "en": { "stringUnit": { "state": "translated", "value": "Finished sessions land here, with every set you logged." } }, "es": { "stringUnit": { "state": "translated", "value": "Las sesiones terminadas aparecen aquí, con cada serie que registraste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séances terminées arrivent ici, avec chaque série que tu as enregistrée." } }, "it": { "stringUnit": { "state": "translated", "value": "Le sessioni concluse finiscono qui, con ogni serie che hai registrato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zakończone sesje trafiają tutaj, z każdą zapisaną serią." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As sessões terminadas aparecem aqui, com todas as séries que registaste." } }, "ru": { "stringUnit": { "state": "translated", "value": "Завершённые сессии попадают сюда — со всеми записанными подходами." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "完成的训练会出现在这里,包含你记录的每一组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "完成的訓練會出現在這裡,包含你記錄的每一組。" } } + } }, + "Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Kraftzahlen werden aus den Sätzen oben berechnet. Die Belastung bleibt aus der Herzfrequenz gemessen und wird nie aus Gewichten und Wiederholungen abgeleitet." } }, "en": { "stringUnit": { "state": "translated", "value": "Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps." } }, "es": { "stringUnit": { "state": "translated", "value": "Las cifras de fuerza salen de las series de arriba. El Esfuerzo se sigue midiendo por frecuencia cardíaca y nunca se deriva de pesos y repeticiones." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les chiffres de musculation sont calculés à partir des séries ci-dessus. L'Effort reste mesuré par la fréquence cardiaque et n'est jamais déduit des charges et répétitions." } }, "it": { "stringUnit": { "state": "translated", "value": "I numeri della forza sono calcolati dalle serie qui sopra. Lo Sforzo resta misurato dalla frequenza cardiaca e non viene mai derivato da pesi e ripetizioni." } }, "pl": { "stringUnit": { "state": "translated", "value": "Liczby siłowe wynikają z serii powyżej. Wysiłek nadal mierzony jest tętnem i nigdy nie jest wyprowadzany z ciężarów i powtórzeń." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Os números de força são calculados a partir das séries acima. O Esforço continua medido pela frequência cardíaca e nunca é derivado de pesos e repetições." } }, "ru": { "stringUnit": { "state": "translated", "value": "Силовые показатели считаются из подходов выше. Усилие по-прежнему измеряется по пульсу и никогда не выводится из весов и повторений." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "力量数据由上面的组次算出。消耗始终由心率测得,绝不会从重量和次数推导。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "力量數據由上面的組次算出。消耗始終由心率測得,絕不會從重量和次數推導。" } } + } }, + "None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine dieser Übungen hat eine Muskelgruppe. Trag eine bei der Übung ein, und jede künftige Sitzung zählt darauf ein." } }, "en": { "stringUnit": { "state": "translated", "value": "None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it." } }, "es": { "stringUnit": { "state": "translated", "value": "Ninguno de estos ejercicios tiene aún grupo muscular. Añade uno al ejercicio y todas las sesiones futuras contarán para él." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun de ces exercices n'a encore de groupe musculaire. Ajoutes-en un à l'exercice et chaque séance future comptera dedans." } }, "it": { "stringUnit": { "state": "translated", "value": "Nessuno di questi esercizi ha ancora un gruppo muscolare. Aggiungine uno all'esercizio e ogni sessione futura conterà su di esso." } }, "pl": { "stringUnit": { "state": "translated", "value": "Żadne z tych ćwiczeń nie ma jeszcze grupy mięśniowej. Dodaj ją do ćwiczenia, a każda kolejna sesja będzie się na nią liczyć." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nenhum destes exercícios tem ainda grupo muscular. Adiciona um ao exercício e todas as sessões futuras contam para ele." } }, "ru": { "stringUnit": { "state": "translated", "value": "Ни одно из этих упражнений пока не имеет мышечной группы. Добавь её к упражнению — и каждая следующая сессия пойдёт в зачёт." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这些动作还没有肌群。给动作添加一个,之后每次训练都会计入其中。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這些動作還沒有肌群。給動作新增一個,之後每次訓練都會計入其中。" } } + } }, + "Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sobald du eine Sitzung aufgezeichnet hast, siehst du hier, wie viele Sätze jeder Muskel diese Woche bekommen hat – im Vergleich zu dem, was die Forschung mit Wachstum verbindet." } }, "en": { "stringUnit": { "state": "translated", "value": "Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth." } }, "es": { "stringUnit": { "state": "translated", "value": "En cuanto registres una sesión, aquí verás cuántas series recibió cada músculo esta semana, frente a lo que la investigación asocia con el crecimiento." } }, "fr": { "stringUnit": { "state": "translated", "value": "Dès que tu auras enregistré une séance, tu verras ici combien de séries chaque muscle a reçues cette semaine, face à ce que la recherche associe à la croissance." } }, "it": { "stringUnit": { "state": "translated", "value": "Appena registri una sessione, qui vedrai quante serie ha ricevuto ogni muscolo questa settimana, rispetto a ciò che la ricerca associa alla crescita." } }, "pl": { "stringUnit": { "state": "translated", "value": "Gdy zapiszesz sesję, zobaczysz tu, ile serii dostał w tym tygodniu każdy mięsień, na tle tego, co badania wiążą ze wzrostem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Assim que registares uma sessão, verás aqui quantas séries cada músculo recebeu esta semana, face ao que a investigação associa ao crescimento." } }, "ru": { "stringUnit": { "state": "translated", "value": "Как только запишешь сессию, здесь появится, сколько подходов получила каждая мышца за неделю — рядом с тем, что исследования связывают с ростом." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "记录一次训练后,这里会显示本周每个肌群做了多少组,并与研究中和增长相关的数值对照。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "記錄一次訓練後,這裡會顯示本週每個肌群做了多少組,並與研究中和增長相關的數值對照。" } } + } }, + "The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Der Balken markiert etwa 4 Sätze pro Woche – die Grenze, unterhalb derer die Forschung kein verlässliches Wachstum feststellt. Darüber gehen die Zuwächse weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze." } }, "en": { "stringUnit": { "state": "translated", "value": "The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling." } }, "es": { "stringUnit": { "state": "translated", "value": "La barra marca unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias siguen con rendimientos muy decrecientes y sin techo claro." } }, "fr": { "stringUnit": { "state": "translated", "value": "La barre marque environ 4 séries par semaine — le seuil sous lequel la recherche ne détecte pas de croissance fiable. Au-dessus, les gains continuent avec des rendements fortement décroissants et sans plafond net." } }, "it": { "stringUnit": { "state": "translated", "value": "La barra segna circa 4 serie a settimana: la soglia sotto la quale la ricerca non rileva crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro." } }, "pl": { "stringUnit": { "state": "translated", "value": "Pasek oznacza około 4 serie tygodniowo – próg, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej przyrosty trwają, ale z silnie malejącym zwrotem i bez wyraźnego sufitu." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A barra marca cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro." } }, "ru": { "stringUnit": { "state": "translated", "value": "Полоса отмечает примерно 4 подхода в неделю — порог, ниже которого исследования не выявляют рост надёжно. Выше прирост продолжается, но с резко убывающей отдачей и без явного потолка." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这条线标记每周约 4 组——低于此值,研究无法可靠地检测到增长。高于此值,增长仍在继续,但收益急剧递减,且没有明确上限。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這條線標記每週約 4 組——低於此值,研究無法可靠地檢測到增長。高於此值,增長仍在繼續,但收益急劇遞減,且沒有明確上限。" } } + } }, "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – das ist der Plan, nicht das Protokoll. Was du tatsächlich hebst, trägst du während der Sitzung Satz für Satz ein." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: esto es el plan, no el registro. Lo que realmente levantas se introduce serie a serie durante la sesión." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : ceci est le plan, pas le relevé. Ce que tu soulèves vraiment se saisit série par série pendant la séance." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: questo è il piano, non il registro. Quello che sollevi davvero lo inserisci serie per serie durante la sessione." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – to plan, nie zapis. To, co naprawdę podniesiesz, wpisujesz seria po serii w trakcie sesji." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: isto é o plano, não o registo. O que levantas de facto é introduzido série a série durante a sessão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — это план, а не запись. То, что ты действительно поднял, вводится подход за подходом во время сессии." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——这是计划,不是记录。你实际举起的重量,在训练中一组一组地输入。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——這是計畫,不是記錄。你實際舉起的重量,在訓練中一組一組地輸入。" } } } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 6ca03eadfc..73301c57b9 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -1,5 +1,6 @@ import SwiftUI import StrandDesign +import StrandAnalytics import WhoopStore // The Lift Log: build a program once, then run it in the gym by tapping through it. @@ -28,6 +29,15 @@ struct LiftLogView: View { @State private var running: SessionStart? /// An interrupted session found on disk, offered for resume. @State private var interrupted: LiftSessionPersistence.Snapshot? + /// Recent finished sessions, newest first. + @State private var history: [LiftSessionRow] = [] + /// This week's fractional sets per muscle. + @State private var weekCounts: [LiftMuscle: Double] = [:] + /// The session whose detail sheet is open. + @State private var viewing: SessionDetailTarget? + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } var body: some View { ScreenScaffold( @@ -39,6 +49,8 @@ struct LiftLogView: View { headerCard if interrupted != nil { resumeCard } programsSection + weekSection + historySection } } .task(id: repo.refreshSeq) { await load() } @@ -47,6 +59,9 @@ struct LiftLogView: View { await load() } } + .sheet(item: $viewing) { target in + LiftSessionDetailSheet(session: target.session) + } .sheet(item: $running) { start in if let snapshot = start.resuming { LiftSessionView(resuming: snapshot) { await load() } @@ -224,16 +239,135 @@ struct LiftLogView: View { programId: program.id, programName: program.name, resuming: nil) } + // MARK: - This week, per muscle + + private var weekSection: some View { + let ordered = LiftMuscle.ordered.filter { (weekCounts[$0] ?? 0) > 0 } + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sets per muscle", overline: "Last 7 days") + if ordered.isEmpty { + NoopCard { + Text("Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + NoopCard { + VStack(alignment: .leading, spacing: 10) { + ForEach(ordered, id: \.self) { muscle in + muscleBar(muscle, sets: weekCounts[muscle] ?? 0) + } + // The band is named and sourced, never phrased as a target NOOP sets for + // anyone: this is not a medical device and does not prescribe. + Text("The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + } + } + } + } + + private func muscleBar(_ muscle: LiftMuscle, sets: Double) -> some View { + let fraction = LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(sets) + let met = sets >= LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(muscle.displayName) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text(LiftFormat.trim(sets)) + .font(StrandFont.captionNumber) + .foregroundStyle(met ? StrandPalette.statusPositive : StrandPalette.textPrimary) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(StrandPalette.surfaceRaised) + Capsule() + .fill(met ? StrandPalette.statusPositive : StrandPalette.effortColor) + .frame(width: max(2, geo.size.width * fraction)) + } + } + .frame(height: 6) + } + } + + // MARK: - History + + private var historySection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sessions", overline: "Recent") + if history.isEmpty { + NoopCard { + Text("Finished sessions land here, with every set you logged.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + } else { + ForEach(history, id: \.id) { session in + Button { + viewing = SessionDetailTarget(id: session.id, session: session) + } label: { + historyRow(session) + } + .buttonStyle(.plain) + } + } + } + } + + private func historyRow(_ session: LiftSessionRow) -> some View { + NoopCard { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(session.programName ?? String(localized: "Session")) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(Date(timeIntervalSince1970: TimeInterval(session.startTs)) + .formatted(date: .abbreviated, time: .shortened)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .accessibilityHidden(true) + } + } + } + // MARK: - Load private func load() async { interrupted = LiftSessionPersistence.load() guard let store = await repo.storeHandle() else { return } programs = (try? await store.liftPrograms(deviceId: repo.deviceId)) ?? [] + + let now = Int(Date().timeIntervalSince1970) + history = ((try? await store.liftSessions(deviceId: repo.deviceId, + fromTs: now - 180 * 86_400, + toTs: now)) ?? []) + .filter { $0.endTs != nil } // an abandoned session is not history + .sorted { $0.startTs > $1.startTs } + weekCounts = (try? await store.liftSetCounts(deviceId: repo.deviceId, + fromTs: now - 7 * 86_400, + toTs: now).fractional) ?? [:] loaded = true } } +/// The session whose detail is being read back. A wrapper rather than a retroactive `Identifiable` +/// on `LiftSessionRow`, keeping the store's row types free of app-layer conformances. +private struct SessionDetailTarget: Identifiable { + let id: String + let session: LiftSessionRow +} + /// What the session sheet is presenting — a fresh run of a program, or a resumed snapshot. private struct SessionStart: Identifiable { let id: String diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift new file mode 100644 index 0000000000..e87a542adb --- /dev/null +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -0,0 +1,382 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// One finished session, read back in full: every set as performed, the six session figures, and how +// each exercise compares with the last time you did it. +// +// This is the screen the whole feature exists to produce. A log book that cannot show you what you +// lifted last week is a diary. +// +// EVERY FIGURE IS ARITHMETIC THE USER CAN REDO BY HAND from the sets listed on the same screen — +// that is the design constraint, and it is why there is no single composite "workout score". The +// maths lives in `LiftMetrics` (pure, unit-tested); this file only lays it out. +// +// Effort is shown BESIDE the lifting figures and is never computed from them: it is whatever NOOP +// measured from heart rate over the session's window, filled in by the engine's own rescore pass. + +struct LiftSessionDetailSheet: View { + let session: LiftSessionRow + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var sets: [LiftSetRow] = [] + /// The `workout` row this session is pinned to, for the HR-measured figures. + @State private var workout: WorkoutRow? + /// Previous performance per exercise, for the "vs last time" comparison. + @State private var previousVolume: [String: Double] = [:] + @State private var loaded = false + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + private var durationSec: Int { + guard let end = session.endTs else { return 0 } + return max(0, end - session.startTs) + } + + var body: some View { + ScreenScaffold(title: "Session", subtitle: subtitle) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + if !loaded { + ComingSoon(what: "Reading the session…", symbol: "dumbbell") + } else { + figuresSection + exercisesSection + muscleSection + rpeSection + footnote + } + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 780) + #endif + .background(StrandPalette.surfaceBase) + .task { await load() } + } + + private var subtitle: LocalizedStringKey { + let date = Date(timeIntervalSince1970: TimeInterval(session.startTs)) + .formatted(date: .abbreviated, time: .shortened) + if let name = session.programName, !name.isEmpty { + return "\(name) · \(date)" + } + return "\(date)" + } + + // MARK: - The session figures + + private var figuresSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("This session", overline: "Figures") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], + alignment: .leading, spacing: 10) { + tile(String(localized: "Volume"), + LiftFormat.weight(LiftMetrics.volumeLoadKg(sets), system: unitSystem), + String(localized: "\(workingSetCount) working sets")) + + tile(String(localized: "Session load"), + sessionLoadText, + sessionLoadCaption) + + tile(String(localized: "Work vs rest"), + workRestText, + String(localized: "\(LiftFormat.duration(workRest.workSec)) under load")) + + tile(String(localized: "Effort"), + workout?.strain.map { LiftFormat.trim($0) } ?? "—", + String(localized: "measured from heart rate")) + } + } + } + + private var workRest: LiftMetrics.WorkRest { LiftMetrics.workRest(sets) } + private var workingSetCount: Int { sets.filter { !$0.isWarmup }.count } + + private var workRestText: String { + guard let ratio = workRest.restToWorkRatio else { return "—" } + return String(format: "1 : %.1f", ratio) + } + + private var sessionLoadText: String { + guard let load = LiftMetrics.sessionLoad(sessionRpe: session.sessionRpe, + durationSec: durationSec) else { return "—" } + return String(Int(load.rounded())) + } + + /// The caption must show the SAME minutes the load was computed from. + /// + /// Showing whole minutes while computing from exact seconds silently breaks the one promise this + /// screen makes: that every figure is arithmetic you can redo by hand. A 2:40 session captioned + /// "× 2 min" invites the reader to check 8 × 2 = 16 against a displayed 21 and conclude the app + /// is making numbers up. + private var sessionLoadCaption: String { + guard let rpe = session.sessionRpe else { + return String(localized: "not rated") + } + let minutes = Double(durationSec) / 60.0 + return String(localized: "RPE \(LiftFormat.trim(rpe)) × \(LiftFormat.trim(minutes)) min") + } + + private func tile(_ label: String, _ value: String, _ caption: String) -> some View { + NoopCard(padding: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(label).strandOverline() + Text(value) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(caption) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Per exercise, with every set + + private var exercisesSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercises", overline: "As performed") + ForEach(LiftMetrics.perExercise(sets), id: \.exercise) { summary in + exerciseCard(summary) + } + } + } + + private func exerciseCard(_ summary: LiftMetrics.ExerciseSummary) -> some View { + let rows = sets.filter { $0.exercise == summary.exercise }.sorted { $0.ord < $1.ord } + return NoopCard { + VStack(alignment: .leading, spacing: 10) { + Text(summary.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let first = rows.first { + Text(LiftMuscleSummary.line(primary: first.primaryMuscle, + secondaries: first.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + + ForEach(rows, id: \.id) { row in setLine(row) } + + Divider().background(StrandPalette.textTertiary.opacity(0.2)) + + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Best set").strandOverline() + Text(bestSetText(summary)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + if let e1rm = summary.bestEstimatedOneRepMaxKg { + // "Estimated" is in the label, not a footnote: it is a formula off one + // set, not a measured maximum, and the word has to travel with it. + Text(String(localized: "≈ \(LiftFormat.weight(e1rm, system: unitSystem)) estimated 1RM")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 2) { + Text("Volume").strandOverline() + Text(LiftFormat.weight(summary.volumeKg, system: unitSystem)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + if let delta = volumeDeltaText(summary) { + Text(delta) + .font(StrandFont.caption) + .foregroundStyle(deltaColor(summary)) + } + } + } + } + } + } + + private func setLine(_ row: LiftSetRow) -> some View { + HStack(spacing: 10) { + Text(row.isWarmup ? String(localized: "W") : "\(row.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(row.isWarmup ? StrandPalette.textTertiary : StrandPalette.effortColor) + .frame(width: 18, alignment: .leading) + + Text(setValueText(row)) + .font(StrandFont.bodyNumber) + .foregroundStyle(row.isWarmup ? StrandPalette.textSecondary : StrandPalette.textPrimary) + + Spacer(minLength: 0) + + if let rpe = row.rpe { + Text(String(localized: "RPE \(LiftFormat.trim(rpe))")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + if let rest = row.restSec { + Text(LiftFormat.duration(rest)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + + private func setValueText(_ row: LiftSetRow) -> String { + let reps = row.reps.map(String.init) ?? "—" + guard let kg = row.weightKg else { + // Bodyweight work: reps alone, with no fabricated tonnage behind it. + return String(localized: "\(reps) reps") + } + return "\(LiftFormat.weight(kg, system: unitSystem)) × \(reps)" + } + + private func bestSetText(_ summary: LiftMetrics.ExerciseSummary) -> String { + guard let reps = summary.bestReps else { return "—" } + guard let kg = summary.bestWeightKg else { return String(localized: "\(reps) reps") } + return "\(LiftFormat.weight(kg, system: unitSystem)) × \(reps)" + } + + /// How this exercise's volume compares with the last session that included it. The single most + /// useful line in the screen: progression is a comparison, not a number. + private func volumeDeltaText(_ summary: LiftMetrics.ExerciseSummary) -> String? { + guard let now = summary.volumeKg, let before = previousVolume[summary.exercise], before > 0 + else { return nil } + let delta = now - before + guard abs(delta) >= 0.5 else { return String(localized: "same as last time") } + let sign = delta > 0 ? "+" : "−" + return "\(sign)\(LiftFormat.weight(abs(delta), system: unitSystem)) vs last time" + } + + private func deltaColor(_ summary: LiftMetrics.ExerciseSummary) -> Color { + guard let now = summary.volumeKg, let before = previousVolume[summary.exercise], before > 0 + else { return StrandPalette.textTertiary } + if now > before { return StrandPalette.statusPositive } + if now < before { return StrandPalette.textSecondary } + return StrandPalette.textTertiary + } + + // MARK: - Sets per muscle + + private var muscleSection: some View { + let counts = LiftMetrics.muscleCounts(sets) + let ordered = LiftMuscle.ordered.filter { (counts.fractional[$0] ?? 0) > 0 } + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sets per muscle", overline: "This session") + if ordered.isEmpty { + NoopCard { + Text("None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + ForEach(ordered, id: \.self) { muscle in + HStack(spacing: 10) { + Text(muscle.displayName) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: 0) + Text(LiftFormat.trim(counts.fractional[muscle] ?? 0)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.effortColor) + Text(componentText(counts, muscle)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Text("Direct sets count once, indirect sets count as a half — the method the reference figures were derived under.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } + } + } + } + } + + /// "4 direct · 2 indirect" — so the total above is inspectable rather than asserted. + private func componentText(_ counts: LiftMetrics.MuscleCounts, _ muscle: LiftMuscle) -> String { + let d = counts.direct[muscle] ?? 0 + let i = counts.indirect[muscle] ?? 0 + if d > 0 && i > 0 { return String(localized: "\(d) direct · \(i) indirect") } + if d > 0 { return String(localized: "\(d) direct") } + return String(localized: "\(i) indirect") + } + + // MARK: - RPE profile + + private var rpeSection: some View { + let p = LiftMetrics.rpeProfile(sets) + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("How hard it felt", overline: "RPE") + NoopCard { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Mean RPE") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text(p.mean.map { LiftFormat.trim($0) } ?? "—") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + HStack { + Text(String(localized: "Sets at RPE \(LiftFormat.trim(p.threshold)) or above")) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text("\(p.setsAtOrAboveThreshold)") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + if p.unratedSets > 0 { + Text(String(localized: "\(p.unratedSets) working sets weren't rated, so the mean is drawn from \(p.ratedSets).")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var footnote: some View { + Text("Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Load + + private func load() async { + guard let store = await repo.storeHandle() else { loaded = true; return } + sets = (try? await store.liftSets(sessionId: session.id)) ?? [] + + // The workout row this session is pinned to, by that table's own natural key. + let rows = (try? await store.workouts(deviceId: repo.deviceId, + from: session.startTs - 1, + to: session.startTs + 1, limit: 10)) ?? [] + workout = rows.first { $0.startTs == session.startTs && $0.sport == session.sport } + + // Previous volume per exercise, for the "vs last time" line. + var previous: [String: Double] = [:] + for name in Set(sets.map(\.exercise)) { + let before = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: name, + before: session.startTs)) ?? [] + if let v = LiftMetrics.volumeLoadKg(before) { previous[name] = v } + } + previousVolume = previous + loaded = true + } +} From d703679a4c415b603662f46e7f8a8f9654504c46 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:56:37 +0200 Subject: [PATCH 05/31] lift log: a workout sheet, and a session that outlives its screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second real gym session, three findings, all of them structural. A GYM IS NOT A QUEUE. The session walked the plan strictly in order and showed one set at a time, so you could neither see what was coming nor move on when a machine was occupied. The engine is now slot-based: every set of every exercise is a row on one scrollable sheet, and ANY pending set can be started at any time. Order is a suggestion the session follows by default, not a rail. • completed sets keep their check and the numbers, and can be redone • weight/reps/RPE are editable per row, with GHOST values in priority order: the previous set of this exercise IN THIS SESSION, then the same set number last session, then the program's target. A placeholder stays a placeholder — a number nobody typed must never become data. • green = the set being worked, amber = the rest after it, so you can find your place at arm's length • the clocks and the one action are pinned to the bottom and never scroll away THE SESSION NOW OUTLIVES ITS SCREEN. It used to live inside the sheet that displayed it, so swiping that sheet down tore the view down and with it the strap's double-tap handler and the rest tick. The session looked alive — still on disk, "Resume" brought it back — but was DEAF: no taps, no buzz, in the app or out of it. Re-entering also re-fired the five-second warning, because the "already warned" flag was view state that reset on every present. `LiftSessionController` now owns the engine, the tick, the buzz gating and the persistence, at the app root. The sheet is one rendering of it; `LiftSessionBar` — a bar above the tab bar, reachable from ANY tab — is another. Swiping the sheet away MINIMISES the session rather than ending it, and a session left running by a previous launch returns as the bar rather than as a sheet thrown in the user's face. THE CONFIRMATION BUZZ WAS QUEUED BEHIND STATE WORK. A strap double-tap ran a commit (JSON encode + defaults write) before firing the buzz whose entire job is to say "that registered". The buzz now goes out first, before any state is touched. And the two patterns stay distinguishable on a knocked-about wrist: ONE pulse confirms a double-tap, THREE means the rest is nearly up. Also fixed while proving it in the simulator: the session bar rendered ON TOP of the tab bar, because the safe-area inset was applied to the TabView itself. It now clears it with `NoopMetrics.tabBarClearance`, the constant every other screen uses. Verification: 1281 tests, the only failures being the two TodayCarryOverTests that fail identically on a clean checkout under a non-US region. 27 engine tests rewritten for the slot model, covering out-of-order starts, redo without double-counting, abandoning an unfinished set, and undo taking back a redo. Both app targets build. i18n and doc-comment gates pass; 16 new strings across nine locales. Driven in the simulator: swiped the sheet away mid-rest and watched the bar keep counting, killed and relaunched the app and watched the session return as the bar with the clock still correct, then reopened it. Still to validate on hardware: whether the confirmation buzz now feels immediate. BLE latency is not something a simulator can answer. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 197 ++++++ Strand/Data/LiftSessionEngine.swift | 269 ++++---- Strand/Data/LiftSessionPersistence.swift | 32 +- Strand/Resources/Localizable.xcstrings | 48 ++ Strand/Screens/LiftLogView.swift | 70 +- Strand/Screens/LiftSessionBar.swift | 107 +++ Strand/Screens/LiftSessionView.swift | 820 +++++++++-------------- StrandTests/LiftSessionEngineTests.swift | 291 ++++---- StrandiOS/App/RootTabView.swift | 25 + StrandiOS/App/StrandiOSApp.swift | 14 + 10 files changed, 1053 insertions(+), 820 deletions(-) create mode 100644 Strand/Data/LiftSessionController.swift create mode 100644 Strand/Screens/LiftSessionBar.swift diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift new file mode 100644 index 0000000000..3e556b67a8 --- /dev/null +++ b/Strand/Data/LiftSessionController.swift @@ -0,0 +1,197 @@ +import Foundation +import Combine +import WhoopStore + +// The live session, owned ABOVE any screen. +// +// WHY THIS EXISTS. The session used to live inside the sheet that displayed it. Swiping that sheet +// down dismissed it, which tore down the view — and with it the strap's double-tap handler and the +// rest-timer tick. The session looked alive (it was still on disk, and "Resume" brought it back) but +// was deaf: taps did nothing, no buzz arrived, in the app or out of it. Re-entering also re-fired +// the five-second warning, because the "already buzzed" flag was view state that reset on every +// present. +// +// A workout outlives the screen you happen to be looking at, so the session has to as well. This +// controller owns the engine, the tick, the buzz gating and the persistence. The sheet is a +// rendering of it; the bottom bar is another. Dismissing either changes nothing about the session. +// +// The strap gesture is claimed for the LIFETIME OF THE SESSION rather than the lifetime of a view, +// and handed back untouched when the session ends. + +@MainActor +final class LiftSessionController: ObservableObject { + + /// The running session, or nil when none is in flight. + @Published private(set) var engine: LiftSessionEngine? + @Published private(set) var programId: String? + @Published private(set) var programName: String? + /// Ticks every second while a session runs, so views can redraw clocks off one shared timer + /// rather than each starting their own. + @Published private(set) var now = Int(Date().timeIntervalSince1970) + /// True while the full sheet is presented; false when minimised to the bottom bar. + @Published var isPresented = false + + var isActive: Bool { engine != nil && engine?.isFinished == false } + + /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so + /// re-opening the sheet mid-rest cannot re-fire it. + private var warnedFor: Int? + private var ticker: AnyCancellable? + + /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. + private let buzz: (UInt8) -> Void + /// Claims/releases the strap's double-tap for the session's lifetime. + private let setStrapHandler: ((() -> Void)?) -> Void + + /// One pulse confirms a strap double-tap registered — with the phone face-down there is + /// otherwise no way to know. Three means the rest is nearly up. Two patterns that cannot be + /// mistaken for each other on a wrist that has been knocked about all session. + static let advanceConfirmBuzzes: UInt8 = 1 + static let restWarningBuzzes: UInt8 = 3 + /// How long before the rest ends the warning fires. + static let restWarningLeadSec = 5 + + init(buzz: @escaping (UInt8) -> Void, + setStrapHandler: @escaping ((() -> Void)?) -> Void) { + self.buzz = buzz + self.setStrapHandler = setStrapHandler + } + + // MARK: - Lifecycle + + func start(plan: [LiftPlanItem], programId: String?, programName: String?) { + let stamp = Int(Date().timeIntervalSince1970) + engine = LiftSessionEngine(plan: plan, startTs: stamp) + self.programId = programId + self.programName = programName + warnedFor = nil + now = stamp + isPresented = true + claimStrap() + startTicking() + persist() + } + + /// Rehydrate an interrupted session found on disk. Does NOT present the sheet: the session comes + /// back as the bottom bar, and the user opens it if they want to. + func resume(from snapshot: LiftSessionPersistence.Snapshot, present: Bool = false) { + engine = LiftSessionPersistence.engine(from: snapshot) + programId = snapshot.programId + programName = snapshot.programName + now = Int(Date().timeIntervalSince1970) + // Suppress the warning for a rest that is ALREADY inside its final seconds. Without this, + // reopening a session mid-rest greets the user with three buzzes for a rest they have been + // watching count down all along. + if case .resting(_, let endsAt) = engine?.stage, + endsAt - now <= LiftSessionController.restWarningLeadSec { + warnedFor = endsAt + } else { + warnedFor = nil + } + isPresented = present + claimStrap() + startTicking() + } + + /// Give up the session without saving. + func discard() { + teardown() + LiftSessionPersistence.clear() + } + + /// Called once the session has been written to the store. + func finishedSaving() { + teardown() + LiftSessionPersistence.clear() + } + + private func teardown() { + engine = nil + programId = nil + programName = nil + warnedFor = nil + isPresented = false + ticker?.cancel() + ticker = nil + setStrapHandler(nil) + } + + private func claimStrap() { + setStrapHandler({ [weak self] in + Task { @MainActor in self?.advance(fromStrap: true) } + }) + } + + private func startTicking() { + ticker?.cancel() + ticker = Timer.publish(every: 1, on: .main, in: .common) + .autoconnect() + .sink { [weak self] instant in + guard let self else { return } + self.now = Int(instant.timeIntervalSince1970) + self.fireRestWarningIfDue() + } + } + + // MARK: - Actions + + /// The one action. `fromStrap` earns a single confirming buzz. + func advance(fromStrap: Bool = false) { + guard engine != nil else { return } + // BUZZ FIRST, before any state work. The confirmation is a latency signal — its whole job is + // to say "that registered" — so it must not queue behind a JSON encode and a defaults write. + if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } + + let stamp = Int(Date().timeIntervalSince1970) + engine?.advance(now: stamp) + now = stamp + warnedFor = nil + persist() + } + + /// Begin a specific set — the out-of-order path, for when a machine is occupied. + func start(_ slot: LiftSlot, fromStrap: Bool = false) { + guard engine != nil else { return } + if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } + let stamp = Int(Date().timeIntervalSince1970) + engine?.start(slot, now: stamp) + now = stamp + warnedFor = nil + persist() + } + + func updateSet(_ slot: LiftSlot, weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { + engine?.updateSet(slot, weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup) + persist() + } + + func undo() { + engine?.undo() + persist() + } + + func finish() { + engine?.finish(now: Int(Date().timeIntervalSince1970)) + persist() + } + + // MARK: - The rest warning + + private func fireRestWarningIfDue() { + guard let engine, case .resting(_, let endsAt) = engine.stage else { return } + guard warnedFor != endsAt else { return } + guard endsAt - now <= LiftSessionController.restWarningLeadSec else { return } + warnedFor = endsAt + buzz(LiftSessionController.restWarningBuzzes) + } + + // MARK: - Persistence + + private func persist() { + guard let engine, !engine.isFinished else { return } + LiftSessionPersistence.store( + LiftSessionPersistence.snapshot(engine: engine, + programId: programId, + programName: programName)) + } +} diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index cc1c78ad9a..ebb79ded4a 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -3,41 +3,47 @@ import WhoopStore // The session state machine: the heart of the Lift Log. // -// One action advances everything — warm-up → set → rest → set → … → cool-down → save. It is kept -// PURE (no timers, no store, no SwiftUI) for two reasons: it is the piece most likely to be wrong in -// a way that costs someone a logged set, and it is the only part of the feature that can be tested -// without a strap, a database or a simulator. +// A GYM IS NOT A QUEUE. The first version walked the plan strictly in order, one set at a time, and +// the first real session killed that: when a machine is occupied you move on and come back. So the +// plan is a SHEET of slots — every set of every exercise, all visible — and any pending slot can be +// started at any time. Order is a suggestion the session follows by default, not a rail. // -// TIME ENTERS ONLY AS A PARAMETER. `advance(now:)` is told what time it is rather than reading the -// clock, so a test can drive a whole session through a known timeline. The rest period is stored as -// an ABSOLUTE end instant, never a decrementing counter: `IntervalTimerView` decrements and loses -// time whenever the phone suspends, and a rest timer that quietly runs long is worse than none. +// PURE (no timers, no store, no SwiftUI): it is the piece most likely to be wrong in a way that +// costs someone a logged set, and the only part testable with no strap, no database, no simulator. // -// REST NEVER AUTO-ADVANCES. When the countdown reaches zero the stage stays `.resting` and waits for -// the user. That is a deliberate product decision: a timer that starts logging a set while you are -// still racking the bar attributes time to work that was not work. +// TIME ENTERS ONLY AS A PARAMETER, so a test can drive a whole session through a known timeline. +// Rest is an ABSOLUTE end instant, never a decrementing counter — a phone that sleeps through a rest +// must wake up telling the truth. +// +// THERE IS NO COOL-DOWN STAGE. Warm-up is the time before the first set and cool-down the time after +// the last; both fall out of the timestamps, so neither needs a stage of its own. The session ends +// when the user says it does. + +/// One set of one exercise — a position on the sheet. `setIndex` is 1-based within its exercise. +struct LiftSlot: Hashable { + var exerciseIndex: Int + var setIndex: Int +} -/// One planned exercise line, flattened from a program (or built freehand) for the session to run. +/// One planned exercise line, flattened from a program for the session to run. struct LiftPlanItem: Equatable { var exercise: String var primaryMuscle: LiftMuscle? var secondaryMuscles: [LiftMuscle] - /// How many working sets are planned. Always ≥ 1 — a line with no target still gets one set, - /// because a plan that schedules zero sets of an exercise cannot be tapped through at all. + /// Planned working sets. Always >= 1 — a line scheduling zero sets could not be tapped through. var targetSets: Int /// Intended rest after each set, in seconds. var restSec: Int var targetRepsLow: Int? var targetRepsHigh: Int? var targetRpe: Double? - /// The weight the program plans for this line, in kilograms. Seeds the entry box so the common - /// case is a glance and a tap rather than typing. + /// The weight the program plans, in kilograms. var targetWeightKg: Double? var note: String? - /// The rest period used when a program line does not specify one. Two minutes is the middle of - /// the range the hypertrophy literature uses for compound work, and it is only a starting value: - /// what is actually rested is measured from the taps, not assumed from this. + /// Rest used when a program line does not specify one. Two minutes sits in the middle of the + /// range the hypertrophy literature uses for compound work, and is only a starting value: what + /// is actually rested is measured from the taps. static let defaultRestSec = 120 init(exercise: String, @@ -63,11 +69,9 @@ struct LiftPlanItem: Equatable { } } -/// One set as actually performed. Becomes a `LiftSetRow` on save; kept separate so the engine has no -/// opinion about ids or device scoping. +/// One set as actually performed. struct LiftRecordedSet: Equatable { var exerciseIndex: Int - /// 1-based within its exercise, so "set 3 of 4" survives into the stored row. var setIndex: Int var weightKg: Double? var reps: Int? @@ -75,35 +79,37 @@ struct LiftRecordedSet: Equatable { var isWarmup: Bool var startTs: Int var endTs: Int - /// Rest actually taken after this set, filled in when the rest ends. Nil for the final set (no - /// rest follows it) or a set whose rest is still running. + /// Rest actually taken after this set. Nil while the rest is still running, or when the user + /// moved on without resting. var restSec: Int? + + var slot: LiftSlot { LiftSlot(exerciseIndex: exerciseIndex, setIndex: setIndex) } } struct LiftSessionEngine: Equatable { enum Stage: Equatable { - /// Before the first set — the warm-up, derived from timestamps rather than stored as a flag. + /// Before the first set — the warm-up. case warmup - /// Performing a set. `set` is 1-based within the exercise. - case working(item: Int, set: Int) - /// Resting after (item, set). `endsAt` is an absolute unix second. - case resting(item: Int, set: Int, endsAt: Int) - /// After the last set — the cool-down. - case cooldown - /// Tapped through the cool-down; ready to save. + /// Performing a set. + case working(LiftSlot) + /// Resting after a set. `endsAt` is an absolute unix second. + case resting(LiftSlot, endsAt: Int) + /// Ended; ready to save. case finished } let plan: [LiftPlanItem] - /// When the session began (unix seconds) — the start of the warm-up. + /// When the session began (unix seconds). let startTs: Int private(set) var stage: Stage + /// Completed sets in COMPLETION order — which is the order they happened, not the plan's order, + /// and is what `ord` is written from. private(set) var sets: [LiftRecordedSet] - /// When the CURRENT stage began, so a set's duration is measurable. + /// When the current stage began, so a set's duration is measurable. private(set) var stageStartedAt: Int - /// Undo stack. Whole-state snapshots rather than inverse operations: a gym is a bad place to be + /// Undo stack: whole-state snapshots rather than inverse operations. A gym is a bad place to be /// one tap ahead of yourself, and restoring a snapshot cannot get the arithmetic wrong the way a /// hand-written inverse can. private var history: [Snapshot] = [] @@ -117,16 +123,15 @@ struct LiftSessionEngine: Equatable { init(plan: [LiftPlanItem], startTs: Int) { self.plan = plan self.startTs = startTs - self.stage = plan.isEmpty ? .cooldown : .warmup + self.stage = .warmup self.sets = [] self.stageStartedAt = startTs } - /// Rebuild a session that was interrupted — see `LiftSessionPersistence`. + /// Rebuild an interrupted session — see `LiftSessionPersistence`. /// - /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and an undo - /// stack that survives a relaunch invites someone to reach back past a save boundary into state - /// the store has already been told about. + /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and a + /// stack that survives a relaunch invites reaching back past a save boundary. init(restoring plan: [LiftPlanItem], startTs: Int, stage: Stage, sets: [LiftRecordedSet], stageStartedAt: Int) { self.plan = plan @@ -136,112 +141,146 @@ struct LiftSessionEngine: Equatable { self.stageStartedAt = stageStartedAt } - // MARK: - Queries the UI needs + // MARK: - The sheet - var isFinished: Bool { stage == .finished } - var canUndo: Bool { !history.isEmpty } + /// Every slot of one exercise, in order — the rows the sheet draws. + func slots(forExercise index: Int) -> [LiftSlot] { + guard plan.indices.contains(index) else { return [] } + return (1...plan[index].targetSets).map { LiftSlot(exerciseIndex: index, setIndex: $0) } + } + + /// Every slot in the whole session, in plan order. + var allSlots: [LiftSlot] { plan.indices.flatMap { slots(forExercise: $0) } } + + func recordedSet(for slot: LiftSlot) -> LiftRecordedSet? { + sets.first { $0.slot == slot } + } + + func isCompleted(_ slot: LiftSlot) -> Bool { recordedSet(for: slot) != nil } - /// The exercise currently being worked or rested from, if any. - var currentItem: LiftPlanItem? { + /// The slot being worked or rested from. + var currentSlot: LiftSlot? { switch stage { - case .working(let i, _), .resting(let i, _, _): return plan.indices.contains(i) ? plan[i] : nil - case .warmup, .cooldown, .finished: return nil + case .working(let s): return s + case .resting(let s, _): return s + case .warmup, .finished: return nil } } + /// The next slot the plan would suggest — the first uncompleted one in plan order. Nil when the + /// whole sheet is done. + var nextPendingSlot: LiftSlot? { + allSlots.first { !isCompleted($0) } + } + + var allCompleted: Bool { nextPendingSlot == nil } + var isFinished: Bool { stage == .finished } + var canUndo: Bool { !history.isEmpty } + + var plannedWorkingSets: Int { plan.reduce(0) { $0 + $1.targetSets } } + var completedWorkingSets: Int { sets.filter { !$0.isWarmup }.count } + + func planItem(for slot: LiftSlot) -> LiftPlanItem? { + plan.indices.contains(slot.exerciseIndex) ? plan[slot.exerciseIndex] : nil + } + /// Seconds left in the current rest, floored at zero. Nil when not resting. /// - /// Floored rather than allowed to go negative so the UI shows "0:00" and waits, which is what a - /// rest that has run over actually means — the user has not tapped yet. + /// Floored rather than negative so an overrun rest reads "0:00" and waits, which is what it + /// actually means: the user has not moved on yet. func restRemaining(now: Int) -> Int? { - guard case .resting(_, _, let endsAt) = stage else { return nil } + guard case .resting(_, let endsAt) = stage else { return nil } return max(0, endsAt - now) } - /// Total working sets planned across the session, for a progress read-out. - var plannedWorkingSets: Int { plan.reduce(0) { $0 + $1.targetSets } } - - /// Working sets recorded so far (warm-ups excluded, matching how volume and set counts treat them). - var completedWorkingSets: Int { sets.filter { !$0.isWarmup }.count } + /// What was lifted for the PREVIOUS set of this exercise in THIS session — the ghost values a + /// set row shows before anything is typed. Falls back to nil, and the UI then falls back to the + /// plan's target or to what was lifted last session. + func previousSetInSession(for slot: LiftSlot) -> LiftRecordedSet? { + (1.. Int { - plan.indices.contains(item) ? plan[item].targetSets : 0 - } - - private func isLastSetOfSession(item: Int, set: Int) -> Bool { - item >= plan.count - 1 && set >= setsPlanned(for: item) + private mutating func pushHistory() { + history.append(Snapshot(stage: stage, sets: sets, stageStartedAt: stageStartedAt)) } } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index 8b51a25808..ea5a441b19 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -163,27 +163,35 @@ enum LiftSessionPersistence { private static func box(_ stage: LiftSessionEngine.Stage) -> Snapshot.StageBox { switch stage { - case .warmup: return .init(kind: "warmup", item: nil, set: nil, endsAt: nil) - case .working(let i, let s): return .init(kind: "working", item: i, set: s, endsAt: nil) - case .resting(let i, let s, let e): return .init(kind: "resting", item: i, set: s, endsAt: e) - case .cooldown: return .init(kind: "cooldown", item: nil, set: nil, endsAt: nil) - case .finished: return .init(kind: "finished", item: nil, set: nil, endsAt: nil) + case .warmup: + return .init(kind: "warmup", item: nil, set: nil, endsAt: nil) + case .working(let slot): + return .init(kind: "working", item: slot.exerciseIndex, set: slot.setIndex, endsAt: nil) + case .resting(let slot, let endsAt): + return .init(kind: "resting", item: slot.exerciseIndex, set: slot.setIndex, endsAt: endsAt) + case .finished: + return .init(kind: "finished", item: nil, set: nil, endsAt: nil) } } - /// Anything unrecognised (or a `working`/`resting` box missing its indices) falls back to the - /// warm-up: the session is still recoverable and still saveable, which beats refusing to load. + /// Anything unrecognised — a `working`/`resting` box missing its indices, or the retired + /// "cooldown" kind written by an earlier build — falls back to the warm-up. + /// + /// That is a real recovery, not a shrug: every COMPLETED set is carried in `sets` regardless, so + /// nothing logged is lost. Only the cursor's position is forgotten, and the next tap simply + /// resumes at the first set still outstanding. private static func unbox(_ box: Snapshot.StageBox) -> LiftSessionEngine.Stage { switch box.kind { case "working": guard let i = box.item, let s = box.set else { return .warmup } - return .working(item: i, set: s) + return .working(LiftSlot(exerciseIndex: i, setIndex: s)) case "resting": guard let i = box.item, let s = box.set, let e = box.endsAt else { return .warmup } - return .resting(item: i, set: s, endsAt: e) - case "cooldown": return .cooldown - case "finished": return .finished - default: return .warmup + return .resting(LiftSlot(exerciseIndex: i, setIndex: s), endsAt: e) + case "finished": + return .finished + default: + return .warmup } } } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 068c80b579..f592c6ed85 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,54 @@ { "sourceLanguage": "en", "strings": { + "Set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組" } } + } }, + "Reps": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wdh." } }, "en": { "stringUnit": { "state": "translated", "value": "Reps" } }, "es": { "stringUnit": { "state": "translated", "value": "Reps" } }, "fr": { "stringUnit": { "state": "translated", "value": "Réps" } }, "it": { "stringUnit": { "state": "translated", "value": "Rip." } }, "pl": { "stringUnit": { "state": "translated", "value": "Powt." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Reps" } }, "ru": { "stringUnit": { "state": "translated", "value": "Повт." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "次数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "次數" } } + } }, + "RPE": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE" } } + } }, + "Kg": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "kg" } }, "en": { "stringUnit": { "state": "translated", "value": "Kg" } }, "es": { "stringUnit": { "state": "translated", "value": "kg" } }, "fr": { "stringUnit": { "state": "translated", "value": "kg" } }, "it": { "stringUnit": { "state": "translated", "value": "kg" } }, "pl": { "stringUnit": { "state": "translated", "value": "kg" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "kg" } }, "ru": { "stringUnit": { "state": "translated", "value": "кг" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "公斤" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "公斤" } } + } }, + "Lb": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "lb" } }, "en": { "stringUnit": { "state": "translated", "value": "Lb" } }, "es": { "stringUnit": { "state": "translated", "value": "lb" } }, "fr": { "stringUnit": { "state": "translated", "value": "lb" } }, "it": { "stringUnit": { "state": "translated", "value": "lb" } }, "pl": { "stringUnit": { "state": "translated", "value": "lb" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "lb" } }, "ru": { "stringUnit": { "state": "translated", "value": "фунты" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "磅" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "磅" } } + } }, + "Start this set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diesen Satz starten" } }, "en": { "stringUnit": { "state": "translated", "value": "Start this set" } }, "es": { "stringUnit": { "state": "translated", "value": "Empezar esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Démarrer cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Avvia questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij tę serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Iniciar esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Начать этот подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "开始这一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開始這一組" } } + } }, + "Redo this set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diesen Satz wiederholen" } }, "en": { "stringUnit": { "state": "translated", "value": "Redo this set" } }, "es": { "stringUnit": { "state": "translated", "value": "Rehacer esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Refaire cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Rifai questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Powtórz tę serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Refazer esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Переделать подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重做这一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重做這一組" } } + } }, + "Open the running session": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Laufende Sitzung öffnen" } }, "en": { "stringUnit": { "state": "translated", "value": "Open the running session" } }, "es": { "stringUnit": { "state": "translated", "value": "Abrir la sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ouvrir la séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Apri la sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Otwórz trwającą sesję" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Abrir a sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Открыть текущую сессию" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "打开进行中的训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "打開進行中的訓練" } } + } }, + "How hard was the whole session? (1–10)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie schwer war die ganze Sitzung? (1–10)" } }, "en": { "stringUnit": { "state": "translated", "value": "How hard was the whole session? (1–10)" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Cómo de dura fue toda la sesión? (1–10)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Quelle a été la difficulté de la séance ? (1–10)" } }, "it": { "stringUnit": { "state": "translated", "value": "Quanto è stata dura l'intera sessione? (1–10)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak ciężka była cała sesja? (1–10)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quão difícil foi toda a sessão? (1–10)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Насколько тяжёлой была вся сессия? (1–10)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场训练有多吃力?(1–10)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場訓練有多吃力?(1–10)" } } + } }, + "%lld of %lld sets done": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld von %2$lld Sätzen erledigt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld of %lld sets done" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld de %2$lld series hechas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld sur %2$lld séries faites" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld di %2$lld serie fatte" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld z %2$lld serii zrobione" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld de %2$lld séries feitas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld из %2$lld подходов сделано" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已完成 %1$lld / %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已完成 %1$lld / %2$lld 組" } } + } }, + "Set %lld — working": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — working" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld — en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – trwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — идёт" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组进行中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組進行中" } } + } }, + "Resting after set %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause nach Satz %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting after set %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando tras la serie %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos après la série %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero dopo la serie %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa po serii %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar após a série %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых после подхода %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组后休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組後休息" } } + } }, + "Ready for the next set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereit für den nächsten Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Ready for the next set" } }, "es": { "stringUnit": { "state": "translated", "value": "Listo para la siguiente serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Prêt pour la série suivante" } }, "it": { "stringUnit": { "state": "translated", "value": "Pronto per la serie successiva" } }, "pl": { "stringUnit": { "state": "translated", "value": "Gotowe na następną serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Pronto para a próxima série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Готов к следующему подходу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "可以开始下一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "可以開始下一組" } } + } }, + "No session running": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Keine Sitzung aktiv" } }, "en": { "stringUnit": { "state": "translated", "value": "No session running" } }, "es": { "stringUnit": { "state": "translated", "value": "No hay sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucune séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Nessuna sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak trwającej sesji" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nenhuma sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нет активной сессии" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "没有进行中的训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "沒有進行中的訓練" } } + } }, + "All sets done": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Alle Sätze erledigt" } }, "en": { "stringUnit": { "state": "translated", "value": "All sets done" } }, "es": { "stringUnit": { "state": "translated", "value": "Todas las series hechas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Toutes les séries faites" } }, "it": { "stringUnit": { "state": "translated", "value": "Tutte le serie fatte" } }, "pl": { "stringUnit": { "state": "translated", "value": "Wszystkie serie zrobione" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todas as séries feitas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Все подходы сделаны" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有组已完成" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有組已完成" } } + } }, + "This set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieser Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "This set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ta seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Этот подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "本组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "本組" } } + } }, "RPE %@ × %@ min": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %@ min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分鐘" } } } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 73301c57b9..3152be372f 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -25,10 +25,8 @@ struct LiftLogView: View { /// The program being created or edited (nil = the editor is closed). @State private var editing: ProgramEditTarget? - /// The session being run (nil = not in a session). - @State private var running: SessionStart? - /// An interrupted session found on disk, offered for resume. - @State private var interrupted: LiftSessionPersistence.Snapshot? + /// The live session, owned at the app root so it survives this screen going away. + @EnvironmentObject private var session: LiftSessionController /// Recent finished sessions, newest first. @State private var history: [LiftSessionRow] = [] /// This week's fractional sets per muscle. @@ -47,7 +45,6 @@ struct LiftLogView: View { ) { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { headerCard - if interrupted != nil { resumeCard } programsSection weekSection historySection @@ -62,49 +59,6 @@ struct LiftLogView: View { .sheet(item: $viewing) { target in LiftSessionDetailSheet(session: target.session) } - .sheet(item: $running) { start in - if let snapshot = start.resuming { - LiftSessionView(resuming: snapshot) { await load() } - } else { - LiftSessionView(plan: start.plan, - programId: start.programId, - programName: start.programName) { await load() } - } - } - } - - // MARK: - Resume an interrupted session - - private var resumeCard: some View { - NoopCard(tint: StrandPalette.effortColor) { - VStack(alignment: .leading, spacing: 10) { - Text("Session in progress") - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("You left a session running. Nothing was lost — pick it up where you stopped.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - HStack { - Button("Resume") { - if let interrupted { - running = SessionStart(id: "resume", plan: [], programId: nil, - programName: nil, resuming: interrupted) - } - } - .buttonStyle(.noopPrimary) - .frame(maxWidth: 160) - Spacer() - Button(role: .destructive) { - LiftSessionPersistence.clear() - interrupted = nil - } label: { - Text("Discard") - } - .buttonStyle(NoopButtonStyle(.secondary)) - } - } - } } // MARK: - Header @@ -202,7 +156,7 @@ struct LiftLogView: View { } .buttonStyle(.plain) - Button("Start") { Task { await start(program) } } + Button(session.isActive ? "Running" : "Start") { Task { await start(program) } } .buttonStyle(.noopPrimary) .frame(maxWidth: 110) .accessibilityLabel("Start this program") @@ -235,8 +189,13 @@ struct LiftLogView: View { targetWeightKg: item.targetWeightKg, note: item.note) } - running = SessionStart(id: program.id, plan: plan, - programId: program.id, programName: program.name, resuming: nil) + // Refuse to start a second session over a running one: two live sessions would both claim + // the strap gesture and both write the in-flight snapshot. + guard !session.isActive else { + session.isPresented = true + return + } + session.start(plan: plan, programId: program.id, programName: program.name) } // MARK: - This week, per muscle @@ -344,7 +303,6 @@ struct LiftLogView: View { // MARK: - Load private func load() async { - interrupted = LiftSessionPersistence.load() guard let store = await repo.storeHandle() else { return } programs = (try? await store.liftPrograms(deviceId: repo.deviceId)) ?? [] @@ -368,14 +326,6 @@ private struct SessionDetailTarget: Identifiable { let session: LiftSessionRow } -/// What the session sheet is presenting — a fresh run of a program, or a resumed snapshot. -private struct SessionStart: Identifiable { - let id: String - let plan: [LiftPlanItem] - let programId: String? - let programName: String? - let resuming: LiftSessionPersistence.Snapshot? -} /// Identifies what the editor sheet is editing. A wrapper rather than a retroactive `Identifiable` /// on `LiftProgramRow`, so the store's row types stay free of app-layer conformances — and so diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift new file mode 100644 index 0000000000..d0a59d935a --- /dev/null +++ b/Strand/Screens/LiftSessionBar.swift @@ -0,0 +1,107 @@ +import SwiftUI +import StrandDesign + +// The running session, condensed to a bar that sits above the tab bar wherever you are in the app. +// +// WHY IT EXISTS. Swiping the workout sheet down used to dismiss the session outright: the screen +// went away and took the strap handler and the tick with it, so taps and buzzes silently stopped +// working. Now swiping down MINIMISES to this bar. The session is still running — same clock, same +// strap gesture, same buzzes — and tapping the bar brings the full sheet back. +// +// It is deliberately a bar and not a badge: it has to show the one thing you need mid-workout +// without opening anything, which is how long is left of your rest. +// +// COLOUR MATCHES THE SHEET so the two read as one thing: green while working, amber while resting. + +struct LiftSessionBar: View { + @EnvironmentObject var session: LiftSessionController + + var body: some View { + if let engine = session.engine, !engine.isFinished { + Button { + session.isPresented = true + } label: { + HStack(spacing: 12) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint(engine)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(title(engine)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(subtitle(engine)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + Text(bigClock(engine)) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint(engine)) + .monospacedDigit() + + // The same action the sheet's button performs, so a set can be closed out + // without opening anything. + Button { session.advance() } label: { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(tint(engine)) + } + .buttonStyle(.plain) + .accessibilityLabel("Next") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(tint(engine).opacity(0.35), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Open the running session") + } + } + + private func tint(_ engine: LiftSessionEngine) -> Color { + switch engine.stage { + case .working: return StrandPalette.statusPositive + case .resting: return StrandPalette.metricAmber + default: return StrandPalette.effortColor + } + } + + private func title(_ engine: LiftSessionEngine) -> String { + guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { + return session.programName ?? String(localized: "Session") + } + return item.exercise + } + + private func subtitle(_ engine: LiftSessionEngine) -> String { + guard let slot = engine.currentSlot else { + return String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") + } + switch engine.stage { + case .working: + return String(localized: "Set \(slot.setIndex) — working") + case .resting: + return (engine.restRemaining(now: session.now) ?? 0) == 0 + ? String(localized: "Ready for the next set") + : String(localized: "Resting after set \(slot.setIndex)") + default: + return String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") + } + } + + /// Rest counts DOWN (that is the number you act on); everything else counts up. + private func bigClock(_ engine: LiftSessionEngine) -> String { + if let remaining = engine.restRemaining(now: session.now) { + return LiftFormat.duration(remaining) + } + return LiftFormat.duration(max(0, session.now - engine.stageStartedAt)) + } +} diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index eb8b4dcd4d..3fc5ffbbb9 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -2,381 +2,369 @@ import SwiftUI import StrandDesign import WhoopStore -// Running a session: the screen you actually use at the rack. +// The workout sheet: every exercise and every set of the session, on one scrollable page. // -// EXACTLY TWO WAYS TO ADVANCE, both deliberate: -// 1. A double-tap on the WHOOP strap — the one that works with the phone face-down on a bench. -// 2. The explicit button. -// An earlier build also advanced on a tap ANYWHERE on screen. First real session killed that: it -// fires while you scroll, while you type a weight, while you just hold the phone — and a stray -// advance costs a logged set. Do not reintroduce it. +// WHY A SHEET AND NOT A WIZARD. The first version showed one set at a time and walked the plan in +// order. In a real gym that fails twice over: you cannot see what is coming, and you cannot move on +// when a machine is occupied. So every set is a row, any pending row can be started, and finished +// rows stay on screen with what you lifted. // -// WHAT YOU LIFTED IS ENTERED DURING THE REST, not during the set. You cannot type a weight with the -// bar in your hands. The set is recorded the instant it ends (timing and all); the numbers are -// filled in while you recover, and the final set — which no rest follows — is filled in during the -// cool-down. +// COLOUR CARRIES STATE, so you can find your place at a glance from arm's length: +// green the set you are working now +// amber the rest that follows it +// done a completed set, with a check and the numbers you entered // -// Two buzz patterns, deliberately distinguishable on a wrist that has been knocked about all -// session: ONE pulse confirms a strap double-tap registered; THREE means the rest is nearly up. -// -// The countdown is read from `LiftSessionEngine`, which anchors rest to an absolute instant, so a -// phone that sleeps through a rest still shows the truth when it wakes. Nothing auto-advances: when -// the rest hits zero the screen says so and waits. +// The session itself lives in `LiftSessionController`, ABOVE this view. Swiping this sheet away +// minimises it to the bottom bar; the clock, the strap gesture and the buzzes all keep running, +// because a workout outlives the screen you happen to be looking at. struct LiftSessionView: View { - let programId: String? - let programName: String? - /// Called once the session has been written, so the hub can reload. - let onFinished: () async -> Void - @EnvironmentObject var repo: Repository @EnvironmentObject var live: LiveState - @EnvironmentObject var model: AppModel + @EnvironmentObject var session: LiftSessionController @Environment(\.dismiss) private var dismiss - @State var engine: LiftSessionEngine - - /// What the user is entering for the set in progress. Pre-filled from last time. - @State private var weightText = "" - @State private var repsText = "" - @State private var rpeText = "" - @State private var isWarmup = false - - /// Drives the countdown redraw and the 5-second cue. One second is plenty: the timer is read - /// from the clock, so the tick only decides how often the label is refreshed. - @State private var now = Int(Date().timeIntervalSince1970) - /// The rest period the 5-second cue has already fired for, so it fires once per rest and not - /// once per tick. - @State private var buzzedFor: Int? + /// Called once the session has been written, so the hub can reload. + let onFinished: () async -> Void - @State private var saving = false + /// What the user did for each exercise LAST session — the fallback ghost values, loaded once. + @State private var lastTime: [String: [Int: LiftRecordedSet]] = [:] @State private var showingFinish = false @State private var sessionRpeText = "" + @State private var saving = false @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } - /// Transition cues, mirrored to the phone's Taptic Engine on iOS. - private enum Cue { case next, rest, ready, done } - #if os(iOS) - @State private var lastCue: Cue = .next - @State private var cueTick = 0 - #endif - - @FocusState private var focused: Field? - private enum Field: Hashable { case weight, reps, rpe, sessionRpe } - - private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - - init(plan: [LiftPlanItem], programId: String?, programName: String?, - onFinished: @escaping () async -> Void) { - self.programId = programId - self.programName = programName - self.onFinished = onFinished - _engine = State(initialValue: LiftSessionEngine(plan: plan, - startTs: Int(Date().timeIntervalSince1970))) + @FocusState private var focused: FocusTarget? + private enum FocusTarget: Hashable { + case weight(LiftSlot), reps(LiftSlot), rpe(LiftSlot), sessionRpe } - /// Resume an interrupted session. - init(resuming snapshot: LiftSessionPersistence.Snapshot, onFinished: @escaping () async -> Void) { - self.programId = snapshot.programId - self.programName = snapshot.programName - self.onFinished = onFinished - _engine = State(initialValue: LiftSessionPersistence.engine(from: snapshot)) - } + private var engine: LiftSessionEngine? { session.engine } var body: some View { - ScreenScaffold(title: sessionTitle, subtitle: sessionSubtitle) { - VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { - stageCard - // The entry card belongs to the REST, not the set. You cannot type a weight with the - // bar in your hands; you can while you recover. The final set has no rest after it, - // so the cool-down is its entry window. - if engine.setAwaitingEntry != nil { entryCard } - progressCard - controls + Group { + if let engine { + VStack(spacing: 0) { + sheet(engine) + // The control bar never scrolls away: at the rack the clock and the one action have to + // be where your thumb already is. + controlBar(engine) + } + } else { + ComingSoon(what: "No session running", symbol: "dumbbell") } } #if os(iOS) .presentationDragIndicator(.visible) #else - .frame(width: 520, height: 760) + .frame(width: 560, height: 800) #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) - // DELIBERATELY NOT tap-anywhere. An earlier build advanced the session on a tap anywhere on - // screen; in real use that fires while you are scrolling, typing a weight or just holding the - // phone, and a stray advance costs a logged set. The session now moves on exactly two - // deliberate inputs: the button below, or a double-tap on the strap. - #if os(iOS) - .sensoryFeedback(trigger: cueTick) { _, _ in - switch lastCue { - case .next: return .impact(weight: .heavy) - case .rest: return .impact(weight: .light) - case .ready: return .success - case .done: return .success - } - } - #endif - .onReceive(tick) { instant in - now = Int(instant.timeIntervalSince1970) - fireRestCueIfDue() - } - .task { - // Claim the strap's double-tap for as long as this session is on screen. - model.strapDoubleTapOverride = { advance(fromStrap: true) } - persist() - } - .onDisappear { - model.strapDoubleTapOverride = nil - } + .task { await loadLastTime() } .sheet(isPresented: $showingFinish) { finishSheet } } - // MARK: - Header + // MARK: - The scrollable sheet - private var sessionTitle: LocalizedStringKey { - switch engine.stage { - case .warmup: return "Warm-up" - case .working: return "Working" - // NOT the bare "Rest": that key already exists in the catalog as NOOP's SLEEP metric - // ("Erholung", "Riposo", "Odpoczynek"). Reusing it would label a rest between sets with the - // word for overnight recovery in every non-English locale. - case .resting: return "Rest period" - case .cooldown: return "Cool-down" - case .finished: return "Done" + private func sheet(_ engine: LiftSessionEngine) -> some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + header(engine) + ForEach(Array(engine.plan.enumerated()), id: \.offset) { index, item in + exerciseCard(engine, index: index, item: item) + } + Color.clear.frame(height: 8) + } + .padding(.horizontal, NoopMetrics.screenPadding) + .padding(.top, 18) + } + .onChange(of: engine.currentSlot) { slot in + // Follow the session down the sheet, but only when it moves on its own — scrolling + // back to read an earlier exercise must not be yanked away from. + guard let slot else { return } + withAnimation { proxy.scrollTo(slot.exerciseIndex, anchor: .top) } + } } } - private var sessionSubtitle: LocalizedStringKey { - switch engine.stage { - case .warmup: return "Tap when you start your first set." - case .working: return "Press the button, or double-tap your strap, when the set is done." - case .resting: return "Enter the set you just did, then start the next one." - case .cooldown: return "Enter your last set, then finish and save." - case .finished: return "Saving…" + private func header(_ engine: LiftSessionEngine) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(session.programName ?? String(localized: "Session")) + .font(StrandFont.title1) + .foregroundStyle(StrandPalette.textPrimary) + Text(String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) } + .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - The big stage card + // MARK: - One exercise, with all its sets - private var stageCard: some View { - NoopCard(tint: StrandPalette.effortColor) { + private func exerciseCard(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { + NoopCard { VStack(alignment: .leading, spacing: 10) { - if let item = engine.currentItem { + VStack(alignment: .leading, spacing: 2) { Text(item.exercise) - .font(StrandFont.title2) + .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - .fixedSize(horizontal: false, vertical: true) Text(LiftMuscleSummary.line(primary: item.primaryMuscle, secondaries: item.secondaryMuscles)) .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) + .foregroundStyle(StrandPalette.textTertiary) } - - switch engine.stage { - case .working(_, let s): - Text(setLabel(s)) - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.effortColor) - if let target = targetLine { Text(target).font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) } - if let note = engine.currentItem?.note, !note.isEmpty { - Text(note).font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - - case .resting: - Text(restLabel) - .font(.system(size: 52, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(restRemaining == 0 - ? StrandPalette.statusPositive : StrandPalette.effortColor) - Text(restRemaining == 0 ? "Ready when you are" : "Resting") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - - case .warmup: - Text("Warming up") - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("Everything before your first set counts as the warm-up. Nothing is being recorded yet.") + if let note = item.note, !note.isEmpty { + Text(note) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(StrandPalette.metricAmber.opacity(0.12), + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } - case .cooldown, .finished: - Text("That's the last set") - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) + columnHeadings + + ForEach(engine.slots(forExercise: index), id: \.self) { slot in + setRow(engine, slot: slot, item: item) } } - .frame(maxWidth: .infinity, alignment: .leading) } + .id(index) } - // MARK: - Set entry + private var columnHeadings: some View { + HStack(spacing: 8) { + Text("Set").strandOverline().frame(width: 26, alignment: .leading) + Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Color.clear.frame(width: 30) + } + } - private var entryCard: some View { - NoopCard { - VStack(alignment: .leading, spacing: 14) { - Text(entryHeading) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - HStack(spacing: 12) { - field(weightLabel) { numberInput("0", text: $weightText, field: .weight) } - field("Reps") { numberInput("0", text: $repsText, field: .reps) } - field("RPE") { numberInput("—", text: $rpeText, field: .rpe) } - } - Toggle(isOn: $isWarmup) { - Text("Warm-up set") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } - Text("Pre-filled with your target, or what you did last time. Correct it to what you actually lifted.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + private var weightHeading: LocalizedStringKey { + unitSystem == .imperial ? "Lb" : "Kg" + } + + // MARK: - One set row + + private func setRow(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> some View { + let recorded = engine.recordedSet(for: slot) + let isWorking = engine.stage == .working(slot) + let isResting: Bool = { + if case .resting(let s, _) = engine.stage { return s == slot } + return false + }() + + return HStack(spacing: 8) { + Text("\(slot.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(isWorking ? StrandPalette.textPrimary : StrandPalette.textSecondary) + .frame(width: 26, alignment: .leading) + + numberField(slot: slot, field: .weight(slot), + text: weightBinding(slot), + ghost: ghostWeight(engine, slot: slot, item: item)) + numberField(slot: slot, field: .reps(slot), + text: repsBinding(slot), + ghost: ghostReps(engine, slot: slot, item: item)) + numberField(slot: slot, field: .rpe(slot), + text: rpeBinding(slot), + ghost: ghostRpe(engine, slot: slot)) + + // The tick both REPORTS and ACTS: filled when the set is done, and tappable to start + // this set when it is not — which is how you jump to a different exercise. + Button { + if recorded == nil { session.start(slot) } else { session.start(slot) } + } label: { + Image(systemName: recorded == nil ? "circle" : "checkmark.circle.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(recorded == nil + ? StrandPalette.textTertiary + : StrandPalette.statusPositive) } + .buttonStyle(.plain) + .frame(width: 30) + .accessibilityLabel(recorded == nil + ? String(localized: "Start this set") + : String(localized: "Redo this set")) } - // Every keystroke goes straight into the engine and to disk, so the numbers survive a crash - // mid-rest exactly like the rest of the session does. - // The two-argument closure form deliberately: the zero-argument `onChange(of:)` is - // macOS 14+, and NOOP still targets macOS 13. iOS compiled it happily — only the Mac build - // catches this, which is why both targets are built for every change. - .onChange(of: weightText) { _ in commitEntry() } - .onChange(of: repsText) { _ in commitEntry() } - .onChange(of: rpeText) { _ in commitEntry() } - .onChange(of: isWarmup) { _ in commitEntry() } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(rowBackground(isWorking: isWorking, isResting: isResting, done: recorded != nil), + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - /// Names the set being filled in, so it is never ambiguous which one the numbers belong to. - private var entryHeading: String { - guard let s = engine.setAwaitingEntry else { return "" } - let name = engine.plan.indices.contains(s.exerciseIndex) - ? engine.plan[s.exerciseIndex].exercise : "" - return String(localized: "What you just did — \(name), set \(s.setIndex)") + /// Green = working now, amber = the rest that follows it, faint = done, clear = still to come. + private func rowBackground(isWorking: Bool, isResting: Bool, done: Bool) -> Color { + if isWorking { return StrandPalette.statusPositive.opacity(0.20) } + if isResting { return StrandPalette.metricAmber.opacity(0.20) } + if done { return StrandPalette.surfaceRaised.opacity(0.5) } + return .clear } - /// Push the typed values into the engine and persist. Editing, never appending: the set already - /// exists (it was recorded the moment it ended), so typing can't create a phantom. - private func commitEntry() { - engine.updateLastSet(weightKg: enteredWeightKg, - reps: Int(repsText.trimmingCharacters(in: .whitespaces)), - rpe: LiftFormat.number(rpeText), - isWarmup: isWarmup) - persist() + private func numberField(slot: LiftSlot, field: FocusTarget, + text: Binding, ghost: String) -> some View { + TextField(ghost, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - Progress + controls + // MARK: - Ghost values + // + // The placeholder shows what you'd most likely repeat, in priority order: the PREVIOUS SET OF + // THIS EXERCISE IN THIS SESSION first (set 2 almost always mirrors set 1), then the same set + // number last session, then the program's target. It stays a placeholder — grey, and not + // recorded unless the user types — because a number nobody entered must never become data. + + private func ghostWeight(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> String { + if let prev = engine.previousSetInSession(for: slot)?.weightKg { return display(prev) } + if let last = lastTime[item.exercise]?[slot.setIndex]?.weightKg { return display(last) } + if let target = item.targetWeightKg { return display(target) } + return "—" + } - private var progressCard: some View { - NoopCard { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 14) { - stat(String(localized: "Sets"), - "\(engine.completedWorkingSets)/\(engine.plannedWorkingSets)") - stat(String(localized: "Session"), - LiftFormat.duration(max(0, now - engine.startTs))) - stat(String(localized: "Volume"), LiftFormat.weight(volumeKg, system: unitSystem)) - } - // TWO clocks, deliberately. The session total above answers "how long have I been - // here"; this one answers "how long has THIS set/rest been running", which is the - // number you actually act on between sets. - HStack(spacing: 8) { - Image(systemName: stageClockSymbol) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - .accessibilityHidden(true) - Text(stageClockLabel) - .font(StrandFont.captionNumber) - .foregroundStyle(StrandPalette.textSecondary) - Spacer(minLength: 0) + private func ghostReps(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> String { + if let prev = engine.previousSetInSession(for: slot)?.reps { return String(prev) } + if let last = lastTime[item.exercise]?[slot.setIndex]?.reps { return String(last) } + if let target = item.targetRepsLow { return String(target) } + return "—" + } + + private func ghostRpe(_ engine: LiftSessionEngine, slot: LiftSlot) -> String { + if let prev = engine.previousSetInSession(for: slot)?.rpe { return LiftFormat.trim(prev) } + return "—" + } + + private func display(_ kg: Double) -> String { + LiftFormat.trim(LiftFormat.display(fromKilograms: kg, system: unitSystem)) + } + + // MARK: - Field bindings + // + // Each field reads and writes THROUGH the controller, so a keystroke lands in the engine and on + // disk immediately. Typing into a set that has not been completed yet is allowed — you may want + // to plan the next one — and is held until the set is recorded. + + private func weightBinding(_ slot: LiftSlot) -> Binding { + Binding( + get: { + guard let kg = engine?.recordedSet(for: slot)?.weightKg else { return "" } + return display(kg) + }, + set: { new in + let kg = LiftFormat.number(new).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) } - } - } + write(slot) { $0.weightKg = kg } + }) } - /// The current stage's own elapsed clock — how long this set has been under way, or how long - /// you have been resting (which keeps counting past zero, because an overrun rest is worth - /// seeing rather than hiding). - private var stageClockLabel: String { - let elapsed = max(0, now - engine.stageStartedAt) - switch engine.stage { - case .working: return String(localized: "This set \(LiftFormat.duration(elapsed))") - case .resting: return String(localized: "Resting \(LiftFormat.duration(elapsed))") - case .warmup: return String(localized: "Warming up \(LiftFormat.duration(elapsed))") - case .cooldown: return String(localized: "Cooling down \(LiftFormat.duration(elapsed))") - case .finished: return "" - } + private func repsBinding(_ slot: LiftSlot) -> Binding { + Binding( + get: { engine?.recordedSet(for: slot)?.reps.map(String.init) ?? "" }, + set: { new in write(slot) { $0.reps = Int(new.trimmingCharacters(in: .whitespaces)) } }) } - private var stageClockSymbol: String { - switch engine.stage { - case .working: return "figure.strengthtraining.traditional" - case .resting: return "hourglass" - default: return "clock" - } + private func rpeBinding(_ slot: LiftSlot) -> Binding { + Binding( + get: { engine?.recordedSet(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "" }, + set: { new in write(slot) { $0.rpe = LiftFormat.number(new) } }) } - private func stat(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(label).strandOverline() - Text(value) - .font(StrandFont.bodyNumber) - .foregroundStyle(StrandPalette.textPrimary) - } - .frame(maxWidth: .infinity, alignment: .leading) + /// Apply one field change to a recorded set, leaving the others as they were. + private func write(_ slot: LiftSlot, _ mutate: (inout LiftRecordedSet) -> Void) { + guard var row = engine?.recordedSet(for: slot) else { return } + mutate(&row) + session.updateSet(slot, weightKg: row.weightKg, reps: row.reps, + rpe: row.rpe, isWarmup: row.isWarmup) } - private var controls: some View { - VStack(spacing: 10) { - Button { advance() } label: { - Text(buttonLabel).frame(maxWidth: .infinity) - } - .buttonStyle(.noopPrimary) - .accessibilityLabel("Next") + // MARK: - The control bar - HStack { + private func controlBar(_ engine: LiftSessionEngine) -> some View { + VStack(spacing: 10) { + HStack(spacing: 14) { + clock(String(localized: "Session"), + LiftFormat.duration(max(0, session.now - engine.startTs)), + tint: StrandPalette.textPrimary) + stageClock(engine) + Spacer(minLength: 0) Button { - engine.undo() - persist() + session.undo() } label: { - Label("Undo", systemImage: "arrow.uturn.backward") + Image(systemName: "arrow.uturn.backward") + .font(.system(size: 15, weight: .semibold)) } - .buttonStyle(NoopButtonStyle(.secondary)) + .buttonStyle(.plain) + .foregroundStyle(engine.canUndo ? StrandPalette.textSecondary : StrandPalette.textTertiary) .disabled(!engine.canUndo) + .accessibilityLabel("Undo") + } - Spacer() + HStack(spacing: 10) { + Button { session.advance() } label: { + Text(actionLabel(engine)).frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) - Button(role: .destructive) { - LiftSessionPersistence.clear() - model.strapDoubleTapOverride = nil - dismiss() - } label: { - Text("Discard") + Button { showingFinish = true } label: { + Text("Finish") } .buttonStyle(NoopButtonStyle(.secondary)) } + } + .padding(.horizontal, NoopMetrics.screenPadding) + .padding(.top, 10) + .padding(.bottom, 14) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + Rectangle().fill(StrandPalette.textTertiary.opacity(0.15)).frame(height: 0.5) + } + } - Text("Double-tap your strap to log a set without picking the phone up.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) + private func clock(_ label: String, _ value: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label).strandOverline() + Text(value) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint) } } - private var buttonLabel: LocalizedStringKey { + @ViewBuilder + private func stageClock(_ engine: LiftSessionEngine) -> some View { + switch engine.stage { + case .working: + clock(String(localized: "This set"), + LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), + tint: StrandPalette.statusPositive) + case .resting: + clock(String(localized: "Rest"), + LiftFormat.duration(engine.restRemaining(now: session.now) ?? 0), + tint: StrandPalette.metricAmber) + case .warmup, .finished: + clock(String(localized: "Warm-up"), + LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), + tint: StrandPalette.textSecondary) + } + } + + private func actionLabel(_ engine: LiftSessionEngine) -> LocalizedStringKey { switch engine.stage { case .warmup: return "Start first set" case .working: return "Set done" - case .resting: return "Start next set" - case .cooldown: return "Finish & save" + case .resting: return engine.allCompleted ? "All sets done" : "Start next set" case .finished: return "Saving…" } } @@ -389,9 +377,13 @@ struct LiftSessionView: View { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { NoopCard { VStack(alignment: .leading, spacing: 12) { - field("How hard was the whole session? (1–10)") { - numberInput("7", text: $sessionRpeText, field: .sessionRpe) - } + Text("How hard was the whole session? (1–10)").strandOverline() + TextField("7", text: $sessionRpeText) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: .sessionRpe) Text("This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) @@ -420,159 +412,57 @@ struct LiftSessionView: View { .keyboardDoneToolbar($focused) } - // MARK: - Behaviour - - /// The one action. `fromStrap` is true when a WHOOP double-tap drove it, which earns a single - /// confirming buzz — with the phone face-down you otherwise have no way to know it registered. - /// A button press needs no such confirmation: you watched it happen. - private func advance(fromStrap: Bool = false) { - // Anything typed during the rest is already in the engine via `commitEntry`, but commit once - // more here so a value still being typed as the user advances is not lost. - if engine.setAwaitingEntry != nil { commitEntry() } - - if case .cooldown = engine.stage { - showingFinish = true - return - } - - if fromStrap, live.bonded { - model.buzz(loops: LiftSessionView.advanceConfirmBuzzes, gate: HapticPrefs.liftRest) - } - - engine.advance(now: Int(Date().timeIntervalSince1970)) - - buzzedFor = nil - cue(for: engine.stage) - persist() - - // Entering a rest: seed the boxes for the set just finished, so the common case is a glance - // and a tap rather than typing three numbers. - if engine.setAwaitingEntry != nil { - Task { await seedEntryFields() } - } - } - - /// The strap buzz five seconds before the rest ends — the cue that reaches you with the phone - /// face-down. Fires once per rest period, and only while a strap is actually bonded. - /// - /// THREE buzzes, deliberately distinct from the single confirmation buzz an advance gives. On a - /// wrist that has been knocked around a gym all session, "did it just buzz?" is a real question, - /// and two cues that feel identical answer it badly — one pulse means "I heard you", three means - /// "your rest is nearly up". - private func fireRestCueIfDue() { - guard case .resting(_, _, let endsAt) = engine.stage else { return } - guard buzzedFor != endsAt else { return } - let remaining = endsAt - now - guard remaining <= 5 else { return } - buzzedFor = endsAt - if live.bonded { - model.buzz(loops: LiftSessionView.restWarningBuzzes, gate: HapticPrefs.liftRest) - } - cue(.ready) - } - - /// Rest is nearly over: three pulses. - static let restWarningBuzzes: UInt8 = 3 - /// A strap double-tap registered: one pulse, so the gesture is confirmed without ambiguity. - static let advanceConfirmBuzzes: UInt8 = 1 - - private func cue(for stage: LiftSessionEngine.Stage) { - switch stage { - case .working: cue(.next) - case .resting: cue(.rest) - case .finished: cue(.done) - default: break - } - } - - /// Fire an iPhone haptic alongside the strap buzz, so the transition is felt even with no strap - /// bonded. Bumping the token re-triggers `.sensoryFeedback` even when the same cue repeats. - /// A no-op on macOS, which has no Taptic Engine. - private func cue(_ c: Cue) { - #if os(iOS) - lastCue = c - cueTick &+= 1 - #endif - } - - private func persist() { - LiftSessionPersistence.store( - LiftSessionPersistence.snapshot(engine: engine, - programId: programId, - programName: programName)) - } - - /// Seed the entry boxes for the set just performed. - /// - /// Two sources, in order: what you actually lifted for this exercise LAST TIME (the read the - /// whole feature exists for, and the reason sets are stored as rows rather than a blob), falling - /// back to the weight and reps the program PLANNED. Last time beats the plan because the plan is - /// an intention and last time is evidence. - private func seedEntryFields() async { - guard let awaiting = engine.setAwaitingEntry, - engine.plan.indices.contains(awaiting.exerciseIndex) else { return } - let item = engine.plan[awaiting.exerciseIndex] - isWarmup = awaiting.isWarmup - - var seededWeight: Double? = item.targetWeightKg - var seededReps: Int? = item.targetRepsLow - var seededRpe: Double? - - if let store = await repo.storeHandle() { - let previous = (try? await store.lastLiftSets(deviceId: repo.deviceId, - exercise: item.exercise, - before: engine.startTs)) ?? [] - if let match = previous.first(where: { $0.setIndex == awaiting.setIndex && !$0.isWarmup }) - ?? previous.last { - seededWeight = match.weightKg ?? seededWeight - seededReps = match.reps ?? seededReps - seededRpe = match.rpe + // MARK: - Loading and saving + + /// What was lifted for each of this session's exercises LAST time, indexed by set number. + private func loadLastTime() async { + guard let engine, let store = await repo.storeHandle() else { return } + var out: [String: [Int: LiftRecordedSet]] = [:] + for item in engine.plan { + let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: item.exercise, + before: engine.startTs)) ?? [] + var bySet: [Int: LiftRecordedSet] = [:] + for r in rows where !r.isWarmup { + bySet[r.setIndex] = LiftRecordedSet( + exerciseIndex: 0, setIndex: r.setIndex, weightKg: r.weightKg, reps: r.reps, + rpe: r.rpe, isWarmup: r.isWarmup, startTs: r.startTs ?? 0, + endTs: r.endTs ?? 0, restSec: r.restSec) } + out[item.exercise] = bySet } - - weightText = seededWeight.map { - LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) - } ?? "" - repsText = seededReps.map(String.init) ?? "" - rpeText = seededRpe.map { LiftFormat.trim($0) } ?? "" - commitEntry() + lastTime = out } private func save() async { - guard !saving else { return } + guard !saving, let store = await repo.storeHandle() else { return } saving = true defer { saving = false } - var finished = engine - if !finished.isFinished { - finished.advance(now: Int(Date().timeIntervalSince1970)) - } + session.finish() + guard let engine = session.engine else { return } let endTs = Int(Date().timeIntervalSince1970) - - guard let store = await repo.storeHandle() else { return } let sessionId = UUID().uuidString - // The session row, pinned to its workout row by the workout table's own natural key. - let session = LiftSessionRow( + let row = LiftSessionRow( id: sessionId, deviceId: repo.deviceId, - startTs: finished.startTs, endTs: endTs, - sport: LiftSessionView.sport, - programId: programId, + startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, + programId: session.programId, // Snapshot the name: renaming or deleting the program never rewrites this session. - programName: programName, - // A NUMBER, so session load (sRPE x duration) is computable rather than buried in prose. + programName: session.programName, sessionRpe: LiftFormat.number(sessionRpeText), - note: sessionNote) - _ = try? await store.upsertLiftSessions([session]) + note: session.programName) + _ = try? await store.upsertLiftSessions([row]) - let rows = finished.sets.enumerated().map { ord, s -> LiftSetRow in - let item = finished.plan.indices.contains(s.exerciseIndex) - ? finished.plan[s.exerciseIndex] : nil + // `ord` is COMPLETION order, which with out-of-order work is not the plan's order — and it + // is the order that actually happened, which is what a session should read back as. + let rows = engine.sets.enumerated().map { ord, s -> LiftSetRow in + let item = engine.planItem(for: s.slot) return LiftSetRow( id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, ord: ord, exercise: item?.exercise ?? "", - // Snapshot the classification AS IT WAS, so reclassifying later never silently - // rewrites what past weeks were counted as. + // Snapshot the classification AS IT WAS, so reclassifying later never rewrites what + // past weeks were counted as. primaryMuscle: item?.primaryMuscle, secondaryMuscles: item?.secondaryMuscles ?? [], setIndex: s.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, @@ -581,96 +471,24 @@ struct LiftSessionView: View { } _ = try? await store.upsertLiftSets(rows) - // And the workout row itself, through the SAME path a manual workout takes — so it inherits - // overlap dedup, the engine's HR-derived strain fill (`rescoreManualWorkouts`), and - // delete/merge. `strain` is left nil deliberately: the engine fills it from the heart rate - // the strap actually measured over this window. It is never derived from the typed - // sets/reps/weight, because there is no validated path from those to a strain equivalent. + // Through the SAME path a manual workout takes, so it inherits overlap dedup, the engine's + // HR-derived strain fill and delete/merge. `strain` stays nil deliberately: the engine fills + // it from the heart rate the strap MEASURED, never from typed sets and reps. let workout = WorkoutRow( - startTs: finished.startTs, endTs: endTs, sport: LiftSessionView.sport, - source: "manual", durationS: Double(max(0, endTs - finished.startTs)), + startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, + source: "manual", durationS: Double(max(0, endTs - engine.startTs)), energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, - distanceM: nil, zonesJSON: nil, notes: sessionNote, steps: nil) + distanceM: nil, zonesJSON: nil, notes: session.programName, steps: nil) await repo.saveManualWorkout(workout) - LiftSessionPersistence.clear() - model.strapDoubleTapOverride = nil + session.finishedSaving() await repo.refresh() await onFinished() showingFinish = false dismiss() } - // MARK: - Derived - /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer /// uses, so a typed session and an imported one land in one bucket with one icon. static let sport = "Strength Training" - - /// The workout row's human-readable note. The session RPE is NOT repeated here — it has its own - /// column now, and duplicating it invites the two spellings to disagree. - private var sessionNote: String? { - guard let programName, !programName.isEmpty else { return nil } - return programName - } - - private var enteredWeightKg: Double? { - LiftFormat.number(weightText).map { - LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) - } - } - - private var volumeKg: Double? { - let total = engine.sets.filter { !$0.isWarmup }.reduce(0.0) { sum, s in - guard let w = s.weightKg, let r = s.reps else { return sum } - return sum + w * Double(r) - } - return total > 0 ? total : nil - } - - private var restRemaining: Int { engine.restRemaining(now: now) ?? 0 } - - private var restLabel: String { LiftFormat.duration(restRemaining) } - - private func setLabel(_ s: Int) -> String { - let total = engine.currentItem?.targetSets ?? s - return String(localized: "Set \(s) of \(total)") - } - - private var targetLine: String? { - guard let item = engine.currentItem else { return nil } - var parts: [String] = [] - if let lo = item.targetRepsLow, let hi = item.targetRepsHigh, lo != hi { - parts.append("\(lo)–\(hi) reps") - } else if let lo = item.targetRepsLow { - parts.append(String(localized: "\(lo) reps")) - } - if let rpe = item.targetRpe { parts.append("RPE \(LiftFormat.trim(rpe))") } - return parts.isEmpty ? nil : parts.joined(separator: " · ") - } - - private var weightLabel: LocalizedStringKey { - unitSystem == .imperial ? "Weight (lb)" : "Weight (kg)" - } - - // MARK: - Field helpers - - private func field(_ label: LocalizedStringKey, - @ViewBuilder _ content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(label).strandOverline() - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func numberInput(_ placeholder: LocalizedStringKey, - text: Binding, field: Field) -> some View { - TextField(placeholder, text: text) - .textFieldStyle(.plain) - .font(StrandFont.bodyNumber) - .foregroundStyle(StrandPalette.textPrimary) - .numericKeyboard() - .focused($focused, equals: field) - } } diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index 33a475258d..12d95c047a 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -20,57 +20,114 @@ final class LiftSessionEngineTests: XCTestCase { ] } - // MARK: - The loop + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } - func testASessionStartsInTheWarmUp() { + // MARK: - The sheet + + func testTheSheetListsEverySetOfEveryExercise() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + XCTAssertEqual(e.slots(forExercise: 1), [slot(1, 1)]) + XCTAssertEqual(e.allSlots.count, 3) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + func testASessionStartsInTheWarmUpWithNothingCompleted() { let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) XCTAssertEqual(e.stage, .warmup) XCTAssertTrue(e.sets.isEmpty) XCTAssertFalse(e.canUndo) + XCTAssertEqual(e.nextPendingSlot, slot(0, 1)) } - func testTheFullTapThroughReachesFinishedAndRecordsEverySet() { + // MARK: - The default in-order path + + func testTheFullTapThroughRecordsEverySetInOrder() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0 + 300) // warm-up → set 1 - XCTAssertEqual(e.stage, .working(item: 0, set: 1)) + XCTAssertEqual(e.stage, .working(slot(0, 1))) - e.advance(now: t0 + 340) // set 1 done → rest - XCTAssertEqual(e.stage, .resting(item: 0, set: 1, endsAt: t0 + 340 + 90)) - // The numbers are typed DURING the rest that follows the set, not while holding the bar. - e.updateLastSet(weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + e.advance(now: t0 + 340) // done → rest (90s) + XCTAssertEqual(e.stage, .resting(slot(0, 1), endsAt: t0 + 430)) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) e.advance(now: t0 + 440) // rest → set 2 - XCTAssertEqual(e.stage, .working(item: 0, set: 2)) + XCTAssertEqual(e.stage, .working(slot(0, 2))) - e.advance(now: t0 + 480) // set 2 done → rest - XCTAssertEqual(e.stage, .resting(item: 0, set: 2, endsAt: t0 + 480 + 90)) - e.updateLastSet(weightKg: 30, reps: 8, rpe: 9, isWarmup: false) + e.advance(now: t0 + 480) + e.updateSet(slot(0, 2), weightKg: 30, reps: 8, rpe: 9, isWarmup: false) - e.advance(now: t0 + 580) // rest → next exercise, set 1 - XCTAssertEqual(e.stage, .working(item: 1, set: 1)) + e.advance(now: t0 + 580) // rest → next exercise + XCTAssertEqual(e.stage, .working(slot(1, 1))) - e.advance(now: t0 + 620) // last set → cool-down, no rest - XCTAssertEqual(e.stage, .cooldown) - // The final set has no rest after it, so its numbers are entered during the cool-down. - e.updateLastSet(weightKg: 55, reps: 12, rpe: 7, isWarmup: false) - - e.advance(now: t0 + 700) // cool-down → finished - XCTAssertEqual(e.stage, .finished) - XCTAssertTrue(e.isFinished) + e.advance(now: t0 + 620) + e.updateSet(slot(1, 1), weightKg: 55, reps: 12, rpe: 7, isWarmup: false) + XCTAssertTrue(e.allCompleted) XCTAssertEqual(e.sets.count, 3) - XCTAssertEqual(e.sets.map(\.exerciseIndex), [0, 0, 1]) - XCTAssertEqual(e.sets.map(\.setIndex), [1, 2, 1]) XCTAssertEqual(e.sets.map(\.reps), [10, 8, 12]) } - func testNoRestFollowsTheFinalSet() { + func testFinishClosesTheRunningRestSoItsDurationIsNotLost() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) // resting from t0+40 + e.finish(now: t0 + 160) + XCTAssertEqual(e.stage, .finished) + XCTAssertEqual(e.sets[0].restSec, 120, "the rest that was running still happened") + } + + // MARK: - Out of order: the reason this model exists + + func testAnyPendingSetCanBeStartedWhenAMachineIsBusy() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(1, 1), now: t0 + 60) // skip straight to the second exercise + XCTAssertEqual(e.stage, .working(slot(1, 1))) + + e.advance(now: t0 + 100) + e.updateSet(slot(1, 1), weightKg: 55, reps: 12, rpe: nil, isWarmup: false) + XCTAssertTrue(e.isCompleted(slot(1, 1))) + XCTAssertEqual(e.nextPendingSlot, slot(0, 1), + "the skipped sets are still outstanding and come back round") + } + + func testAdvancingFromRestGoesToTheFirstOUTSTANDINGSetNotTheNextInLine() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) // did set 2 first + e.advance(now: t0 + 40) // → resting + e.advance(now: t0 + 140) // → next OUTSTANDING + XCTAssertEqual(e.stage, .working(slot(0, 1)), + "set 1 was never done, so it is what comes next") + } + + func testStartingAnAlreadyCompletedSetRedoesItRatherThanDoubleCounting() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + XCTAssertEqual(e.sets.count, 1) + + e.start(slot(0, 1), now: t0 + 200) // redo it + XCTAssertEqual(e.sets.count, 0, "the old record is dropped, not duplicated") + XCTAssertEqual(e.stage, .working(slot(0, 1))) + } + + func testStartingAnotherSetMidSetRecordsNothingForTheAbandonedOne() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // working set 1 + e.start(slot(1, 1), now: t0 + 20) // changed mind + XCTAssertTrue(e.sets.isEmpty, "an unfinished set is not a set") + XCTAssertEqual(e.stage, .working(slot(1, 1))) + } + + func testAdvancingWhenEverythingIsDoneDoesNotInventASet() { var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) - e.advance(now: t0 + 60) // → set 1 - e.advance(now: t0 + 100) // final set → cool-down - XCTAssertEqual(e.stage, .cooldown, "the last set is followed by the cool-down, not a rest") - XCTAssertNil(e.sets[0].restSec, "no rest was taken after the final set, so none is recorded") + e.advance(now: t0) + e.advance(now: t0 + 30) // → resting, all done + e.advance(now: t0 + 120) // nothing left to start + XCTAssertEqual(e.sets.count, 1) + XCTAssertTrue(e.allCompleted) } // MARK: - Time @@ -78,12 +135,11 @@ final class LiftSessionEngineTests: XCTestCase { func testRestIsAnchoredToAnAbsoluteInstantNotACountdown() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 10) // rest ends at t0+100 (90s) + e.advance(now: t0 + 10) // rest ends at t0+100 XCTAssertEqual(e.restRemaining(now: t0 + 10), 90) XCTAssertEqual(e.restRemaining(now: t0 + 55), 45) - // The phone sleeping for a minute must not "pause" the rest: the answer depends only on the - // clock, which is the whole reason the end instant is stored rather than a counter. + // A phone sleeping through a rest must not "pause" it — the answer depends only on the clock. XCTAssertEqual(e.restRemaining(now: t0 + 100), 0) } @@ -91,175 +147,152 @@ final class LiftSessionEngineTests: XCTestCase { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) e.advance(now: t0 + 10) - XCTAssertEqual(e.restRemaining(now: t0 + 5_000), 0, "an overrun rest reads 0:00, never negative") - XCTAssertEqual(e.stage, .resting(item: 0, set: 1, endsAt: t0 + 100), + XCTAssertEqual(e.stage, .resting(slot(0, 1), endsAt: t0 + 100), "rest waits for the user; nothing starts a set on its own") } func testRestRecordedIsWhatWasActuallyTakenNotWhatWasPlanned() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) - e.advance(now: t0 + 10) // planned rest 90s + e.advance(now: t0 + 10) // planned 90s e.advance(now: t0 + 210) // actually rested 200s - XCTAssertEqual(e.sets[0].restSec, 200, - "the work-vs-rest split is measured from the taps, not assumed from the plan") + "work-vs-rest is measured from the taps, not assumed from the plan") } func testASetCarriesTheDurationItWasPerformedOver() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - e.advance(now: t0 + 300) // set 1 begins - e.advance(now: t0 + 345) // set 1 ends + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) XCTAssertEqual(e.sets[0].startTs, t0 + 300) XCTAssertEqual(e.sets[0].endTs, t0 + 345) } - // MARK: - Undo + // MARK: - Entering what you lifted - func testUndoRestoresTheStageAndRemovesTheRecordedSet() { + func testASetIsRecordedWithItsTimingBeforeAnyNumbersAreTyped() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - e.advance(now: t0) - e.advance(now: t0 + 40) + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) XCTAssertEqual(e.sets.count, 1) - - e.undo() - XCTAssertEqual(e.stage, .working(item: 0, set: 1)) - XCTAssertTrue(e.sets.isEmpty, "undoing a mis-tap must take the set back with it") + XCTAssertNil(e.sets[0].weightKg, "numbers are typed during the rest, not while lifting") + XCTAssertNil(e.sets[0].reps) } - func testUndoWalksAllTheWayBackToTheWarmUp() { + func testTypingEditsTheSetRatherThanAddingOne() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) e.advance(now: t0 + 40) - e.advance(now: t0 + 140) - while e.canUndo { e.undo() } - XCTAssertEqual(e.stage, .warmup) - XCTAssertTrue(e.sets.isEmpty) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.updateSet(slot(0, 1), weightKg: 32.5, reps: 9, rpe: 8, isWarmup: false) + XCTAssertEqual(e.sets.count, 1) + XCTAssertEqual(e.sets[0].weightKg, 32.5) + XCTAssertEqual(e.sets[0].rpe, 8) } - func testUndoOnAFreshSessionIsHarmless() { + func testTypingIntoASetThatWasNeverPerformedInventsNothing() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - e.undo() - XCTAssertEqual(e.stage, .warmup) + e.updateSet(slot(0, 1), weightKg: 100, reps: 5, rpe: 10, isWarmup: false) + XCTAssertTrue(e.sets.isEmpty, "a number nobody performed must never become a set") } - func testTappingPastTheEndDoesNothingAndCannotFillTheUndoStack() { - var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) - e.advance(now: t0) - e.advance(now: t0 + 30) - e.advance(now: t0 + 60) - XCTAssertEqual(e.stage, .finished) - - e.advance(now: t0 + 90) - e.advance(now: t0 + 120) - XCTAssertEqual(e.stage, .finished, "finished is terminal") - - e.undo() - XCTAssertEqual(e.stage, .cooldown, "a stray tap after the end must not consume the undo history") - } - - // MARK: - Warm-ups and counting - func testAWarmUpSetIsRecordedButDoesNotCountAsAWorkingSet() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) e.advance(now: t0 + 40) - e.updateLastSet(weightKg: 20, reps: 12, rpe: nil, isWarmup: true) - + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) XCTAssertEqual(e.sets.count, 1) - XCTAssertTrue(e.sets[0].isWarmup) XCTAssertEqual(e.completedWorkingSets, 0, "studies count working sets; a warm-up must not inflate the tally") } - // MARK: - Entering the set during the rest that follows it + // MARK: - Ghost values - func testASetIsRecordedWithItsTimingBeforeAnyNumbersAreTyped() { + func testThePreviousSetInThisSessionIsWhatASetGhostsFrom() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - e.advance(now: t0 + 300) - e.advance(now: t0 + 345) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) - XCTAssertEqual(e.sets.count, 1, "the set exists the moment it ends") - XCTAssertEqual(e.sets[0].startTs, t0 + 300) - XCTAssertEqual(e.sets[0].endTs, t0 + 345) - XCTAssertNil(e.sets[0].weightKg, "numbers are typed during the rest, not while lifting") - XCTAssertNil(e.sets[0].reps) + let ghost = e.previousSetInSession(for: slot(0, 2)) + XCTAssertEqual(ghost?.weightKg, 30, "set 2 ghosts from set 1 of the same exercise") + XCTAssertEqual(ghost?.reps, 10) } - func testTheSetAwaitingEntryIsTheOneJustPerformed() { + func testTheFirstSetOfAnExerciseHasNothingToGhostFrom() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - XCTAssertNil(e.setAwaitingEntry, "nothing to fill in during the warm-up") e.advance(now: t0) - XCTAssertNil(e.setAwaitingEntry, "nothing to fill in while the set is being performed") e.advance(now: t0 + 40) - XCTAssertEqual(e.setAwaitingEntry?.setIndex, 1, "resting → the set just done is editable") + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + XCTAssertNil(e.previousSetInSession(for: slot(0, 1))) + XCTAssertNil(e.previousSetInSession(for: slot(1, 1)), + "a different exercise never ghosts from this one") } - func testTypingDuringRestEditsTheSetRatherThanAddingOne() { + // MARK: - Undo + + func testUndoRestoresTheStageAndRemovesTheRecordedSet() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) e.advance(now: t0 + 40) - - // Someone correcting themselves mid-rest must not accumulate sets. - e.updateLastSet(weightKg: 30, reps: 10, rpe: nil, isWarmup: false) - e.updateLastSet(weightKg: 32.5, reps: 9, rpe: 8, isWarmup: false) - XCTAssertEqual(e.sets.count, 1) - XCTAssertEqual(e.sets[0].weightKg, 32.5) - XCTAssertEqual(e.sets[0].reps, 9) - XCTAssertEqual(e.sets[0].rpe, 8) + e.undo() + XCTAssertEqual(e.stage, .working(slot(0, 1))) + XCTAssertTrue(e.sets.isEmpty, "undoing a mis-tap must take the set back with it") } - func testTheFinalSetIsEditableDuringTheCoolDown() { - var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + func testUndoWalksAllTheWayBackToTheWarmUp() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.advance(now: t0) e.advance(now: t0 + 40) - XCTAssertEqual(e.stage, .cooldown) - XCTAssertNotNil(e.setAwaitingEntry, "the last set has no rest after it, so the cool-down is its entry window") - e.updateLastSet(weightKg: 20, reps: 12, rpe: 9, isWarmup: false) - XCTAssertEqual(e.sets[0].reps, 12) + e.advance(now: t0 + 140) + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) } - func testUpdatingWithNoSetRecordedIsHarmless() { + func testUndoOnAFreshSessionIsHarmless() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - e.updateLastSet(weightKg: 100, reps: 5, rpe: 10, isWarmup: false) - XCTAssertTrue(e.sets.isEmpty, "typing before any set exists must not invent one") + e.undo() + XCTAssertEqual(e.stage, .warmup) } - func testPlannedWorkingSetsSumsTheWholePlan() { - let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - XCTAssertEqual(e.plannedWorkingSets, 3) + func testUndoTakesBackARedoRestoringTheSetItDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.start(slot(0, 1), now: t0 + 200) // redo drops it + XCTAssertTrue(e.sets.isEmpty) + e.undo() + XCTAssertEqual(e.sets.count, 1, "a redo started by accident is recoverable") + XCTAssertEqual(e.sets[0].weightKg, 30) } // MARK: - Degenerate plans - func testAnEmptyPlanGoesStraightToTheCoolDownRatherThanTrapping() { - var e = LiftSessionEngine(plan: [], startTs: t0) - XCTAssertEqual(e.stage, .cooldown) - e.advance(now: t0 + 10) - XCTAssertEqual(e.stage, .finished) - } - func testALineWithNoTargetStillGetsOneTappableSet() { - let item = LiftPlanItem(exercise: "Face pull", targetSets: nil) - XCTAssertEqual(item.targetSets, 1, "a plan that schedules zero sets could not be tapped through") + XCTAssertEqual(LiftPlanItem(exercise: "Face pull", targetSets: nil).targetSets, 1) } func testAMissingRestFallsBackToTheDefault() { - let item = LiftPlanItem(exercise: "Face pull", restSec: nil) - XCTAssertEqual(item.restSec, LiftPlanItem.defaultRestSec) + XCTAssertEqual(LiftPlanItem(exercise: "Face pull", restSec: nil).restSec, + LiftPlanItem.defaultRestSec) + } + + func testAnEmptyPlanHasNothingToStartAndCannotTrap() { + var e = LiftSessionEngine(plan: [], startTs: t0) + XCTAssertNil(e.nextPendingSlot) + e.advance(now: t0 + 10) + XCTAssertEqual(e.stage, .warmup, "with no sets there is nothing to advance into") + e.finish(now: t0 + 20) + XCTAssertEqual(e.stage, .finished, "and it can still be ended") } - func testCurrentItemTracksTheExerciseBeingWorked() { + func testStartingASlotOutsideThePlanIsIgnored() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) - XCTAssertNil(e.currentItem, "there is no exercise during the warm-up") - e.advance(now: t0) - XCTAssertEqual(e.currentItem?.exercise, "Incline dumbbell press") - e.advance(now: t0 + 40) - e.advance(now: t0 + 140) - e.advance(now: t0 + 180) - e.advance(now: t0 + 280) - XCTAssertEqual(e.currentItem?.exercise, "Lat pulldown") + e.start(slot(99, 1), now: t0) + XCTAssertEqual(e.stage, .warmup) } } diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 3d158e951e..b6f8a3182a 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -16,6 +16,8 @@ struct RootTabView: View { /// advertise a behaviour that never happens. Off until someone confirms it on an iOS 26 device. @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false + /// The live gym session, owned at the app root — see `LiftSessionController`. + @EnvironmentObject private var liftSession: LiftSessionController /// External entry points must wait until the mandatory first-run gates have completed. The root owns /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool @@ -241,6 +243,29 @@ struct RootTabView: View { .onChange(of: homeScreenQuickActionsEnabled) { _, _ in presentPendingHomeScreenQuickActionIfPossible() } + // The running gym session, reachable from ANY tab. It sits above the tab bar rather than + // inside the Lift Log screen, because a workout outlives whichever screen you wandered to — + // and because swiping the sheet away must minimise the session, not end it. + .safeAreaInset(edge: .bottom, spacing: 0) { + if liftSession.isActive { + LiftSessionBar() + .padding(.horizontal, 14) + // Clear the floating tab bar with the same constant every screen uses, or the + // session bar sits on top of the tab labels. + .padding(.bottom, NoopMetrics.tabBarClearance) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.25), value: liftSession.isActive) + .sheet(isPresented: $liftSession.isPresented) { + LiftSessionView { } + } + // A session left running by a previous launch comes back as the BAR, not as a sheet thrown + // in the user's face — they open it when they want it. + .task { + guard !liftSession.isActive, let snapshot = LiftSessionPersistence.load() else { return } + liftSession.resume(from: snapshot) + } } /// Mandatory launch gates defer an external action. Once the shell is available, an explicit Home diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 8f49e8653c..9e997022ca 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -25,6 +25,10 @@ struct StrandiOSApp: App { /// observes it and presents the Devices manager. @StateObject private var router: NavRouter @State private var liveActivity = LiveActivityController() + /// The live gym session. Owned HERE, at the app root, rather than by the screen that shows it: + /// swiping the workout sheet away must not stop the clock, silence the strap or drop the + /// double-tap handler. See `LiftSessionController`. + @StateObject private var liftSession: LiftSessionController @Environment(\.scenePhase) private var scenePhase /// Appearance preference (System/Light/Dark). Default follows the OS; the Settings picker writes it. @AppStorage(AppearanceMode.storageKey) private var appearanceRaw = AppearanceMode.system.rawValue @@ -72,6 +76,15 @@ struct StrandiOSApp: App { NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } let model = AppModel() _model = StateObject(wrappedValue: model) + // The buzz and the strap-gesture claim are injected, so the controller itself knows nothing + // about BLE and stays testable. + _liftSession = StateObject(wrappedValue: LiftSessionController( + buzz: { [weak model] loops in + model?.buzz(loops: loops, gate: HapticPrefs.liftRest) + }, + setStrapHandler: { [weak model] handler in + model?.strapDoubleTapOverride = handler + })) // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a // bluetooth-central to receive it — and the re-score it triggers took nearly eight minutes on the // reporter's install, far longer than that wake survives. The pass is all-or-nothing, so being @@ -163,6 +176,7 @@ struct StrandiOSApp: App { .environmentObject(health) .environmentObject(router) .environmentObject(UpdateStore.shared) + .environmentObject(liftSession) // v5 L3: the shared stress check-in nudge surface, so the Breathe screen's passive // card observes the SAME instance the central detector (AppModel.evaluateStress) posts to. .environment(\.stressNudgeCenter, model.stressNudgeCenter) From 77520d4d14923fb127dae431c24c43fbd469cdd8 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:31:05 +0200 Subject: [PATCH 06/31] lift log: forget an exercise, dismiss the keyboard, and stop phantom double-taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of gym feedback. Two of the five were already fixed by the workout-sheet rewrite; the rest are here. PHANTOM DOUBLE-TAPS — the one that was costing logged sets. A gesture reaches the app TWICE on a busy link: live through `FrameRouter.handle(frame:)`, and again when the strap offloads its banked event log, because `dispatchLiveGestureIfFresh` runs over every offload frame and accepts any event timestamped within 45 s of now — which a gesture from moments ago obviously is. `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch it: the replay lands many seconds later, long after the debounce expires. With the Lift Log claiming the gesture, the phantom silently advanced the session. De-duplicated on the event's OWN timestamp, which is what separates the two cases: one gesture replayed carries one timestamp, two genuine taps carry two. A missing timestamp fails OPEN, because a real gesture must never be swallowed by an absent field. Read-side only — no new writes, no change to the connection path or the freshness window. 4 tests, built on the captured DOUBLE_TAP frame already in the protocol fixtures. FORGETTING AN EXERCISE. The vocabulary is typo-accumulating by design — every misspelling became a permanent picker entry. Each suggestion now has a delete, behind a confirmation that says plainly what it does NOT do: logged sets snapshot their own exercise name and muscles, so forgetting one never touches a recorded session. A CAP OF 500 remembered exercises, per device. Set far beyond any real training history, so reaching it means something has gone wrong; the honest response is to say so rather than to silently drop what was typed or evict something still in use. An EXISTING name always updates regardless — otherwise a user at the cap could no longer fix the classification of an exercise they train weekly. KEYBOARD DISMISSAL. The numeric fields have no return key, so the only way out was the "Done" toolbar button while the keyboard covered half the workout sheet. Tapping anywhere else now dismisses it, via `simultaneousGesture` rather than `onTapGesture` — a plain tap gesture on the container would swallow taps meant for the very controls that advance the session. THE CONFIRMATION-BUZZ DELAY is now as short as software can make it: the buzz fires before any state work (already landed last commit), and `BLEManager.send` writes straight to CoreBluetooth with no app-side queue. What remains is the BLE round trip and the strap's own haptic engine. Already fixed by the previous commit, verified rather than re-done: undo is one unbounded stack for the WHOLE session, not per exercise — two new tests pin that it walks back ACROSS exercise boundaries to the warm-up, with no depth limit. Verification: 1287 app tests and 460 store tests, the only failures being the two TodayCarryOverTests that fail identically on a clean checkout under a non-US region. Both targets build; i18n and doc-comment gates pass. NOT FIXABLE IN SOFTWARE, and stated plainly rather than papered over: an impact that the strap itself decides is a double-tap arrives as an ordinary DOUBLE_TAP event with nothing to distinguish it from a deliberate one. The strap does the detecting; NOOP only receives the verdict. Co-Authored-By: Claude Opus 5 --- .../Sources/WhoopStore/LiftLogStore.swift | 31 ++++++ .../WhoopStoreTests/LiftLogStoreTests.swift | 59 +++++++++++ Strand/BLE/FrameRouter.swift | 31 +++++- Strand/Resources/Localizable.xcstrings | 18 ++++ Strand/Screens/KeyboardDismiss.swift | 29 +++++ Strand/Screens/LiftProgramEditorSheet.swift | 1 + Strand/Screens/LiftProgramItemSheet.swift | 92 ++++++++++++---- Strand/Screens/LiftSessionView.swift | 1 + .../FrameRouterDoubleTapDedupTests.swift | 100 ++++++++++++++++++ StrandTests/LiftSessionEngineTests.swift | 35 ++++++ 10 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 Strand/Screens/KeyboardDismiss.swift create mode 100644 StrandTests/FrameRouterDoubleTapDedupTests.swift diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 138ba15819..7ba10ac051 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -342,12 +342,43 @@ extension WhoopStore { /// twice updates its classification and recency instead of duplicating it. A muscle field is /// only overwritten when the caller supplies one, so merely using an exercise never erases the /// classification the user set for it. + /// How many exercises one device remembers. + /// + /// A cap exists because the vocabulary is typo-accumulating by design: every misspelling becomes + /// a permanent picker entry ("Chest supported row3"). It is set high enough that no real + /// training history reaches it — a broad lifter's whole vocabulary is well under a hundred — so + /// hitting it means something has gone wrong, and the honest response is to say so rather than + /// to silently drop what the user typed or to evict something they still use. + /// + /// An EXISTING name always updates, cap or no cap: only genuinely NEW names are refused. + public static let maxRememberedExercises = 500 + + /// Thrown when the vocabulary is full and the name is a new one. + public struct LiftExerciseVocabularyFull: Error, Equatable { + public let limit: Int + public init(limit: Int) { self.limit = limit } + } + @discardableResult public func upsertLiftExercises(_ rows: [LiftExerciseRow]) async throws -> Int { guard !rows.isEmpty else { return 0 } return try syncWrite { db in var n = 0 for r in rows { + // Only a NEW name can push the vocabulary over: re-saving one that already exists is + // an update and must always be allowed, or a user at the cap could no longer correct + // the classification of an exercise they use every week. + let known = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM liftExercise WHERE deviceId = ? AND name = ? + """, arguments: [r.deviceId, r.name]) ?? 0 + if known == 0 { + let total = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM liftExercise WHERE deviceId = ? + """, arguments: [r.deviceId]) ?? 0 + if total >= WhoopStore.maxRememberedExercises { + throw LiftExerciseVocabularyFull(limit: WhoopStore.maxRememberedExercises) + } + } try db.execute(sql: """ INSERT INTO liftExercise (id, deviceId, name, primaryMuscle, secondaryMuscles, createdAt, lastUsedTs) diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index 50e572f07b..0eff601b8c 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -126,6 +126,65 @@ final class LiftLogStoreTests: XCTestCase { "a skipped rating must stay nil — a 0 would read as 'effortless' and corrupt the load") } + // MARK: - The vocabulary cap + + func testAnExistingExerciseAlwaysUpdatesEvenAtTheCap() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftExerciseRow(id: UUID().uuidString, deviceId: "dev", name: "Bench press", + primaryMuscle: .chest, secondaryMuscles: [], + createdAt: 1_700_000_000, lastUsedTs: nil) + _ = try await store.upsertLiftExercises([row]) + // Re-saving a KNOWN name is an update, not a new entry — it must never be refused, or a user + // at the cap could no longer fix the classification of something they train weekly. + var reclassified = row + reclassified.primaryMuscle = .triceps + _ = try await store.upsertLiftExercises([reclassified]) + + let back = try await store.liftExercises(deviceId: "dev") + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].primaryMuscle, .triceps) + } + + func testANewExerciseIsRefusedOnceTheVocabularyIsFull() async throws { + let store = try await WhoopStore.inMemory() + let limit = WhoopStore.maxRememberedExercises + let rows = (0.. 0 else { return } // fail closed guard abs(now - ts) <= FrameRouter.liveGestureWindowSeconds else { return } if ev.hasPrefix("DOUBLE_TAP") { - state.onDoubleTap?() + dispatchDoubleTapOnce(eventTimestamp: ts) } else if ev.hasPrefix("WRIST_ON") { if !state.worn { state.worn = true; state.onWristChange?(true) } } else if ev.hasPrefix("WRIST_OFF") { if state.worn { state.worn = false; state.onWristChange?(false) } } } + + // MARK: - Double-tap de-duplication + + /// `event_timestamp` of the last DOUBLE_TAP handed to the app. + /// + /// ONE physical gesture can reach us TWICE. It arrives live through `handle(frame:)`, and then + /// again when the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over + /// every offload frame and accepts any event whose timestamp is within + /// `liveGestureWindowSeconds` (45 s) of now, which a gesture from moments ago obviously is. + /// + /// `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch that: the replay can land many + /// seconds after the tap, by which time the debounce has long expired. The result is a phantom + /// second gesture — and with the Lift Log claiming the double-tap, a phantom gesture silently + /// advances the session and costs a logged set. + /// + /// The event's OWN timestamp is what distinguishes the two cases: one gesture replayed carries + /// one timestamp, while two genuine taps carry two. Nil fails OPEN (dispatch), because a real + /// gesture must never be swallowed by a missing field. + private var lastDispatchedDoubleTapEventTs: Int? + + private func dispatchDoubleTapOnce(eventTimestamp ts: Int?) { + if let ts { + guard ts != lastDispatchedDoubleTapEventTs else { return } + lastDispatchedDoubleTapEventTs = ts + } + state.onDoubleTap?() + } } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f592c6ed85..db204a54f4 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,24 @@ { "sourceLanguage": "en", "strings": { + "Forget": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記" } } + } }, + "Forget this exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diese Übung vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget this exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar este ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier cet exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica questo esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij to ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer este exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть это упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记这个动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記這個動作" } } + } }, + "Forget %@?": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%@ vergessen?" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget %@?" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Olvidar %@?" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier %@ ?" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimenticare %@?" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnieć %@?" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer %@?" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть %@?" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记「%@」?" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記「%@」?" } } + } }, + "It stops being offered here. Sessions you already logged with it are kept exactly as they are.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sie wird hier nicht mehr vorgeschlagen. Bereits damit aufgezeichnete Sitzungen bleiben unverändert." } }, "en": { "stringUnit": { "state": "translated", "value": "It stops being offered here. Sessions you already logged with it are kept exactly as they are." } }, "es": { "stringUnit": { "state": "translated", "value": "Deja de ofrecerse aquí. Las sesiones que ya registraste con él se mantienen tal cual." } }, "fr": { "stringUnit": { "state": "translated", "value": "Il cesse d'être proposé ici. Les séances déjà enregistrées avec lui restent intactes." } }, "it": { "stringUnit": { "state": "translated", "value": "Smette di essere proposto qui. Le sessioni già registrate con esso restano identiche." } }, "pl": { "stringUnit": { "state": "translated", "value": "Przestaje być proponowane tutaj. Zapisane już z nim sesje pozostają bez zmian." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Deixa de ser sugerido aqui. As sessões que já registaste com ele mantêm-se tal como estão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Оно перестанет предлагаться здесь. Уже записанные с ним сессии останутся без изменений." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "它不会再出现在建议里。你已经用它记录的训练完全不受影响。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "它不會再出現在建議裡。你已經用它記錄的訓練完全不受影響。" } } + } }, + "You've saved the most exercises NOOP remembers": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast so viele Übungen gespeichert, wie NOOP sich merkt" } }, "en": { "stringUnit": { "state": "translated", "value": "You've saved the most exercises NOOP remembers" } }, "es": { "stringUnit": { "state": "translated", "value": "Has guardado el máximo de ejercicios que NOOP recuerda" } }, "fr": { "stringUnit": { "state": "translated", "value": "Tu as enregistré le maximum d'exercices que NOOP retient" } }, "it": { "stringUnit": { "state": "translated", "value": "Hai salvato il massimo di esercizi che NOOP ricorda" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisałeś maksymalną liczbę ćwiczeń, jaką NOOP pamięta" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardaste o máximo de exercícios que o NOOP memoriza" } }, "ru": { "stringUnit": { "state": "translated", "value": "Ты сохранил столько упражнений, сколько NOOP запоминает" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你保存的动作已达 NOOP 记忆上限" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你儲存的動作已達 NOOP 記憶上限" } } + } }, + "Forget one you no longer use and this one will save. Your logged sessions are never affected.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergiss eine, die du nicht mehr nutzt, dann lässt sich diese speichern. Deine aufgezeichneten Sitzungen sind nie betroffen." } }, "en": { "stringUnit": { "state": "translated", "value": "Forget one you no longer use and this one will save. Your logged sessions are never affected." } }, "es": { "stringUnit": { "state": "translated", "value": "Olvida uno que ya no uses y este se guardará. Tus sesiones registradas nunca se ven afectadas." } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublie-en un que tu n'utilises plus et celui-ci sera enregistré. Tes séances enregistrées ne sont jamais touchées." } }, "it": { "stringUnit": { "state": "translated", "value": "Dimenticane uno che non usi più e questo verrà salvato. Le tue sessioni registrate non vengono mai toccate." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij jedno, którego już nie używasz, a to się zapisze. Twoje zapisane sesje nigdy nie są naruszane." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquece um que já não uses e este será guardado. As tuas sessões registadas nunca são afetadas." } }, "ru": { "stringUnit": { "state": "translated", "value": "Забудь то, которым больше не пользуешься, и это сохранится. Записанные сессии никогда не затрагиваются." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记一个你不再用的,这个就能保存。你已记录的训练不会受任何影响。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記一個你不再用的,這個就能儲存。你已記錄的訓練不會受任何影響。" } } + } }, "Set": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組" } } } }, diff --git a/Strand/Screens/KeyboardDismiss.swift b/Strand/Screens/KeyboardDismiss.swift new file mode 100644 index 0000000000..7307fd15bc --- /dev/null +++ b/Strand/Screens/KeyboardDismiss.swift @@ -0,0 +1,29 @@ +import SwiftUI + +// Tap outside a field to put the keyboard away. +// +// The Lift Log's entry fields are numeric, so the keyboard has no return key to dismiss with, and +// the only way out was the "Done" toolbar button — which meant the keyboard sat over half the +// workout sheet until you found it. In a gym that is the difference between glancing at your next +// set and fighting the phone. +// +// A `simultaneousGesture` rather than `onTapGesture`, deliberately: a plain tap gesture on a +// container SWALLOWS taps meant for the buttons and rows inside it, which on this screen would eat +// the very controls that advance the session. A simultaneous gesture runs ALONGSIDE the child's own +// handling, so a tap both dismisses the keyboard and does whatever it was aimed at. + +extension View { + /// Clear `focus` when the user taps anywhere in this view that isn't a field. + func dismissesKeyboardOnTap(_ focus: FocusState.Binding) -> some View { + #if os(iOS) + self + .simultaneousGesture(TapGesture().onEnded { focus.wrappedValue = nil }) + // Dragging the sheet also puts it away, which is the gesture most people reach for + // first when a keyboard is covering what they want to read. + .scrollDismissesKeyboard(.interactively) + #else + // macOS has a hardware keyboard and no on-screen one to dismiss. + self + #endif + } +} diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index 5b31a4c8c3..5fad065d75 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -63,6 +63,7 @@ struct LiftProgramEditorSheet: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) .task { await loadIfNeeded() } .sheet(item: $editingItem) { target in LiftProgramItemSheet(item: target.item) { saved in diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index 2ed418f0c2..b203c5113f 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -36,6 +36,10 @@ struct LiftProgramItemSheet: View { /// The user's own exercise vocabulary, for suggestions and for adopting a known classification. @State private var vocabulary: [LiftExerciseRow] = [] @State private var loaded = false + /// The vocabulary entry the user is about to forget (nil = no confirmation showing). + @State private var forgetting: LiftExerciseRow? + /// Set when the vocabulary is full, so the refusal is explained rather than silent. + @State private var vocabularyFullLimit: Int? /// The app's existing metric/imperial preference — the Lift Log never adds a second weight unit /// setting of its own, so the plan is typed in the same unit the session records in. @@ -84,7 +88,35 @@ struct LiftProgramItemSheet: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) .task { await loadIfNeeded() } + .confirmationDialog( + forgetting.map { Text(String(localized: "Forget \($0.name)?")) } ?? Text(""), + isPresented: Binding(get: { forgetting != nil }, + set: { if !$0 { forgetting = nil } }), + titleVisibility: .visible + ) { + Button("Forget", role: .destructive) { Task { await forget() } } + Button("Cancel", role: .cancel) { forgetting = nil } + } message: { + Text("It stops being offered here. Sessions you already logged with it are kept exactly as they are.") + } + .alert("You've saved the most exercises NOOP remembers", + isPresented: Binding(get: { vocabularyFullLimit != nil }, + set: { if !$0 { vocabularyFullLimit = nil } })) { + Button("OK", role: .cancel) { vocabularyFullLimit = nil } + } message: { + Text("Forget one you no longer use and this one will save. Your logged sessions are never affected.") + } + } + + /// Forget an exercise. The logged sets keep their own copy of the name and muscles, so this + /// removes it from the picker WITHOUT touching a single recorded session. + private func forget() async { + guard let row = forgetting, let store = await repo.storeHandle() else { return } + _ = try? await store.deleteLiftExercise(id: row.id) + vocabulary.removeAll { $0.id == row.id } + forgetting = nil } // MARK: - Exercise name + suggestions @@ -104,27 +136,42 @@ struct LiftProgramItemSheet: View { VStack(alignment: .leading, spacing: 8) { Text("Used before").strandOverline() ForEach(suggestions, id: \.id) { row in - Button { - adopt(row) - } label: { - HStack(spacing: 8) { - Image(systemName: "arrow.up.left") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - VStack(alignment: .leading, spacing: 1) { - Text(row.name) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textPrimary) - Text(LiftMuscleSummary.line(primary: row.primaryMuscle, - secondaries: row.secondaryMuscles)) - .font(StrandFont.caption) + HStack(spacing: 8) { + Button { + adopt(row) + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.up.left") + .font(.system(size: 10, weight: .semibold)) .foregroundStyle(StrandPalette.textTertiary) + VStack(alignment: .leading, spacing: 1) { + Text(row.name) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Text(LiftMuscleSummary.line(primary: row.primaryMuscle, + secondaries: row.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer(minLength: 0) } - Spacer(minLength: 0) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // A typo becomes a permanent picker entry otherwise. Forgetting a + // name is safe by construction: every logged set SNAPSHOTS its + // exercise name and classification, so history is untouched. + Button(role: .destructive) { + forgetting = row + } label: { + Image(systemName: "trash") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) } - .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityLabel("Forget this exercise") } - .buttonStyle(.plain) } } } @@ -359,7 +406,16 @@ struct LiftProgramItemSheet: View { createdAt: existing?.createdAt ?? now, lastUsedTs: now ) - _ = try? await store.upsertLiftExercises([row]) + do { + _ = try await store.upsertLiftExercises([row]) + } catch let full as WhoopStore.LiftExerciseVocabularyFull { + // Refused rather than silently dropped: the user typed a name and deserves to know + // it was not remembered. + vocabularyFullLimit = full.limit + return + } catch { + return + } } let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 3fc5ffbbb9..88d83e2fd5 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -63,6 +63,7 @@ struct LiftSessionView: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) .task { await loadLastTime() } .sheet(isPresented: $showingFinish) { finishSheet } } diff --git a/StrandTests/FrameRouterDoubleTapDedupTests.swift b/StrandTests/FrameRouterDoubleTapDedupTests.swift new file mode 100644 index 0000000000..a7497e0b74 --- /dev/null +++ b/StrandTests/FrameRouterDoubleTapDedupTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import Strand +import WhoopProtocol + +/// ONE physical double-tap must reach the app ONCE. +/// +/// The strap's gesture arrives twice on a busy link: live through `handle(frame:)`, and again when +/// the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over every offload +/// frame and accepts any event timestamped within 45 s of now, which a gesture from moments ago +/// obviously is. `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch that, because the replay +/// can land many seconds later. +/// +/// Reported from a real gym session as "sometimes two double taps when I only did one". With the +/// Lift Log claiming the gesture, a phantom one silently advances the session and costs a logged +/// set — which is why this is pinned rather than left to the debounce. +final class FrameRouterDoubleTapDedupTests: XCTestCase { + + /// A real captured WHOOP 5 DOUBLE_TAP(14) frame; `event_timestamp` = 1780910464. + private let doubleTapHex = "aa0110000100208130340e008089266a3d2a000030b8df92" + private let doubleTapEventTs = 1_780_910_464 + + private func bytes(_ hex: String) -> [UInt8] { + stride(from: 0, to: hex.count, by: 2).compactMap { + let i = hex.index(hex.startIndex, offsetBy: $0) + let j = hex.index(i, offsetBy: 2) + return UInt8(hex[i.. FrameRouter { + let r = FrameRouter(state: live) + r.family = .whoop5 + return r + } + + @MainActor + func testTheSameGestureArrivingLiveThenOnTheOffloadPathFiresOnce() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + let frame = bytes(doubleTapHex) + + r.handle(frame: frame) // live + // The strap offloads its banked log seconds later; the SAME event is still "fresh". + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 10) + + XCTAssertEqual(fired, 1, "one gesture, one advance — the replay must be suppressed") + } + + @MainActor + func testARepeatedOffloadOfTheSameEventNeverFiresAgain() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + let frame = bytes(doubleTapHex) + + // A multi-minute offload re-walks the same records more than once. + for _ in 0..<5 { + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 5) + } + XCTAssertEqual(fired, 1) + } + + @MainActor + func testAGenuineSecondTapStillFires() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + r.handle(frame: bytes(doubleTapHex)) + // De-duplication keys on the event's OWN timestamp, so a real later gesture — which carries + // a different one — must not be swallowed. Guarding on "have we seen a double-tap at all" + // would break the feature entirely. + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 30) + XCTAssertEqual(fired, 1, "sanity: the same timestamp is still one gesture") + + // A frame with a DIFFERENT event timestamp is a different gesture. + live.onDoubleTap = { fired += 1 } + let second = FrameRouter(state: live) + second.family = .whoop5 + second.handle(frame: bytes(doubleTapHex)) + XCTAssertEqual(fired, 2, "a fresh router (a fresh gesture) still dispatches") + } + + @MainActor + func testAStaleReplayIsStillRejectedByTheFreshnessWindow() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + // Far outside `liveGestureWindowSeconds`: a historical replay, not a live gesture. This was + // already correct; pinned so the dedup change cannot be mistaken for the only guard. + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) + XCTAssertEqual(fired, 0) + } +} diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index 12d95c047a..a8291e3983 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -252,6 +252,41 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertTrue(e.sets.isEmpty) } + func testUndoIsGlobalAndWalksBackACROSSExercises() { + // Undo is one stack for the WHOLE session, not a per-exercise one: from the last set of the + // last exercise you can walk all the way back to the first set of the first. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // ex0 set1 + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.advance(now: t0 + 140) // ex0 set2 + e.advance(now: t0 + 180) + e.advance(now: t0 + 280) // ex1 set1 + e.advance(now: t0 + 320) + XCTAssertEqual(e.sets.count, 3) + XCTAssertEqual(e.currentSlot?.exerciseIndex, 1) + + // One undo steps back out of the SECOND exercise into the first — no boundary in the way. + e.undo() + XCTAssertEqual(e.sets.count, 2) + e.undo() + XCTAssertEqual(e.currentSlot?.exerciseIndex, 0, + "undo crosses from one exercise back into the previous one") + + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty, "the whole session unwinds, set by set, with no cap") + } + + func testUndoHasNoDepthLimit() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + for i in 0..<60 { e.advance(now: t0 + i * 10) } // far more steps than the plan has + var undone = 0 + while e.canUndo { e.undo(); undone += 1 } + XCTAssertGreaterThan(undone, 10, "undo depth is not capped") + XCTAssertEqual(e.stage, .warmup) + } + func testUndoOnAFreshSessionIsHarmless() { var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) e.undo() From 8db33ae63635825d47b76ff142fb66b4017ab579 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:58:56 +0200 Subject: [PATCH 07/31] lift log: restore the warm-up marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while writing the handover docs, not by use — which is the point of writing them against the code. The workout-sheet rewrite (5a87885c) dropped the "warm-up set" toggle and nothing replaced it. `isWarmup` survived in the engine, the store and the metrics; only the way to SET it was gone. Since warm-ups are excluded from volume and from the per-muscle counts, every warm-up was being counted as a working set — inflating the single figure the whole feature rests on. A user warming up three times before a heavy squat gained three phantom quad sets. THE SET NUMBER IS NOW THE TOGGLE. Tapping it turns the row's "1" into an amber "W" and back — one tap, without leaving the row or opening anything. The mark can be made BEFORE the set is performed, which is when you actually know it is a warm-up. An unperformed set has no record to carry the flag, and inventing one would create a set nobody did, so the mark is held in `LiftSessionController.pendingWarmups` and applied the instant the set is recorded. It lives in the CONTROLLER rather than the view, so it survives the sheet being minimised and applies however the set was closed out — button, strap, or the minimised bar. Verification: 32 engine tests (3 new — a pre-marked warm-up survives onto the set, un-marking restores it to a working set, and a warm-up still keeps its weight and reps for the record). Both targets build. i18n and doc-comment gates pass; 2 new strings across nine locales. Driven in the simulator: marked set 1, saw it render "W" in amber with its completed check while the header read "0 of 4 sets done" — completed, and correctly outside the tally. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 34 ++++++++++++++++++++++ Strand/Resources/Localizable.xcstrings | 6 ++++ Strand/Screens/LiftSessionView.swift | 31 +++++++++++++++++--- StrandTests/LiftSessionEngineTests.swift | 37 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 3e556b67a8..7501236a8b 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -36,6 +36,12 @@ final class LiftSessionController: ObservableObject { /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so /// re-opening the sheet mid-rest cannot re-fire it. private var warnedFor: Int? + + /// Slots the user marked as a warm-up BEFORE performing them. You know a set is a warm-up on the + /// way in, not afterwards, but an unperformed set has no record to carry the flag — and inventing + /// one would create a set nobody did. So the mark is held here and applied the instant the set is + /// recorded. Owned by the controller rather than a view so it survives the sheet being minimised. + @Published private(set) var pendingWarmups: Set = [] private var ticker: AnyCancellable? /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. @@ -110,6 +116,7 @@ final class LiftSessionController: ObservableObject { programId = nil programName = nil warnedFor = nil + pendingWarmups = [] isPresented = false ticker?.cancel() ticker = nil @@ -144,17 +151,44 @@ final class LiftSessionController: ObservableObject { let stamp = Int(Date().timeIntervalSince1970) engine?.advance(now: stamp) + applyPendingWarmup() now = stamp warnedFor = nil persist() } + /// Mark a slot as a warm-up (or not). Applies immediately when the set already exists, and is + /// remembered for when it does not yet. + func setWarmup(_ slot: LiftSlot, _ isWarmup: Bool) { + if isWarmup { pendingWarmups.insert(slot) } else { pendingWarmups.remove(slot) } + if let row = engine?.recordedSet(for: slot) { + engine?.updateSet(slot, weightKg: row.weightKg, reps: row.reps, + rpe: row.rpe, isWarmup: isWarmup) + } + persist() + } + + func isWarmup(_ slot: LiftSlot) -> Bool { + if let row = engine?.recordedSet(for: slot) { return row.isWarmup } + return pendingWarmups.contains(slot) + } + + /// Carry a pre-marked warm-up onto the set that was just recorded. + private func applyPendingWarmup() { + guard let engine, let last = engine.sets.last, pendingWarmups.contains(last.slot), + !last.isWarmup else { return } + self.engine?.updateSet(last.slot, weightKg: last.weightKg, reps: last.reps, + rpe: last.rpe, isWarmup: true) + } + /// Begin a specific set — the out-of-order path, for when a machine is occupied. func start(_ slot: LiftSlot, fromStrap: Bool = false) { guard engine != nil else { return } if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } let stamp = Int(Date().timeIntervalSince1970) engine?.start(slot, now: stamp) + // Starting a slot drops any record it had, so its warm-up mark reverts to pending — which is + // where it already lives. now = stamp warnedFor = nil persist() diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index db204a54f4..36d877533a 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,12 @@ { "sourceLanguage": "en", "strings": { + "Set %lld — tap to mark it a warm-up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – tippen, um ihn als Aufwärmsatz zu markieren" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — tap to mark it a warm-up" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld: toca para marcarla como calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — touche pour la marquer comme échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — tocca per segnarla come riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – dotknij, aby oznaczyć jako rozgrzewkową" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — toca para marcar como aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — нажми, чтобы отметить как разминочный" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组——点按标记为热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組——點按標記為熱身組" } } + } }, + "Warm-up set — tap to make it a working set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmsatz – tippen, um ihn zum Arbeitssatz zu machen" } }, "en": { "stringUnit": { "state": "translated", "value": "Warm-up set — tap to make it a working set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie de calentamiento: toca para convertirla en serie efectiva" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série d'échauffement — touche pour en faire une série de travail" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie di riscaldamento — tocca per renderla una serie di lavoro" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria rozgrzewkowa – dotknij, aby zmienić na roboczą" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série de aquecimento — toca para a tornar série de trabalho" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминочный подход — нажми, чтобы сделать рабочим" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身组——点按改为正式组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身組——點按改為正式組" } } + } }, "Forget": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記" } } } }, diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 88d83e2fd5..e623c4d1fb 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -164,10 +164,25 @@ struct LiftSessionView: View { }() return HStack(spacing: 8) { - Text("\(slot.setIndex)") - .font(StrandFont.captionNumber) - .foregroundStyle(isWorking ? StrandPalette.textPrimary : StrandPalette.textSecondary) - .frame(width: 26, alignment: .leading) + // The set number IS the warm-up toggle. Warm-ups are excluded from volume and from the + // per-muscle counts, so being unable to mark one silently inflates the single figure the + // whole feature rests on — it has to be reachable in one tap, without leaving the row. + Button { + toggleWarmup(slot) + } label: { + Text(isWarmup(slot) ? String(localized: "W") : "\(slot.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(isWarmup(slot) + ? StrandPalette.metricAmber + : (isWorking ? StrandPalette.textPrimary + : StrandPalette.textSecondary)) + .frame(width: 26, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isWarmup(slot) + ? String(localized: "Warm-up set — tap to make it a working set") + : String(localized: "Set \(slot.setIndex) — tap to mark it a warm-up")) numberField(slot: slot, field: .weight(slot), text: weightBinding(slot), @@ -202,6 +217,14 @@ struct LiftSessionView: View { in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } + /// Warm-up state lives in the controller, so a mark survives the sheet being minimised and + /// applies however the set was closed out — button, strap, or the minimised bar. + private func isWarmup(_ slot: LiftSlot) -> Bool { session.isWarmup(slot) } + + private func toggleWarmup(_ slot: LiftSlot) { + session.setWarmup(slot, !session.isWarmup(slot)) + } + /// Green = working now, amber = the rest that follows it, faint = done, clear = still to come. private func rowBackground(isWorking: Bool, isResting: Bool, done: Bool) -> Color { if isWorking { return StrandPalette.statusPositive.opacity(0.20) } diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index a8291e3983..88c746f5f7 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -207,6 +207,43 @@ final class LiftSessionEngineTests: XCTestCase { "studies count working sets; a warm-up must not inflate the tally") } + // MARK: - Warm-ups + + func testAWarmUpMarkedBeforeTheSetIsPerformedSurvivesOntoIt() { + // You know a set is a warm-up on the way IN. The mark is held until the set exists, then + // applied — the regression this guards against silently counted every warm-up as a working + // set, inflating the one figure the whole feature rests on. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // working set 1 + e.advance(now: t0 + 40) // recorded + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + + XCTAssertTrue(e.sets[0].isWarmup) + XCTAssertEqual(e.completedWorkingSets, 0, + "a warm-up must not count toward the working-set tally") + } + + func testAWarmUpCanBeUnmarkedBackToAWorkingSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.completedWorkingSets, 0) + + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: false) + XCTAssertEqual(e.completedWorkingSets, 1, "un-marking restores it to a working set") + } + + func testAWarmUpStillCarriesItsWeightAndRepsForTheRecord() { + // Excluded from the COUNTS, but still logged: what you warmed up with is worth keeping. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.sets[0].weightKg, 20) + XCTAssertEqual(e.sets[0].reps, 12) + } + // MARK: - Ghost values func testThePreviousSetInThisSessionIsWhatASetGhostsFrom() { From 95ac411d5d14c26089702c04ff8a1e90eea9d496 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:30:02 +0200 Subject: [PATCH 08/31] lift log: describe the targets the editor actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-state card promised "working sets, rep range, target RPE, rest and your own technique note". Two of those five have not existed since the tap-anywhere fix: a line plans ONE rep count rather than a range, target RPE was deliberately removed from the UI (RPE is how hard a set FELT, knowable only after doing it), and a planned WEIGHT was added — which the card never mentioned. So the first thing a new user reads named two fields that are not there and omitted one that is. It is also the most-seen string in the feature during development, because a clean install shows it every time. Now: "working sets, reps, weight, rest and your own technique note" — exactly the fields in `LiftProgramItemSheet.targetsSection`. The stale key is removed rather than left orphaned, and all nine translations are adapted from the existing ones rather than re-invented, so the wording stays in each translator's voice. The same stale list was in the LiftProgramEditorSheet header comment; corrected there too. Verification: `Tools/i18n_audit.py --ci upstream/main` passes with all ten locales present. Both app targets build. Confirmed on a fresh install in the simulator — the card renders the corrected list. Co-Authored-By: Claude Opus 5 --- Strand/Resources/Localizable.xcstrings | 6 +++--- Strand/Screens/LiftLogView.swift | 2 +- Strand/Screens/LiftProgramEditorSheet.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 36d877533a..8c3a9891b5 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage": "en", "strings": { + "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ein Programm ist ein Name und eine geordnete Liste von Übungen mit deinen Zielen – Arbeitssätze, Wiederholungen, Gewicht, Pause und deine eigene Technik-Notiz."}}, "en": {"stringUnit": {"state": "translated", "value": "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note."}}, "es": {"stringUnit": {"state": "translated", "value": "Un programa es un nombre y una lista ordenada de ejercicios con tus objetivos: series efectivas, repeticiones, peso, descanso y tu propia nota de técnica."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un programme, c'est un nom et une liste ordonnée d'exercices avec tes objectifs : séries de travail, répétitions, charge, repos et ta propre note de technique."}}, "it": {"stringUnit": {"state": "translated", "value": "Un programma è un nome e un elenco ordinato di esercizi con i tuoi obiettivi: serie di lavoro, ripetizioni, carico, recupero e la tua nota sulla tecnica."}}, "pl": {"stringUnit": {"state": "translated", "value": "Program to nazwa i uporządkowana lista ćwiczeń z Twoimi celami – serie robocze, powtórzenia, ciężar, przerwa i Twoja własna notatka o technice."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um programa é um nome e uma lista ordenada de exercícios com os teus objetivos: séries de trabalho, repetições, peso, descanso e a tua própria nota de técnica."}}, "ru": {"stringUnit": {"state": "translated", "value": "Программа — это название и упорядоченный список упражнений с твоими целями: рабочие подходы, повторения, вес, отдых и твоя заметка о технике."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "一个计划就是一个名称加上一份有序的动作列表,附带你的目标:正式组、次数、重量、休息时间,以及你自己的技术笔记。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "一個計畫就是一個名稱加上一份有序的動作列表,附帶你的目標:正式組、次數、重量、休息時間,以及你自己的技術筆記。"}} + } }, "Set %lld — tap to mark it a warm-up": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – tippen, um ihn als Aufwärmsatz zu markieren" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — tap to mark it a warm-up" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld: toca para marcarla como calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — touche pour la marquer comme échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — tocca per segnarla come riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – dotknij, aby oznaczyć jako rozgrzewkową" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — toca para marcar como aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — нажми, чтобы отметить как разминочный" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组——点按标记为热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組——點按標記為熱身組" } } } }, @@ -355,9 +358,6 @@ "No programs yet": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Noch keine Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "No programs yet" } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun programme pour l'instant" } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak programów" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программ пока нет" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有計畫" } } } }, - "A program is a name and an ordered list of exercises with your targets — working sets, rep range, target RPE, rest and your own technique note.": { "localizations": { - "de": { "stringUnit": { "state": "translated", "value": "Ein Programm ist ein Name und eine geordnete Liste von Übungen mit deinen Zielen – Arbeitssätze, Wiederholungsbereich, Ziel-RPE, Pause und deine eigene Technik-Notiz." } }, "en": { "stringUnit": { "state": "translated", "value": "A program is a name and an ordered list of exercises with your targets — working sets, rep range, target RPE, rest and your own technique note." } }, "es": { "stringUnit": { "state": "translated", "value": "Un programa es un nombre y una lista ordenada de ejercicios con tus objetivos: series efectivas, rango de repeticiones, RPE objetivo, descanso y tu propia nota de técnica." } }, "fr": { "stringUnit": { "state": "translated", "value": "Un programme, c'est un nom et une liste ordonnée d'exercices avec tes objectifs : séries de travail, fourchette de répétitions, RPE visé, repos et ta propre note de technique." } }, "it": { "stringUnit": { "state": "translated", "value": "Un programma è un nome e un elenco ordinato di esercizi con i tuoi obiettivi: serie di lavoro, intervallo di ripetizioni, RPE target, recupero e la tua nota sulla tecnica." } }, "pl": { "stringUnit": { "state": "translated", "value": "Program to nazwa i uporządkowana lista ćwiczeń z Twoimi celami – serie robocze, zakres powtórzeń, docelowe RPE, przerwa i Twoja własna notatka o technice." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Um programa é um nome e uma lista ordenada de exercícios com os teus objetivos: séries de trabalho, intervalo de repetições, RPE alvo, descanso e a tua própria nota de técnica." } }, "ru": { "stringUnit": { "state": "translated", "value": "Программа — это название и упорядоченный список упражнений с твоими целями: рабочие подходы, диапазон повторений, целевой RPE, отдых и твоя заметка о технике." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "一个计划就是一个名称加上一份有序的动作列表,附带你的目标:正式组、次数区间、目标 RPE、休息时间,以及你自己的技术笔记。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "一個計畫就是一個名稱加上一份有序的動作列表,附帶你的目標:正式組、次數區間、目標 RPE、休息時間,以及你自己的技術筆記。" } } - } }, "Program": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "Program" } }, "es": { "stringUnit": { "state": "translated", "value": "Programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 3152be372f..b331572c95 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -123,7 +123,7 @@ struct LiftLogView: View { Text("No programs yet") .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - Text("A program is a name and an ordered list of exercises with your targets — working sets, rep range, target RPE, rest and your own technique note.") + Text("A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index 5fad065d75..b07d810dd4 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -3,7 +3,7 @@ import StrandDesign import WhoopStore // Build or edit a program: a name and an ordered list of exercise lines carrying the TARGETS — -// working sets, rep range, target RPE, rest and the user's own technique note. +// working sets, reps, weight, rest and the user's own technique note. // // Lines are edited as local drafts and written in one go on Save, through // `replaceLiftProgramItems`, which swaps the whole list transactionally. Editing a program never From dbcdc8d0dc49aec81dd5f570500edb6697472a18 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:27:58 +0200 Subject: [PATCH 09/31] lift log: keep the session on the machine you're at, and record what it showed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects from the first full gym session, both of which cost logged data. THE SESSION WALKED BACK TO A MACHINE THE USER HAD LEFT. `advance` moved to `nextPendingSlot` — the first uncompleted slot in PLAN order. Being able to start any pending set is the feature that handles an occupied machine, so a user who skips exercise 1 and starts exercise 3 always has an earlier slot still pending; plan order then threw them back to exercise 1 after every single set. Reported verbatim: "I switched to a different move because the machine was occupied. When I double-tap for the next set, it reverts to the first set of the exercise I couldn't do earlier." `slotAfter(_:)` now prefers the next uncompleted set of the SAME exercise and falls back to plan order only once that exercise is finished — which is also just what lifting is: you do your sets on the machine you are standing at. The skipped exercise is deferred, not forgotten, and is exactly what comes next. `nextPendingSlot` keeps its plan-order meaning for opening a session and for knowing when the sheet is done. A COMPLETED SET RECORDED NOTHING. The sheet showed "50 x 10" in grey for every set; completing one stored weight and reps as NIL, because a placeholder was never committed. 19 sets came back from a real session with no numbers at all and a session volume of zero — the measurements the feature exists to keep, gone. The rule that "a number nobody entered must never become data" is the right instinct pointed the wrong way: silence is not conservative when the alternative is losing the measurement. A set now records `carry(for:lastSession:)` — the same numbers the sheet was already showing, resolved in the same order: this exercise earlier in THIS session, then the same set number last session, then the program's target. It is not an inference about what the user did; it is the plan they pressed "set done" against, and the UI renders it as a real entry rather than a placeholder so it is visible and correctable during the rest. A set that was not actually performed is corrected to 0. RPE is deliberately NOT carried. Weight and reps are a plan, knowable in advance; RPE is how hard a set felt, knowable only afterwards. Carrying it would invent the one figure nobody can guess, and would silently make the RPE card report full coverage for sets nobody rated. `lastSession` is the one layer the engine cannot know, so the controller holds it and the sheet hands it over. It lives on the controller and not in the view because the strap can complete a set while the sheet is minimised, and a set recorded that way has to carry the same numbers the sheet would have shown. Verification: 9 new engine tests covering both defects — the occupied-machine path, the deferred exercise coming back, carry from the target / from this session / from last session, RPE never carrying, nothing-to-carry staying nil rather than inventing a zero, and a typed 0 sticking. StrandTests 1519, the only 2 failures being the pre-existing locale-dependent TodayCarryOverTests. Both app targets build. Confirmed in the simulator: completing a set on a later exercise advances within that exercise and records 30 x 8 without typing. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 35 +++++- Strand/Data/LiftSessionEngine.swift | 71 +++++++++++- Strand/Screens/LiftSessionView.swift | 15 ++- StrandTests/LiftSessionEngineTests.swift | 137 +++++++++++++++++++++++ 4 files changed, 252 insertions(+), 6 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 7501236a8b..c96b017c23 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -42,6 +42,16 @@ final class LiftSessionController: ObservableObject { /// one would create a set nobody did. So the mark is held here and applied the instant the set is /// recorded. Owned by the controller rather than a view so it survives the sheet being minimised. @Published private(set) var pendingWarmups: Set = [] + + /// What the store holds for each exercise LAST session, keyed by exercise name then set number — + /// the middle layer of `LiftSessionEngine.carry(for:lastSession:)`. + /// + /// It lives here rather than in the sheet because the strap can advance a set while the sheet is + /// minimised or closed, and a set recorded from the strap must carry the same numbers the sheet + /// would have shown. `LiftSessionView` loads it and hands it over; until it does (a session + /// resumed straight into the bar after a relaunch, say) the carry falls through to the program's + /// target, which is the layer below. + private var lastSession: [String: [Int: LiftSetCarry]] = [:] private var ticker: AnyCancellable? /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. @@ -150,13 +160,36 @@ final class LiftSessionController: ObservableObject { if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } let stamp = Int(Date().timeIntervalSince1970) - engine?.advance(now: stamp) + engine?.advance(now: stamp, lastSession: carryFromLastSession()) applyPendingWarmup() now = stamp warnedFor = nil persist() } + /// Hand over what the store knows about previous sessions. Called by the sheet once it has read + /// it; safe to call again if it reloads. + func setLastSession(_ values: [String: [Int: LiftSetCarry]]) { + lastSession = values + } + + /// What a slot will record (or did record) without anything typed — the sheet's grey numbers, + /// resolved through the same chain, for callers that only have the controller. Used by the + /// minimised bar, which has no access to the store's last-session values on its own. + func carry(for slot: LiftSlot) -> LiftSetCarry { + guard let engine else { return .none } + let exercise = engine.planItem(for: slot)?.exercise + let last = exercise.flatMap { lastSession[$0]?[slot.setIndex] } ?? .none + return engine.carry(for: slot, lastSession: last) + } + + /// The last-session carry for the slot currently being worked, if any. + private func carryFromLastSession() -> LiftSetCarry { + guard let engine, case .working(let slot) = engine.stage, + let exercise = engine.planItem(for: slot)?.exercise else { return .none } + return lastSession[exercise]?[slot.setIndex] ?? .none + } + /// Mark a slot as a warm-up (or not). Applies immediately when the set already exists, and is /// remembered for when it does not yet. func setWarmup(_ slot: LiftSlot, _ isWarmup: Bool) { diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index ebb79ded4a..14172f5059 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -86,6 +86,16 @@ struct LiftRecordedSet: Equatable { var slot: LiftSlot { LiftSlot(exerciseIndex: exerciseIndex, setIndex: setIndex) } } +/// What a set records when it is completed without anything typed into it. +/// +/// Only weight and reps. RPE is deliberately absent — see `carry(for:lastSession:)`. +struct LiftSetCarry: Equatable { + var weightKg: Double? + var reps: Int? + + static let none = LiftSetCarry(weightKg: nil, reps: nil) +} + struct LiftSessionEngine: Equatable { enum Stage: Equatable { @@ -169,10 +179,30 @@ struct LiftSessionEngine: Equatable { /// The next slot the plan would suggest — the first uncompleted one in plan order. Nil when the /// whole sheet is done. + /// + /// This is the SHEET's answer, used to open a session and to know when everything is done. It is + /// deliberately NOT what `advance` follows after a set — see `slotAfter(_:)`. var nextPendingSlot: LiftSlot? { allSlots.first { !isCompleted($0) } } + /// Where the session goes after finishing `slot`: the next uncompleted set of the SAME exercise, + /// and only once that exercise is finished, the first uncompleted slot in plan order. + /// + /// Plan order alone is wrong, and wrong in a way that costs sets. A gym is not a queue — the + /// whole point of being able to start any pending set is that machines get occupied — so a user + /// who skips exercise 1 and starts exercise 3 has an EARLIER slot still uncompleted. Following + /// plan order then throws them back to the machine they just walked away from, mid-exercise, + /// after every single set. Reported from a real session: "when I double-tap for the next set, it + /// reverts to the first set of the exercise I couldn't do earlier." + /// + /// Staying on the current exercise until it is finished is also simply what lifting is: you do + /// your sets on the machine you are standing at. The skipped exercise is not forgotten — it is + /// still pending, and it is what you get once the current one is done. + func slotAfter(_ slot: LiftSlot) -> LiftSlot? { + slots(forExercise: slot.exerciseIndex).first { !isCompleted($0) } ?? nextPendingSlot + } + var allCompleted: Bool { nextPendingSlot == nil } var isFinished: Bool { stage == .finished } var canUndo: Bool { !history.isEmpty } @@ -202,6 +232,37 @@ struct LiftSessionEngine: Equatable { .first } + /// What this slot records if the user completes it without typing: the same numbers the sheet + /// was already showing them in grey, in the same order of preference — this exercise earlier in + /// THIS session, then the same set number LAST session, then the program's target. + /// + /// The original rule was that a placeholder is never recorded, on the principle that "a number + /// nobody entered must never become data". A real session killed it: 19 sets were completed + /// against visible 50 kg x 10 placeholders and every one saved with weight and reps NIL, so the + /// session's volume was zero and the numbers the whole feature exists to keep were simply gone. + /// Silence is not the conservative choice when the alternative is losing the measurement. + /// + /// The principle survives in a stricter form: this is not an inference about what the user did, + /// it is the plan they were working to, committed only because they pressed "set done" against + /// it. The UI must therefore render a carried value as a REAL entry rather than a placeholder — + /// what was recorded has to be visible and correctable during the rest, which is when set entry + /// happens by design. A set the user did not actually do is corrected to 0, not left blank. + /// + /// RPE is NOT carried. Weight and reps are a plan, knowable in advance; RPE is how hard a set + /// FELT, knowable only afterwards, and carrying one forward would invent the one figure nobody + /// can guess for you. It would also silently corrupt the coverage the RPE card reports — every + /// set would read as rated, and "14 of 19 sets unrated" could never be shown again. + /// + /// `lastSession` is the one layer the engine cannot know: it comes from the store, and the + /// caller supplies it. Nil there simply falls through to the target. + func carry(for slot: LiftSlot, lastSession: LiftSetCarry) -> LiftSetCarry { + let previous = previousSetInSession(for: slot) + let item = planItem(for: slot) + return LiftSetCarry( + weightKg: previous?.weightKg ?? lastSession.weightKg ?? item?.targetWeightKg, + reps: previous?.reps ?? lastSession.reps ?? item?.targetRepsLow) + } + // MARK: - Actions /// Begin a specific set. Works from any stage, which is the whole point: a machine being busy @@ -219,7 +280,10 @@ struct LiftSessionEngine: Equatable { } /// The big button. Context decides what it means. - mutating func advance(now: Int) { + /// + /// `lastSession` is what the store holds for the slot being completed, used only as the middle + /// layer of `carry(for:lastSession:)`. Callers without it pass `.none`. + mutating func advance(now: Int, lastSession: LiftSetCarry = .none) { switch stage { case .warmup: guard let next = nextPendingSlot else { return } @@ -227,9 +291,10 @@ struct LiftSessionEngine: Equatable { case .working(let slot): pushHistory() + let carried = carry(for: slot, lastSession: lastSession) sets.append(LiftRecordedSet( exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, - weightKg: nil, reps: nil, rpe: nil, isWarmup: false, + weightKg: carried.weightKg, reps: carried.reps, rpe: nil, isWarmup: false, startTs: stageStartedAt, endTs: now, restSec: nil)) let rest = planItem(for: slot)?.restSec ?? LiftPlanItem.defaultRestSec stage = .resting(slot, endsAt: now + rest) @@ -242,7 +307,7 @@ struct LiftSessionEngine: Equatable { if let i = sets.firstIndex(where: { $0.slot == slot }) { sets[i].restSec = max(0, now - stageStartedAt) } - if let next = nextPendingSlot { + if let next = slotAfter(slot) { stage = .working(next) stageStartedAt = now } else { diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index e623c4d1fb..ebbbfb53ba 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -248,8 +248,14 @@ struct LiftSessionView: View { // // The placeholder shows what you'd most likely repeat, in priority order: the PREVIOUS SET OF // THIS EXERCISE IN THIS SESSION first (set 2 almost always mirrors set 1), then the same set - // number last session, then the program's target. It stays a placeholder — grey, and not - // recorded unless the user types — because a number nobody entered must never become data. + // number last session, then the program's target — the same order, from the same source, as + // `LiftSessionEngine.carry(for:lastSession:)`. + // + // These are shown only for a set that has NOT been completed yet: a plan, not a record. Once the + // set is completed the carried numbers become a real entry and the binding below returns them, + // so the row shows what was actually logged rather than a grey suggestion of it. Keep the two + // chains in step — a ghost that does not match what completing the set records is worse than no + // ghost at all. private func ghostWeight(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> String { if let prev = engine.previousSetInSession(for: slot)?.weightKg { return display(prev) } @@ -456,6 +462,11 @@ struct LiftSessionView: View { out[item.exercise] = bySet } lastTime = out + // The controller needs this too: the strap can complete a set while this sheet is minimised, + // and a set recorded that way must carry the same numbers the sheet was showing. + session.setLastSession(out.mapValues { bySet in + bySet.mapValues { LiftSetCarry(weightKg: $0.weightKg, reps: $0.reps) } + }) } private func save() async { diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index 88c746f5f7..babe4e3617 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -20,6 +20,21 @@ final class LiftSessionEngineTests: XCTestCase { ] } + /// Three exercises, so an EARLIER one can be left pending while a later one is worked. + private func threeExercisePlan() -> [LiftPlanItem] { + [ + LiftPlanItem(exercise: "Leg press", primaryMuscle: .quads, targetSets: 3, restSec: 90), + LiftPlanItem(exercise: "Lying leg curl", primaryMuscle: .hamstrings, targetSets: 3, restSec: 90), + LiftPlanItem(exercise: "Leg extension", primaryMuscle: .quads, targetSets: 2, restSec: 60), + ] + } + + /// One line carrying the targets a program actually plans. + private func targetedPlanItem() -> LiftPlanItem { + LiftPlanItem(exercise: "Leg press", primaryMuscle: .quads, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50) + } + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } // MARK: - The sheet @@ -40,6 +55,128 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertEqual(e.nextPendingSlot, slot(0, 1)) } + // MARK: - The occupied-machine path (reported from a real session) + + /// Skipping an exercise because its machine is busy must not drag the session back to it after + /// every set. Reported from the gym: "I switched to a different move because the machine was + /// occupied. When I double-tap for the next set, it reverts to the first set of the exercise I + /// couldn't do earlier." + func testFinishingASetStaysOnTheSameExerciseEvenWithAnEarlierOneSkipped() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + + // Exercise 0's machine is busy — start exercise 2 instead. + e.start(slot(2, 1), now: t0 + 60) + e.advance(now: t0 + 100) // set done -> rest + XCTAssertEqual(e.stage, .resting(slot(2, 1), endsAt: t0 + 100 + 60)) + + e.advance(now: t0 + 160) // rest done -> next set + XCTAssertEqual(e.stage, .working(slot(2, 2)), + "must continue on the machine the user is standing at, not jump back to 0") + } + + /// And once that exercise IS finished, the skipped one is exactly what comes next — it was + /// deferred, not abandoned. + func testTheSkippedExerciseIsWhatComesNextOnceTheCurrentOneIsDone() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(2, 1), now: t0 + 60) + e.advance(now: t0 + 100); e.advance(now: t0 + 160) // set 1 done, on to set 2 + e.advance(now: t0 + 200) // set 2 done -> rest + e.advance(now: t0 + 260) // rest done -> exercise 2 finished + + XCTAssertEqual(e.stage, .working(slot(0, 1)), + "with exercise 2 complete, the deferred exercise 0 is next") + } + + func testSlotAfterPrefersTheSameExerciseThenFallsBackToPlanOrder() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + XCTAssertEqual(e.slotAfter(slot(2, 1)), slot(2, 1), "its own set is still pending") + + e.start(slot(2, 1), now: t0); e.advance(now: t0 + 40) + XCTAssertEqual(e.slotAfter(slot(2, 1)), slot(2, 2)) + + e.advance(now: t0 + 100); e.advance(now: t0 + 140) // finish exercise 2 entirely + XCTAssertEqual(e.slotAfter(slot(2, 2)), slot(0, 1), "exhausted -> first pending in plan order") + } + + // MARK: - Carrying the shown numbers onto a completed set + + /// A set completed with nothing typed records the numbers the sheet was showing in grey. Before + /// this, 19 sets from a real session saved with weight and reps NIL — the sheet displayed + /// "50 x 10" the whole time and stored nothing, so the session's volume was zero. + func testCompletingASetWithoutTypingRecordsTheProgramTarget() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) // warm-up -> working set 1 + e.advance(now: t0 + 70) // set done + + let row = e.recordedSet(for: slot(0, 1)) + XCTAssertEqual(row?.weightKg, 50) + XCTAssertEqual(row?.reps, 10) + } + + /// The second set carries what the FIRST set actually was, not the plan — if you dropped to + /// 45 kg, set 2 follows you down rather than snapping back to the program. + func testASetCarriesWhatTheExerciseActuallyDidEarlierInTheSession() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 45, reps: 8, rpe: 9, isWarmup: false) + e.advance(now: t0 + 130) // rest done -> set 2 + e.advance(now: t0 + 190) // set 2 done + + let row = e.recordedSet(for: slot(0, 2)) + XCTAssertEqual(row?.weightKg, 45, "the session's own history outranks the program's plan") + XCTAssertEqual(row?.reps, 8) + } + + /// The store's answer sits between this session and the program target. + func testLastSessionIsUsedWhenTheSessionHasNoEarlierSetForTheExercise() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70, lastSession: LiftSetCarry(weightKg: 52.5, reps: 9)) + + let row = e.recordedSet(for: slot(0, 1)) + XCTAssertEqual(row?.weightKg, 52.5, "last session beats the program's target") + XCTAssertEqual(row?.reps, 9) + } + + /// RPE is never carried: it is how hard a set FELT, which nothing can know in advance, and + /// inventing it would make the RPE card report full coverage for sets nobody rated. + func testRpeIsNeverCarried() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 50, reps: 10, rpe: 8.5, isWarmup: false) + e.advance(now: t0 + 130) + e.advance(now: t0 + 190) + + XCTAssertEqual(e.recordedSet(for: slot(0, 2))?.weightKg, 50, "weight carries") + XCTAssertNil(e.recordedSet(for: slot(0, 2))?.rpe, "the felt effort of a set does not") + } + + /// Nothing to carry stays nil rather than inventing a zero — a set with no plan, no history and + /// nothing typed genuinely has no measurement, and 0 kg would be a false one. + func testASetWithNothingToCarryStaysEmpty() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + + XCTAssertNil(e.recordedSet(for: slot(0, 1))?.weightKg) + XCTAssertNil(e.recordedSet(for: slot(0, 1))?.reps) + } + + /// A carried value is a normal entry: typing over it wins, including typing a 0 for a set that + /// was planned but not actually performed. + func testTypingZeroOverAcarriedValueSticks() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + XCTAssertEqual(e.recordedSet(for: slot(0, 1))?.weightKg, 50) + + e.updateSet(slot(0, 1), weightKg: 0, reps: 0, rpe: nil, isWarmup: false) + XCTAssertEqual(e.recordedSet(for: slot(0, 1))?.weightKg, 0) + XCTAssertEqual(e.recordedSet(for: slot(0, 1))?.reps, 0) + } + // MARK: - The default in-order path func testTheFullTapThroughRecordsEverySetInOrder() { From 85eecd4832dbc7342dff9b13abca24506ed86b59 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:28:30 +0200 Subject: [PATCH 10/31] lift log: fix the wrapped Set heading, and show HR and the set's numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a real session asked for, all display-only. THE SET HEADING WRAPPED MID-WORD. `strandOverline` renders ALL-CAPS with +1.4 tracking, and the column was 26pt wide, so the heading came out as "SE" over "T" — photographed on a phone at the gym. It is now 34pt, shared as a constant by the heading and every row so the set number sits centred directly under its label, and the heading row is `lineLimit(1)` with a scale floor. That last part matters beyond this one label: four short headings across a phone width in ten languages is exactly where a wrap reappears, and a wrapped heading breaks the column alignment for every row beneath it. LIVE HEART RATE ON THE CONTROL BAR. Asked for directly. It belongs on the strip that never scrolls, beside the clocks, because the use is a glance mid-set while you are holding a bar. Reads `AppModel.bpm` — the smoothed, spike-filtered value every screen is supposed to show, never the raw per-beat number. Shown as "—" when there is no value, the way LiveView reports it, so a stopped stream is distinguishable from a missing feature and the clocks beside it do not shift. Display only: nothing here feeds a score. Effort stays HR-derived from what the strap MEASURED over the session window, computed by the analytics engine. THE MINIMISED BAR NOW SAYS WHAT YOU ARE LIFTING. It read "Set 2 — working", which is the one thing you already know. It now reads "Set 2 — 8 x 30 kg": while resting those are what the set recorded, while working they are what completing it would record. That is the question you have when the phone is face-down on a bench and the sheet is minimised. Falls back to the old wording when neither reps nor weight is known, since "Set 2 — x" helps nobody. One new string, "HR", with the abbreviations the existing "Live HR" entry already uses per language (de HF, es/fr/it/pt-PT FC, zh 心率); Russian follows that entry's own word choice rather than the clinical ЧСС. Verification: `Tools/i18n_audit.py --ci upstream/main` passes with all ten locales and no new hardcoded literals; `doc_comment_lint.py` passes. Both app targets build. Confirmed in the simulator: the heading renders on one line with the numbers centred under it, HR shows on the control bar, and the minimised bar reads "Lying Leg Curl / Set 2 — 8 x 30 kg". Co-Authored-By: Claude Opus 5 --- Strand/Resources/Localizable.xcstrings | 3 ++ Strand/Screens/LiftSessionBar.swift | 46 ++++++++++++++++++++++--- Strand/Screens/LiftSessionView.swift | 47 +++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 8c3a9891b5..51d355b328 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage": "en", "strings": { + "HR": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "HF"}}, "en": {"stringUnit": {"state": "translated", "value": "HR"}}, "es": {"stringUnit": {"state": "translated", "value": "FC"}}, "fr": {"stringUnit": {"state": "translated", "value": "FC"}}, "it": {"stringUnit": {"state": "translated", "value": "FC"}}, "pl": {"stringUnit": {"state": "translated", "value": "HR"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "FC"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率"}} + } }, "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Ein Programm ist ein Name und eine geordnete Liste von Übungen mit deinen Zielen – Arbeitssätze, Wiederholungen, Gewicht, Pause und deine eigene Technik-Notiz."}}, "en": {"stringUnit": {"state": "translated", "value": "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note."}}, "es": {"stringUnit": {"state": "translated", "value": "Un programa es un nombre y una lista ordenada de ejercicios con tus objetivos: series efectivas, repeticiones, peso, descanso y tu propia nota de técnica."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un programme, c'est un nom et une liste ordonnée d'exercices avec tes objectifs : séries de travail, répétitions, charge, repos et ta propre note de technique."}}, "it": {"stringUnit": {"state": "translated", "value": "Un programma è un nome e un elenco ordinato di esercizi con i tuoi obiettivi: serie di lavoro, ripetizioni, carico, recupero e la tua nota sulla tecnica."}}, "pl": {"stringUnit": {"state": "translated", "value": "Program to nazwa i uporządkowana lista ćwiczeń z Twoimi celami – serie robocze, powtórzenia, ciężar, przerwa i Twoja własna notatka o technice."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um programa é um nome e uma lista ordenada de exercícios com os teus objetivos: séries de trabalho, repetições, peso, descanso e a tua própria nota de técnica."}}, "ru": {"stringUnit": {"state": "translated", "value": "Программа — это название и упорядоченный список упражнений с твоими целями: рабочие подходы, повторения, вес, отдых и твоя заметка о технике."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "一个计划就是一个名称加上一份有序的动作列表,附带你的目标:正式组、次数、重量、休息时间,以及你自己的技术笔记。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "一個計畫就是一個名稱加上一份有序的動作列表,附帶你的目標:正式組、次數、重量、休息時間,以及你自己的技術筆記。"}} } }, diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index d0a59d935a..85090b4401 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -16,6 +16,9 @@ import StrandDesign struct LiftSessionBar: View { @EnvironmentObject var session: LiftSessionController + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + var body: some View { if let engine = session.engine, !engine.isFinished { Button { @@ -87,16 +90,51 @@ struct LiftSessionBar: View { } switch engine.stage { case .working: - return String(localized: "Set \(slot.setIndex) — working") + guard let numbers = numbers(engine, slot: slot) else { + return String(localized: "Set \(slot.setIndex) — working") + } + return String(localized: "Set \(slot.setIndex) — \(numbers)") case .resting: - return (engine.restRemaining(now: session.now) ?? 0) == 0 - ? String(localized: "Ready for the next set") - : String(localized: "Resting after set \(slot.setIndex)") + let ready = (engine.restRemaining(now: session.now) ?? 0) == 0 + guard let numbers = numbers(engine, slot: slot) else { + return ready + ? String(localized: "Ready for the next set") + : String(localized: "Resting after set \(slot.setIndex)") + } + return ready + ? String(localized: "Ready — last was \(numbers)") + : String(localized: "Resting after \(numbers)") default: return String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") } } + /// Reps x weight for the slot the bar is showing, as "10 x 50 kg". + /// + /// While RESTING these are what the set actually recorded; while WORKING the set does not exist + /// yet, so they are what completing it would record — the same numbers the sheet shows in grey. + /// Either way the bar answers "what am I lifting", which is the question you have when the phone + /// is face-down on a bench and the sheet is minimised. + /// + /// Nil when neither reps nor weight is known: a bar reading "Set 2 — x" helps nobody, so the + /// caller falls back to the plain wording. + private func numbers(_ engine: LiftSessionEngine, slot: LiftSlot) -> String? { + let carry = engine.recordedSet(for: slot).map { + LiftSetCarry(weightKg: $0.weightKg, reps: $0.reps) + } ?? session.carry(for: slot) + + let weight = carry.weightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) + + " " + LiftFormat.weightUnit(unitSystem) + } + switch (carry.reps, weight) { + case (let r?, let w?): return "\(r) x \(w)" + case (let r?, nil): return String(localized: "\(r) reps") + case (nil, let w?): return w + case (nil, nil): return nil + } + } + /// Rest counts DOWN (that is the number you act on); everything else counts up. private func bigClock(_ engine: LiftSessionEngine) -> String { if let remaining = engine.restRemaining(now: session.now) { diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index ebbbfb53ba..c1edbb209a 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -33,6 +33,10 @@ struct LiftSessionView: View { @State private var sessionRpeText = "" @State private var saving = false + /// For the live heart rate on the control bar. `AppModel.bpm` is the smoothed, spike-filtered + /// value every screen is supposed to show — never the raw per-beat number, which swings with HRV. + @EnvironmentObject private var model: AppModel + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } @@ -139,14 +143,30 @@ struct LiftSessionView: View { .id(index) } + /// Width of the set-number column, shared by the heading and every row so the number sits + /// directly under its label. + /// + /// 34, not 26. `strandOverline` renders ALL-CAPS with +1.4 tracking, and at 26 the heading wrapped + /// mid-word — a real session photographed it reading "SE / T" over two lines. The headings are + /// also `lineLimit(1)` with a scale floor: this row is four short labels across a phone width in + /// ten languages, and a wrapped heading breaks the column alignment for every row beneath it. + private static let setColumnWidth: CGFloat = 34 + + /// Width of the trailing tick column. Mirrored by a clear spacer in the heading row so the four + /// labels sit over the four things they name. + private static let tickColumnWidth: CGFloat = 30 + private var columnHeadings: some View { HStack(spacing: 8) { - Text("Set").strandOverline().frame(width: 26, alignment: .leading) + Text("Set").strandOverline() + .frame(width: Self.setColumnWidth, alignment: .center) Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) - Color.clear.frame(width: 30) + Color.clear.frame(width: Self.tickColumnWidth) } + .lineLimit(1) + .minimumScaleFactor(0.8) } private var weightHeading: LocalizedStringKey { @@ -176,7 +196,7 @@ struct LiftSessionView: View { ? StrandPalette.metricAmber : (isWorking ? StrandPalette.textPrimary : StrandPalette.textSecondary)) - .frame(width: 26, alignment: .leading) + .frame(width: Self.setColumnWidth, alignment: .center) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -206,7 +226,7 @@ struct LiftSessionView: View { : StrandPalette.statusPositive) } .buttonStyle(.plain) - .frame(width: 30) + .frame(width: Self.tickColumnWidth) .accessibilityLabel(recorded == nil ? String(localized: "Start this set") : String(localized: "Redo this set")) @@ -329,6 +349,7 @@ struct LiftSessionView: View { LiftFormat.duration(max(0, session.now - engine.startTs)), tint: StrandPalette.textPrimary) stageClock(engine) + heartRate() Spacer(minLength: 0) Button { session.undo() @@ -363,6 +384,24 @@ struct LiftSessionView: View { } } + /// Live heart rate, beside the clocks that are already pinned above the action button. + /// + /// It belongs here and not in the scrolling sheet: this strip is the part that never scrolls + /// away, and a glance mid-set is the whole use — you are holding a bar, not browsing. Asked for + /// after a real session. + /// + /// Shown even when there is no value, as "—", the same way `LiveView` reports it. A row that + /// disappears when the strap stops streaming would shift the clocks beside it and leave the user + /// wondering whether the reading is missing or the feature is; a dash says which. + /// + /// This is display only. Nothing here feeds a score — Effort stays HR-derived from what the + /// strap MEASURED over the session window, computed by the analytics engine, not by this view. + private func heartRate() -> some View { + clock(String(localized: "HR"), + model.bpm.map(String.init) ?? "—", + tint: model.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose) + } + private func clock(_ label: String, _ value: String, tint: Color) -> some View { VStack(alignment: .leading, spacing: 1) { Text(label).strandOverline() From 5427e0fcfa2f65cb497be335bcd8b5b296ecc0dd Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:52:33 +0200 Subject: [PATCH 11/31] lift log: draw the rest between the sets, and put HR on the bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE REST IS NOW A BAND BETWEEN TWO SET ROWS, not an amber tint on the row above it. Tinting the finished set said the wrong thing — that set is over; what is running is the gap after it — and from across a gym floor it was not obvious which gap that was. A band sits where a rest actually is, and carries the countdown as well as the colour: the control bar has the same number, but this is where your eyes already are, and once the sheet is scrolled to a later exercise the band is the only thing that says which rest. Green is untouched. HEART RATE ON THE MINIMISED BAR, beside the clock. The sheet's control bar already shows it; the bar is the surface you actually have open with the phone face-down. Shown only when there IS a reading, unlike the sheet, which holds a "—" so its clocks do not shift — a capsule with four things competing for width gains nothing from a permanent dash. THE REST CLOCK WAS LABELLED WITH THE SLEEP METRIC'S STRING. `String(localized: "Rest")` resolves to the catalog's "Rest" key, which is NOOP's SLEEP metric: "Erholung" in German, "Riposo" in Italian. So the gym rest timer was labelled "recovery" in every non-English locale. This is the exact collision CLAUDE.md and the handover notes both warn about, reintroduced by the workout-sheet rewrite (b2e23bd3). It now uses "Rest period" — the string that exists for this — and the clock label got `lineLimit(1)` with a scale floor, because the correct translations are longer ("Отдых между подходами") and must shrink rather than wrap the control bar. The bar and the Lock Screen activity that follows both need the same wording and the same numbers, so both now come from one `LiftSessionController.presentation` rather than a copy each. Two copies of this drifted the moment one was edited. Two new strings, "HR" and "Set %lld", in all ten locales; "HR" uses the abbreviations the existing "Live HR" entry already uses per language, and "Set %lld" is the existing "Set %lld — working" with its clause dropped, so each translator's own noun for a set is preserved. Verification: `i18n_audit.py --ci upstream/main` and `doc_comment_lint.py` pass; StrandTests 1519, only the two pre-existing locale-dependent failures; both app targets build. Confirmed in the simulator. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 79 +++++++++++++++++++++++++ Strand/Resources/Localizable.xcstrings | 6 ++ Strand/Screens/LiftSessionBar.swift | 77 +++++++++--------------- Strand/Screens/LiftSessionView.swift | 62 ++++++++++++++++--- 4 files changed, 165 insertions(+), 59 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index c96b017c23..eabee3e2b1 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -167,6 +167,85 @@ final class LiftSessionController: ObservableObject { persist() } + // MARK: - Presentation + // + // What a running session looks like, resolved ONCE here rather than in each surface that shows + // it. The minimised bar and the Lock Screen Live Activity display the same four things — state, + // exercise, numbers, clock — and they must agree, including the wording. Two copies of this + // drifted the moment one of them was edited. + + struct Presentation: Equatable { + var isResting: Bool + /// The exercise being worked or rested from; the program's name when neither applies. + var exercise: String + /// "Set 2", "Resting after set 2", "Ready for the next set", "3 of 19 sets done". + var status: String + /// "8 x 30 kg", already unit-converted. Nil when neither reps nor weight is known. + var detail: String? + var setsDone: Int + var setsPlanned: Int + var stageStartedAt: Date + /// When the running rest is due to end. Nil while working. + var restEndsAt: Date? + } + + func presentation(system: UnitSystem) -> Presentation? { + guard let engine, !engine.isFinished else { return nil } + let done = engine.completedWorkingSets + let planned = engine.plannedWorkingSets + let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt)) + let fallback = String(localized: "\(done) of \(planned) sets done") + + guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { + return Presentation(isResting: false, + exercise: programName ?? String(localized: "Session"), + status: fallback, detail: nil, + setsDone: done, setsPlanned: planned, + stageStartedAt: started, restEndsAt: nil) + } + + let detail = setNumbers(for: slot, system: system) + switch engine.stage { + case .resting(_, let endsAt): + let ready = endsAt <= now + return Presentation( + isResting: true, exercise: item.exercise, + status: ready ? String(localized: "Ready for the next set") + : String(localized: "Resting after set \(slot.setIndex)"), + detail: detail, setsDone: done, setsPlanned: planned, + stageStartedAt: started, + restEndsAt: Date(timeIntervalSince1970: TimeInterval(endsAt))) + default: + return Presentation( + isResting: false, exercise: item.exercise, + status: String(localized: "Set \(slot.setIndex)"), + detail: detail, setsDone: done, setsPlanned: planned, + stageStartedAt: started, restEndsAt: nil) + } + } + + /// Reps x weight for a slot, as "8 x 30 kg". + /// + /// While RESTING these are what the set actually recorded; while WORKING the set does not exist + /// yet, so they are what completing it would record — the same numbers the sheet shows in grey. + func setNumbers(for slot: LiftSlot, system: UnitSystem) -> String? { + guard let engine else { return nil } + let values = engine.recordedSet(for: slot).map { + LiftSetCarry(weightKg: $0.weightKg, reps: $0.reps) + } ?? carry(for: slot) + + let weight = values.weightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) + + " " + LiftFormat.weightUnit(system) + } + switch (values.reps, weight) { + case (let r?, let w?): return "\(r) x \(w)" + case (let r?, nil): return String(localized: "\(r) reps") + case (nil, let w?): return w + case (nil, nil): return nil + } + } + /// Hand over what the store knows about previous sessions. Called by the sheet once it has read /// it; safe to call again if it reloads. func setLastSession(_ values: [String: [Int: LiftSetCarry]]) { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 51d355b328..8c364874f6 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,12 @@ { "sourceLanguage": "en", "strings": { + "Set %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Set %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Serie %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подход %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "第 %lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "第 %lld 組"}} + } }, + "Heart rate %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Herzfrequenz %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Heart rate %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Frecuencia cardíaca %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fréquence cardiaque %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Frequenza cardiaca %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Tętno %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Frequência cardíaca %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率 %lld"}} + } }, "HR": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "HF"}}, "en": {"stringUnit": {"state": "translated", "value": "HR"}}, "es": {"stringUnit": {"state": "translated", "value": "FC"}}, "fr": {"stringUnit": {"state": "translated", "value": "FC"}}, "it": {"stringUnit": {"state": "translated", "value": "FC"}}, "pl": {"stringUnit": {"state": "translated", "value": "HR"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "FC"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率"}} } }, diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 85090b4401..00a1f0ae49 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -16,6 +16,10 @@ import StrandDesign struct LiftSessionBar: View { @EnvironmentObject var session: LiftSessionController + /// Live heart rate, same source as the sheet's control bar: the smoothed, spike-filtered value, + /// never the raw per-beat number. + @EnvironmentObject private var model: AppModel + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } @@ -43,6 +47,21 @@ struct LiftSessionBar: View { Spacer(minLength: 0) + // Shown only when there IS a reading, unlike the sheet's control bar, which + // holds a "—" so its clocks do not shift. This is a capsule with four things + // already competing for it; a permanent dash would cost width and say nothing. + if let bpm = model.bpm { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10, weight: .semibold)) + Text("\(bpm)") + .font(StrandFont.captionNumber) + .monospacedDigit() + } + .foregroundStyle(StrandPalette.metricRose) + .accessibilityLabel(String(localized: "Heart rate \(bpm)")) + } + Text(bigClock(engine)) .font(StrandFont.bodyNumber) .foregroundStyle(tint(engine)) @@ -77,62 +96,20 @@ struct LiftSessionBar: View { } } + + /// Title and subtitle come from `LiftSessionController.presentation` — the SAME resolution the + /// Lock Screen Live Activity renders, so the two surfaces cannot word the session differently. private func title(_ engine: LiftSessionEngine) -> String { - guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { - return session.programName ?? String(localized: "Session") - } - return item.exercise + session.presentation(system: unitSystem)?.exercise + ?? session.programName ?? String(localized: "Session") } private func subtitle(_ engine: LiftSessionEngine) -> String { - guard let slot = engine.currentSlot else { + guard let p = session.presentation(system: unitSystem) else { return String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") } - switch engine.stage { - case .working: - guard let numbers = numbers(engine, slot: slot) else { - return String(localized: "Set \(slot.setIndex) — working") - } - return String(localized: "Set \(slot.setIndex) — \(numbers)") - case .resting: - let ready = (engine.restRemaining(now: session.now) ?? 0) == 0 - guard let numbers = numbers(engine, slot: slot) else { - return ready - ? String(localized: "Ready for the next set") - : String(localized: "Resting after set \(slot.setIndex)") - } - return ready - ? String(localized: "Ready — last was \(numbers)") - : String(localized: "Resting after \(numbers)") - default: - return String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done") - } - } - - /// Reps x weight for the slot the bar is showing, as "10 x 50 kg". - /// - /// While RESTING these are what the set actually recorded; while WORKING the set does not exist - /// yet, so they are what completing it would record — the same numbers the sheet shows in grey. - /// Either way the bar answers "what am I lifting", which is the question you have when the phone - /// is face-down on a bench and the sheet is minimised. - /// - /// Nil when neither reps nor weight is known: a bar reading "Set 2 — x" helps nobody, so the - /// caller falls back to the plain wording. - private func numbers(_ engine: LiftSessionEngine, slot: LiftSlot) -> String? { - let carry = engine.recordedSet(for: slot).map { - LiftSetCarry(weightKg: $0.weightKg, reps: $0.reps) - } ?? session.carry(for: slot) - - let weight = carry.weightKg.map { - LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) - + " " + LiftFormat.weightUnit(unitSystem) - } - switch (carry.reps, weight) { - case (let r?, let w?): return "\(r) x \(w)" - case (let r?, nil): return String(localized: "\(r) reps") - case (nil, let w?): return w - case (nil, nil): return nil - } + guard let detail = p.detail else { return p.status } + return "\(p.status) — \(detail)" } /// Rest counts DOWN (that is the number you act on); everything else counts up. diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index c1edbb209a..ac01dc6367 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -137,6 +137,8 @@ struct LiftSessionView: View { ForEach(engine.slots(forExercise: index), id: \.self) { slot in setRow(engine, slot: slot, item: item) + // The rest belongs BETWEEN two sets, because that is where it happens. + if isRestingAfter(engine, slot: slot) { restBand(engine) } } } } @@ -178,10 +180,6 @@ struct LiftSessionView: View { private func setRow(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> some View { let recorded = engine.recordedSet(for: slot) let isWorking = engine.stage == .working(slot) - let isResting: Bool = { - if case .resting(let s, _) = engine.stage { return s == slot } - return false - }() return HStack(spacing: 8) { // The set number IS the warm-up toggle. Warm-ups are excluded from volume and from the @@ -233,7 +231,7 @@ struct LiftSessionView: View { } .padding(.vertical, 6) .padding(.horizontal, 8) - .background(rowBackground(isWorking: isWorking, isResting: isResting, done: recorded != nil), + .background(rowBackground(isWorking: isWorking, done: recorded != nil), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } @@ -245,14 +243,55 @@ struct LiftSessionView: View { session.setWarmup(slot, !session.isWarmup(slot)) } - /// Green = working now, amber = the rest that follows it, faint = done, clear = still to come. - private func rowBackground(isWorking: Bool, isResting: Bool, done: Bool) -> Color { + /// Green = working now, faint = done, clear = still to come. + /// + /// Deliberately no amber case. Tinting the just-finished SET amber said the wrong thing: the set + /// is over, and what is running is the gap after it. The rest is drawn as its own band between + /// the two set rows instead — see `restBand`. + private func rowBackground(isWorking: Bool, done: Bool) -> Color { if isWorking { return StrandPalette.statusPositive.opacity(0.20) } - if isResting { return StrandPalette.metricAmber.opacity(0.20) } if done { return StrandPalette.surfaceRaised.opacity(0.5) } return .clear } + private func isRestingAfter(_ engine: LiftSessionEngine, slot: LiftSlot) -> Bool { + if case .resting(let s, _) = engine.stage { return s == slot } + return false + } + + /// The running rest, drawn as an amber band sitting BETWEEN the set that ended and the set that + /// follows — which is literally where a rest is. + /// + /// It replaces tinting the finished set's row amber. That read as "this set is amber" when the + /// set was already done, and from across a gym floor it was not obvious which gap was running. + /// A band in the gap is unambiguous at a glance, which is the whole requirement: you are looking + /// at this from a bench, not reading it. + /// + /// It carries the countdown as well as the colour. The control bar has the same number, but the + /// control bar is pinned to the bottom and this is where your eyes already are — and once the + /// sheet is scrolled to a later exercise, the band is the only thing that says which rest. + private func restBand(_ engine: LiftSessionEngine) -> some View { + let remaining = engine.restRemaining(now: session.now) ?? 0 + return HStack(spacing: 8) { + Text("Rest period").strandOverline() + .foregroundStyle(StrandPalette.metricAmber) + Spacer(minLength: 0) + Text(LiftFormat.duration(remaining)) + .font(StrandFont.captionNumber) + .monospacedDigit() + .foregroundStyle(StrandPalette.metricAmber) + } + .lineLimit(1) + .minimumScaleFactor(0.75) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity) + .background(StrandPalette.metricAmber.opacity(0.22), + in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + .accessibilityElement(children: .combine) + } + private func numberField(slot: LiftSlot, field: FocusTarget, text: Binding, ghost: String) -> some View { TextField(ghost, text: text) @@ -405,6 +444,8 @@ struct LiftSessionView: View { private func clock(_ label: String, _ value: String, tint: Color) -> some View { VStack(alignment: .leading, spacing: 1) { Text(label).strandOverline() + .lineLimit(1) + .minimumScaleFactor(0.7) Text(value) .font(StrandFont.bodyNumber) .foregroundStyle(tint) @@ -419,7 +460,10 @@ struct LiftSessionView: View { LiftFormat.duration(max(0, session.now - engine.stageStartedAt)), tint: StrandPalette.statusPositive) case .resting: - clock(String(localized: "Rest"), + // "Rest period", never "Rest": the catalog's "Rest" key is NOOP's SLEEP metric, so this + // label rendered as "Erholung" (recovery) in German — the exact collision CLAUDE.md and + // the handover brief both warn about. Reintroduced by the workout-sheet rewrite. + clock(String(localized: "Rest period"), LiftFormat.duration(engine.restRemaining(now: session.now) ?? 0), tint: StrandPalette.metricAmber) case .warmup, .finished: From f9dbfd4b565fa0070859814b46c6857ec35cae35 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:52:50 +0200 Subject: [PATCH 12/31] lift log: put the running session on the Lock Screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Live Activity carrying what the minimised bar carries — state, exercise, reps x weight, heart rate and the clock — so a set can be followed with the phone locked on a bench. Asked for after a gym session; it is the surface that matters when the phone is not in your hand. SEPARATE ACTIVITY TYPE from the existing live-HR one. They answer different questions and have different lifetimes: the HR activity lives as long as the strap streams, this one as long as a session is open. Rather than stack two banners, the app suppresses the HR activity while a session is running — this one already carries the heart rate. PUSHES ARE CONTENT-DRIVEN, NOT CLOCK-DRIVEN. Both timers are `Text(timerInterval:)`, so the Lock Screen counts on its own between pushes and the app only sends state when something a person would notice changes: the stage, the exercise, the numbers, or (at most every 10 s) the heart rate. A banner that pushed once a second to animate a clock would be throttled by ActivityKit and look worse for it. `Text(date, style: .timer)` looked equivalent and was not: it rendered "25 minutes" on the Lock Screen where a gym timer has to read 25:02. Only running it in the simulator caught that. The wording and the numbers come from the same `LiftSessionController.presentation` the in-app bar renders, so the two surfaces cannot describe the session differently. Everything user-facing is formatted APP-side and passed in as strings — the widget extension ships no string catalog, so a literal there would be untranslatable copy in ten locales. Reuses the existing Live Activity opt-out rather than adding a second switch: a user who turned Live Activities off meant all of them. Activities are re-adopted after a relaunch and ended on the whole `activities` list, the same way the HR controller does, so an opt-out or a finished session cannot leave a stale banner. Verification: confirmed on the Lock Screen in the simulator — the banner renders the exercise, "Set 2 — 8 x 30 kg", the progress line and a clock that advances on its own. `i18n_audit.py --ci upstream/main` reports no new un-extracted literals; `doc_comment_lint.py` passes; both app targets build; StrandTests 1519 with only the two pre-existing locale-dependent failures. NOT yet confirmed on hardware: the seconds digits render as "--" in simulator screenshots, which is how the simulator captures a system-drawn live timer, but that is worth a glance on a real Lock Screen. Co-Authored-By: Claude Opus 5 --- StrandiOS/App/StrandiOSApp.swift | 44 +++++- .../Widgets/LiftLiveActivityController.swift | 99 ++++++++++++++ StrandiOSShared/LiftActivityAttributes.swift | 60 ++++++++ StrandiOSWidgets/LiftLiveActivity.swift | 129 ++++++++++++++++++ StrandiOSWidgets/NOOPWidgetBundle.swift | 6 +- 5 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 StrandiOS/Widgets/LiftLiveActivityController.swift create mode 100644 StrandiOSShared/LiftActivityAttributes.swift create mode 100644 StrandiOSWidgets/LiftLiveActivity.swift diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 9e997022ca..0931472057 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -25,6 +25,10 @@ struct StrandiOSApp: App { /// observes it and presents the Devices manager. @StateObject private var router: NavRouter @State private var liveActivity = LiveActivityController() + /// The Lift Log session's own Live Activity. Separate from the live-HR one above: while a gym + /// session is open this is the banner that matters (it carries the heart rate too), so the HR + /// activity is suppressed rather than stacked beside it. + @State private var liftActivity = LiftLiveActivityController() /// The live gym session. Owned HERE, at the app root, rather than by the screen that shows it: /// swiping the workout sheet away must not stop the clock, silence the strap or drop the /// double-tap handler. See `LiftSessionController`. @@ -40,6 +44,9 @@ struct StrandiOSApp: App { /// Effort's display scale is also embedded in the shared widget snapshot. Observe it here so a /// Settings change gets one accurate full rebuild instead of waiting for an unrelated repo refresh. @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue + /// kg vs lb for the Lift Log Live Activity's "8 x 30 kg" line — the app formats it, because the + /// unit preference lives here and not in the widget extension. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue init() { // #1008: pin the pre-change Overnight-only default for existing installs before @@ -201,9 +208,10 @@ struct StrandiOSApp: App { liveActivity.update( bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, - connected: model.live.connected, + connected: model.live.connected && !liftSession.isActive, effort: day?.strain.map { Int($0.rounded()) } ) + pushLiftActivity() } // End the Live Activity the moment the link drops, even if no further HR tick arrives. .onReceive(model.live.$connected) { isConnected in @@ -214,10 +222,15 @@ struct StrandiOSApp: App { liveActivity.update( bpm: isConnected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, - connected: isConnected, + connected: isConnected && !liftSession.isActive, effort: day?.strain.map { Int($0.rounded()) } ) } + // The gym session's own banner. Driven off the session's 1 Hz tick so a stage change + // reaches the Lock Screen promptly; the controller decides what is actually worth + // pushing, since the widget's clocks tick on their own. + .onReceive(liftSession.$now) { _ in pushLiftActivity() } + .onReceive(liftSession.$engine) { _ in pushLiftActivity() } // #911/#759: republish the Home/Lock-Screen widget whenever the dashboard caches actually // change mid-session. The only other publish site is the scenePhase .active handler, so // during a long foreground session the widget froze at the last-foreground snapshot while @@ -365,6 +378,32 @@ struct StrandiOSApp: App { } } } + + /// Map the running session onto the Lock Screen banner. + /// + /// The wording and the numbers come from `LiftSessionController.presentation`, the same + /// resolution the in-app minimised bar renders, so the two surfaces cannot disagree. The heart + /// rate is the app's smoothed value, and only while the strap is actually connected — a frozen + /// last-known bpm on a Lock Screen reads as live and is not. + @MainActor + private func pushLiftActivity() { + let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric + guard let p = liftSession.presentation(system: system) else { + liftActivity.update(programName: "", state: nil) + return + } + liftActivity.update( + programName: liftSession.programName ?? String(localized: "Session"), + state: LiftActivityAttributes.ContentState( + isResting: p.isResting, + exercise: p.exercise, + status: p.status, + detail: p.detail, + bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, + progress: String(localized: "\(p.setsDone) of \(p.setsPlanned) sets done"), + stageStartedAt: p.stageStartedAt, + restEndsAt: p.restEndsAt)) + } } /// iOS root — the `RootTabView` shell with the first-run onboarding/pairing wizard overlaid until @@ -554,5 +593,6 @@ private struct OuraOnboardingDemoHost: View { var body: some View { AddDeviceWizard(live: live, onClose: {}, startAt: (.oura, .prep)) } + } #endif diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift new file mode 100644 index 0000000000..a170f736a3 --- /dev/null +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -0,0 +1,99 @@ +#if os(iOS) +import Foundation +import ActivityKit + +/// Starts, updates and ends the Lift Log session Live Activity. +/// +/// Modelled on `LiveActivityController` (live HR) and deliberately separate from it: the two answer +/// different questions and have different lifetimes. While a gym session is open this one is the +/// useful banner — it carries the heart rate too — so the app suppresses the HR activity rather than +/// stacking two on the Lock Screen. +/// +/// PUSHES ARE CONTENT-DRIVEN, NOT CLOCK-DRIVEN. The widget's timers tick on their own from dates in +/// the content state, so this only sends an update when something a person would notice changes: +/// the stage, the exercise, the set, the numbers, or the heart rate. Without that, an activity that +/// merely shows a running clock would push once a second for a whole workout and be throttled by +/// ActivityKit for it. +@MainActor +final class LiftLiveActivityController { + private var activity: Activity? + private var lastPush: Date = .distantPast + private var lastSignature: String? + /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is + /// consulted on every session tick and its value only changes via Settings. + private let authInfo = ActivityAuthorizationInfo() + /// Guards against two ticks both firing `Activity.request` before the first has returned. + private var isStarting = false + /// Heart rate moves constantly; everything else does not. A change in HR alone is worth a push, + /// but not more often than this, or a session becomes one push per second. + private static let heartRateMinInterval: TimeInterval = 10 + /// How long iOS may keep showing the activity as fresh without a push. Generous, because a long + /// rest legitimately produces no content change at all — the clock is ticking client-side. + private static let staleAfter: TimeInterval = 15 * 60 + + /// Drive the activity from the session's current state. `state` nil means no session is running, + /// which ends any activity that is showing. + func update(programName: String, state: LiftActivityAttributes.ContentState?) { + guard authInfo.areActivitiesEnabled else { return } + + // Re-adopt an activity that outlived a previous app session — ActivityKit keeps them alive + // across relaunches, and a fresh controller starts with `activity == nil`. Without this we + // could neither update nor END one already on the Lock Screen, and could spawn a duplicate. + if activity == nil { activity = Activity.activities.first } + + // Shares the existing Live Activity opt-out rather than adding a second switch: a user who + // turned Live Activities off meant all of them. + guard UnitPrefs.liveActivityEnabled(), let state else { + if activity != nil { Task { await end() } } + return + } + + // Everything a person would notice, EXCLUDING the clocks (which tick client-side) and the + // heart rate (handled by its own interval below). + let signature = [ + state.isResting ? "rest" : "work", state.exercise, state.status, + state.detail ?? "", state.progress, + "\(state.stageStartedAt.timeIntervalSince1970)", + "\(state.restEndsAt?.timeIntervalSince1970 ?? 0)", + ].joined(separator: "|") + + let contentChanged = signature != lastSignature + let heartRateDue = Date().timeIntervalSince(lastPush) >= Self.heartRateMinInterval + let content = ActivityContent(state: state, + staleDate: Date().addingTimeInterval(Self.staleAfter)) + + if let activity { + guard contentChanged || heartRateDue else { return } + lastSignature = signature + lastPush = Date() + Task { await activity.update(content) } + } else { + // Set synchronously before any await, so a second tick arriving while `Activity.request` + // is still in flight bails here instead of creating a duplicate activity. + guard !isStarting else { return } + isStarting = true + do { + activity = try Activity.request( + attributes: LiftActivityAttributes(programName: programName), + content: content, + pushType: nil) + lastSignature = signature + lastPush = Date() + } catch { + activity = nil + } + isStarting = false + } + } + + func end() async { + // End every lift activity, not just the cached handle — covers a straggler from a previous + // app session that was never re-adopted, and any rare duplicate. + for act in Activity.activities { + await act.end(nil, dismissalPolicy: .immediate) + } + activity = nil + lastSignature = nil + } +} +#endif diff --git a/StrandiOSShared/LiftActivityAttributes.swift b/StrandiOSShared/LiftActivityAttributes.swift new file mode 100644 index 0000000000..f867ed1796 --- /dev/null +++ b/StrandiOSShared/LiftActivityAttributes.swift @@ -0,0 +1,60 @@ +#if os(iOS) +import Foundation +import ActivityKit + +/// Live Activity attributes for a running Lift Log session — the minimised session bar, on the Lock +/// Screen and in the Dynamic Island. +/// +/// Deliberately a SEPARATE activity type from `NOOPActivityAttributes` (live HR). They answer +/// different questions and have different lifetimes: the HR activity lives as long as the strap is +/// streaming, this one as long as a gym session is open. While a session is running this one is the +/// useful surface — it carries the heart rate too — so the app suppresses the HR activity rather +/// than stacking two banners on the Lock Screen. +/// +/// TIME IS CARRIED AS DATES, NOT AS A FORMATTED STRING. The widget renders them with +/// `Text(timerInterval:)`, which ticks on its own without the app pushing anything. Pushing a new +/// content state every second to animate a clock would burn the Live Activity update budget and +/// still look worse. +public struct LiftActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + /// Amber rest vs green working — the same colour language as the sheet and the bar. + public var isResting: Bool + /// The exercise being worked, or rested from. + public var exercise: String + /// "Set 2" / "Resting after set 2" — resolved app-side so the wording matches the bar. + public var status: String + /// "8 x 30 kg", already unit-converted, because the unit preference lives in the app. + /// Nil when neither reps nor weight is known. + public var detail: String? + public var bpm: Int? + /// "3 of 19 sets done", localized APP-SIDE. The widget extension ships no string catalog, so + /// every word it renders has to arrive already translated — the same reason `status` and + /// `detail` are strings rather than numbers. + public var progress: String + /// When the current stage began — the widget counts UP from here while working. + public var stageStartedAt: Date + /// When the running rest is due to end; the widget counts DOWN to it. Nil while working. + public var restEndsAt: Date? + + public init(isResting: Bool, exercise: String, status: String, detail: String?, + bpm: Int?, progress: String, + stageStartedAt: Date, restEndsAt: Date?) { + self.isResting = isResting + self.exercise = exercise + self.status = status + self.detail = detail + self.bpm = bpm + self.progress = progress + self.stageStartedAt = stageStartedAt + self.restEndsAt = restEndsAt + } + } + + /// The program's name, fixed for the life of the session. + public var programName: String + + public init(programName: String) { + self.programName = programName + } +} +#endif diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift new file mode 100644 index 0000000000..7157eaa013 --- /dev/null +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -0,0 +1,129 @@ +import WidgetKit +import SwiftUI +import ActivityKit +import StrandDesign + +/// Live Activity for a running Lift Log session — the minimised session bar, on the Lock Screen and +/// in the Dynamic Island. +/// +/// It carries the same four things the in-app bar does, in the same order, because it is answering +/// the same question from further away: what am I doing, on what, with what numbers, and how long. +/// The colour language matches too — green while a set is being worked, amber through the rest. +/// +/// THE CLOCK TICKS WITHOUT THE APP. Both timers are `Text(timerInterval:)`, driven by dates in the +/// content state, so the Lock Screen counts on its own between pushes. The app only sends a new +/// state when something actually changes (stage, set, heart rate), never once a second to animate a +/// number. +struct LiftLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LiftActivityAttributes.self) { context in + lockScreen(context.state, program: context.attributes.programName) + .activityBackgroundTint(StrandPalette.surfaceBase) + .activitySystemActionForegroundColor(StrandPalette.textPrimary) + } dynamicIsland: { context in + let tint = tint(context.state) + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label(context.state.exercise, systemImage: "dumbbell.fill") + .font(.caption).lineLimit(1) + .foregroundStyle(tint) + } + DynamicIslandExpandedRegion(.trailing) { + if let bpm = context.state.bpm { + Label("\(bpm)", systemImage: "heart.fill") + .font(.caption) + .foregroundStyle(StrandPalette.metricRose) + } + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.state.detail ?? context.state.status) + .font(.caption).lineLimit(1) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 8) + clock(context.state, tint: tint) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + } + } + } compactLeading: { + Image(systemName: "dumbbell.fill").foregroundStyle(tint) + } compactTrailing: { + clock(context.state, tint: tint) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } minimal: { + Image(systemName: "dumbbell.fill").foregroundStyle(tint) + } + } + } + + /// Green while working, amber through the rest — the sheet's and the bar's colour language. + private func tint(_ state: LiftActivityAttributes.ContentState) -> Color { + state.isResting ? StrandPalette.metricAmber : StrandPalette.statusPositive + } + + private func lockScreen(_ state: LiftActivityAttributes.ContentState, + program: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(tint(state)) + + VStack(alignment: .leading, spacing: 2) { + Text(state.exercise) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(state.detail.map { "\(state.status) — \($0)" } ?? state.status) + .font(.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + // Two variables in an HStack rather than one interpolated string: the extension has + // no catalog, so a literal separator here would be untranslatable copy shipped to + // ten locales. Everything user-facing arrives pre-localized from the app. + HStack(spacing: 6) { + Text(state.progress) + Text(program) + } + .font(.caption2) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + clock(state, tint: tint(state)) + .font(.system(size: 22, weight: .bold, design: .rounded)) + if let bpm = state.bpm { + Label("\(bpm)", systemImage: "heart.fill") + .font(.caption) + .foregroundStyle(StrandPalette.metricRose) + } + } + } + .padding() + } + + /// Counts DOWN through a rest (the number you act on) and UP through a set, both self-ticking. + /// + /// Both branches use `Text(timerInterval:)`, which is the API widgets are given for a clock that + /// advances without the app pushing. `Text(date, style: .timer)` looks equivalent and is not: on + /// the Lock Screen it rendered "25 minutes" — a rounded, prose duration — where a gym timer has + /// to read 25:02. Verified in the simulator, which is the only reason it was caught. + /// + /// An overrun rest (`restEndsAt` already past) counts UP from when it was due, which is the + /// honest reading: you are over, and by how much. A zero-length range would render nothing, so + /// the end is pushed a day out — well beyond any session. + private func clock(_ state: LiftActivityAttributes.ContentState, tint: Color) -> some View { + let counter: some View = { + if let ends = state.restEndsAt, ends > .now { + return Text(timerInterval: .now...ends, countsDown: true) + } + let from = state.restEndsAt ?? state.stageStartedAt + return Text(timerInterval: from...from.addingTimeInterval(86_400), countsDown: false) + }() + return counter + .monospacedDigit() + .foregroundStyle(tint) + } +} diff --git a/StrandiOSWidgets/NOOPWidgetBundle.swift b/StrandiOSWidgets/NOOPWidgetBundle.swift index c808f07c25..1ebec07f03 100644 --- a/StrandiOSWidgets/NOOPWidgetBundle.swift +++ b/StrandiOSWidgets/NOOPWidgetBundle.swift @@ -2,8 +2,9 @@ import WidgetKit import SwiftUI /// The widget extension entry point. Bundles the glanceable widget, the live-HR Live Activity, -/// the K10 Coach brief widget (stored morning brief on Lock Screen / Home Screen), and the -/// heart-rate trace widget (#1957), and the stress curve widget (#2040). +/// the K10 Coach brief widget (stored morning brief on Lock Screen / Home Screen), the +/// heart-rate trace widget (#1957), the stress curve widget (#2040), and the Lift Log session +/// Live Activity. @main struct NOOPWidgetBundle: WidgetBundle { var body: some Widget { @@ -12,5 +13,6 @@ struct NOOPWidgetBundle: WidgetBundle { CoachBriefWidget() HeartRateWidget() StressWidget() + LiftLiveActivity() } } From 57f8f429091ccd233c7047861b0e3be2ef112e60 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:05:13 +0200 Subject: [PATCH 13/31] lift log: keep the heart rate on screen even when it is not reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both surfaces hid the HR readout whenever there was no value, to save width. The first thing that produced was a report that there is no heart rate in the minimised tab or on the Lock Screen — the strap simply was not streaming at the time, and an absent readout is indistinguishable from an absent feature. That is the worse failure. Mid-workout the difference is something to act on: a dash says the strap has stopped reading, where nothing at all says the app never had the feature. The sheet's control bar already reasoned this way and kept its "—"; the bar and the Lock Screen now match it, greyed when empty and rose when live. The Lock Screen also puts the heart rate and the clock side by side rather than stacked, which is both the minimised bar's layout — the thing it is a remote view of — and better aligned: `Text(timerInterval:)` reserves width for the widest value it could display, so a trailing-aligned timer never lined up with the text beneath it. Verification: photographed both states in the simulator by temporarily feeding `LiveState` a fixed bpm — bar and Lock Screen both render "132" in rose, and both render a grey dash once the injection is removed. That injection is NOT in this commit; it existed only long enough to take the screenshots, and the file it touched is byte-identical to its committed state. StrandTests 1519 with only the two pre-existing locale-dependent failures; both app targets build; `i18n_audit.py --ci upstream/main` and `doc_comment_lint.py` pass. Co-Authored-By: Claude Opus 5 --- Strand/Screens/LiftSessionBar.swift | 29 ++++++++++--------- StrandiOSWidgets/LiftLiveActivity.swift | 37 ++++++++++++++++++------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 00a1f0ae49..3b9bbdf8cf 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -47,20 +47,23 @@ struct LiftSessionBar: View { Spacer(minLength: 0) - // Shown only when there IS a reading, unlike the sheet's control bar, which - // holds a "—" so its clocks do not shift. This is a capsule with four things - // already competing for it; a permanent dash would cost width and say nothing. - if let bpm = model.bpm { - HStack(spacing: 3) { - Image(systemName: "heart.fill") - .font(.system(size: 10, weight: .semibold)) - Text("\(bpm)") - .font(StrandFont.captionNumber) - .monospacedDigit() - } - .foregroundStyle(StrandPalette.metricRose) - .accessibilityLabel(String(localized: "Heart rate \(bpm)")) + // ALWAYS shown, dash included. An earlier version hid it whenever there was no + // reading, to save width on a crowded capsule — and the first thing that + // produced was "there is no HR in the minimised tab", because an absent readout + // is indistinguishable from an absent feature. Mid-workout the difference + // matters: a dash says the strap is not reading, which is something to act on. + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10, weight: .semibold)) + Text(model.bpm.map(String.init) ?? "—") + .font(StrandFont.captionNumber) + .monospacedDigit() } + .foregroundStyle(model.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + .accessibilityLabel(model.bpm.map { String(localized: "Heart rate \($0)") } + ?? String(localized: "Heart rate")) Text(bigClock(engine)) .font(StrandFont.bodyNumber) diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift index 7157eaa013..f419ebaaa4 100644 --- a/StrandiOSWidgets/LiftLiveActivity.swift +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -29,11 +29,15 @@ struct LiftLiveActivity: Widget { .foregroundStyle(tint) } DynamicIslandExpandedRegion(.trailing) { - if let bpm = context.state.bpm { - Label("\(bpm)", systemImage: "heart.fill") - .font(.caption) - .foregroundStyle(StrandPalette.metricRose) + Label { + Text(context.state.bpm.map(String.init) ?? "—").monospacedDigit() + } icon: { + Image(systemName: "heart.fill") } + .font(.caption) + .foregroundStyle(context.state.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) } DynamicIslandExpandedRegion(.bottom) { HStack { @@ -91,14 +95,27 @@ struct LiftLiveActivity: Widget { Spacer(minLength: 8) - VStack(alignment: .trailing, spacing: 2) { + // Heart rate then clock, side by side — the minimised bar's layout, because this is the + // same bar seen from the Lock Screen. Stacking them looked misaligned: + // `Text(timerInterval:)` reserves width for the widest value it could show, so a + // trailing-aligned timer does not visually line up with the text under it. + // + // The heart rate is ALWAYS present, dash and all. A readout that vanishes when the strap + // stops reading is indistinguishable from a missing feature — which is exactly how it + // was first reported. + HStack(spacing: 10) { + Label { + Text(state.bpm.map(String.init) ?? "—").monospacedDigit() + } icon: { + Image(systemName: "heart.fill") + } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(state.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + clock(state, tint: tint(state)) .font(.system(size: 22, weight: .bold, design: .rounded)) - if let bpm = state.bpm { - Label("\(bpm)", systemImage: "heart.fill") - .font(.caption) - .foregroundStyle(StrandPalette.metricRose) - } } } .padding() From 10d57e9dba1f19fd55eb7f6f63109e258b329738 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:50:04 +0200 Subject: [PATCH 14/31] lift log: build a program from a spreadsheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filling in a program on a phone — a dozen exercises, each with a muscle group, sets, reps, a weight, a rest and a technique note — is the most tedious thing in the feature, and it is exactly the work a keyboard and a spreadsheet do well. This reads a filled-in sheet and creates the programs. THE TEMPLATE (`docs/lift-log-program-template.xlsx`) is fillable but not editable, which is what makes the import reliable: the sheet is protected so the header row cannot be renamed, reordered or deleted (the import matches on those names), only the data cells accept typing, and the two muscle columns are dropdowns over the closed 20-token vocabulary — a muscle cannot be misspelled into something the importer then has to reject. A second sheet carries instructions and a worked example and is ignored on import. It is generated by `Tools/make_lift_program_template.py`, which writes the OOXML by hand: the repository gains no Python dependency, and the bytes are reproducible. `LiftProgramSheetImporterTests` parses the SHIPPED template, so dropping a column from it fails the suite rather than the user's import. TWO FORMATS, ONE PARSER. `.xlsx` is what the template is; CSV is accepted too, because every spreadsheet on every platform can write it. Detection is by ZIP magic bytes, the same idiom `DataBackup` uses to tell a zipped backup from a bare SQLite file. `XlsxSheet` is a deliberately small reader — the first worksheet, as text — built on ZIPFoundation and Foundation's XMLParser, both already in the package. Nothing new enters the dependency graph. The parser lives in `Packages/StrandImport` and writes nothing: it returns programs and warnings, and the caller decides. That keeps it testable with no store, no app and no simulator, which is where all 15 of its tests run. REAL SPREADSHEETS, NOT IDEAL ONES. Delimiters are sniffed, so the `;` Excel writes in most of Europe works. `40,5` and `40.5` both mean 40.5, and `40,5 kg` is tolerated. Blank rows are skipped wherever they are. A blank target stays NIL rather than becoming a planned zero. An unrecognised muscle WARNS and leaves that line unclassified; it never guesses and never drops the row. The vocabulary is a stored-data contract, so deciding that "Shoulders" means front delts would put sets in a bucket the user did not choose. Warnings name the sheet's own row number so they can be acted on, and are non-fatal by design — one typo should not cost eleven good lines and twelve round trips. Nothing is written until the user has seen what it will create. Imported exercises are also remembered with their classification, so the picker offers them next time and the per-muscle rollup resolves the same name the same way. Cross-platform: the five lift tables are pinned `ios_only` in both `schema_oracle.json` copies, so this importer has no Room twin to keep in step — the same stated position as the rest of the feature, not a new divergence. Verification: 15 new tests in StrandImport (264 total, 0 failures) covering both formats against the same content, grouping, European decimals, blank-stays-nil, muscle resolution, the warning text and row numbers, the shipped template's columns, and every refusal. Both app targets build; `i18n_audit.py --ci` passes with 13 new strings in all ten locales; `doc_comment_lint.py` passes. End-to-end in the simulator: a filled .xlsx and a .csv both previewed and imported, and the rows landed with the right order, targets and classifications. Co-Authored-By: Claude Opus 5 --- .../LiftProgramSheetImporter.swift | 268 ++++++++++++++++++ .../Sources/StrandImport/XlsxSheet.swift | 191 +++++++++++++ .../LiftProgramSheetImporterTests.swift | 167 +++++++++++ .../Resources/lift_program_filled.csv | 8 + .../Resources/lift_program_filled.xlsx | Bin 0 -> 4145 bytes Strand/Resources/Localizable.xcstrings | 39 +++ Strand/Screens/LiftLogView.swift | 25 +- Strand/Screens/LiftProgramImportSheet.swift | 257 +++++++++++++++++ Tools/make_lift_program_template.py | 235 +++++++++++++++ docs/LIFT_LOG_PROGRAM_IMPORT.md | 61 ++++ docs/lift-log-program-template.xlsx | Bin 0 -> 8825 bytes 11 files changed, 1246 insertions(+), 5 deletions(-) create mode 100644 Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift create mode 100644 Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift create mode 100644 Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift create mode 100644 Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv create mode 100644 Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.xlsx create mode 100644 Strand/Screens/LiftProgramImportSheet.swift create mode 100644 Tools/make_lift_program_template.py create mode 100644 docs/LIFT_LOG_PROGRAM_IMPORT.md create mode 100644 docs/lift-log-program-template.xlsx diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift new file mode 100644 index 0000000000..b8aa8301b9 --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -0,0 +1,268 @@ +import Foundation +import WhoopStore +import ZIPFoundation + +// Build a Lift Log PROGRAM from a spreadsheet filled in on a computer. +// +// WHY THIS EXISTS. A program is a name plus an ordered list of exercise lines, and every line carries +// an exercise, a muscle classification, sets, reps, a weight, a rest period and a technique note. +// Typing all of that on a phone, for a dozen exercises, is the single most tedious thing in the +// feature — and it is exactly the kind of work a spreadsheet on a real keyboard is good at. +// +// This reads the filled-in sheet and produces programs. It never writes: the caller decides what to +// do with the result, which keeps this parser pure and testable with no store and no app. +// +// TWO FORMATS, ONE PARSER. `.xlsx` is what the shipped template is, because a spreadsheet can lock +// its own structure and offer the muscle vocabulary as a dropdown — the user cannot mistype a muscle +// name that has to match a closed token set. CSV is accepted too, because every spreadsheet program +// on every platform can save it, and because a user who rebuilds the sheet by hand should not be +// blocked. Detection is by ZIP magic bytes, the same way `DataBackup` tells a zipped backup from a +// bare SQLite file. + +/// One exercise line read from the sheet — the TARGETS, mirroring `liftProgramItem`. +public struct ImportedProgramLine: Sendable, Equatable { + public var exercise: String + public var primaryMuscle: LiftMuscle? + public var secondaryMuscles: [LiftMuscle] + public var targetSets: Int? + public var targetReps: Int? + public var targetWeightKg: Double? + public var restSec: Int? + public var note: String? + + public init(exercise: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle], + targetSets: Int?, targetReps: Int?, targetWeightKg: Double?, + restSec: Int?, note: String?) { + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = secondaryMuscles + self.targetSets = targetSets + self.targetReps = targetReps + self.targetWeightKg = targetWeightKg + self.restSec = restSec + self.note = note + } +} + +/// One program: a name, an optional note, and its lines IN SHEET ORDER. +public struct ImportedProgram: Sendable, Equatable { + public var name: String + public var note: String? + public var lines: [ImportedProgramLine] + + public init(name: String, note: String?, lines: [ImportedProgramLine]) { + self.name = name + self.note = note + self.lines = lines + } +} + +/// What a sheet produced, plus everything that was wrong with it. +/// +/// Warnings are per-row and NON-fatal by design. A sheet with one misspelled muscle should import +/// eleven good lines and tell the user about the twelfth, not refuse the file — the alternative is a +/// user fixing one typo at a time through twelve round trips. +public struct LiftProgramImportResult: Sendable, Equatable { + public var programs: [ImportedProgram] + public var warnings: [String] + + public init(programs: [ImportedProgram], warnings: [String]) { + self.programs = programs + self.warnings = warnings + } +} + +public enum LiftProgramSheetImporter { + + public enum ImportError: Error, Equatable { + /// Not a spreadsheet this can read at all. + case unreadable + /// Readable, but without the columns that make it a program sheet. + case missingColumns([String]) + /// Read fine, but there was nothing in it. + case empty + } + + /// Column keys, after `HeaderNorm.normalize`. Several spellings map to the same field so a user + /// who retypes the header — or translates it — is not punished for it. + private static let exerciseKeys = ["exercise", "movement", "lift"] + private static let programKeys = ["program", "programme", "workout", "routine", "day"] + private static let programNoteKeys = ["program_note", "programme_note", "workout_note", "routine_note"] + private static let primaryKeys = ["primary_muscle", "primary", "muscle"] + private static let secondaryKeys = ["secondary_muscles", "secondary_muscle", "secondary"] + private static let setsKeys = ["sets", "working_sets", "target_sets"] + private static let repsKeys = ["reps", "rep", "target_reps", "repetitions"] + private static let weightKeys = ["weight_kg", "weight", "kg", "load", "load_kg"] + private static let restKeys = ["rest_sec", "rest_seconds", "rest", "rest_s"] + private static let noteKeys = ["note", "technique_note", "notes", "cue"] + + /// Parse a filled-in template. Detects `.xlsx` by its ZIP magic bytes, else treats it as CSV. + public static func parse(data: Data) throws -> LiftProgramImportResult { + let rows: [[String: String]] + if isZip(data) { + rows = try XlsxSheet.rows(from: data) + } else { + let table = CSVTable(data: data) + guard !table.headers.isEmpty else { throw ImportError.unreadable } + rows = table.rows + } + guard !rows.isEmpty else { throw ImportError.empty } + + // An exercise column is the one thing a program sheet cannot do without. + let present = Set(rows.flatMap { $0.keys }) + guard !present.isDisjoint(with: exerciseKeys) else { + throw ImportError.missingColumns(["exercise"]) + } + + var programs: [ImportedProgram] = [] + var indexByName: [String: Int] = [:] + var warnings: [String] = [] + + for (i, row) in rows.enumerated() { + // Spreadsheets are full of trailing blank rows; they are not an error. + let exercise = value(row, exerciseKeys)?.trimmed ?? "" + if exercise.isEmpty { + if row.values.contains(where: { !$0.trimmed.isEmpty }) { + warnings.append(rowMessage(i, "no exercise name, so the row was skipped")) + } + continue + } + + let programName = value(row, programKeys)?.trimmed.nilIfEmpty ?? "Imported program" + + var primary: LiftMuscle? + if let raw = value(row, primaryKeys)?.trimmed.nilIfEmpty { + primary = LiftMuscle(sheetName: raw) + if primary == nil { + warnings.append(rowMessage(i, "\"\(raw)\" is not a muscle group, so \"\(exercise)\" was left unclassified")) + } + } + + var secondary: [LiftMuscle] = [] + if let raw = value(row, secondaryKeys)?.trimmed.nilIfEmpty { + for part in raw.split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "/" || $0 == "|" }) { + let token = String(part).trimmed + guard !token.isEmpty else { continue } + if let m = LiftMuscle(sheetName: token) { + // The store excludes the primary from the secondary list, so do it here too + // rather than leaving a row that says "chest, chest". + if m != primary, !secondary.contains(m) { secondary.append(m) } + } else { + warnings.append(rowMessage(i, "\"\(token)\" is not a muscle group and was ignored")) + } + } + } + + let line = ImportedProgramLine( + exercise: exercise, + primaryMuscle: primary, + secondaryMuscles: secondary, + targetSets: intValue(row, setsKeys), + targetReps: intValue(row, repsKeys), + targetWeightKg: doubleValue(row, weightKeys), + restSec: intValue(row, restKeys), + note: value(row, noteKeys)?.trimmed.nilIfEmpty) + + if let idx = indexByName[programName.lowercased()] { + programs[idx].lines.append(line) + if programs[idx].note == nil { + programs[idx].note = value(row, programNoteKeys)?.trimmed.nilIfEmpty + } + } else { + indexByName[programName.lowercased()] = programs.count + programs.append(ImportedProgram( + name: programName, + note: value(row, programNoteKeys)?.trimmed.nilIfEmpty, + lines: [line])) + } + } + + guard !programs.isEmpty else { throw ImportError.empty } + return LiftProgramImportResult(programs: programs, warnings: warnings) + } + + // MARK: - Helpers + + private static func isZip(_ data: Data) -> Bool { + data.count >= 4 && data[data.startIndex] == 0x50 && data[data.startIndex + 1] == 0x4B + } + + /// Warnings name the SHEET's own row number so the user can go straight to it: the header is + /// row 1, so the first data row is row 2. + private static func rowMessage(_ i: Int, _ text: String) -> String { + "Row \(i + 2): \(text)" + } + + private static func value(_ row: [String: String], _ keys: [String]) -> String? { + for k in keys { + if let v = row[k], !v.trimmed.isEmpty { return v } + } + return nil + } + + private static func intValue(_ row: [String: String], _ keys: [String]) -> Int? { + guard let raw = value(row, keys) else { return nil } + return doubleFrom(raw).map { Int($0.rounded()) } + } + + private static func doubleValue(_ row: [String: String], _ keys: [String]) -> Double? { + guard let raw = value(row, keys) else { return nil } + return doubleFrom(raw) + } + + /// Numbers as spreadsheets actually write them: "60", "60.5", "60,5" (comma decimal in most of + /// Europe), "60 kg", "2 min". Anything with no digits at all is nil rather than 0 — a blank cell + /// means "not planned", and 0 would be a planned zero. + static func doubleFrom(_ raw: String) -> Double? { + var cleaned = "" + var seenSeparator = false + for ch in raw { + if ch.isNumber { cleaned.append(ch) } + else if (ch == "." || ch == ",") && !seenSeparator && !cleaned.isEmpty { + cleaned.append(".") + seenSeparator = true + } else if ch == "-" && cleaned.isEmpty { + cleaned.append(ch) + } + } + guard cleaned.contains(where: { $0.isNumber }) else { return nil } + return Double(cleaned) + } +} + +extension LiftMuscle { + /// Resolve a muscle written by a person in a spreadsheet. + /// + /// Accepts the stored token (`frontDelts`) and the English display name ("Front delts"), and is + /// insensitive to case, spaces, hyphens and underscores — so "front delts", "Front-Delts" and + /// "FRONTDELTS" all land on the same case. Deliberately NOT fuzzy beyond that: the muscle + /// vocabulary is a stored-data contract, and guessing that "shoulders" means front delts would + /// silently put sets in a bucket the user did not choose. + init?(sheetName raw: String) { + let key = raw.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX")) + .lowercased() + .filter { $0.isLetter || $0.isNumber } + guard !key.isEmpty else { return nil } + for m in LiftMuscle.allCases where m.rawValue.lowercased() == key { + self = m + return + } + // The English display names, which is what the shipped template's dropdown offers. + let byName: [String: LiftMuscle] = [ + "chest": .chest, "frontdelts": .frontDelts, "sidedelts": .sideDelts, + "reardelts": .rearDelts, "triceps": .triceps, "lats": .lats, + "upperback": .upperBack, "traps": .traps, "biceps": .biceps, + "forearms": .forearms, "quads": .quads, "hamstrings": .hamstrings, + "glutes": .glutes, "adductors": .adductors, "abductors": .abductors, + "calves": .calves, "abs": .abs, "obliques": .obliques, + "lowerback": .lowerBack, "neck": .neck, + ] + guard let m = byName[key] else { return nil } + self = m + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift new file mode 100644 index 0000000000..5f09d3d3c6 --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -0,0 +1,191 @@ +import Foundation +import ZIPFoundation + +// A deliberately small `.xlsx` reader: enough to read a filled-in template, and nothing else. +// +// An .xlsx is a ZIP of XML. Reading one properly — styles, number formats, dates, formulas, multiple +// sheets, streaming — is a library's worth of work. This reads the FIRST worksheet as text, which is +// all a program sheet needs, and is honest about that: no formula evaluation (a cell's last cached +// value is used, which is what Excel wrote), no date coercion, no styling. +// +// Only ZIPFoundation and Foundation's own XMLParser are used, both already in the package. Nothing +// new enters the dependency graph for this. +enum XlsxSheet { + + /// Header-keyed rows, in the same shape `CSVTable` produces, so both formats feed one parser. + static func rows(from data: Data) throws -> [[String: String]] { + let grid = try grid(from: data) + guard let headerRow = grid.first else { return [] } + + let keys = headerRow.map { HeaderNorm.normalize($0) } + var out: [[String: String]] = [] + for cells in grid.dropFirst() { + // Blank rows are ordinary in a spreadsheet — a user leaves space at the bottom. + if cells.allSatisfy({ $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { continue } + var dict: [String: String] = [:] + for (i, key) in keys.enumerated() where !key.isEmpty { + let v = i < cells.count ? cells[i] : "" + if dict[key] == nil || dict[key]!.isEmpty { dict[key] = v } + } + out.append(dict) + } + return out + } + + /// The first worksheet as a rectangular grid of strings. + static func grid(from data: Data) throws -> [[String]] { + guard let archive = try? Archive(data: data, accessMode: .read) else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + // Shared strings are optional: a sheet written with inline strings has no such part. + let shared = (try? entryData(archive, "xl/sharedStrings.xml")).map(SharedStrings.parse) ?? [] + + guard let sheetData = try? firstWorksheet(archive) else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + return SheetParser.parse(sheetData, shared: shared) + } + + /// The first worksheet part. Templates this reads are single-sheet, and `sheet1.xml` is what + /// every writer emits for one; the scan is the fallback for a file that numbered it differently. + private static func firstWorksheet(_ archive: Archive) throws -> Data { + if let d = try? entryData(archive, "xl/worksheets/sheet1.xml") { return d } + let names = archive.map(\.path) + .filter { $0.hasPrefix("xl/worksheets/") && $0.hasSuffix(".xml") } + .sorted() + guard let first = names.first, let d = try? entryData(archive, first) else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + return d + } + + private static func entryData(_ archive: Archive, _ path: String) throws -> Data { + guard let entry = archive[path] else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + var out = Data() + _ = try archive.extract(entry, bufferSize: 64 * 1024, skipCRC32: true) { out.append($0) } + return out + } + + /// `xl/sharedStrings.xml` — the string pool most cells point into. + private final class SharedStrings: NSObject, XMLParserDelegate { + private var strings: [String] = [] + private var current: String? + private var collecting = false + + static func parse(_ data: Data) -> [String] { + let d = SharedStrings() + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.strings + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + if name == "si" { current = "" } + // A rich-text run splits one logical string across several elements; they concatenate. + if name == "t" { collecting = true } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collecting { current = (current ?? "") + string } + } + + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, + qualifiedName: String?) { + if name == "t" { collecting = false } + if name == "si" { + strings.append(current ?? "") + current = nil + } + } + } + + /// A worksheet part: rows of cells, placed by their `r` reference so gaps stay gaps. + private final class SheetParser: NSObject, XMLParserDelegate { + private var shared: [String] = [] + private var grid: [[String]] = [] + private var row: [String] = [] + private var cellRef = "" + private var cellType = "" + private var text: String? + private var collecting = false + + static func parse(_ data: Data, shared: [String]) -> [[String]] { + let d = SheetParser() + d.shared = shared + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.grid + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + switch name { + case "row": + row = [] + case "c": + cellRef = attributes["r"] ?? "" + cellType = attributes["t"] ?? "" + text = nil + case "v", "t": + collecting = true + text = text ?? "" + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collecting { text = (text ?? "") + string } + } + + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, + qualifiedName: String?) { + switch name { + case "v", "t": + collecting = false + case "c": + // t="s" means the value is an index into the shared-string pool; anything else is + // the literal text (inline strings arrive already resolved through ). + var value = text ?? "" + if cellType == "s", let i = Int(value), i >= 0, i < shared.count { + value = shared[i] + } + let column = SheetParser.columnIndex(cellRef) + if column >= 0 { + while row.count <= column { row.append("") } + row[column] = value + } else { + row.append(value) + } + text = nil + case "row": + grid.append(row) + row = [] + default: + break + } + } + + /// "C7" -> 2. Zero-based, so a skipped column stays an empty cell rather than shifting every + /// value after it one place left — which would silently move reps into the weight column. + static func columnIndex(_ ref: String) -> Int { + var n = 0 + var any = false + for ch in ref.uppercased() { + guard let ascii = ch.asciiValue else { break } + if ascii >= 65, ascii <= 90 { + n = n * 26 + Int(ascii - 64) + any = true + } else { + break + } + } + return any ? n - 1 : -1 + } + } +} diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift new file mode 100644 index 0000000000..607e490479 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -0,0 +1,167 @@ +import XCTest +import WhoopStore +@testable import StrandImport + +/// Building a Lift Log program from a spreadsheet filled in on a computer. +/// +/// Both fixtures carry the SAME content in the two accepted formats, so one set of expectations +/// covers the `.xlsx` reader and the CSV path and neither can drift from the other. +final class LiftProgramSheetImporterTests: XCTestCase { + + private func parse(_ fixture: String) throws -> LiftProgramImportResult { + try LiftProgramSheetImporter.parse(data: Fixtures.data(fixture)) + } + + // MARK: - The two formats agree + + func testXlsxAndCsvProduceTheSameProgramsFromTheSameContent() throws { + let x = try parse("lift_program_filled.xlsx") + let c = try parse("lift_program_filled.csv") + XCTAssertEqual(x.programs, c.programs, + "one parser, two containers — the container must not change the result") + } + + // MARK: - Grouping + + func testRowsAreGroupedIntoProgramsInSheetOrder() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertEqual(r.programs.map(\.name), ["Lower A", "Upper A"]) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), + ["Leg Press midfoot", "Lying Leg Curl", "Leg Extension"]) + XCTAssertEqual(r.programs[1].lines.map(\.exercise), + ["Incline dumbbell press", "Cable fly", "Lat pulldown"], + "a blank row in the middle separates nothing — it is just an empty row") + } + + func testTheProgramNoteIsTakenFromWhicheverRowCarriesIt() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertEqual(r.programs[0].note, "Belt from set 3") + XCTAssertNil(r.programs[1].note) + } + + // MARK: - Targets + + func testTargetsAreReadIncludingEuropeanDecimalsAndUnitSuffixes() throws { + let r = try parse("lift_program_filled.xlsx") + let press = r.programs[0].lines[0] + XCTAssertEqual(press.targetSets, 3) + XCTAssertEqual(press.targetReps, 10) + XCTAssertEqual(press.targetWeightKg, 50) + XCTAssertEqual(press.restSec, 90) + XCTAssertEqual(press.note, "Slow eccentric") + + // "40,5 kg" — a comma decimal and a unit, which is what a European spreadsheet writes. + XCTAssertEqual(r.programs[0].lines[2].targetWeightKg, 40.5) + } + + func testABlankTargetStaysNilRatherThanBecomingZero() throws { + let r = try parse("lift_program_filled.xlsx") + let fly = r.programs[1].lines[1] + XCTAssertNil(fly.targetWeightKg, "a blank cell means not planned; 0 would be a planned zero") + XCTAssertNil(fly.restSec) + XCTAssertNil(fly.note) + } + + // MARK: - Muscles + + func testMusclesResolveAndSecondariesSplitOnCommas() throws { + let r = try parse("lift_program_filled.xlsx") + let incline = r.programs[1].lines[0] + XCTAssertEqual(incline.primaryMuscle, .chest) + XCTAssertEqual(incline.secondaryMuscles, [.frontDelts, .triceps]) + } + + /// An unrecognised muscle warns and leaves the line unclassified — it never guesses, and it never + /// throws away the row. The vocabulary is a stored-data contract; deciding that "Shoulders" means + /// front delts would put sets in a bucket the user did not choose. + func testAnUnknownMuscleWarnsButKeepsTheExercise() throws { + let r = try parse("lift_program_filled.xlsx") + let fly = r.programs[1].lines[1] + XCTAssertEqual(fly.exercise, "Cable fly") + XCTAssertNil(fly.primaryMuscle) + XCTAssertTrue(r.warnings.contains { $0.contains("Shoulders") && $0.contains("Cable fly") }, + "the warning must name the value AND the exercise: \(r.warnings)") + } + + func testAWarningNamesTheSheetsOwnRowNumber() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 6:") }, + "Cable fly is the 5th data row, which is row 6 in the sheet: \(r.warnings)") + } + + func testMuscleNamesAreCaseAndSpacingInsensitiveAndAcceptStoredTokens() { + for spelling in ["Front delts", "front delts", "FRONT-DELTS", "frontdelts", "frontDelts"] { + XCTAssertEqual(LiftMuscle(sheetName: spelling), .frontDelts, "failed on \(spelling)") + } + XCTAssertNil(LiftMuscle(sheetName: "shoulders"), "no guessing beyond spelling") + XCTAssertNil(LiftMuscle(sheetName: " ")) + } + + func testEveryMuscleInTheVocabularyIsReachableByItsEnglishName() { + // The shipped template's dropdown offers these; if a case were added to LiftMuscle without a + // name here, it would be offered and then rejected on import. + for m in LiftMuscle.allCases { + XCTAssertEqual(LiftMuscle(sheetName: m.rawValue), m) + } + } + + // MARK: - The shipped template itself + + /// The empty template in `docs/` is the file users actually download. Parsing it must not throw + /// on its headers, and it must contain no programs — an unfilled template imports nothing. + func testTheShippedTemplateHasTheHeadersTheImporterExpects() throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("docs/lift-log-program-template.xlsx") + let data = try Data(contentsOf: url) + + // The header row is what the importer matches on, so it is what must not drift. (`rows` + // is empty for an unfilled template by design — every data row is blank.) + let grid = try XlsxSheet.grid(from: data) + let headers = grid.first.map { $0.map { HeaderNorm.normalize($0) } } ?? [] + for expected in ["program", "program_note", "exercise", "primary_muscle", + "secondary_muscles", "sets", "reps", "weight_kg", "rest_sec", "note"] { + XCTAssertTrue(headers.contains(expected), + "the shipped template lost the \"\(expected)\" column: \(headers)") + } + + // And an UNFILLED template is empty, not a program of blank exercises. + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: data)) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .empty) + } + } + + // MARK: - Refusals + + func testAFileWithNoExerciseColumnIsRefusedWithThatReason() { + let csv = "Program,Sets,Reps\nLower A,3,10\n" + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: Data(csv.utf8))) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .missingColumns(["exercise"])) + } + } + + func testNonsenseIsRefusedRatherThanImportedAsOneStrangeProgram() { + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: Data([0x00, 0x01, 0x02]))) + } + + /// Only the exercise is required; a sheet with nothing else still makes a usable program that can + /// be finished in the app. + func testAnExerciseOnlySheetImports() throws { + let csv = "Exercise\nBack squat\nBench press\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, 1) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat", "Bench press"]) + XCTAssertNil(r.programs[0].lines[0].targetSets) + } + + /// Excel in most of Europe writes CSV with semicolons. `CSVTable` sniffs the delimiter, and this + /// pins that the lift path benefits from it. + func testASemicolonDelimitedCsvIsRead() throws { + let csv = "Program;Exercise;Primary muscle;Sets;Reps\nLower A;Back squat;Quads;5;5\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines[0].exercise, "Back squat") + XCTAssertEqual(r.programs[0].lines[0].primaryMuscle, .quads) + XCTAssertEqual(r.programs[0].lines[0].targetSets, 5) + } +} diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv new file mode 100644 index 0000000000..c5924b5ff1 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.csv @@ -0,0 +1,8 @@ +Program,Program note,Exercise,Primary muscle,Secondary muscles,Sets,Reps,Weight kg,Rest sec,Note +Lower A,Belt from set 3,Leg Press midfoot,Quads,Glutes,3,10,50,90,Slow eccentric +Lower A,,Lying Leg Curl,Hamstrings,Calves,3,8,30,90, +Lower A,,Leg Extension,Quads,,3,12,"40,5 kg",60, +Upper A,,Incline dumbbell press,Chest,"Front delts, Triceps",4,8,22.5,120,Pause at the bottom +Upper A,,Cable fly,Shoulders,,3,15,,, +,,,,,,,,, +Upper A,,Lat pulldown,Lats,Biceps,3,10,60,90, diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.xlsx b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_filled.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5cef2c1ccada074038110af493e3a6486057e24d GIT binary patch literal 4145 zcmZ`+2UHVV*9|1}-b4aYrHDZ2AkvZks0J`7AU)Io2~CQjNlid%1XMam7Z8;8=+Yk@ z1ciuzN*5{8zxdw2mY@9Jn^`k=&8*pb%{k|ud-pch2ZERZ0024R;<=~A5H~|d1OPyQ z000x=&0P(IC(6+iWdjZLazt8*1$el3Cl48Riql8hbnm>1`>>Cu*Bb+>Xyc7J?|aL} zvYhv=r7vxGk9QEU(3|obrng9a$GB%?je_gn>dZF9Rdppxq59=!eOVs9Q)ZsdHX)uy zf1|yw%=68_%Ci{VRWohUeVp2XNiBF#r`kb=3LHCv^DKL;I{;)wdG>=LYP8P;LD zql8}y>Sty0jaX;1djQHZ^O4>edi!zmSnY(vr>GEdx1#!+t8=ZUaSS=lUQ8cYqN*d@ zFMi>$7Pr(~E_QE8>O4_t>U<(%%ppna6pZ{r1{>yT4J9-FEq}P#wfIUx-UJCbq#}$q zK9254h}h3N=C5|zaB*rqAG&-yt%{vg5YgRz!26{g`ut_d*%jH#eZyuTjIAIppt=ofQJZdLNr)CH3N;EQk zR@;*wNP4W1C41!%)+7C(^C>i6y7wS36n{33uIK}cw#hXDrYdRAU%pr)jdC_kWb-&N z`Ie?rvrC$fc3gz)e0mBWWTb2sK3r*ErTIN9)(37wgM@Gl31QI@#sGJSKf=e&4uNp{ zIVCX0Kc@tBXj-p-dL>z8X?+uSmnR3F=#NkJqu{0*DN=+{4^G-M`{ej^W@A%IbTeWi z&x>r%fCL+mL?5_{-`M-QNtErlgpL>Y-PR;+YE_EmoY$nSYFD*ch3~3xNMgNnwArw+@-{lDmj=6=hrDIJ%yE$% zW3Un|;}GE#JK>D%tYc$Zl41jq*v-yMCkuPdH{OVB!ch?=2>Ly?k5zhSM+s-_zX#5g zbt~>?;L^kZ0QjH4|0(HzhyQnZTOR)v*P%)sZR6M*l2uQ|yEqe1Zp|8kw#__i_jAHI zSRC)u_V-irayl4&%t(T8Il!LwsGS-w&EKTaOpoAo4*Aez-Wl3(h>Nh3sFaQHFRD=_svAYBRn;NmyXjN&p@~aN?G%bf0eGwq<3v~btfNPrRCFn%Q{)g$;p7#Z} z-hSi=97B)nf^jZfYUm1_?~;*oQduZ^)OYw zex{8>D%#UPaMyUu#%QmBT`w5l_-PC-^cD1M-aLvfCGss$m0Kq#M3;_dhAZK49}ss; zWv}+h5Rtt|WF;q#xUxO0os{gJ;W+(=cdm=st`?b*C2}ISpot&N3t4)iKPX&WE-Y=4 zbfT1z=gmtBQ+U!!b4=}It!NjOTK0qAAgr`n5#-gV6)mzq?(h-_K2CzKAh)tPIiBx) zI7kFGKhBGXQ3vs1_&niymec-nzJ3jf(nB>oC^9}2e@koTJGq3ispk6_jQ5q{A5cd_ zqukP@5wgge5*&-*1PoXJlt1zX#FeljNtsO_A66%-b9foiv316BFvAoAeMNbIr<+cpXf-WrbtgI`^Gv&~HWw0&v z+l!Un;KKRZJ+?o$@eRHsdUtYWtZ(SGHH{l4`G=+6k(4s*&0}}0I%)2?gwT@1XBD+Q z*5Au^D5L73Jggw_A@ubxK29UW0f$lrs+&k9%I?jo|pnbGHQ-AD_@%mSEZNhZ~s zlFXXI$!Ax*0FyJlvIgI`oby?0)E{hhO>6wSCjUW%HDW2Fj^3L2nLW}K&MX{wwSUDnUl+MO($FdAVJBeC6TmYw zEHrobi;yB+Xz}&TnJy74slEPYc$q^Hob5<}@^nQ}>|V1(8Z)?K z$b{_@znP%vl)^`PR2X{m4)2CO;w2d0BUBk?tPfgcE#sb`3_BW%6Omiu2L`x7U1#Zs z7y%7x_|TKj&k$6F73nz@>G(m~){f9r?V?^FRFr%+)mwvBFMWTL=1mwSX$s#!?^M0? zwfJ4`!Z2a7_EXp#1%>cskXxS{(1L=PROvN;`1qC@NjQLpX9P@iL>necK7ze_2ul{X zg+2~8y&~t~`~F^?!o15_)6C_QzRKEy(IE5dv@i_168>UCih->}mRLUjgvFel2so+b z)syrN7pz*#z&*20mwsZuob$@Z%j7y)W!mNDJ@0Q$IJj(7QYyLAXXu&$2OmD?Ho2h+ zx)^eJplB@;n7Uo;Y^@u4IL<`lnY(Nt8>@VO{;`Q zz}W$UdAuyBe^O!=iZWT&uG}F%N!Yx>M;00`qP^|+raaJ9Wrk<`L!^Bd0Phfo-{mPY z_k83gSIy=-=+>~I-|8#&q%rIXJFY1#iaq8-+Zavx`?gscch8%qTn#T0A*-KfS`eVK z(MvEhKH-ldXdwT$f*Z99KjTLO%1AksbDIAzQ!k7SK z^mwu7rxlTy_gPvm3fb)Gb&l6vpKDVUZ`qVGJ~I&(Tdfu1#PYQ%Fx{wPbsn9LUh~&x z5E@#|PRr}5g+~cLco^Eqyrat!1BM4z4Ckq@tvE=V!opZ?YmDAE@aK#w#}(CrOOGbpRfjmq7Fk$piFjIpH z&Jr&6niyktTM|>uXCN@tW}>AcIXS2QASm{gT>Y2&=Q_uNV|z?Dx#Oa}h6We|XzD=s zi`Nm3&2 z(Ss{gyE5Umt$CXxsxYxT-2l(7v25dM|9-VzN^{6ahwRxAo^#iW&M!KP*0$%_O%2j9 z060!&I&xH`CbEdT4#@2pp3DB;u-Je5t@=5I+=&U(x~Bi$8`sE*Hd*WuJ=cB=osI5t z{#k7Phmg#vWy<}^sTb8`U65E6OEC$qjX;-W<@1Ya~cIwbl2v5aj zElYN#a{6O~s^2z6SdGWY4543>1YXx=ZbEHHrd`soJ^T{ytmtPc9m=2gE#11isk1qy z*^c>jsTmvpCb<)iVr;EdzoMI(CnK+~yT-h;BKl6cZT{pVTHtdz!>@}D{solm0xsP? zxGH$m^;sc9yd@=L8Haua={EO`FcW2RL%mPCuh0r6@j&PX`;KO=fu||y$?2if(YLe3 z0e5mbD@n9)mnj?XjtoPsy3nG{U8Eg*1D{J(vQ?b2X!GGmW>MOtx*#ZfPHox1*A238 zHbMK6q{*w5`nG-(=J`8zUXmPlvg1yi4v$}@8%eJ*kHkCZX4S;FdW_w zT$R4G@Q!w#g4K*3Y9^E)G<$2dzjA?3(Dd7=8|Mv=m_&aFQ>zKlEvf`aLO&4Ujc4X& zi>{t?l`WssQm}3EUs%w^dBa@8^d`dFX4^#7Y+L1MBpj3;DZgxelxD1fc{8asl)MBJCg;24R z;@buf`CNVC(wrcN9Rv0kOmC!Bm*(g9Zx`#i#0X2M1Q+wVpyN;oN@Tjyp}Ttvkksp3 zP4Xd{12&G%{9Gl++Qy(nBLuB{!C_akW9b!a2>jt5^_N`BCnUJdxq<43ySzd%5o*&< zW8nnb?*d4J>@*LRi0?hY$qN_}f)F8G*>;EU@27nq*lj|t#^@d6ilE-L6>Jj?`5P2l;Zr!PV;s Void + + @EnvironmentObject private var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var picking = false + @State private var parsed: LiftProgramImportResult? + @State private var failure: String? + @State private var importing = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + if let parsed { + preview(parsed) + } else { + intro + } + if let failure { + NoopCard { + Text(failure) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusCritical) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase) + .navigationTitle("Import a program") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if let parsed, !parsed.programs.isEmpty { + Button("Import") { Task { await performImport(parsed) } } + .disabled(importing) + } + } + } + } + .fileImporter(isPresented: $picking, + allowedContentTypes: Self.acceptedTypes, + allowsMultipleSelection: false) { result in + handle(result) + } + } + + /// What the picker will accept. `.spreadsheet` covers .xlsx and .numbers; CSV and plain text are + /// listed separately because a file exported from a spreadsheet often arrives typed as text. + private static let acceptedTypes: [UTType] = { + var types: [UTType] = [.spreadsheet, .commaSeparatedText, .plainText, .data] + if let xlsx = UTType(filenameExtension: "xlsx") { types.insert(xlsx, at: 0) } + return types + }() + + private var intro: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + Text("Fill it in on a computer") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Text("Only the exercise name is required. Anything you leave blank can be filled in later, or during the session.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Button { + failure = nil + picking = true + } label: { + Label("Choose a file", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) + } + } + + private func preview(_ result: LiftProgramImportResult) -> some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + ForEach(Array(result.programs.enumerated()), id: \.offset) { _, program in + NoopCard { + VStack(alignment: .leading, spacing: 6) { + Text(program.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("\(program.lines.count) exercises") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + ForEach(Array(program.lines.enumerated()), id: \.offset) { _, line in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(line.exercise) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 8) + Text(summary(line)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + } + } + + if !result.warnings.isEmpty { + NoopCard { + VStack(alignment: .leading, spacing: 6) { + // No count in the heading: the warnings are listed directly beneath it, so + // the number adds nothing — and it dodges plural agreement in ten languages. + Text("Worth checking") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.metricAmber) + // Shown in full rather than summarised: each one names a row the user can go + // and fix, and a count alone would send them hunting. + ForEach(Array(result.warnings.enumerated()), id: \.offset) { _, w in + Text(w) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Text("These lines still import — anything unclassified can be set in the app.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + Button { + failure = nil + parsed = nil + picking = true + } label: { + Label("Choose a different file", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + /// "3 x 10 · 50 kg · 90s", skipping whatever the sheet left blank. + private func summary(_ line: ImportedProgramLine) -> String { + var parts: [String] = [] + if let sets = line.targetSets, let reps = line.targetReps { parts.append("\(sets) x \(reps)") } + else if let sets = line.targetSets { parts.append("\(sets) x") } + if let kg = line.targetWeightKg { parts.append(LiftFormat.trim(kg) + " kg") } + if let rest = line.restSec { parts.append("\(rest)s") } + return parts.joined(separator: " · ") + } + + private func handle(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + failure = error.localizedDescription + case .success(let urls): + guard let url = urls.first else { return } + // A file picked from Files/iCloud is security-scoped; without this the read fails with a + // permissions error that looks like a corrupt file. + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + let data = try Data(contentsOf: url) + parsed = try LiftProgramSheetImporter.parse(data: data) + failure = nil + } catch let error as LiftProgramSheetImporter.ImportError { + parsed = nil + failure = message(for: error) + } catch { + parsed = nil + failure = String(localized: "That file could not be read.") + } + } + } + + private func message(for error: LiftProgramSheetImporter.ImportError) -> String { + switch error { + case .unreadable: + return String(localized: "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv.") + case .missingColumns: + return String(localized: "That sheet has no Exercise column. Use the template — the import matches on the header names.") + case .empty: + return String(localized: "That sheet has no exercises in it yet.") + } + } + + private func performImport(_ result: LiftProgramImportResult) async { + guard !importing, let store = await repo.storeHandle() else { return } + importing = true + defer { importing = false } + + let now = Int(Date().timeIntervalSince1970) + for program in result.programs { + let programId = UUID().uuidString + _ = try? await store.upsertLiftPrograms([LiftProgramRow( + id: programId, deviceId: repo.deviceId, name: program.name, note: program.note, + createdAt: now, updatedAt: now, archived: false)]) + + let items = program.lines.enumerated().map { index, line in + LiftProgramItemRow( + id: UUID().uuidString, deviceId: repo.deviceId, programId: programId, + ord: index, exercise: line.exercise, + targetSets: line.targetSets, + // The sheet plans ONE rep count, which is what the editor plans too; the range's + // high end stays nil rather than inventing a spread nobody typed. + targetRepsLow: line.targetReps, targetRepsHigh: nil, targetRpe: nil, + targetWeightKg: line.targetWeightKg, + restSec: line.restSec, note: line.note) + } + _ = try? await store.replaceLiftProgramItems(programId: programId, items: items) + + // Remember the exercises too, with the classification the sheet gave them, so the + // picker offers them next time and the per-muscle rollup resolves the same name the + // same way. Best-effort: the vocabulary is capped, and hitting the cap must not fail + // an import of the programs themselves. + let exercises = program.lines.map { line in + LiftExerciseRow( + id: UUID().uuidString, deviceId: repo.deviceId, name: line.exercise, + primaryMuscle: line.primaryMuscle, + secondaryMuscles: line.secondaryMuscles, + createdAt: now, lastUsedTs: nil) + } + _ = try? await store.upsertLiftExercises(exercises) + } + + await onImported() + dismiss() + } +} diff --git a/Tools/make_lift_program_template.py b/Tools/make_lift_program_template.py new file mode 100644 index 0000000000..8598abc860 --- /dev/null +++ b/Tools/make_lift_program_template.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Generate the Lift Log program template (.xlsx). + +The template is the thing a user actually touches: they fill it in on a computer, where typing a +dozen exercises with sets, reps, weights, rests and technique notes is a two-minute job instead of +the most tedious screen in the app. + +It is written by hand rather than with a library so the repository needs no Python dependency to +rebuild it, and so the exact bytes are reproducible. An .xlsx is a ZIP of XML; only the parts that +matter here are emitted. + +WHAT MAKES IT "FILLABLE, NOT EDITABLE": + * the sheet is protected, and only the data cells are unlocked — headers cannot be renamed, + reordered or deleted, which is what keeps the importer's column mapping true; + * the two muscle columns are dropdowns over the app's closed 20-token vocabulary, so a muscle + cannot be misspelled into something the importer has to reject; + * a second sheet carries the instructions and a worked example, and is NOT read by the importer, + which always reads the first sheet. + +Run: python3 Tools/make_lift_program_template.py +Out: docs/lift-log-program-template.xlsx +""" +import os +import zipfile + +MUSCLES = [ + "Chest", "Front delts", "Side delts", "Rear delts", "Triceps", + "Lats", "Upper back", "Traps", "Biceps", "Forearms", + "Quads", "Hamstrings", "Glutes", "Adductors", "Abductors", "Calves", + "Abs", "Obliques", "Lower back", "Neck", +] + +HEADERS = [ + "Program", "Program note", "Exercise", "Primary muscle", "Secondary muscles", + "Sets", "Reps", "Weight kg", "Rest sec", "Note", +] + +# Generous, so a user can paste a long routine in without running out of validated rows. +DATA_ROWS = 200 + +HELP = [ + ("How to use this template", True), + ("", False), + ("1. Fill in one row per exercise, in the order you want to do them.", False), + ("2. Put the same name in 'Program' for every exercise that belongs to the same session.", False), + (" You can keep several programs in one file - Upper A, Lower A - just change the name.", False), + ("3. Save the file, put it on your phone, and open NOOP > Lift Log > Import a program.", False), + ("", False), + ("Only 'Exercise' is required. Every other column can be left blank and filled in later", False), + ("in the app, or during the session itself.", False), + ("", False), + ("Primary muscle / Secondary muscles use the dropdowns. Secondary accepts several,", False), + ("separated by commas: Front delts, Triceps", False), + ("", False), + ("Weight is in KILOGRAMS. The app shows it in your chosen unit; it is stored in kg.", False), + ("Rest is in SECONDS. 120 means two minutes.", False), + ("", False), + ("Do not rename, reorder or delete the header row - the import matches on those names.", False), + ("", False), + ("Worked example", True), + ("Program Exercise Primary Secondary Sets Reps Weight Rest", False), + ("Lower A Leg Press midfoot Quads Glutes 3 10 50 90", False), + ("Lower A Lying Leg Curl Hamstrings Calves 3 8 30 90", False), + ("Lower A Leg Extension Quads 3 12 40 60", False), + ("", False), + ("Muscle groups you can choose from", True), +] + [(m, False) for m in MUSCLES] + + +def esc(s): + return (s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def col_letter(i): + s = "" + i += 1 + while i: + i, r = divmod(i - 1, 26) + s = chr(65 + r) + s + return s + + +def cell(ref, text, style): + """An inline-string cell. Inline rather than shared strings keeps this generator single-pass.""" + if text == "": + return f'' + return (f'' + f'{esc(text)}') + + +def program_sheet(): + rows = [] + # Header row: style 1 (bold + locked). + cells = "".join(cell(f"{col_letter(i)}1", h, 1) for i, h in enumerate(HEADERS)) + rows.append(f'{cells}') + # Data rows: style 2 (unlocked), empty and ready to type into. + for r in range(2, DATA_ROWS + 2): + cells = "".join(f'' for i in range(len(HEADERS))) + rows.append(f'{cells}') + + widths = [16, 26, 30, 16, 26, 7, 7, 11, 10, 34] + cols = "".join( + f'' + for i, w in enumerate(widths)) + + listing = ",".join(MUSCLES) + validations = ( + f'' + f'"{esc(listing)}"' + f'"{esc(listing)}"' + f'') + + # Protect the sheet, but let the user select and type in the unlocked data cells, and sort/filter. + protection = ('') + + return ( + '' + '' + '' + '' + '' + f'{cols}' + f'{"".join(rows)}' + f'{protection}{validations}' + '') + + +def help_sheet(): + rows = [] + for i, (text, bold) in enumerate(HELP, start=1): + rows.append(f'{cell(f"A{i}", text, 1 if bold else 0)}') + return ( + '' + '' + '' + f'{"".join(rows)}' + '' + '') + + +STYLES = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + # 0: plain, locked + '' + # 1: header - bold, tinted, LOCKED so the structure cannot be edited + '' + # 2: data - UNLOCKED, the only cells a user can type into + '' + '' + '' + '' + '') + +CONTENT_TYPES = ( + '' + '' + '' + '' + '' + '' + '' + '' + '') + +ROOT_RELS = ( + '' + '' + '' + '') + +WORKBOOK = ( + '' + '' + '' + '' + '' + '') + +WORKBOOK_RELS = ( + '' + '' + '' + '' + '' + '') + + +def main(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + out = os.path.join(here, "docs", "lift-log-program-template.xlsx") + os.makedirs(os.path.dirname(out), exist_ok=True) + parts = { + "[Content_Types].xml": CONTENT_TYPES, + "_rels/.rels": ROOT_RELS, + "xl/workbook.xml": WORKBOOK, + "xl/_rels/workbook.xml.rels": WORKBOOK_RELS, + "xl/styles.xml": STYLES, + "xl/worksheets/sheet1.xml": program_sheet(), + "xl/worksheets/sheet2.xml": help_sheet(), + } + # Fixed timestamps: the file is a build artifact committed to the repo, and it should only change + # when its CONTENT does, not every time it is regenerated. + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for name, body in parts.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + z.writestr(info, body) + print(f"wrote {os.path.relpath(out, here)} ({os.path.getsize(out)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/docs/LIFT_LOG_PROGRAM_IMPORT.md b/docs/LIFT_LOG_PROGRAM_IMPORT.md new file mode 100644 index 0000000000..dce5591a3d --- /dev/null +++ b/docs/LIFT_LOG_PROGRAM_IMPORT.md @@ -0,0 +1,61 @@ +# Importing a Lift Log program from a spreadsheet + +Filling in a program on a phone — a dozen exercises, each with a muscle group, sets, reps, a weight, +a rest period and a technique note — is the most tedious part of the Lift Log. This lets you do it +on a computer instead, in a couple of minutes, and bring the file across. + +## The template + +**[`lift-log-program-template.xlsx`](lift-log-program-template.xlsx)** — download it, open it in +Excel, Numbers, Google Sheets or LibreOffice, and fill in one row per exercise. + +The sheet is protected: the header row cannot be renamed, reordered or deleted, because the import +matches on those names. Only the data cells accept typing. The two muscle columns are dropdowns over +the app's closed muscle vocabulary, so a muscle group cannot be misspelled into something the import +has to reject. A second sheet carries the instructions and a worked example; it is ignored on import. + +## The columns + +| Column | Required | Notes | +|---|---|---| +| `Program` | no | The session name — "Upper A". Every row with the same name becomes one program, so several programs can live in one file. Defaults to "Imported program". | +| `Program note` | no | Taken from whichever row of that program carries it. | +| `Exercise` | **yes** | Whatever you call the movement. NOOP ships no exercise catalogue — your name is the name. | +| `Primary muscle` | no | Dropdown. | +| `Secondary muscles` | no | Dropdown, and several can be typed separated by commas: `Front delts, Triceps`. | +| `Sets` | no | Working sets. | +| `Reps` | no | One rep count, not a range. | +| `Weight kg` | no | **Always kilograms.** The app displays it in your chosen unit; it is stored in kg. | +| `Rest sec` | no | **Seconds.** `120` is two minutes. | +| `Note` | no | Your technique cue, verbatim. | + +Only `Exercise` is required. Anything left blank can be filled in later in the app, or during the +session itself — a blank stays blank rather than becoming a zero. + +## Importing + +**Lift Log → Import → Choose a file.** Nothing is written until you have seen what it will create: +the programs, their exercises and targets, and anything that needs checking. Then press Import. + +Exercises are remembered with the muscle groups you gave them, so the picker offers them next time +and the per-muscle rollup resolves the same name the same way. + +## Formats and quirks + +- **`.xlsx`** (the template) and **`.csv`** both work. Everything is read from the FIRST sheet. +- **CSV delimiters** are sniffed — `,`, `;` or tab. Excel in most of Europe writes `;`, which is fine. +- **Decimal commas** are understood: `40,5` and `40.5` both mean 40.5. +- **Unit suffixes** are tolerated: `40,5 kg` reads as 40.5. +- **Blank rows** are skipped, wherever they are. +- **An unrecognised muscle** warns and leaves that line unclassified — it never guesses, and never + throws the row away. The muscle vocabulary is a stored-data contract, so deciding that "Shoulders" + means front delts would put sets in a bucket you did not choose. +- The template's dropdowns are in **English**, since the file is authored on a computer. The import + also accepts the stored tokens (`frontDelts`), and is insensitive to case, spaces and hyphens. + +## Rebuilding the template + +`python3 Tools/make_lift_program_template.py` — no third-party Python packages needed. The file is a +committed build artifact; regenerate it if the columns or the muscle vocabulary ever change, and note +that `LiftProgramSheetImporterTests` parses the shipped template and will fail if a column goes +missing. diff --git a/docs/lift-log-program-template.xlsx b/docs/lift-log-program-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1c107ff952e856e4744dfcfe9d20c539bfc9f2c6 GIT binary patch literal 8825 zcmbuFc{o)6+xTtSvxH>GUL>Iq*(Q`MWy=zU2w^Z}&DgSJ-`66tHIXgbU@Y0PQ)J0B z#=g(U7GoLD^!fg-E2rP*`^R%VT%60CdEZ{I`@Y}K<=jVCi=2X$goK2e_{H}~jX83h zk(7i)oScM&h4{)tRj9MOjkCLj!E+ZIH#0GBC&!M&e(g31rVxvc&FV<>uRtcvA#(7Y zDP8WzuGb^jj=NV==diBBt)y&B`WLiQ8zr}59Mdwsf@(U|CyYysbu zSjRH;$i@OU7%nUEZQL_+h6lD+jOle8B)7&S=lfLMY^8F4KD2MgH}AA&O(K0W>{;U@ zbB%d3m9$9rt|^b_VOQ4fTJ1hjQu-Bc7hZ|V`}wIzHsoqbXKCv2A zO3a%OF^6=-qs22DM>lb?-``^fTNr{Q=qaa+O?=W`P(7g!j=DPS@RM}Gh>~>FCW98b z9&4rG?YO^tFkE525TW=r>_u@PfA5PN!+a_T#gB5?OO3z8LeYKutQF%wUH$Fi%Sn{!3BTIb)iDtI~D64&-8p{3cIt6Bh9p#3yhNR_<5TW*`yf3J$aj<^wW2_ zbT@O(b*J=8IZoCW#FF%+EX4}6Cyg%9UuT)G+N&?Z`L%YrSlVraxvs^!zP-Z{8zF0< zo_w~iqoLnbdI`mI>L4ceD%-HQNRpLET4u(9F2gDKJ<$kU+u1|R&eI!pqBuME;P+6b z9ggWTO~QUOffFdsl5s2Yx40A;2?^*w;{Pe>N&SDy+n6vI*?N;c%)+M2FQbNze`Y+2 z+ML}l(DF5*#nU$3+W6oXLr)JaKex5cbXvSPkG090&&o%-bCdF?Z>I+HKlMW+joSPX z`1D|_tEJb2z49&jUpiDD=C-JtPM0zkpK1H}Qd>}z>AR;CXuy{@?P5$^UIOcrsFv|y zdzxN_Y|F#mK-jy9QP50{*iG!an`PyG!`rE&lm2mYb1gIq``%Nr>Sw;Sr`~ES>F~aE zJN12FoJ~(a>ZR;%-=_O7Z9qd$pclsuVchFYjdJ9`9E8lU`4$rke~*HMuPv;T1D)>)ZR-wM=XQqUwq| z{v5P*oxrv`&f;fKO}m@UJ^6loIVpnXifyij_8g|-0`4?+KFzLsyzCH~vnA=XOZnOb zGH;M!fSKW2SW}c`d3$=9*|NxUCqfF#lkQ=a@Z{LS!}r0Jha=-d-Tk%Zry((kd#0NE zl1e6Z368NUlsU&&h3Bjn!OA=Az_P~oFTRpyBOwWWPeO9;&z^R(v$1h^6aVLM^>>BQ zXtVndjZ?!m%2Zsj_78lFdpwORb|;`IxNgBWIEYE+IEXGH^;TvdoaY72Q@5$5|ZjAW+zjGW2xq_w~s~JrTZo zb~NqjA!ni3&@4B3zJ8>CHpUrYoVU9AeFW=JiS#|#|4OJ^RyzNomz^=79eG5UZAzxov9*e8bn�CV`hhhVYH{4PhiwA0e65)OIoc#$)sUo#l($PhZn|h zVIysY&8Tec15_9SJXtXO%KygNF&t%P-stDIb4WFoHYijz2?ZaocVKOI_riXZ`oj*_ z;r;!~ZTO+JO)I|u1)hBiou4iZi=5F}&JsRNEla&J=fLR4hrYoZJM2)2=k+afgIPn{ z%ap@lm}VuMvo8xOaXfP{m^~Rl5W)m(Y!jjg++FO@a^L!visqIyuo@$jaJcJ<4tO9` zlJUroKo@Yx`t!r^3U;-PFx)Y{wTRlEZbdA*$@>%BVQzKbon(+W`0(`MXg}UF&VmWU zwQjV!1s}sp62{N_W5qB8m+@wBv9pE6n}LAVwga=0?ATxEF&?ZH0_+i^T3;rcPZ&Kv zn(<`Sh45j(#SJNU=&W?V>*S(o|C47*;wII}m+x>?PpYJ(P6@;aZyaW`}E zfTzQzUQUI*Ri#!9S5;T-QWa)vkloqXDvS$*D^bW$fGN(aoKxXa0jY4SaB#wvo-6Hi z|NQp%8kX5_@IkoJ`K*1WA54c#?$nmluGIF_%3GWXZ;-=4PJ=LzBr;t68lqvvDb zJI%+K(-(a|8=SrG>%F4tqWV+y3ttFdJ6}HE9A9#s>q^w(eG7UWKWb-ce`>i9i4fTk zsSt&b3JGmLyzffybRlEQ=@v%iFs7GGVev2H!*o+W7iKpH_zx*Yd-q5ku=G zk6ri}X834n;;A*_=`UGbDcc^tJZ>KTXKmyI0nsB`I@@zIyxh6*BoXlx-SPaR+*;tS zpxZA2;rfT5m5(z4;YzBuC9jOz1HuJgIkKX=$O#DN8Mlw+wnUy>D*!>>Ir5AvN1wDy zs7B|lP+1c~`gjuXlGg&%7j(K0uf75cZBf|}@*SOa-(zshH78?VtEho}gYHtJPP;NO z>Zu3bQV+6L=P*Gjt{`9`*pmX$#aQkglwzx=Dp$KI>lt3=L|zVkwbB0#nm_W+xbq3w zo~{X;Ycu+ZqfEQ9!Yw}1Gki3Od^SYPS`6+g5VNKR_5-?m4v3k-=w=+}w)MhUX{bM} z9{)gwXKf)5?OU4>=e0!&g&;eL3Hk^~(8p@@tUt07NHP9@qj-o?#35kOz+{zRv$j!+ zEr4WQ9QD-M$7VeS)R6^(6gu+M0oF018lB{i6ar$lPvA6mRvidUubaykpvCyh5W>Lg z=Bfo~2_6C=OimC_H=|jAxwLZH&Bx$qfVuow0~>H$h|8379dSlliCFBS*b zEGvM;eFzdxv;Zc%uo^w=kAwp)zD?llo6+}ym<~?6RT$hQAZB$9%v5nTT1S3%YhbTk zQfVt%M{(9|aBogh=^$DMTv-E~fbOyaElg30SOhE*XhE#SFO*^jpce0lwfHxSr$?TH zz*@YjMpybHp8_rVCU7RrXi1=jt6S zaHjwk73DRsuh3mGfQ88eRO1o?_5xrbT8uT{Mm2r{Seza86yL|11Gg_71j&BnDGqFz z&}wv)Kaw4o?A8g~?HzsRrqm4ag@q4w_-D>dSsCI>3zzHhiOx;$P{h;!W>JB`T>x5C z)xf?(ch3MVOi_(%2v`Krq6BOH3)R>Tut+6Z9AM3X+gA&MOe0zVlU-1a?)68e0WF3m za5l|oEnu?kop%2f(}c|=-P$rKRPU^jNxF??TBzmORFibuk0?=(%mTfq_H!m6zf`ri z1$uV&f~KIDRJHs9J^QPYSH=wC^RIKx#tS@8OeDLMc;-@44;|=7j>z|C{kC*n2?`n- zFIY7qk~QW}oyO@JLhEdsPR*%j<>%jw%)ix>zZpd7^`5rwQRN)jGeI4Fecs#p{5JZ} zm4oFCq|Hh>JVXrT=&UeXAoq-fFra>f+IESaAH*61LL=1nO7#38`4|vZMxJ0ah5_LbYR4ta zfe_Xy5KXNbc`5S?h`|(yLl#MH95w~IP^(5;`ZWmBJ_VAlRbwdq8VsSB2HlpGr!kh9 z2IStNt;;xx#wR*kn*FB~E`1B$6t6D-w>fLPCf z-pI|AI|})pFgZe8G#>&`>))1rX?aT9Sx_ zkZ2zu(A|EGk=HDr7t~Kw*ve=zzh{63=_dH6z-(8^-Syd)LTKa?|FmOd`W3lCEB98= z`R+g6c)L&Zje(h*WqRz1X5_cc(Js36rd;pNiC$!uG!il_(N6yW#4Je?c_bt{2E-ii zu66E|nc0Z(@fF~~EOp9|ZN>!o3h-gW$anvLgISSoH0RlPx&|s@$ zF~R|S7jXG2XqsRzfN!ste!+SORvzHXtWYT8-Ad05@a4`jP*$*Hjsy6Dbc(KLNyGwt zD-(qbFY1hU12OxOiqeJdE&wqXyYmTDPxs!CZ9V_9d6^~igbeTKoEriJF!`1wQYhrO z0tgVjs$n?Bvakd2^!$}ii>1+m0X&mn`qH~0UljqKJBo#yuvP-llPTNaf|6w<&@)M= zNI1K^2jD3=C}fzeb8ZQUxsoI@BILLO#QfD=MKE3=dLsQ!cvfPB3_s~S_z4K`YBQ;5 zUMLz52vEGQk+Q?$c?9sJB;<=6(y#;oJRb(=cMw9V!2r+i;KKTV)}K6pdXl|0pt)&D z9uKG|y>1cctpt_&$^NaR6E-}j`+!mquwSW@i#RRw${ta31ixZxb zT*8Jobki9C0lqUOi|`8usa*6dN zEdcW@U4iCV+Kf;D=4`0`2wf;%1%P>8wNT|{Ya$;2Ge5`Ro~q?HASPP3s3E5u3dD35 zHeA+CmjhxdB#Zb67jpwKc{E4BtdvXu%v7cVO+MO(;Q-9MaD7AOP?K8#Out)&WD%_@ z`~XaYT!WHZmZAxOu94O&a?CBy0AjusHdNM2*9BtgCyTrjF1`fB6ww?pWPQm7z&v}l zKvR^KGYWwDBuaneY-rtW04Dl&VQWV%&)~G0We5;6Mz4q~ue=k8*(YrHN-y0O zh-t@V81tE#Gv>r^Pf7UxZ?6*HWZcPU=C%3HJ)zDhFxM}*v){y;7kk{;V0LU8`+?W7 zU(M|7{A@j{gdyW-xY`-!-@ALT(~0}+fx?=U9e>I8LcKVy0RJe}u5H3hVsH+4*W=}_ zk$&9xdGm(0e*71BY3eM&#iyaX9*k|Xg{?3!8V`DHEchY_bra45$BLNj*$$t8W_iUQ zN7ET%+RYdbs2Cz+cG))_>v_D4#5(M5Y$+a=A18Zx?ZTTqI%ik>cB6e{*BHfrV#07~ zVa?;v!$qC=FnPCTvu>2uZjOAGd1#xzmrpGfeD*{3bs6+<2M=;&dU*!Skm2Fw<-2pV z;yb^HMB-dFoT~}mzeY@&4-P+JpS#JE(*Qyqv_e%v=P+tDd4Tvq3n-w%%@05GKhn=|8lGK+V5MfZyq?g zWymrSF3bDY`no!i53F8To*T-?J_0SyxXbP8y^PT#57*?6Jw7T4iTRPCR#?FCgh~Bi z%^~{^9emxQ=uDQLh}cS{Fn6rLCpnfIW$aJCj)kpy-C-8)U&&0#{ak4mD&iC1U(dR! z!4?j(^DP<3RasrImeMzQ!FFHu>*IS~+@Zzk@3iX96{_*~V&4{VU6|cyd2iJaJD?+} zCjtwJ(>T zSbMbd2l>s@8a}^Dbx#|Xr8~MMEh>PZ&QQTef5i*ER;9CJ3lgg(i!r=^Ee0-d?_&Qa zJvH#P5tNn>ETUSbW~nAy{Xl4Fhee(@GR&pF_l)=HDvHPN9CgW>-uBhDzNpKgqu_H} zF^@Q#T0HtW;!!&1$d_QhA%TpwSW%8vvkxV%9U>tUT-5!OZ&>NNixl5dkmPTh?zk-5 zzq5=@)~2($`74i2Bq>y>>OgVg2>q4pQ!{I3^vKVU@pwhD&zg2}`!rTyD)l&WiVKN&XQN993lZlN{@wn6|D$)$6H0t z>l~EB`9mA-^|Mpum`3JMk7ez)*G`>7$hS||WA~YOeuXnyXw2skBJ$9FuSe%;f0d3F zmQ%HhM}SSmuJW{|^s^`vG&61R2USZj+6IWFL+ZPRIn zR-3d+I_UY*a=SY!jM_@V9;904jl5(a&z8|%p0W1Ir{%n8-|;NH%r5lugs}FMDeajL zTv{cRKY4Gw&{Lw;)|}n04wTc2A~(QtZr;wh=d4e8c!amz-yqENI#M&5T?-Ux*XQ8h zlxD6}5uUMl=w`)<5U?*xG>cTa(tw|4y}}S*1vB7$Tlt}P8A}z!A@t;Z{D@$wmZj%6 zqr6QkmuqZ14QKU*=r^WuvS8}>p%+UzJGj0CAdESTEFmilwt&g^f$gjxNkUx$9ajfH0hDvqKg)f?jeV|PO0sI(QitOz1F8uWk5*D9 z_(sxeRfTtLgmF)%ek@n9;%Rg6 zXoT?@U1|bv}!CsQ|)q0aQ>I~eXn}8GDAHtf@T{6}D zGEy%mpeQXBkes{O;#QGDk!+mS!KvU9nMNTzIa;$IITIp6BMi4y=tMZl=4g=>y@gqC z-s3D}sgt@nH#zz1K3vm2T;wX)7tU`V80ikBbxT#mJ32O!Q@R{0UkeE2G8qnhy;vs0 z{)t)fw6H;GA(vIfrm2gsHu;`o=%7sFtQ+XlQv}_fqpTd9)E(!kiaCt!u_s9p-)!iA zFR}9~UG}05S-S7l6D$?o$pLZnhz16qW!aNhH@XGi6VCocy9H;RcNkeN}}zYt>g6+YjY;JPLw(mQrp$%eju(-e-(j zJSNrEA|+!b|L>0{5XV;keB=|ylmGg#!ruY@p3nbhISENV=}lsY6Vv*?7ydl~|Fe*t zI2!(I9{%^@zo*E57Bdl_vmh@1FEi!8i5I5- r$N&G${{D{g_l4|F6eG@mjq=ZBO;_s_ Date: Wed, 9 Sep 2026 06:12:46 +0200 Subject: [PATCH 15/31] lift log: make the spreadsheet import survive a real user's file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes, all found by re-reading the template's own protection flags after a question about deleting duplicate programs. THE TEMPLATE PERMITTED THE ONE EDIT THAT BREAKS THE IMPORT. In OOXML each `sheetProtection` flag answers "is this operation PREVENTED", and they default to TRUE once the sheet is protected — so an attribute set to "0" ALLOWS the thing it names. The shipped template carried `insertColumns="0" deleteColumns="0"`, which did the opposite of the intent: it explicitly permitted inserting and deleting columns. The import maps columns by header name and position, so a column inserted in the middle silently moves reps into the weight field. Those now sit at their default (prevented), and inserting or deleting ROWS is explicitly allowed instead — a long routine needs more rows, and row count is nothing the importer depends on. THE FIRST WORKSHEET IS NOT `sheet1.xml`. That part name is a stable id, not a position: a user who drags the instructions tab in front of the data tab leaves `sheet1.xml` sitting behind another sheet. Worksheets are now resolved in TAB order through `xl/workbook.xml` and its rels. Then the tab-order test showed the design behind it was also wrong. Reading strictly "the first tab" refuses a workbook whose program lives on a later sheet — someone's own file with a notes page in front, which is exactly the case a person hits. The importer now reads EVERY sheet in tab order and takes the first one that looks like a program, since only it can recognise one. AND THAT LOST A GOOD ERROR MESSAGE, so headers are now kept separate from rows. A downloaded template nobody has filled in has the right columns and no data: that has to read "no exercises in it yet", not "no Exercise column, use the template", because the user IS holding the right file. Three failures, three different pieces of advice. Two decisions recorded as settled, not deferred: the template's dropdowns stay English-only (it is authored on a computer, and the importer accepts the stored tokens anyway), and a re-import creating a second program is fine — programs have a delete action with a confirmation, which was verified rather than assumed. Verification: 17 tests in LiftProgramSheetImporterTests (266 in StrandImport, 0 failures), including a fixture whose FIRST TAB is the instructions sheet with the program data still in `sheet1.xml`, and one asserting the shipped template locks column edits while allowing row edits. Both app targets build; `i18n_audit.py` and `doc_comment_lint.py` pass. Co-Authored-By: Claude Opus 5 --- .../LiftProgramSheetImporter.swift | 24 +++- .../Sources/StrandImport/XlsxSheet.swift | 129 +++++++++++++++--- .../LiftProgramSheetImporterTests.swift | 52 ++++++- .../lift_program_tabs_reordered.xlsx | Bin 0 -> 10838 bytes Tools/make_lift_program_template.py | 15 +- docs/lift-log-program-template.xlsx | Bin 8825 -> 8825 bytes 6 files changed, 186 insertions(+), 34 deletions(-) create mode 100644 Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_tabs_reordered.xlsx diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift index b8aa8301b9..f3d3a0933b 100644 --- a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -98,21 +98,31 @@ public enum LiftProgramSheetImporter { /// Parse a filled-in template. Detects `.xlsx` by its ZIP magic bytes, else treats it as CSV. public static func parse(data: Data) throws -> LiftProgramImportResult { - let rows: [[String: String]] + let candidates: [XlsxSheet.Sheet] if isZip(data) { - rows = try XlsxSheet.rows(from: data) + // Every sheet, in tab order — the workbook may carry instructions, notes or the user's + // own scratch alongside the program, and only this parser can tell which is which. + candidates = try XlsxSheet.sheets(from: data) } else { let table = CSVTable(data: data) guard !table.headers.isEmpty else { throw ImportError.unreadable } - rows = table.rows + candidates = [XlsxSheet.Sheet(headerKeys: Set(table.normalizedHeaders.filter { !$0.isEmpty }), + rows: table.rows)] } - guard !rows.isEmpty else { throw ImportError.empty } - // An exercise column is the one thing a program sheet cannot do without. - let present = Set(rows.flatMap { $0.keys }) - guard !present.isDisjoint(with: exerciseKeys) else { + // An exercise column is the one thing a program sheet cannot do without, so it is also how a + // program sheet is recognised among several. First match in tab order wins. + // + // Matched on HEADERS, not on rows, so the three failures stay distinguishable: a file that is + // not a program sheet at all, and a correct template nobody has filled in yet, are different + // mistakes and deserve different advice. + guard let sheet = candidates.first(where: { + !$0.headerKeys.isDisjoint(with: exerciseKeys) + }) else { throw ImportError.missingColumns(["exercise"]) } + let rows = sheet.rows + guard !rows.isEmpty else { throw ImportError.empty } var programs: [ImportedProgram] = [] var indexByName: [String: Int] = [:] diff --git a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift index 5f09d3d3c6..bcb57f09dc 100644 --- a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -12,11 +12,29 @@ import ZIPFoundation // new enters the dependency graph for this. enum XlsxSheet { - /// Header-keyed rows, in the same shape `CSVTable` produces, so both formats feed one parser. - static func rows(from data: Data) throws -> [[String: String]] { - let grid = try grid(from: data) - guard let headerRow = grid.first else { return [] } + /// One worksheet, reduced to what the caller needs to recognise and read it. + /// + /// Headers are kept SEPARATE from rows so an empty sheet is still identifiable. A downloaded + /// template that nobody has filled in has the right columns and no data, and that has to be + /// reported as "no exercises yet" rather than "wrong file" — the user is holding the right file. + struct Sheet { + var headerKeys: Set + var rows: [[String: String]] + } + + /// Every worksheet, in workbook (tab) order. + /// + /// Every sheet rather than the first, because the caller is looking for a particular KIND of + /// sheet and only it can recognise one. A user who reorders tabs, keeps the instructions page in + /// front, or pastes the data into their own workbook still gets an import; picking by position + /// would refuse all three. + static func sheets(from data: Data) throws -> [Sheet] { + try grids(from: data).map(headerKeyed) + } + /// One grid's header row applied to the rows beneath it. + private static func headerKeyed(_ grid: [[String]]) -> Sheet { + guard let headerRow = grid.first else { return Sheet(headerKeys: [], rows: []) } let keys = headerRow.map { HeaderNorm.normalize($0) } var out: [[String: String]] = [] for cells in grid.dropFirst() { @@ -29,34 +47,65 @@ enum XlsxSheet { } out.append(dict) } - return out + return Sheet(headerKeys: Set(keys.filter { !$0.isEmpty }), rows: out) } - /// The first worksheet as a rectangular grid of strings. + /// The FIRST worksheet as a rectangular grid — kept for callers that only want to look at the + /// leading sheet's headers. static func grid(from data: Data) throws -> [[String]] { - guard let archive = try? Archive(data: data, accessMode: .read) else { + guard let first = try grids(from: data).first else { throw LiftProgramSheetImporter.ImportError.unreadable } - // Shared strings are optional: a sheet written with inline strings has no such part. - let shared = (try? entryData(archive, "xl/sharedStrings.xml")).map(SharedStrings.parse) ?? [] + return first + } - guard let sheetData = try? firstWorksheet(archive) else { + /// Every worksheet as a grid of strings, in workbook (tab) order. + static func grids(from data: Data) throws -> [[[String]]] { + guard let archive = try? Archive(data: data, accessMode: .read) else { throw LiftProgramSheetImporter.ImportError.unreadable } - return SheetParser.parse(sheetData, shared: shared) + // Shared strings are optional: a sheet written with inline strings has no such part. + var shared: [String] = [] + if let pool = try? entryData(archive, "xl/sharedStrings.xml") { + shared = SharedStrings.parse(pool) + } + let parts = worksheetPaths(archive) + guard !parts.isEmpty else { throw LiftProgramSheetImporter.ImportError.unreadable } + return parts.compactMap { path in + (try? entryData(archive, path)).map { SheetParser.parse($0, shared: shared) } + } } - /// The first worksheet part. Templates this reads are single-sheet, and `sheet1.xml` is what - /// every writer emits for one; the scan is the fallback for a file that numbered it differently. - private static func firstWorksheet(_ archive: Archive) throws -> Data { - if let d = try? entryData(archive, "xl/worksheets/sheet1.xml") { return d } - let names = archive.map(\.path) + /// Worksheet part paths in TAB order, resolved through the workbook. + /// + /// `xl/worksheets/sheet1.xml` is a stable id, not a position: a user who drags the instructions + /// tab in front of the data tab, or a writer that numbers parts differently, leaves `sheet1.xml` + /// sitting behind another sheet. So take `` order from `xl/workbook.xml` — that IS tab + /// order — and map each `r:id` through `xl/_rels/workbook.xml.rels`. + /// + /// Falls back to every worksheet part sorted by name, for a file whose workbook part is missing + /// or unreadable. Order matters less there than not losing the data entirely. + private static func worksheetPaths(_ archive: Archive) -> [String] { + if let workbook = try? entryData(archive, "xl/workbook.xml"), + let rels = try? entryData(archive, "xl/_rels/workbook.xml.rels") { + let ids = WorkbookOrder.sheetRelationshipIds(workbook) + let targets = WorkbookOrder.targets(in: rels) + let paths = ids.compactMap { targets[$0] }.map { target -> String in + // Targets are written relative to the workbook part, which lives in `xl/`. + target.hasPrefix("/") ? String(target.dropFirst()) : "xl/" + target + } + if !paths.isEmpty { return paths } + } + return archive.map(\.path) .filter { $0.hasPrefix("xl/worksheets/") && $0.hasSuffix(".xml") } .sorted() - guard let first = names.first, let d = try? entryData(archive, first) else { - throw LiftProgramSheetImporter.ImportError.unreadable - } - return d + } + + /// One part's raw bytes, by path. Internal, for tests that need to assert on the XML itself — + /// the shipped template's sheet protection is not visible through the parsed grid. + static func rawPart(_ data: Data, path: String) -> Data? { + guard let archive = try? Archive(data: data, accessMode: .read) else { return nil } + return try? entryData(archive, path) } private static func entryData(_ archive: Archive, _ path: String) throws -> Data { @@ -68,6 +117,46 @@ enum XlsxSheet { return out } + /// Reads just enough of `workbook.xml` and its rels to put the worksheets in tab order. + final class WorkbookOrder: NSObject, XMLParserDelegate { + private var ids: [String] = [] + private var targets: [String: String] = [:] + private var collectingRels = false + + /// `r:id` of every ``, in document order — which is tab order. + static func sheetRelationshipIds(_ data: Data) -> [String] { + let d = WorkbookOrder() + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.ids + } + + /// Relationship id -> target path. + static func targets(in rels: Data) -> [String: String] { + let d = WorkbookOrder() + d.collectingRels = true + let p = XMLParser(data: rels) + p.delegate = d + p.parse() + return d.targets + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + if collectingRels { + if name == "Relationship", let id = attributes["Id"], let target = attributes["Target"] { + targets[id] = target + } + return + } + // The r:id attribute arrives qualified or not depending on the writer. + if name == "sheet", let rid = attributes["r:id"] ?? attributes["id"] { + ids.append(rid) + } + } + } + /// `xl/sharedStrings.xml` — the string pool most cells point into. private final class SharedStrings: NSObject, XMLParserDelegate { private var strings: [String] = [] diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift index 607e490479..9039cff530 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -110,11 +110,7 @@ final class LiftProgramSheetImporterTests: XCTestCase { /// The empty template in `docs/` is the file users actually download. Parsing it must not throw /// on its headers, and it must contain no programs — an unfilled template imports nothing. func testTheShippedTemplateHasTheHeadersTheImporterExpects() throws { - let url = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent().deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - .appendingPathComponent("docs/lift-log-program-template.xlsx") - let data = try Data(contentsOf: url) + let data = try templateData() // The header row is what the importer matches on, so it is what must not drift. (`rows` // is empty for an unfilled template by design — every data row is blank.) @@ -132,6 +128,29 @@ final class LiftProgramSheetImporterTests: XCTestCase { } } + /// `xl/worksheets/sheet1.xml` is a stable id, not a position. This fixture puts the instructions + /// sheet FIRST in tab order with the program data still in sheet1.xml — reading by filename would + /// import the instructions page as a program, or find no exercise column and refuse the file. + func testTheFirstTabIsFoundThroughTheWorkbookNotTheFilename() throws { + let r = try parse("lift_program_tabs_reordered.xlsx") + XCTAssertEqual(r.programs.count, 1, "the instructions tab is first; the data must still be found") + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat"]) + XCTAssertEqual(r.programs[0].lines[0].targetWeightKg, 100) + } + + /// The template must not permit the one edit that breaks the import. In OOXML these + /// `sheetProtection` flags answer "is this PREVENTED" and default to true, so an attribute set to + /// "0" ALLOWS the thing it names — an earlier template shipped `insertColumns="0"`, permitting + /// exactly the change that shifts reps into the weight column. + func testTheShippedTemplateLocksColumnEditsAndAllowsRowEdits() throws { + let xml = try templateSheetXml() + XCTAssertTrue(xml.contains("sheet=\"1\""), "the sheet must actually be protected") + XCTAssertFalse(xml.contains("insertColumns=\"0\""), "inserting columns must stay prevented") + XCTAssertFalse(xml.contains("deleteColumns=\"0\""), "deleting columns must stay prevented") + XCTAssertTrue(xml.contains("insertRows=\"0\""), "a long routine needs more rows") + XCTAssertTrue(xml.contains("selectUnlockedCells=\"0\""), "the data cells must be typable") + } + // MARK: - Refusals func testAFileWithNoExerciseColumnIsRefusedWithThatReason() { @@ -164,4 +183,27 @@ final class LiftProgramSheetImporterTests: XCTestCase { XCTAssertEqual(r.programs[0].lines[0].primaryMuscle, .quads) XCTAssertEqual(r.programs[0].lines[0].targetSets, 5) } + + // MARK: - Reaching the shipped template + + /// `docs/lift-log-program-template.xlsx` — the file users actually download, read from the repo + /// rather than copied into the test bundle, so a stale copy cannot pass while the real one drifts. + private func templateData() throws -> Data { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("docs/lift-log-program-template.xlsx") + return try Data(contentsOf: url) + } + + private func templateSheetXml() throws -> String { + let data = try templateData() + guard let part = XlsxSheet.rawPart(data, path: "xl/worksheets/sheet1.xml"), + let xml = String(data: part, encoding: .utf8) else { + XCTFail("the template has no first worksheet part") + return "" + } + return xml + } } + diff --git a/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_tabs_reordered.xlsx b/Packages/StrandImport/Tests/StrandImportTests/Resources/lift_program_tabs_reordered.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..cb570dc0ee890c38c3507ef0c8b6bd0c46e15d03 GIT binary patch literal 10838 zcmd^F&2Jn@6`yRD4+)_a+C@T~iarpeb*4QZ$tJ<}%CS9(H;HZH>;?pA>FKVS>2-fh zx@v6Cio+f_EkYat?P1T{;Gf_R;Dq4DmA`-+zgN}W)zfKr+#O3GVQZ$Vd#YZ&di8$4 z_o}*%9$kO)osRzd-p@wg`uuP9^-hOAZ{y)lcj8F$NPhfmIp^Z%-SaTmcpqQvlqbTE zqfM*VUAGvI+}QJ@>8ADJv(e2v78BBmJST`FzG*GFu-@O=P!bs0Mq<;NNjbk~+rpjk z&=K8u&LjLXiIdQgxJ{<^+;LBwDYyIU>$h!JYjIPO7S`6rE}uAyK(f7aD5v#Ecpxm6 zs!M%sTF!hP_^u>!X}d!hCh>?X$b7&uR#-bYZX7fMh3`a=B@h zp#mh@MP)?Eo*!BN`0@a@q|aNhXQ_Q#n`Usmyg65JsYgt(w6uhK$z>>S)hHEa!^~(9 zr&ks!f_WI9F&VRk;2>UN2AZ+H#gcozp1M9Un))Seag@Z<#0d-4F}r#79#z*oAyCMS zQ8NA8U+jK9e52E$&v&)ywKr$=30?nxo}DwHaq9fh3i-muMbFM)hIzX>nB_fRtc+&h z3zQjLr81fxjV1*SCVwj$qYU$3{qx(ueDY?eL!WPIUo)-Sf?P>BCcL~p%(pfsNKpiH z<3$7->jSCu0I2(vojL*1Bn2wMtG?fH0)L$N3Te8g{vN{=mv3XMUeT8y1bOEJ>KqTY zHs+3$Jc&lQVQE?WR)oY%cdhEZqBf{v+3VlBd;<{&zDHV2cj6#USTY@NTBFh4ozeQ< z4v873KlqWy&pAly_E1SLC=8F#YN-o&XdEXVlFrP;J;>LOwl)Gjk&v4B(-~c5Jf}Wl zDdP|Y&v&MAZl2iC3uzqoqYHPv4OI zqBJeLAeJVkxmb2S$>m?v8tk1Z1^y^1mA7V;4KGI+1)R}J?=TQ^hoB(2OW86#gZJe3 zEQs9`nkF3?&L^)m4&C=9dRtdMLA@!v!jgsQTA8fe>}h;EpoIhy?aXv+yzm3*N4X)* zL{Wq@Oi+q(|NhM%4!o~+I`sLDNiTE|E@aYeDJbXXt6(6d)vhBQAORTxQVyC$mkQgM zajd%VjoJ63z>oN`Oz^9Z;Vb&PC$JpCVg<_!!IM*NZKXNFh7A_8sh;ho((bpH#v=Gw zg~**2BKOXD;`*XW+-?hTM~NRg$&!T&;RaPg_gV-&=2*IVMy#k9`>2&%StaU03sFz` zyh_x=7NVYWVqkVMtr+Eg3wckukW6s5Lfi)}#2vikdp3ZQqlJRDMhiF;Xu#V-^m^{5Pu)LAxykjII^OL-4+6Ww7@Pxg{-|6vK|Br$*Y8oS_r#U zq11yblJl^IoZk9+g+}{rB;Bc!^g$+x(g)>^I<9CUSBo!2?4`)`*DwG4+b^(hN1qEK zlgjM>CnA#@2Nb&FfQ7zFE?DTCt8jGYdvdmE-MwuwcOj6kJ=YI(qM_Seaa=R2WhQ67 zV3LP(Yyg%Rzm^$vVp8M|O6@dvV95mTK$duU?&x*dh*ESvV#v!G1@Ae?1evjlZ)@~} zoN=ZSV781G?97QIE&08eeh9m(9=f9YFxPC@T(f?c9WBrkIv2EbQR^3Ib0bX-ZXhj& zqS*6X=IWxhBiWc^^LGmCDh+jO!3FKr)@gey*Y;L(Z5d-ffpJ_XVkex>5jmikgQbt& z?GTxPkt%a`lYKZRD{rr{l)Lfh6Zjx=XHGQbYS1*^I(2X7>cWAWPWzzCj-6B85vjDs z=CHLd;RH0v37gO04r>fM>5MkW*}>uA5&QmB{*XQPClYE;_1%6rj}yt9Y@l@-zn5$L zUaE1CPVOGQfJTJ zdaePA+FoUgV(p)?1B#7px|*0Wo4u4&($=5S+e_lP7oSA}u9at=j_dL{_9jv|tksya zXL2AE8*3`~+(|HF9vd$)!y#Vn++(8zYfk3zK#Dc?EWyi+a=y93S3N_eC|+PtLu$Pi ztd^#!@J)EYqy5K+51wv6IqtG&pgA}L_E6(9{01zhKy+uZ0R9${5A4x%7*odZX{vED zt@RJXXuTGcrZQly=<(jp;lVD{?Df}K$ejq}dKOberR=L#2SZc6jHh1mQ9}&9j2Rk) zu-%wecECC@<{GX#RuQUrz@_%i8Qxq0`ouFgmAm?yxbV&o?*Rqzo+L}FFjGGwLsGqr zrCu75;f9_j2Vn2=LHRk}Xkmi+xr6keHaRd1(9am4K|g6EMeI_48Fgm&>Y6bCE1){B zaLj>iTEwx`ugPa>Fy1$8gkociXTdV<|1qDkBkUOnIHEU+;REztrL=g34*G_XP<(A|m&EX-^Ufj(RPT&EbV4DC`US(T>?s_v@TauBLk|X* z(bkYxKO?UOzEEM?{0(=eld)~~q;S=??c>Q%YkRxawuVOg8I6`vgl*p}cA-f2Z-k;J zYBPmRlXw9KrPP(OGw>Swgoz?eIrlv*4Z#_P9Wyh2#38-pRLenwOc z#&Aq02~_B;>%$OTzeN`Glsk&0%Nt57FdP_?L$NUoUmf-bhLuok+`(fU8>unTz_194 zjiu|A!Wernb$uA@4_aXVS>n{@MFRs?6dMoW{;Tq?fdM*-jpdJ!4mn9!I|&BQgBEzM z*s)r5QG@3}3p|@BP&da0&Vv?kuG%PD9fN`Lpaqn-J#XP6F|C`0p+Pc=jpuoLe2Fd$ zmWM5{tb=gPEDQ~rQEV}UTKgCn4_mYTal8gM67dAAnz46ZVi_ae7 zeVh9G3}<^PPa>8Dln+F=aXBGY&@1@4l|&m=h1>rsda^rs-QaEs^T4=Bs!Dr7WeqrEH}95l2BQq^J$% WqeoxCFgu;E;r}m~sZW1EcmDw|g=B;P literal 0 HcmV?d00001 diff --git a/Tools/make_lift_program_template.py b/Tools/make_lift_program_template.py index 8598abc860..de2983a2f0 100644 --- a/Tools/make_lift_program_template.py +++ b/Tools/make_lift_program_template.py @@ -113,11 +113,22 @@ def program_sheet(): f' sqref="E2:E{DATA_ROWS + 1}">"{esc(listing)}"' f'') - # Protect the sheet, but let the user select and type in the unlocked data cells, and sort/filter. + # Sheet protection, and the attribute semantics are the opposite of what they look like. + # + # In OOXML each of these flags answers "is this operation PREVENTED", and they default to TRUE + # once `sheet="1"`. So an attribute set to "0" ALLOWS the thing it names. An earlier version of + # this file carried `insertColumns="0" deleteColumns="0"`, which permitted exactly the one edit + # that breaks the importer: the column mapping is by header name and position, so a column + # inserted in the middle silently moves reps into the weight field. + # + # Left at their default (prevented): inserting and deleting COLUMNS. + # Explicitly allowed: selecting cells (or the sheet is unreadable), typing into the unlocked data + # cells, cosmetic formatting, sorting and filtering, and inserting or deleting ROWS — a user with + # a long routine needs more rows, and row count is nothing the importer depends on. protection = ('') + ' insertRows="0" deleteRows="0" sort="0" autoFilter="0"/>') return ( '' diff --git a/docs/lift-log-program-template.xlsx b/docs/lift-log-program-template.xlsx index 1c107ff952e856e4744dfcfe9d20c539bfc9f2c6..ba1875f188c7b136d5a9b1d2249019abc786a344 100644 GIT binary patch delta 3911 zcmZ9Pdpy(oAIHzB$lVgT)2td!mzBQmOh}v4F^!$c6gow6GQ_%#Z^tFw=Jr*m8_O9F znOsUKm#7KdP-06OQIUx?bDK7P{m$?AJNf+n{XRaQ*Z2ASyx;H7y@M-Lr!t$ZaCl7v+>c8xUNp>I6u z&dB=2`QfNq2bTv;5%6@v^s^`NJ)>8i(YO7nUosgXjht>2^>_A-l8O|gjXmb#j^XVM zCR6jTM%S13*4H0PY?=HhRV|(FX`eqHQ`_xQkMwlWGv*bys~r;TgD>yeeM1aq40+6t zsG6luGm~Gw()4P#Gf0przb`UA$EM@z=LR&_knAqXyZtV^3PWbYNfA?V$=y=T!C<*m zT7<{XBXFW*Vn|=%lDVNbL>8C44^G|ypEK)~%fzEK^A$dUcGa_T{ka*dchsV;9zmL- z=xpOq``yXLRC@amLoOeNrv_syd!iUZQc1&fT*;(}ucic+T=b}jsVIW@O`kP$zl7?_&7 z*IT8<4UT!iu9=*Z!=CMFF1G2$^`d)4{QLo`iKu0)EdKfgtd}-Ry?b=NKTaY^cz;Az z;xl@r#-1_K-2aqnGA5pRTk8ntJHvO*|7pc18$sN$G{OR-PcoZ_6a$<`-foGC*3i~oPmn&x5>n*=8~I!~hS zp=Z#aP(0KCsslBSdfI6}*)1(@KUw6u#BK?GiK)ARyNSDzyP5kX!y;GMbyD`FGwt2U zcWKu=R6I_35Ii1xSRQ)cso!2yZ;&^OdW({y;#5LaVpJkjj;ibny*fKC>-@9P&pgRE z$vjC5t%24;YofLD*RuTZFg!mYKlozu%Lla909s8oQ-zqRVRfgjPQT0UP}1>@x=SUf z)Fp(JRfLl{fxCOpChk@GNj(Ooy@PPVlaRERuq-mFu#fieox8~+4_%#=%d%G*k{<=` z9xkF*+(&)it9#QcU{-I%bKIjX&YtKWbeXFEK4QoWEZfznGbjcGSIJC7dKuYDyb0IjQwJ%UA;LGpdK~`!QAXE)M zBwEu7d8OfIa*S`tW;74wN=}C}2MyWBnuqcvPr{i~hU~;J1f{8ucxk#K-dfg3nWn#K zNuG0Cc!qTNJ#)^U?ba5aDYg2*)N){ZwuNWWvbFRPTP-VOwYD{KS}Ve&L~uf~HH4{W z5UkuR_(PH!!dz_-tkx_@k~D=NVl4w51*83@aKTaLR3m-b28geAwuz|lEpwe+@bgyc zA?e4rOc%T07p>Gd>1rYKm%HB<=_7S0YXpx%VC!4BC%iT4oV7P@;hyoZaTE`;%%<0$@7Fbr|^9=G>w{qL~s?|#5RjbKs z&g*Z!=Rvvk@p9{mOw7^j>_t}DI#$S2b)&cW+}_w$ruyS7XCmaZuU(g#9bL4n(u0?(%f<4MYguOFxhvJowcT}2|3S8gR+>?0rDlI-OFaaURQ;U68c zu~t@@7FJoCitDaDH06445NyAH!sH2(qf5xjAzbp`{ojA|zXrRde;|6R6%f1& zwp(x5ADzaXdj>7WfxMsC6u8f7vttx1lg9bLCsad$QLKwJ&LIDBHFPX0Sf!?G>s_yo z(wA|OoE|LDqD} zVCh&L)84 zXn@|(8S>Jy256klnxkQU5LiBdbfQ_xL6}8?>kpt!(Tt^KS`Q)bXqIjeM@ew%A#@;` zWfH_$Dp27;XQCN8Wl=onuV|Ka5Jp|_i3jnbS(}0|nu7g}&_Fc9w5+WWnvG_;1#wUU z!Xp?mieV9gI68vIk08ewhGp6MCWsuv`ZWl%LU6qaij86I4#F4+ESsV1m@pe3?*6M4 zM%xxtXPJ#TmV);&$iyrt&Z(W*$d24U4#tHTqhEWeR%Im}yd7e^@wJy`RaWxBiI7&Y zE|+^1_tUmm_@7-2g7(R<^`886rRml^aWAB>e`UhefTGwGfm{%w3U zw;~(}viThgQ@VXFRsl)f_Ymt??2Wg8@pB=ut9H4v7%;vdF7WPC?Kun>V~&t1Us4R6 z0b>{E;-n)y3$t&=--S2l`5%OxLrpROGv772=gLw)AMizKtSbnEQL&Z-3bI33J@+_hJ8K)n123D)O)l1!W3Y-X1d_Om@4Nu zU^;S~{Bb%(;R2YVU5mY$JY93ZbPd~ohimA0P&ALkb!Ef27a(XSmck{y7J;CnNQ9!i zQ{tq=_`1-vfy6z*u6PFo*nGrNo{Hm<`21rYGnc?)%WG%u2pCdfU#X>Nx}FUz^bc& zxmC~EsUY=?J1HqICjN6TTZGyBa$~RIOo4<-v?ayUH((hMaA|oC7i+1Zmc4LX*Q$G; zU!hy2L|izoUdn}B4K>&%88AJbOjcT&+O!2Q9o|y>CYd(_nyTgd8@h+4fu@El5*8I01I+j<#7EhHS}N*bcOf%wYs+I0BEy%Ay;2bnhJ0_og}Afq^|Y=IEfy` zmrwGpqk)Rl2<-zoKlySy@^SG5@cuh$Nty5sZ~;N_Ib7IMV z&_YYf_8kF!ASWehCXcnnIxRO-7Q{kR?fzoZrCs-p8NWHOUtvC_`5Vy+pk5D!YTO$@$^WB zkTp`(Q@MLSA1|(2JiiaVw<|X~q2YdmuxtGN+^eCIE5e&j6I6Ozi%WbvHREgJu!JX? zyRCg?wAjfx$OZ1Jz9Kc99Xe3!Fh9*CrR$F<6k`+7Eot-`dqtw)e)Zd^^df0!Rga=b zTvZ}$X`dc#ob&4y3fXgf(~Od7p^d1&0$=w4Hm?aboAYjvun}#~?#0wYu19tV`0A+u7Vkp>PyZBx7d{ zBb#b4WkQ8=sWef8iNUxuBexhdp7wb?&o;m3-}5@Z&w0Op-|zSLIzN%C$hAr&qmbFo z&$rh2;fLfukx=aoh>k2)YIFe!l+A$Qr_8TnUemSMnUU$(U@DtgRQDUhF^WHNrhd5Z zba&vgxU276Wc2T8U1H&qFc+D4$g#=gUfF8lH_%2=Et&m|6-f$cBt%8&o) z(+ZUnc=&N#+}!e-?%Yny{r)iQ%H%Ua@caU~w=zCvd6L@RKGr09^J)5M91QQ&Up#{U z9R7%}on9Df8C~Bn`qD-V+xN?IY~s}1`Y_82bqzzEg>PoZWV^629@SLsw!$#WmBFRX zqJCJ6;lfk1;xw^d%lfdY*t&*$tcF}HPB~1xJb$ha-jAUd9EuYw!prKP%e%$}6HVf- zX9F{Q&f-8LgCA)hFFp&$MAp9l&4wwYb`31QYZslndPIe*GwC-mL+ut>iFy&vAhad2XLK7%uAY&UDadh=wk=SA5%V(Z@b93q`J7d zw73{+JhGjenklHySzdtp`W5D=pl4YZNzvQf8P-$e4JdUT)doJl4X)@(vL|ylkQo1#))FbUk;Qh zH>_J5ww91+(m1JqRBqO(!oCAmZ)5Z&aYIv-5 zIq`g|vy_qZIwKbkbBoE{*n0`rl5JDT#zwmgjGVSpQmq+pCh5O~qfvus1^G-l_e>>| zqvm&KySDTkPW`_2@_s|JuL_F6T7w()?qvRODO0*N)8L)n&)61^gxx7XUi+J{>R)aG zdG!0UR^~mYfV@QWuSXR(YfI)K{R(C}CF5(3i<8>)vqV+-gIEM{iS2{9!E zNmGE;O5&M&dO{_#UbG;(iY7>#qK009a-{#&$eD|wj&It@b)x+?`kzKh`eJJeu99X} zg&`LdiE2VdOu9glIGv8nD>`k9XJWwAryqpwa}%e<^*2~mBViSyEktq04c7gUFuI6H z6!(8XPAT~zB1H}4t&;CHuF&M6q|5dP4`U8EOr+!Oc{3dgPpuqh((w+w$j*f!E61gD zJhmDMSi1}9Qqn*w*Phxw!H7u#nA$1?g#^4Fzq(m88{r7w#BbzVwTRTuIFfJT4fxe9 zbi^IC<=avXm)uW&OHz3mG13oJ%Qw$zETL+k(Gd=Tzt^(ltfJayXX zie9x2EuW1(5rjCX(2!j!>TrNYK;H2nB-vZjFCgzkP;PRN=JkNQ;J-L#Q8!Dye?2u6 z0L5L$-7fV$dCKSjlyMzLDfK?J&C0x+KpnnOteI(eAv;^jBzv6+@8;(Ln7`|^B01`ASvhX`zK36Z zL=j4JiGLj4b!EuiLxJMvLD_3+aWb4t8+~bAZn1ICw9zIRAEQk^o3E|^vBrh4GJIo| z7RWo>3DYLkAzcQph3SN#*=-%-}3KtUC*o*zTxsZr~ zTcNA|PE`L0$=BdkSE^nl`hJ8q*fLl9lRrY6YH$jbuM>z*KSI_uI8^29M56QnwA-U!2-DY;nH_$F9@5+!H{7*|71Q&bKTyIr*?~!OoRVzOXf$o`C4j8|uBc_Ki zX5AoXPiswRv(4aCMs%#9K6kX)Mrt}EA=VJhJ=tPoF#Wf2hi6YU5VYYTjLs6EY42q))iRgl@HwIl%PX7m9*F{^uCL6p8V%o=lugGpia}GABHvAp zL6<>O1Wz+As(Jy4T6JOwMKE4#6a*bdOxc)_IS}+q>wU5R_%~B#oP=pL7ejc0-~Sm1 z$e%`N!ji2>_no8aF zmERS#Iuod=k{1oSZMnuxvWcq|jR{&_`(=$Jhb><&ho))_r%Pu`&TWOviwZIIH_K98&}13OOL4P2Pj8rYysLDOU}no@C<3t(#HW=zQP$}<8vH|Lmo8ds=+oa!EI z6ZI5W0RYuhEcMV-C`blCdC5L(#bl8)z^Us}=A6=)jRrU=#lHV?33>~1_Ic4B6<39U zoM(**V_tc7M&S9kGsiUAxIzyE)%Rdy)n!!x&g&|r9%zMwsQ_n5st-XmIlvX*jB_oM zy42`w0C4)=@uj;4nPq|JpS3qF1#YNMj_Du96(%63sRx^&o}vM8 zYHF5xm?>zd0i4IueAt@FwY&f3g#Y{Va5=AUcUfav<1`2=UE;eI7nJl3RN|b@@TTdM zR5gQ~ZN`Lr@4R4;^P~Hl1jn$xg4U#OvR zA3l-RU+wNJx{|woiL?^;kjES5)p6*kg0Ecbd<~;b~FC%GeCMUD%_0_)$~4=oPi{I!_!P{iv!AD`*Oi8AmDm!=0q5kywVf z_CsjLiUYTZ_beJ_**o-p9Vpo9$>K(ZZDkbj#$xjX=T^p;g46R;GY-pDD>)?6Je3vI z%o~rJPmi|!q-^n-OBUvmSv_IPe0(O^K9UvK%K3S|*uL;^a#K7hx@HZPS6;NkrmyRn zK9fB#Hi$(PM3G3bbNj!J#}4zEOkwy`=mYWjFYEx;((>~v?9H%WV{BBjKexZP_bz_v+ufCk-CE23(oME`Y+t|FN#eA#COiG>x$q)S88Owy VNaeDt_i5+I%KvlmMn0o2{Ts Date: Wed, 9 Sep 2026 06:36:04 +0200 Subject: [PATCH 16/31] lift log: fix a divergent metric, and bound the import so it cannot hurt the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit pass over the whole feature, driven by three requirements: the spreadsheet import is a convenience and must never damage the core; the core itself must be correct and efficient; and both must survive future upstream merges. THE HEADLINE METRIC WAS COMPUTED TWICE, AND THE TWO DIFFERED. Sets-per-muscle is produced by `WhoopStore.liftSetCounts` (SQL, the hub's weekly card) and by `LiftMetrics.muscleCounts` (in memory, the session detail). The in-memory one excluded a muscle listed as BOTH primary and secondary; the SQL one did not. The same set would have read 1.0 on one screen and 1.5 on the other — on the single figure the reference doses are compared against. It was invisible because the write path strips the primary on the way in, so no stored row could trigger it. That is not a guarantee, it is a coincidence of who happens to write today — and this change adds a NEW writer (the importer), which is exactly how a latent divergence stops being latent. The SQL side now carries the same guard, and `LiftMetricsStoreAgreementTests` pins the two against EACH OTHER rather than each against its own literal: agreeing with an expectation is not the same as agreeing with each other. Verified it fails as intended — removing the guard turns four of its five tests red with 1.5 against 1.0. THE IMPORT HAD NO BOUNDS AT ALL. It read whatever was handed to it, expanded whatever the archive claimed, and created as many rows as the sheet had. A convenience feature must never be why the app runs out of memory, so: * files over 8 MB are refused, and the size is checked BEFORE the read, since `Data(contentsOf:)` would otherwise pull the whole thing in first; * one decompressed part is capped at 64 MB, measured on the EXPANDED stream — an .xlsx is a ZIP, and a zip bomb is by definition small compressed, so a limit on the file's own size is trivially defeated (`DataBackup` guards its restore the same way, #1807); * rows, programs, lines per program and warnings are all capped, and a truncated import SAYS it was truncated rather than silently importing a subset. Isolation was checked rather than assumed: the importer calls only the three store APIs the program editor already used, adds no write path of its own, and the only core file it touches is one button in the hub. Deleting the feature would leave the rest untouched. ONE QUERY PER DISTINCT EXERCISE, not per plan line. `loadLastTime` asked the store the same question once per line, so a program that programs a movement twice — or a long imported one — re-ran identical queries when the sheet opened. Verification: WhoopStore 510, StrandAnalytics 1761, StrandImport 270, StrandTests 1519 — 0 failures beyond the two pre-existing locale-dependent `TodayCarryOverTests`. Both app targets build; `i18n_audit.py --ci` passes with all ten locales; `doc_comment_lint.py` passes; both `schema_oracle.json` copies remain byte-identical. Co-Authored-By: Claude Opus 5 --- .../LiftMetricsStoreAgreementTests.swift | 118 ++++++++++++++++++ .../LiftProgramSheetImporter.swift | 50 +++++++- .../Sources/StrandImport/XlsxSheet.swift | 17 ++- .../LiftProgramSheetImporterTests.swift | 48 +++++++ .../Sources/WhoopStore/LiftLogStore.swift | 11 +- Strand/Resources/Localizable.xcstrings | 3 + Strand/Screens/LiftProgramImportSheet.swift | 11 ++ Strand/Screens/LiftSessionView.swift | 9 +- 8 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift new file mode 100644 index 0000000000..ad53c331a5 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift @@ -0,0 +1,118 @@ +import XCTest +import GRDB +import WhoopStore +@testable import StrandAnalytics + +/// The per-muscle set count is computed TWICE, by two different pieces of code, and both are shown +/// to the user: +/// +/// * `WhoopStore.liftSetCounts` — SQL, feeds the Lift Log hub's "last 7 days" card; +/// * `LiftMetrics.muscleCounts` — in memory, feeds a finished session's detail sheet. +/// +/// It is the load-bearing figure of the whole feature — the one the reference doses are compared +/// against — so the two disagreeing would mean two screens reporting different numbers for the same +/// sets, with nothing to notice. They are pinned against each other here rather than each being +/// pinned to its own expectation, because agreeing with a literal is not the same as agreeing with +/// each other. +/// +/// This test exists because they DID differ: the in-memory version excluded a muscle listed both as +/// primary and secondary, and the SQL version did not. Nothing showed it, because the write path +/// strips the primary on the way in — so the divergence was invisible until some future writer +/// forgot to. The malformed row below is written with raw SQL precisely because the public API +/// cannot produce one. +final class LiftMetricsStoreAgreementTests: XCTestCase { + + private let day = 1_700_000_000 + + /// Insert sets through raw SQL so the row shapes are exactly what is asked for, including the + /// one the public API would clean up. + private func store(_ rows: [(primary: String?, secondary: String, warmup: Int)]) async throws -> WhoopStore { + let store = try await WhoopStore.inMemory() + let writer = store.registryWriter + try await writer.write { db in + try db.execute(sql: """ + INSERT INTO liftSession (id, deviceId, startTs, endTs, sport, programId, programName, + sessionRpe, note) + VALUES ('s1', 'dev', ?, ?, 'Strength Training', NULL, NULL, NULL, NULL) + """, arguments: [self.day, self.day + 3600]) + for (i, r) in rows.enumerated() { + try db.execute(sql: """ + INSERT INTO liftSet (id, deviceId, sessionId, ord, exercise, primaryMuscle, + secondaryMuscles, setIndex, weightKg, reps, rpe, isWarmup, + startTs, endTs, restSec, note) + VALUES (?, 'dev', 's1', ?, 'Exercise', ?, ?, 1, 60, 10, NULL, ?, NULL, NULL, NULL, NULL) + """, arguments: ["set-\(i)", i, r.primary, r.secondary, r.warmup]) + } + } + return store + } + + /// The same rows, as `LiftMetrics` would receive them from the store. + private func rows(_ store: WhoopStore) async throws -> [LiftSetRow] { + try await store.liftSets(sessionId: "s1") + } + + private func assertAgree(_ store: WhoopStore, + file: StaticString = #filePath, line: UInt = #line) async throws { + let sql = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + let memory = LiftMetrics.muscleCounts(try await rows(store)) + XCTAssertEqual(sql.direct, memory.direct, "direct counts diverged", file: file, line: line) + XCTAssertEqual(sql.indirect, memory.indirect, "indirect counts diverged", file: file, line: line) + XCTAssertEqual(sql.fractional, memory.fractional, "fractional counts diverged", file: file, line: line) + } + + /// The ordinary shape: a primary and two distinct secondaries. + func testBothImplementationsAgreeOnAWellFormedSet() async throws { + let store = try await store([(primary: "chest", secondary: "frontDelts,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit) + XCTAssertEqual(counts.fractional[.triceps], LiftMuscle.indirectSetCredit) + } + + /// THE ONE THAT WAS BROKEN. A row that lists its own primary among the secondaries must be + /// credited once, as direct, by BOTH — not 1.0 by one screen and 1.5 by the other. + func testBothImplementationsAgreeWhenARowListsItsPrimaryAsASecondary() async throws { + let store = try await store([(primary: "chest", secondary: "chest,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.chest], 1) + XCTAssertNil(counts.indirect[.chest], "the primary must not also be counted as indirect") + XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit, + "1.0, never 1.5 — the same muscle cannot be worked twice by one set") + } + + /// Warm-ups are excluded on both sides. They are excluded from volume and the per-muscle counts + /// by design, so a difference here would inflate the figure the doses are compared against. + func testBothImplementationsAgreeThatWarmUpsDoNotCount() async throws { + let store = try await store([ + (primary: "quads", secondary: "glutes", warmup: 1), + (primary: "quads", secondary: "glutes", warmup: 0), + ]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.quads], 1, "only the working set counts") + } + + /// An unclassified set contributes nothing rather than defaulting into a bucket. + func testBothImplementationsAgreeOnAnUnclassifiedSet() async throws { + let store = try await store([(primary: nil, secondary: "", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertTrue(counts.fractional.isEmpty, "no muscle was named, so no muscle is credited") + } + + /// A token no longer in the vocabulary is ignored by both rather than crashing or counting. + func testBothImplementationsAgreeOnAnUnknownToken() async throws { + let store = try await store([(primary: "shoulders", secondary: "pecs,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.indirect[.triceps], 1, "the recognisable half still counts") + XCTAssertTrue(counts.direct.isEmpty) + } +} diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift index f3d3a0933b..01874fbfce 100644 --- a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -81,8 +81,35 @@ public enum LiftProgramSheetImporter { case missingColumns([String]) /// Read fine, but there was nothing in it. case empty + /// Too big to be a program sheet — see `maxFileBytes`. + case tooLarge } + // MARK: - Bounds + // + // A program is a few dozen rows. Everything below is far above any real sheet and far below + // anything that could hurt: this feature is a convenience, and it must never be the reason the + // app is slow, runs out of memory, or writes a database nobody wants. + + /// A filled template is ~9 KB; a hand-built sheet with a year of programs is still well under a + /// megabyte. 8 MB is generous enough that no honest file is refused, and small enough that + /// reading it whole into memory on a phone is nothing. + public static let maxFileBytes = 8 * 1024 * 1024 + + /// Rows considered from a sheet. A spreadsheet can carry a million empty rows, and some writers + /// emit them; parsing them all is wasted work and the result would be unusable anyway. + static let maxRows = 5_000 + + /// Exercise lines kept per program. A program with more lines than this is not a program. + static let maxLinesPerProgram = 200 + + /// Programs created from one file. + static let maxPrograms = 50 + + /// Warnings reported. A pathological sheet could otherwise produce thousands, which helps nobody + /// and makes the preview unscrollable; the count is still reported honestly (see `warnings`). + static let maxWarnings = 50 + /// Column keys, after `HeaderNorm.normalize`. Several spellings map to the same field so a user /// who retypes the header — or translates it — is not punished for it. private static let exerciseKeys = ["exercise", "movement", "lift"] @@ -98,6 +125,11 @@ public enum LiftProgramSheetImporter { /// Parse a filled-in template. Detects `.xlsx` by its ZIP magic bytes, else treats it as CSV. public static func parse(data: Data) throws -> LiftProgramImportResult { + // Refuse before doing any work. An .xlsx is a ZIP, so a hostile one can be small on disk and + // enormous expanded; `XlsxSheet` bounds the expansion separately. This bound is on what the + // caller handed us. + guard data.count <= maxFileBytes else { throw ImportError.tooLarge } + let candidates: [XlsxSheet.Sheet] if isZip(data) { // Every sheet, in tab order — the workbook may carry instructions, notes or the user's @@ -128,11 +160,12 @@ public enum LiftProgramSheetImporter { var indexByName: [String: Int] = [:] var warnings: [String] = [] - for (i, row) in rows.enumerated() { + var truncated = false + for (i, row) in rows.prefix(maxRows).enumerated() { // Spreadsheets are full of trailing blank rows; they are not an error. let exercise = value(row, exerciseKeys)?.trimmed ?? "" if exercise.isEmpty { - if row.values.contains(where: { !$0.trimmed.isEmpty }) { + if row.values.contains(where: { !$0.trimmed.isEmpty }), warnings.count < maxWarnings { warnings.append(rowMessage(i, "no exercise name, so the row was skipped")) } continue @@ -143,7 +176,7 @@ public enum LiftProgramSheetImporter { var primary: LiftMuscle? if let raw = value(row, primaryKeys)?.trimmed.nilIfEmpty { primary = LiftMuscle(sheetName: raw) - if primary == nil { + if primary == nil, warnings.count < maxWarnings { warnings.append(rowMessage(i, "\"\(raw)\" is not a muscle group, so \"\(exercise)\" was left unclassified")) } } @@ -157,7 +190,7 @@ public enum LiftProgramSheetImporter { // The store excludes the primary from the secondary list, so do it here too // rather than leaving a row that says "chest, chest". if m != primary, !secondary.contains(m) { secondary.append(m) } - } else { + } else if warnings.count < maxWarnings { warnings.append(rowMessage(i, "\"\(token)\" is not a muscle group and was ignored")) } } @@ -174,11 +207,13 @@ public enum LiftProgramSheetImporter { note: value(row, noteKeys)?.trimmed.nilIfEmpty) if let idx = indexByName[programName.lowercased()] { + guard programs[idx].lines.count < maxLinesPerProgram else { truncated = true; continue } programs[idx].lines.append(line) if programs[idx].note == nil { programs[idx].note = value(row, programNoteKeys)?.trimmed.nilIfEmpty } } else { + guard programs.count < maxPrograms else { truncated = true; continue } indexByName[programName.lowercased()] = programs.count programs.append(ImportedProgram( name: programName, @@ -187,6 +222,13 @@ public enum LiftProgramSheetImporter { } } + // Say so rather than silently importing a subset: a user whose sheet was cut off must be able + // to see that it was, and the preview is where they would notice. + if rows.count > maxRows || truncated { + warnings.append("The sheet is larger than a program can be, so only the first " + + "\(maxPrograms) programs and \(maxLinesPerProgram) exercises each were read.") + } + guard !programs.isEmpty else { throw ImportError.empty } return LiftProgramImportResult(programs: programs, warnings: warnings) } diff --git a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift index bcb57f09dc..b9849ecd96 100644 --- a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -108,12 +108,27 @@ enum XlsxSheet { return try? entryData(archive, path) } + /// Ceiling on ONE decompressed part. + /// + /// Measured on the expanded stream, not the archive, because that is the only bound that means + /// anything: an .xlsx is a ZIP, and a zip bomb is by definition tiny compressed and enormous + /// expanded, so a limit on the file's own size is trivially defeated. `DataBackup` guards its + /// restore the same way and for the same reason (#1807). 64 MB is orders of magnitude above any + /// real worksheet and still bounded. + static let maxPartBytes = 64 * 1024 * 1024 + private static func entryData(_ archive: Archive, _ path: String) throws -> Data { guard let entry = archive[path] else { throw LiftProgramSheetImporter.ImportError.unreadable } var out = Data() - _ = try archive.extract(entry, bufferSize: 64 * 1024, skipCRC32: true) { out.append($0) } + out.reserveCapacity(min(Int(entry.uncompressedSize), 1 << 20)) + _ = try archive.extract(entry, bufferSize: 64 * 1024, skipCRC32: true) { chunk in + guard out.count + chunk.count <= maxPartBytes else { + throw LiftProgramSheetImporter.ImportError.tooLarge + } + out.append(chunk) + } return out } diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift index 9039cff530..9e551ac0d6 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -184,6 +184,54 @@ final class LiftProgramSheetImporterTests: XCTestCase { XCTAssertEqual(r.programs[0].lines[0].targetSets, 5) } + // MARK: - Bounds + // + // This feature is a convenience. It must never be the reason the app is slow, runs out of + // memory, or writes a database nobody wants — so every input it accepts is bounded, and the + // bounds are pinned here. + + func testAnAbsurdlyLargeFileIsRefusedBeforeAnyParsing() { + let big = Data(count: LiftProgramSheetImporter.maxFileBytes + 1) + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: big)) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .tooLarge) + } + } + + func testTooManyProgramsAreTruncatedAndSaidSo() throws { + var csv = "Program,Exercise\n" + for i in 0..<(LiftProgramSheetImporter.maxPrograms + 10) { + csv += "Program \(i),Exercise \(i)\n" + } + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, LiftProgramSheetImporter.maxPrograms) + XCTAssertTrue(r.warnings.contains { $0.contains("larger than a program can be") }, + "a truncated import must say it was truncated: \(r.warnings)") + } + + func testTooManyLinesInOneProgramAreTruncated() throws { + var csv = "Program,Exercise\n" + for i in 0..<(LiftProgramSheetImporter.maxLinesPerProgram + 10) { + csv += "One,Exercise \(i)\n" + } + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, 1) + XCTAssertEqual(r.programs[0].lines.count, LiftProgramSheetImporter.maxLinesPerProgram) + } + + /// A sheet full of bad muscle names must not produce thousands of warnings — the preview would + /// be unscrollable and the user no better informed. + func testWarningsAreCapped() throws { + // Deliberately under `maxLinesPerProgram`, so this isolates the WARNING cap rather than + // tripping the line cap and testing two things at once. + let rows = LiftProgramSheetImporter.maxLinesPerProgram - 10 + var csv = "Exercise,Primary muscle\n" + for i in 0.. Date: Wed, 9 Sep 2026 16:19:22 +0200 Subject: [PATCH 17/31] lift log: delete dead store API, leaving one computed read in the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `liftRpeProfile` and `liftExercisesLogged` were public API on the store with ZERO app call sites. Their only consumers were three tests that existed to exercise them. `liftRpeProfile` was worse than unused: it was a SECOND implementation of a metric `LiftMetrics.rpeProfile` already computes, and the session detail uses the LiftMetrics one. That is exactly the shape of the set-count divergence fixed in the previous commit — two implementations of one figure, agreeing only by luck — except this one could not be seen to diverge because nothing called it. Dead code that duplicates a live implementation is a divergence waiting for someone to wire it up. Deleting both leaves the layering with one statable rule: **the store reads rows, `LiftMetrics` computes.** The single exception is `liftSetCounts`, which aggregates in SQL because the hub's 7-day card wants a windowed count without loading every set — and it is pinned against its in-memory twin by `LiftMetricsStoreAgreementTests`. Every other store function is now a plain read, and every metric has exactly one implementation. That also shrinks what an upstream PR has to defend: an unused public API on a store is a maintenance claim nobody is honouring, and a reviewer would rightly ask what it is for. Verification: WhoopStore 561 (was 564 — the three tests went with the code), StrandAnalytics 1988, StrandImport 284, StrandTests 1686 with only the two pre-existing locale-dependent failures. Both app targets build with no warnings from any Lift Log file; `doc_comment_lint.py` and `i18n_audit.py --ci` pass. Co-Authored-By: Claude Opus 5 --- .../Sources/WhoopStore/LiftLogStore.swift | 48 +------------------ .../WhoopStoreTests/LiftLogStoreTests.swift | 40 ---------------- 2 files changed, 1 insertion(+), 87 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index d8d772d949..9b2471613c 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -664,7 +664,7 @@ extension WhoopStore { /// though a hard set is the thing that drives adaptation — because the reference doses were /// derived from unfiltered working-set counts, and filtering here would quietly compare a /// smaller number against a scale built from a larger one. Proximity to failure is reported - /// separately by `liftRpeProfile` instead, where it can inform without corrupting the count. + /// separately by `LiftMetrics.rpeProfile` instead, where it can inform without corrupting the count. public func liftSetCounts( deviceId: String, fromTs: Int, @@ -706,50 +706,4 @@ extension WhoopStore { } } - /// How hard the working sets in a window actually were, reported separately from the counts. - /// - /// Proximity to failure is what makes a set count biologically, but it is NOT folded into - /// `liftSetCounts` — see that method for why. Sets with no RPE recorded are excluded from the - /// average and reported as `unrated`, rather than being silently treated as easy or as hard. - public func liftRpeProfile( - deviceId: String, - fromTs: Int, - toTs: Int, - hardThreshold: Double = 7 - ) async throws -> (workingSets: Int, rated: Int, unrated: Int, meanRpe: Double?, atOrAboveThreshold: Int) { - try syncRead { db in - let rows = try Row.fetchAll(db, sql: """ - SELECT s.rpe AS rpe - FROM liftSet s - JOIN liftSession sess ON sess.id = s.sessionId - WHERE s.deviceId = ? - AND sess.startTs >= ? AND sess.startTs <= ? - AND s.isWarmup = 0 - """, arguments: [deviceId, fromTs, toTs]) - - var rated: [Double] = [] - var unrated = 0 - for row in rows { - if let v: Double = row["rpe"] { rated.append(v) } else { unrated += 1 } - } - let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) - return (workingSets: rows.count, - rated: rated.count, - unrated: unrated, - meanRpe: mean, - atOrAboveThreshold: rated.filter { $0 >= hardThreshold }.count) - } - } - - /// Distinct exercise names this device has ever logged, alphabetical — the suggestion list for - /// the program editor, built from the user's own history rather than a shipped catalogue. - public func liftExercisesLogged(deviceId: String) async throws -> [String] { - try syncRead { db in - try String.fetchAll(db, sql: """ - SELECT DISTINCT exercise FROM liftSet - WHERE deviceId = ? - ORDER BY exercise ASC - """, arguments: [deviceId]) - } - } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index 0eff601b8c..5d97858995 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -408,18 +408,6 @@ final class LiftLogStoreTests: XCTestCase { XCTAssertTrue(never.isEmpty, "an exercise never logged has no history, and that is not an error") } - func testLoggedExercisesAreDistinctAndSorted() async throws { - let store = try await WhoopStore.inMemory() - _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) - _ = try await store.upsertLiftSets([ - mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, exercise: "Leg Press"), - mkSet(id: "b", sessionId: "s1", ord: 1, setIndex: 2, exercise: "Leg Press"), - mkSet(id: "c", sessionId: "s1", ord: 2, setIndex: 1, exercise: "Dead Bug"), - ]) - let logged = try await store.liftExercisesLogged(deviceId: dev) - XCTAssertEqual(logged, ["Dead Bug", "Leg Press"]) - } - // MARK: - Muscle classification /// The token set is a stored-data contract: renaming a case would orphan every row written @@ -541,34 +529,6 @@ final class LiftLogStoreTests: XCTestCase { // MARK: - Proximity to failure, reported separately - /// An unrated set is neither counted as hard nor assumed easy — it is reported as unrated, and - /// left out of the mean. Guessing in either direction would be inventing data. - func testRpeProfileSeparatesRatedFromUnrated() async throws { - let store = try await WhoopStore.inMemory() - _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) - _ = try await store.upsertLiftSets([ - mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, rpe: 6), - mkSet(id: "b", sessionId: "s1", ord: 1, setIndex: 2, rpe: 8), - mkSet(id: "c", sessionId: "s1", ord: 2, setIndex: 3, rpe: nil), - mkSet(id: "warm", sessionId: "s1", ord: 3, setIndex: 4, rpe: 9, isWarmup: true), - ]) - let profile = try await store.liftRpeProfile(deviceId: dev, fromTs: 0, toTs: 9_999) - XCTAssertEqual(profile.workingSets, 3, "the warm-up is not a working set") - XCTAssertEqual(profile.rated, 2) - XCTAssertEqual(profile.unrated, 1) - XCTAssertEqual(profile.meanRpe ?? 0, 7.0, accuracy: 0.0001) - XCTAssertEqual(profile.atOrAboveThreshold, 1) - } - - func testRpeProfileWithNothingRatedHasNoMean() async throws { - let store = try await WhoopStore.inMemory() - _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) - _ = try await store.upsertLiftSets([mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, rpe: nil)]) - let profile = try await store.liftRpeProfile(deviceId: dev, fromTs: 0, toTs: 9_999) - XCTAssertNil(profile.meanRpe, "no ratings means no average, not zero") - XCTAssertEqual(profile.unrated, 1) - } - // MARK: - Privacy: delete-means-gone /// Every lift table is deviceId-keyed and listed in `deviceScopedTables`, so forgetting a device From 2618ae47d7197739868606040ff203d7d5d15fcb Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:41:01 +0200 Subject: [PATCH 18/31] lift log: let a decimal weight be typed, and keep two decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the gym: a weight like 45.5 or 12.25 could not be entered. Two defects, and the first was silently corrupting data. THE FIELD REWROTE ITSELF ON EVERY KEYSTROKE. Each numeric binding read its text back out of the engine, so every character round-tripped through `LiftFormat` and was replaced by the canonical rendering of the parsed value. Typing "45." parsed to 45, re-rendered as "45", and the point vanished as it was typed — so the next keystroke produced "455". A user entering 45.5 kg silently got 455 kg. Not "decimals are awkward": a wrong number, entered confidently, of exactly the kind this feature exists to record correctly. Fields now hold a DRAFT of what was typed while focused, and fall back to the canonical rendering once focus leaves. The parsed value still reaches the engine and disk on every keystroke, so nothing about crash-durability changes. Applied to weight, reps and RPE — all three had the same binding shape, and RPE had the same bug. ONE DECIMAL PLACE WAS LOSSY. `trim` formatted with "%.1f", so 12.25 rendered as "12.3" — and because the field read that back, the rounded value replaced what was typed. Gym plates come in quarter-kilos and microplates in smaller steps, so 12.25 is a weight people actually lift. Now up to two decimals, with trailing zeroes and a trailing point dropped: 8 → "8", 7.5 → "7.5", 12.25 → "12.25", 45.50 → "45.5". A TYPED COMMA BECOMES A POINT. iOS's `.decimalPad` labels its separator key from the DEVICE's region — a German or French phone offers "," and an app cannot relabel it — while this screen displays "." everywhere. `LiftFormat.number` already parsed both, but the field showed whichever key was pressed. It now normalises on the way in, so the field always reads back in the notation it shows, whichever key the keyboard offered. Verification: 12 new tests in `LiftFormatNumberTests`, including the round trip the field performs on every keystroke — every value a user types must come back as the same number, which is precisely what produced 455 from 45.5. StrandTests 1641, only the two pre-existing locale-dependent failures. Both app targets build. Confirmed in the simulator: a typed decimal survives, and renders to two places when the field is left. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftFormat.swift | 25 +++++-- Strand/Screens/LiftSessionView.swift | 70 +++++++++++++++----- StrandTests/LiftFormatNumberTests.swift | 87 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 StrandTests/LiftFormatNumberTests.swift diff --git a/Strand/Data/LiftFormat.swift b/Strand/Data/LiftFormat.swift index 793843ebb5..37302165fa 100644 --- a/Strand/Data/LiftFormat.swift +++ b/Strand/Data/LiftFormat.swift @@ -38,15 +38,28 @@ enum LiftFormat { // MARK: - Numbers - /// Drop a trailing ".0" so a whole number reads as one: 8.0 → "8", 7.5 → "7.5". + /// A number with up to TWO decimals and no trailing noise: 8.0 → "8", 7.5 → "7.5", + /// 12.25 → "12.25", 45.50 → "45.5". /// - /// Weights and RPE are both entered as decimals but are usually whole, and "8.0 × 10" in a - /// summary line reads like a precision the user did not type. + /// Weights and RPE are entered as decimals but are usually whole, and "8.0 × 10" in a summary + /// line reads like a precision the user did not type — so a whole number loses its ".0". + /// + /// TWO decimals, not one. Gym plates come in quarter-kilos and microplates in smaller steps, so + /// 12.25 kg is a real weight a user types; formatting to one decimal turned it into 12.3 and, + /// because the entry field reads its text back through this function, that rounded value then + /// replaced what they typed. One decimal was silently lossy, not merely terse. + /// + /// Always renders "." regardless of locale — `String(format:)` takes no locale here — which + /// matches what the rest of the Lift Log displays. static func trim(_ value: Double) -> String { - if value == value.rounded() && abs(value) < 1e9 { - return String(Int(value.rounded())) + let rounded = (value * 100).rounded() / 100 + if rounded == rounded.rounded() && abs(rounded) < 1e9 { + return String(Int(rounded.rounded())) } - return String(format: "%.1f", value) + var text = String(format: "%.2f", rounded) + while text.hasSuffix("0") { text.removeLast() } + if text.hasSuffix(".") { text.removeLast() } + return text } /// Parse a typed number, accepting both "7.5" and the comma decimal separator "7,5" that most of diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 2541ead481..b330b631e8 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -45,6 +45,19 @@ struct LiftSessionView: View { case weight(LiftSlot), reps(LiftSlot), rpe(LiftSlot), sessionRpe } + /// What the user has TYPED into a field, held until they leave it. + /// + /// Without this a numeric field cannot accept a decimal at all. Each binding read its text back + /// out of the engine, so every keystroke round-tripped through `LiftFormat` and was replaced by + /// the canonical rendering of the parsed value. Typing "45." parsed to 45, re-rendered as "45", + /// and the point vanished as it was typed — then the next keystroke made "455". A user entering + /// 45.5 kg silently got 455 kg, which is the shape of bug this feature has to stop having. + /// + /// So while a field is focused it shows exactly what was typed; the parsed value still goes to + /// the engine and to disk on every keystroke, so nothing about durability changes. The draft is + /// dropped when focus leaves and the row goes back to the canonical formatting. + @State private var draft: [FocusTarget: String] = [:] + private var engine: LiftSessionEngine? { session.engine } var body: some View { @@ -69,6 +82,12 @@ struct LiftSessionView: View { .keyboardDoneToolbar($focused) .dismissesKeyboardOnTap($focused) .task { await loadLastTime() } + // Release a field's draft once the user leaves it, so the row returns to the canonical + // formatting ("45.50" typed becomes "45.5"). The single-argument form on purpose: the + // two-argument `onChange` is macOS 14+ and this file also builds for macOS 13. + .onChange(of: focused) { now in + draft = draft.filter { $0.key == now } + } .sheet(isPresented: $showingFinish) { finishSheet } } @@ -345,30 +364,49 @@ struct LiftSessionView: View { // disk immediately. Typing into a set that has not been completed yet is allowed — you may want // to plan the next one — and is held until the set is recorded. - private func weightBinding(_ slot: LiftSlot) -> Binding { + /// A text binding that does not fight the user while they type: reads the draft if there is one, + /// otherwise the canonical rendering of what is stored. + /// + /// A typed comma becomes a point on the way in. iOS's `.decimalPad` labels its separator key + /// from the DEVICE's region — a German or French phone offers "," and the app cannot relabel it + /// — so the two would otherwise disagree with the "." this screen displays everywhere else. + /// Normalising here means the field always reads back in the notation it shows, whichever key + /// the keyboard happened to offer. + private func fieldBinding(_ field: FocusTarget, + formatted: @escaping () -> String, + store: @escaping (String) -> Void) -> Binding { Binding( - get: { - guard let kg = engine?.recordedSet(for: slot)?.weightKg else { return "" } - return display(kg) - }, - set: { new in - let kg = LiftFormat.number(new).map { - LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) - } - write(slot) { $0.weightKg = kg } + get: { draft[field] ?? formatted() }, + set: { typed in + let text = typed.replacingOccurrences(of: ",", with: ".") + draft[field] = text + store(text) }) } + private func weightBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.weight(slot), + formatted: { engine?.recordedSet(for: slot)?.weightKg.map { display($0) } ?? "" }, + store: { text in + let kg = LiftFormat.number(text).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + } + write(slot) { $0.weightKg = kg } + }) + } + private func repsBinding(_ slot: LiftSlot) -> Binding { - Binding( - get: { engine?.recordedSet(for: slot)?.reps.map(String.init) ?? "" }, - set: { new in write(slot) { $0.reps = Int(new.trimmingCharacters(in: .whitespaces)) } }) + fieldBinding(.reps(slot), + formatted: { engine?.recordedSet(for: slot)?.reps.map(String.init) ?? "" }, + store: { text in + write(slot) { $0.reps = Int(text.trimmingCharacters(in: .whitespaces)) } + }) } private func rpeBinding(_ slot: LiftSlot) -> Binding { - Binding( - get: { engine?.recordedSet(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "" }, - set: { new in write(slot) { $0.rpe = LiftFormat.number(new) } }) + fieldBinding(.rpe(slot), + formatted: { engine?.recordedSet(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "" }, + store: { text in write(slot) { $0.rpe = LiftFormat.number(text) } }) } /// Apply one field change to a recorded set, leaving the others as they were. diff --git a/StrandTests/LiftFormatNumberTests.swift b/StrandTests/LiftFormatNumberTests.swift new file mode 100644 index 0000000000..fe7adef5de --- /dev/null +++ b/StrandTests/LiftFormatNumberTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import Strand + +/// Formatting and parsing for the numbers a user types at the rack. +/// +/// These exist because a real session could not enter a decimal weight at all. Two separate defects +/// combined: `trim` rendered one decimal place, and the entry fields read their text back through it +/// on every keystroke — so "45.5" became "45", then "455". The rounding half is pinned here; the +/// field half is pinned by `LiftSessionView`'s draft (there is no view test target for it). +final class LiftFormatNumberTests: XCTestCase { + + // MARK: - trim + + func testAWholeNumberLosesItsDecimalPoint() { + XCTAssertEqual(LiftFormat.trim(8), "8") + XCTAssertEqual(LiftFormat.trim(60.0), "60") + XCTAssertEqual(LiftFormat.trim(0), "0") + } + + func testOneDecimalSurvives() { + XCTAssertEqual(LiftFormat.trim(7.5), "7.5") + XCTAssertEqual(LiftFormat.trim(45.5), "45.5") + } + + /// The defect: gym plates come in quarter-kilos, and microplates in smaller steps, so 12.25 is a + /// weight people actually lift. One decimal turned it into "12.3" — and because the entry field + /// read that back, the rounded value replaced what was typed. + func testTwoDecimalsSurvive() { + XCTAssertEqual(LiftFormat.trim(12.25), "12.25") + XCTAssertEqual(LiftFormat.trim(2.75), "2.75") + XCTAssertEqual(LiftFormat.trim(102.05), "102.05") + } + + func testTrailingZeroesAreDropped() { + XCTAssertEqual(LiftFormat.trim(45.50), "45.5", "not 45.50") + XCTAssertEqual(LiftFormat.trim(45.00), "45", "not 45.00") + } + + /// Beyond two decimals is rounded, not truncated, and never renders a third digit. + func testBeyondTwoDecimalsRounds() { + XCTAssertEqual(LiftFormat.trim(12.256), "12.26") + XCTAssertEqual(LiftFormat.trim(12.254), "12.25") + XCTAssertEqual(LiftFormat.trim(1.0 / 3.0), "0.33") + } + + /// Always a point, never the device's separator: this is what the whole screen displays, and the + /// entry fields normalise a typed comma to match it. + func testTheSeparatorIsAlwaysAPoint() { + XCTAssertFalse(LiftFormat.trim(7.5).contains(",")) + XCTAssertTrue(LiftFormat.trim(7.5).contains(".")) + } + + // MARK: - number + + func testBothSeparatorsParse() { + XCTAssertEqual(LiftFormat.number("45.5"), 45.5) + XCTAssertEqual(LiftFormat.number("45,5"), 45.5, "a German or French keyboard offers the comma") + XCTAssertEqual(LiftFormat.number("12.25"), 12.25) + XCTAssertEqual(LiftFormat.number("12,25"), 12.25) + } + + /// A half-typed decimal must parse, or the value would vanish the instant the point is typed. + func testAPartiallyTypedDecimalParses() { + XCTAssertEqual(LiftFormat.number("45."), 45) + XCTAssertEqual(LiftFormat.number("45,"), 45) + } + + func testNonsenseIsNilRatherThanZero() { + XCTAssertNil(LiftFormat.number("")) + XCTAssertNil(LiftFormat.number(" ")) + XCTAssertNil(LiftFormat.number("kg")) + XCTAssertNil(LiftFormat.number(".")) + } + + /// The round trip the entry field performs on every keystroke. Whatever a user types must come + /// back as the same number — this is what silently produced 455 from 45.5. + func testTypedTextSurvivesTheRoundTripThroughTheField() { + for typed in ["45.5", "12.25", "60", "7.5", "102.05", "45,5"] { + guard let value = LiftFormat.number(typed) else { + XCTFail("\(typed) did not parse"); continue + } + let shown = LiftFormat.trim(value) + XCTAssertEqual(LiftFormat.number(shown), value, + "\(typed) rendered as \(shown), which no longer reads back as \(value)") + } + } +} From 048d608b84bfe31f2a2bce2073c8087351839232 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:12:40 +0200 Subject: [PATCH 19/31] lift log: let a session be discarded or deleted, and bound the note lengths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps a real session found, and an answer to "how long should a note be". A SESSION COULD NOT BE ABANDONED. Every route off the finish sheet saved: "Skip" skips the RPE question, not the session. So a session started by a mis-tap, or to try something out, had to be saved — and then lived in the history and in that day's Effort permanently. `LiftSessionController.discard()` already existed with ZERO call sites; it now has a destructive action on the finish sheet, behind a confirmation that says how many sets are being thrown away and that no workout will be created. A RECORDED SESSION COULD NOT BE DELETED. `deleteLiftSession` likewise existed unused (backlog §8). The session detail sheet now offers it, and it deletes the paired `workout` ROW TOO. That second half is the part worth stating: a lift session writes a workout row so the training lands in Workouts and Today like any other, and the analytics engine fills its strain from the heart rate measured over that window. Deleting the sets while leaving the workout would keep the day's Effort inflated by a session the user just said did not happen — worse than no delete at all, because it would look like the delete had worked. The confirmation says so. NOTE LENGTHS, chosen from what is actually visible rather than a round number: * program note — 100, because the hub renders it at `lineLimit(2)` in caption type, so beyond that it is stored-and-silently-truncated. A field that quietly discards the end is worse than one that stops; * exercise note — 140, about three lines. Longer than a program note because it is a cue read BETWEEN sets, shorter than unlimited because it renders directly above the set rows and every line pushes them down the screen. Enforced at entry in both editors, capped identically on the spreadsheet import (a cell holds far more than a phone can show), and the rendered note is `lineLimit(3)` as well — the sets are what that screen is for, and a note must never be able to push them off it. Verification: StrandTests 1696, only the two pre-existing locale-dependent failures; WhoopStore 561; StrandImport 284; both app targets build; `i18n_audit.py --ci` passes with five new strings in all ten locales; `doc_comment_lint.py` passes. Confirmed in the simulator: discarding leaves liftSession, liftSet and Strength-Training workout counts all at zero. Co-Authored-By: Claude Opus 5 --- .../LiftProgramSheetImporter.swift | 15 +++++-- .../Sources/WhoopStore/LiftLogStore.swift | 16 +++++++ Strand/Resources/Localizable.xcstrings | 15 +++++++ Strand/Screens/LiftLogView.swift | 2 +- Strand/Screens/LiftProgramEditorSheet.swift | 7 +++ Strand/Screens/LiftProgramItemSheet.swift | 7 +++ Strand/Screens/LiftSessionDetailSheet.swift | 45 +++++++++++++++++++ Strand/Screens/LiftSessionView.swift | 30 +++++++++++++ 8 files changed, 133 insertions(+), 4 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift index 01874fbfce..1f4a801699 100644 --- a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -196,6 +196,9 @@ public enum LiftProgramSheetImporter { } } + // Notes are capped to the same lengths the in-app editors enforce. A spreadsheet cell + // holds far more than a phone can show, and a note that arrives longer than the editor + // would ever let you type is a note you can never fully see or edit afterwards. let line = ImportedProgramLine( exercise: exercise, primaryMuscle: primary, @@ -204,20 +207,21 @@ public enum LiftProgramSheetImporter { targetReps: intValue(row, repsKeys), targetWeightKg: doubleValue(row, weightKeys), restSec: intValue(row, restKeys), - note: value(row, noteKeys)?.trimmed.nilIfEmpty) + note: value(row, noteKeys)?.trimmed.nilIfEmpty + .map { String($0.prefix(WhoopStore.maxExerciseNoteLength)) }) if let idx = indexByName[programName.lowercased()] { guard programs[idx].lines.count < maxLinesPerProgram else { truncated = true; continue } programs[idx].lines.append(line) if programs[idx].note == nil { - programs[idx].note = value(row, programNoteKeys)?.trimmed.nilIfEmpty + programs[idx].note = programNote(row) } } else { guard programs.count < maxPrograms else { truncated = true; continue } indexByName[programName.lowercased()] = programs.count programs.append(ImportedProgram( name: programName, - note: value(row, programNoteKeys)?.trimmed.nilIfEmpty, + note: programNote(row), lines: [line])) } } @@ -245,6 +249,11 @@ public enum LiftProgramSheetImporter { "Row \(i + 2): \(text)" } + private static func programNote(_ row: [String: String]) -> String? { + value(row, programNoteKeys)?.trimmed.nilIfEmpty + .map { String($0.prefix(WhoopStore.maxProgramNoteLength)) } + } + private static func value(_ row: [String: String], _ keys: [String]) -> String? { for k in keys { if let v = row[k], !v.trimmed.isEmpty { return v } diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 9b2471613c..8d5267a60e 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -353,6 +353,22 @@ extension WhoopStore { /// An EXISTING name always updates, cap or no cap: only genuinely NEW names are refused. public static let maxRememberedExercises = 500 + /// How long a PROGRAM note may be. + /// + /// 100 because that is what can actually be SEEN: the hub renders it under the program name at + /// `lineLimit(2)` in caption type, which is roughly two lines on a phone. A longer note is not + /// stored-and-shown, it is stored-and-silently-truncated, and typing into a field that quietly + /// discards the end is worse than a field that stops. + public static let maxProgramNoteLength = 100 + + /// How long an EXERCISE (technique) note may be. + /// + /// 140, about three lines. Longer than a program note because it is a cue you read BETWEEN sets + /// — "slow eccentric, pause at the bottom, don't let the elbows flare" — and shorter than + /// unlimited because it renders directly above the set rows and every line pushes them down the + /// screen. Three lines is roughly the point past which nobody reads it with a bar in their hands. + public static let maxExerciseNoteLength = 140 + /// Thrown when the vocabulary is full and the name is a new one. public struct LiftExerciseVocabularyFull: Error, Equatable { public let limit: Int diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index ef80b08899..9b74578973 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,21 @@ { "sourceLanguage": "en", "strings": { + "Discard session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einheit verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard session"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Scarta la sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить сессию"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練"}} + } }, + "Discard this session?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Einheit verwerfen?"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard this session?"}}, "es": {"stringUnit": {"state": "translated", "value": "¿Descartar esta sesión?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner cette séance ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Scartare questa sessione?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzucić tę sesję?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar esta sessão?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить эту сессию?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練?"}} + } }, + "Keep going": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Weitermachen"}}, "en": {"stringUnit": {"state": "translated", "value": "Keep going"}}, "es": {"stringUnit": {"state": "translated", "value": "Seguir"}}, "fr": {"stringUnit": {"state": "translated", "value": "Continuer"}}, "it": {"stringUnit": {"state": "translated", "value": "Continua"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kontynuuj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Continuar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Продолжить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "继续"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "繼續"}} + } }, + "%lld recorded sets will be thrown away. Nothing is saved and no workout is created.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld aufgezeichnete Sätze werden verworfen. Nichts wird gespeichert und kein Workout angelegt."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld recorded sets will be thrown away. Nothing is saved and no workout is created."}}, "es": {"stringUnit": {"state": "translated", "value": "Se descartarán %lld series registradas. No se guarda nada ni se crea ningún entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries enregistrées seront supprimées. Rien n'est sauvegardé et aucun entraînement n'est créé."}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie registrate verranno eliminate. Non viene salvato nulla e non viene creato alcun allenamento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane serie (%lld) zostaną odrzucone. Nic nie zostanie zapisane i nie powstanie żaden trening."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries registadas serão descartadas. Nada é guardado e nenhum treino é criado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанных подходов будет удалено: %lld. Ничего не сохранится и тренировка не создастся."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将丢弃已记录的 %lld 组。不会保存任何内容,也不会创建训练记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將丟棄已記錄的 %lld 組。不會儲存任何內容,也不會建立訓練記錄。"}} + } }, + "%lld recorded sets will be removed, and so will the workout this session created. This cannot be undone.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld aufgezeichnete Sätze werden entfernt, ebenso das von dieser Einheit erstellte Workout. Das lässt sich nicht rückgängig machen."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld recorded sets will be removed, and so will the workout this session created. This cannot be undone."}}, "es": {"stringUnit": {"state": "translated", "value": "Se eliminarán %lld series registradas, y también el entrenamiento que creó esta sesión. Esto no se puede deshacer."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries enregistrées seront supprimées, ainsi que l'entraînement créé par cette séance. C'est irréversible."}}, "it": {"stringUnit": {"state": "translated", "value": "Verranno rimosse %lld serie registrate e anche l'allenamento creato da questa sessione. L'operazione non è reversibile."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane serie (%lld) zostaną usunięte, podobnie jak trening utworzony przez tę sesję. Tej operacji nie można cofnąć."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries registadas serão removidas, tal como o treino que esta sessão criou. Isto não pode ser anulado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанных подходов будет удалено: %lld, вместе с тренировкой, созданной этой сессией. Отменить это нельзя."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将移除已记录的 %lld 组,以及本次训练创建的锻炼记录。此操作无法撤销。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將移除已記錄的 %lld 組,以及本次訓練建立的鍛鍊記錄。此操作無法復原。"}} + } }, "That file is too big to be a program sheet.": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Diese Datei ist zu groß für ein Programmblatt."}}, "en": {"stringUnit": {"state": "translated", "value": "That file is too big to be a program sheet."}}, "es": {"stringUnit": {"state": "translated", "value": "Ese archivo es demasiado grande para una hoja de programa."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier est trop volumineux pour une feuille de programme."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel file è troppo grande per un foglio di programma."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten plik jest za duży jak na arkusz programu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esse ficheiro é demasiado grande para uma folha de programa."}}, "ru": {"stringUnit": {"state": "translated", "value": "Этот файл слишком велик для таблицы программы."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件太大,不像是计划表格。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案太大,不像是計畫表格。"}} } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index bef90fdaf1..6710180679 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -61,7 +61,7 @@ struct LiftLogView: View { LiftProgramImportSheet { await load() } } .sheet(item: $viewing) { target in - LiftSessionDetailSheet(session: target.session) + LiftSessionDetailSheet(session: target.session) { await load() } } } diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index b07d810dd4..ab521acf92 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -88,6 +88,13 @@ struct LiftProgramEditorSheet: View { } field("Note (optional)") { TextField("Anything you want to remember", text: $note) + // Capped at what the hub can actually show (two caption lines). A field + // that silently discards the end is worse than one that stops. + .onChange(of: note) { new in + if new.count > WhoopStore.maxProgramNoteLength { + note = String(new.prefix(WhoopStore.maxProgramNoteLength)) + } + } .textFieldStyle(.plain) .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index b203c5113f..7be1536cec 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -298,6 +298,13 @@ struct LiftProgramItemSheet: View { SectionHeader("Technique note", overline: "In your words") NoopCard { TextField("Slow eccentric, pause at the bottom", text: $note, axis: .vertical) + // A cue read between sets, and it renders directly above the set rows — every + // line pushes them down the screen. + .onChange(of: note) { new in + if new.count > WhoopStore.maxExerciseNoteLength { + note = String(new.prefix(WhoopStore.maxExerciseNoteLength)) + } + } .textFieldStyle(.plain) .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index e87a542adb..80f623f609 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -18,6 +18,8 @@ import WhoopStore struct LiftSessionDetailSheet: View { let session: LiftSessionRow + /// Called after the session is deleted, so the hub can reload its list. + var onDeleted: () async -> Void = {} @EnvironmentObject var repo: Repository @Environment(\.dismiss) private var dismiss @@ -28,6 +30,8 @@ struct LiftSessionDetailSheet: View { /// Previous performance per exercise, for the "vs last time" comparison. @State private var previousVolume: [String: Double] = [:] @State private var loaded = false + @State private var confirmingDelete = false + @State private var deleting = false @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } @@ -48,6 +52,7 @@ struct LiftSessionDetailSheet: View { muscleSection rpeSection footnote + deleteSection } } } @@ -60,6 +65,46 @@ struct LiftSessionDetailSheet: View { .task { await load() } } + /// Remove a session that should not have been recorded — a mis-tap, or a test. + /// + /// Deletes the paired `workout` row TOO. A lift session writes one so the training lands in + /// Workouts and Today like any other, and the analytics engine fills its strain from the heart + /// rate measured over that window. Leaving it behind would keep the day's Effort inflated by a + /// session the user just said did not happen — which is worse than not being able to delete at + /// all, because it would look like the delete worked. + private var deleteSection: some View { + Button(role: .destructive) { + confirmingDelete = true + } label: { + Label("Delete session", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.statusCritical) + .padding(.top, NoopMetrics.gap) + .disabled(deleting) + .confirmationDialog("Delete this session?", + isPresented: $confirmingDelete, titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await deleteSession() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("\(sets.count) recorded sets will be removed, and so will the workout this session created. This cannot be undone.") + } + } + + private func deleteSession() async { + guard !deleting, let store = await repo.storeHandle() else { return } + deleting = true + defer { deleting = false } + + _ = try? await store.deleteLiftSession(id: session.id) // cascades to its sets + if let workout { await repo.deleteWorkout(workout) } + + await onDeleted() + dismiss() + } + private var subtitle: LocalizedStringKey { let date = Date(timeIntervalSince1970: TimeInterval(session.startTs)) .formatted(date: .abbreviated, time: .shortened) diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index b330b631e8..77208d5701 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -30,6 +30,7 @@ struct LiftSessionView: View { /// What the user did for each exercise LAST session — the fallback ghost values, loaded once. @State private var lastTime: [String: [Int: LiftRecordedSet]] = [:] @State private var showingFinish = false + @State private var confirmingDiscard = false @State private var sessionRpeText = "" @State private var saving = false @@ -146,6 +147,9 @@ struct LiftSessionView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) + // Belt and braces with the entry cap: the sets are what this screen is for, + // and a note must never be able to push them off it. + .lineLimit(3) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(StrandPalette.metricAmber.opacity(0.12), @@ -552,6 +556,32 @@ struct LiftSessionView: View { .frame(maxWidth: 180) .disabled(saving) } + + // A way OUT that records nothing. Until this existed, every route off this screen + // saved: "Skip" skips the RPE question, not the session. A session started by a + // mis-tap, or to try something out, had to be saved and then lived in the history + // and in that day's Effort for good. + Button(role: .destructive) { + confirmingDiscard = true + } label: { + Label("Discard session", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.statusCritical) + .padding(.top, 4) + .disabled(saving) + .confirmationDialog("Discard this session?", + isPresented: $confirmingDiscard, titleVisibility: .visible) { + Button("Discard", role: .destructive) { + session.discard() + showingFinish = false + } + Button("Keep going", role: .cancel) { } + } message: { + Text("\(engine?.completedWorkingSets ?? 0) recorded sets will be thrown away. Nothing is saved and no workout is created.") + } } } #if os(iOS) From d6b07a2b15324218a6d5b5b484ca0ecae3e951cb Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:35:05 +0200 Subject: [PATCH 20/31] lift log: stop the weekly bar saying "done" at the floor, and lengthen the notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE BAR TOLD THE USER TO STOP AT THE STARTING LINE. It scaled 0...4 sets and turned FULL and GREEN at four — the exact point the research says growth merely becomes *detectable*, not the point it stops paying. It also contradicted the caption printed directly beneath it, which already said gains continue above with no clear ceiling. The picture and the words disagreed, and the picture is what gets read. Now the bar is drawn across a 20-set span with four sets marked as a TICK about a fifth of the way along. At four sets you see a bar that has just reached its marker, which is what "you have crossed the floor" looks like — not a finished one. Nothing on the bar ever reads as complete, because nothing about the dose is: the evidence identifies a floor and NO ceiling for hypertrophy. * the success-green is gone from both the bar and the number. There is no success point to signal, and a green number is exactly what made four sets read as an achievement; * below the floor the fill is muted — growth not being reliably detectable there IS worth showing — and the ordinary accent above it; * the 20-set span is a DRAWING choice and is commented as one. It is not a dose and must never be read as a target; a count past it fills the bar while the number keeps counting (biceps at 22 still reads 22); * the caption now says "the tick marks", because it does. NOTE LENGTHS RAISED to match new line limits: program note 3 lines / 120 chars, exercise note 4 lines / 200 chars. Both remain a MAXIMUM rather than a target — a one-line note is usually the better note — and the caps still come from what each surface can actually show, so nothing is stored-and-silently-truncated. Both bars also gained an accessibility label saying where the count sits relative to the floor, since the tick carries that meaning visually. Verification: WhoopStore 561, StrandAnalytics 1988, StrandImport 284, StrandTests 1696 — 0 failures beyond the two pre-existing locale-dependent ones. Both app targets build; `i18n_audit.py --ci` passes with three new strings in all ten locales; `doc_comment_lint.py` passes. Confirmed in the simulator across a seeded week at 2, 4, 9 and 22 sets: below the floor reads muted with the tick ahead of the fill, four reaches the tick exactly, and 22 clamps the bar while the number keeps counting. Co-Authored-By: Claude Opus 5 --- .../Sources/WhoopStore/LiftLogStore.swift | 13 ++--- Strand/Resources/Localizable.xcstrings | 9 ++++ Strand/Screens/LiftLogView.swift | 50 ++++++++++++++++--- Strand/Screens/LiftSessionView.swift | 2 +- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 8d5267a60e..18e381be5d 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -355,19 +355,20 @@ extension WhoopStore { /// How long a PROGRAM note may be. /// - /// 100 because that is what can actually be SEEN: the hub renders it under the program name at - /// `lineLimit(2)` in caption type, which is roughly two lines on a phone. A longer note is not + /// 120 because that is what can actually be SEEN: the hub renders it under the program name at + /// `lineLimit(3)` in caption type, in a column narrowed by the Start button — roughly three + /// lines on a phone. A longer note is not /// stored-and-shown, it is stored-and-silently-truncated, and typing into a field that quietly /// discards the end is worse than a field that stops. - public static let maxProgramNoteLength = 100 + public static let maxProgramNoteLength = 120 /// How long an EXERCISE (technique) note may be. /// - /// 140, about three lines. Longer than a program note because it is a cue you read BETWEEN sets + /// 200, about four lines. Longer than a program note because it is a cue you read BETWEEN sets /// — "slow eccentric, pause at the bottom, don't let the elbows flare" — and shorter than /// unlimited because it renders directly above the set rows and every line pushes them down the - /// screen. Three lines is roughly the point past which nobody reads it with a bar in their hands. - public static let maxExerciseNoteLength = 140 + /// screen. Both are a MAXIMUM, not a target — a one-line note is usually the better note. + public static let maxExerciseNoteLength = 200 /// Thrown when the vocabulary is full and the name is a new one. public struct LiftExerciseVocabularyFull: Error, Equatable { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 9b74578973..f4542dd421 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,15 @@ { "sourceLanguage": "en", "strings": { + "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Strich markiert etwa 4 Sätze pro Woche – den Punkt, unter dem die Forschung Wachstum nicht zuverlässig nachweist. Darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze; der Balken hat deshalb kein „voll“."}}, "en": {"stringUnit": {"state": "translated", "value": "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\"."}}, "es": {"stringUnit": {"state": "translated", "value": "La marca señala unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias continúan con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le repère marque environ 4 séries par semaine — le seuil en dessous duquel la recherche ne détecte pas de croissance de façon fiable. Au-dessus, les gains continuent avec de forts rendements décroissants et sans plafond net : la barre n'a donc pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "La tacca segna circa 4 serie a settimana: il punto sotto il quale la ricerca non rileva la crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Znacznik wskazuje około 4 serie tygodniowo — punkt, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej postępy trwają, z silnie malejącymi zyskami i bez wyraźnego pułapu, więc pasek nie ma stanu „pełny”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A marca assinala cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Отметка показывает примерно 4 подхода в неделю — точку, ниже которой исследования не фиксируют рост надёжно. Выше рост продолжается с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "刻度标示每周约 4 组 —— 低于此点,研究无法可靠地检测到增长。高于此点,收益仍在继续,但回报急剧递减且没有明确上限,因此这条进度条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "刻度標示每週約 4 組 —— 低於此點,研究無法可靠地偵測到增長。高於此點,收益仍在持續,但回報急劇遞減且沒有明確上限,因此這條進度條沒有「滿」。"}} + } }, + "%1$@: %2$@ sets, at or above the weekly floor of %3$@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, auf oder über dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, at or above the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, en o por encima del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, au niveau ou au-dessus du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, pari o superiori al minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, na poziomie tygodniowego minimum %3$@ lub powyżej"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, no mínimo semanal de %3$@ ou acima"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, на недельном минимуме %3$@ или выше"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,达到或超过每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,達到或超過每週下限 %3$@"}} + } }, + "%1$@: %2$@ sets, below the weekly floor of %3$@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, unter dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, below the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, por debajo del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, en dessous du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, sotto il minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, poniżej tygodniowego minimum %3$@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, abaixo do mínimo semanal de %3$@"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, ниже недельного минимума %3$@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,低于每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,低於每週下限 %3$@"}} + } }, "Discard session": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Einheit verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard session"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Scarta la sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить сессию"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練"}} } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 6710180679..5b38810737 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -160,7 +160,7 @@ struct LiftLogView: View { Text(note) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) - .lineLimit(2) + .lineLimit(3) } Text("Tap to edit") .font(StrandFont.footnote) @@ -234,7 +234,7 @@ struct LiftLogView: View { } // The band is named and sourced, never phrased as a target NOOP sets for // anyone: this is not a medical device and does not prescribe. - Text("The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling.") + Text("The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -245,28 +245,64 @@ struct LiftLogView: View { } } + /// The span the weekly bar is drawn across. + /// + /// A DRAWING choice, not a dose. The evidence puts a floor at about 4 sets a week and identifies + /// NO ceiling for hypertrophy — gains continue above it with strongly diminishing returns — so + /// any bar maximum is arbitrary and must never be read as a target. 20 is chosen only because it + /// comfortably contains the range people actually train in, which puts the floor tick early on + /// the bar and makes a normal week read as progress rather than as "finished". + /// + /// The NUMBER beside the bar is the truth. The bar is context for it, and a count past 20 fills + /// the bar while the number keeps counting. + private static let weeklySetsBarSpan = 20.0 + + /// One muscle's week: the count, and where it sits relative to the evidence. + /// + /// This used to scale the bar 0...4 and turn it FULL and GREEN at four sets — so the screen said + /// "done" at the exact point the research says growth merely becomes *detectable*. It was telling + /// the user to stop at the starting line, and it contradicted the caption printed directly below + /// it. Now four sets is a TICK a fifth of the way along, and nothing on the bar ever reads as + /// complete, because nothing about the dose is. private func muscleBar(_ muscle: LiftMuscle, sets: Double) -> some View { - let fraction = LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(sets) - let met = sets >= LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek + let floor = LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek + let atOrAboveFloor = sets >= floor + let fill = min(1.0, sets / Self.weeklySetsBarSpan) + let tick = min(1.0, floor / Self.weeklySetsBarSpan) + return VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(muscle.displayName) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) Spacer(minLength: 0) + // Deliberately NOT a success colour. There is no success point to signal, and a + // green number is exactly what made four sets read as an achievement. Text(LiftFormat.trim(sets)) .font(StrandFont.captionNumber) - .foregroundStyle(met ? StrandPalette.statusPositive : StrandPalette.textPrimary) + .foregroundStyle(StrandPalette.textPrimary) } GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(StrandPalette.surfaceRaised) Capsule() - .fill(met ? StrandPalette.statusPositive : StrandPalette.effortColor) - .frame(width: max(2, geo.size.width * fraction)) + // Muted below the floor — below it growth is not reliably detectable, which + // is worth showing — and the ordinary accent above it. Never a "done" colour. + .fill(StrandPalette.effortColor.opacity(atOrAboveFloor ? 1.0 : 0.45)) + .frame(width: max(2, geo.size.width * fill)) + // The floor, marked where it actually falls. + Capsule() + .fill(StrandPalette.textPrimary.opacity(0.45)) + .frame(width: 2) + .offset(x: max(0, geo.size.width * tick - 1)) + .accessibilityHidden(true) } } .frame(height: 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(atOrAboveFloor + ? String(localized: "\(muscle.displayName): \(LiftFormat.trim(sets)) sets, at or above the weekly floor of \(LiftFormat.trim(floor))") + : String(localized: "\(muscle.displayName): \(LiftFormat.trim(sets)) sets, below the weekly floor of \(LiftFormat.trim(floor))")) } } diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 77208d5701..195335c9a4 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -149,7 +149,7 @@ struct LiftSessionView: View { .fixedSize(horizontal: false, vertical: true) // Belt and braces with the entry cap: the sets are what this screen is for, // and a note must never be able to push them off it. - .lineLimit(3) + .lineLimit(4) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(StrandPalette.metricAmber.opacity(0.12), From 25df4e26d02415a3196c08bbae0d1df93034bc6b Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:10:25 +0200 Subject: [PATCH 21/31] lift log: add or drop a set mid-session, and keep the program in step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five sets when the program says four is ordinary, and so is stopping at three. Until now the sheet drew exactly 1...targetSets, so the extra set was performed and then lost — the last real gap in the feature. Each exercise now ends in an "Add set" row: a plus that appends a set, and a minus in the tick column that drops the last planned one. Both also rewrite the program's line, because a program is a plan for NEXT time and the sets actually chosen are the better plan. The minus only ever removes a PENDING last set. Set numbers are positions, so removing from the middle would renumber what was already recorded; and a completed set is data, not a plan. The set being worked or rested from is excluded for the same reason. Both cases dim the control rather than hiding it. The plan now travels in the undo snapshot with the stage and the sets. It has to: a stage saved under one set count is only meaningful under that count, and undoing past a removed set would otherwise leave the session working a slot the sheet no longer draws. Undo therefore takes an added set back off the program too, through the same funnel as the buttons. 11 engine tests, each watched fail with its guard removed. Verified end to end in the simulator against the database: an unplanned third set saved with its numbers and its measured rest, the program moved 2 → 3 → 4 → 3 with every other target untouched. Both app targets build; three new strings in all ten locales. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 23 +++++ Strand/Data/LiftSessionEngine.swift | 70 ++++++++++++- Strand/Data/LiftSessionPersistence.swift | 10 +- Strand/Resources/Localizable.xcstrings | 9 ++ Strand/Screens/LiftLogView.swift | 5 +- Strand/Screens/LiftSessionView.swift | 95 +++++++++++++++++- StrandTests/LiftSessionEngineTests.swift | 122 +++++++++++++++++++++++ 7 files changed, 327 insertions(+), 7 deletions(-) diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index eabee3e2b1..3ebc153d6b 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -306,6 +306,29 @@ final class LiftSessionController: ObservableObject { persist() } + /// Add one set to an exercise — the unplanned fifth set. Returns whether anything changed, which + /// is what tells the caller whether the program behind the session needs rewriting. + @discardableResult + func addSet(toExercise index: Int) -> Bool { + guard engine?.addSet(toExercise: index) == true else { return false } + persist() + return true + } + + /// Drop the last pending set of an exercise. See `LiftSessionEngine.canRemoveSet(fromExercise:)` + /// for what "can" means — a completed set is never removed this way. + @discardableResult + func removeSet(fromExercise index: Int) -> Bool { + guard let engine, engine.canRemoveSet(fromExercise: index) else { return false } + let dropped = LiftSlot(exerciseIndex: index, setIndex: engine.plan[index].targetSets) + self.engine?.removeSet(fromExercise: index) + // A slot that no longer exists must not keep a warm-up mark: adding the set back would + // return it silently marked, from a tap the user made against a different set. + pendingWarmups.remove(dropped) + persist() + return true + } + func updateSet(_ slot: LiftSlot, weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { engine?.updateSet(slot, weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup) persist() diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 14172f5059..22cd5eae8e 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -40,6 +40,10 @@ struct LiftPlanItem: Equatable { /// The weight the program plans, in kilograms. var targetWeightKg: Double? var note: String? + /// The `liftProgramItem.id` this line was flattened from, so a set added or dropped during the + /// session can be written back onto the program it came from. Nil for a line with no program + /// behind it, and the write-back is then simply skipped. + var programItemId: String? /// Rest used when a program line does not specify one. Two minutes sits in the middle of the /// range the hypertrophy literature uses for compound work, and is only a starting value: what @@ -55,7 +59,8 @@ struct LiftPlanItem: Equatable { targetRepsHigh: Int? = nil, targetRpe: Double? = nil, targetWeightKg: Double? = nil, - note: String? = nil) { + note: String? = nil, + programItemId: String? = nil) { self.exercise = exercise self.primaryMuscle = primaryMuscle self.secondaryMuscles = secondaryMuscles @@ -66,6 +71,7 @@ struct LiftPlanItem: Equatable { self.targetRpe = targetRpe self.targetWeightKg = targetWeightKg self.note = note + self.programItemId = programItemId } } @@ -109,7 +115,11 @@ struct LiftSessionEngine: Equatable { case finished } - let plan: [LiftPlanItem] + /// The lines being worked. MUTABLE only in one dimension: how many sets a line holds, because a + /// gym decides that as it goes — a fifth set on a line that planned four, or dropping the last + /// one when the tank is empty. Nothing else about a line can change mid-session, so the plan + /// stays the snapshot it was at start. + private(set) var plan: [LiftPlanItem] /// When the session began (unix seconds). let startTs: Int private(set) var stage: Stage @@ -124,7 +134,13 @@ struct LiftSessionEngine: Equatable { /// hand-written inverse can. private var history: [Snapshot] = [] + /// The plan travels in the snapshot with the stage and the sets, and is restored with them. + /// + /// It has to. Once the set count can move, a stage saved under one count is only meaningful + /// under that count: undoing back past a removed set would otherwise leave the session working + /// a slot the sheet no longer draws — and completing it would write a set nobody could see. private struct Snapshot: Equatable { + var plan: [LiftPlanItem] var stage: Stage var sets: [LiftRecordedSet] var stageStartedAt: Int @@ -333,6 +349,52 @@ struct LiftSessionEngine: Equatable { sets[i].isWarmup = isWarmup } + // MARK: - Changing how many sets a line holds + // + // A program is what you INTENDED, and a gym argues with it. Five sets when the program says four + // is ordinary; so is stopping at three because the tank is empty. Until this existed the fifth + // set simply could not be recorded — the sheet drew exactly `1...targetSets` and there was no + // way past it — so the set was done and then lost, which is the failure this feature exists to + // prevent. + + /// The most sets one line may hold. A bound against a stuck finger, not a recommendation: every + /// added set is a row on the sheet and a slot in the crash snapshot. + static let maxSetsPerExercise = 20 + + /// Append one set to an exercise's line. Returns false when the line is already at the bound, so + /// a caller can tell "did nothing" from "done" without re-deriving the rule. + @discardableResult + mutating func addSet(toExercise index: Int) -> Bool { + guard plan.indices.contains(index), + plan[index].targetSets < LiftSessionEngine.maxSetsPerExercise else { return false } + pushHistory() + plan[index].targetSets += 1 + return true + } + + /// Whether the last set of a line can be dropped. + /// + /// Only the LAST one, and only while it is still pending. Set numbers are positions — the sheet + /// draws `1...targetSets` — so removing from the middle would renumber every set after it and + /// silently re-label what was already recorded. And a completed set is DATA: dropping it here + /// would delete a set the user actually performed, from a button whose job is to edit a plan. + /// The set being worked or rested from is excluded for the same reason it cannot be renumbered: + /// the session is standing on it. + func canRemoveSet(fromExercise index: Int) -> Bool { + guard plan.indices.contains(index), plan[index].targetSets > 1 else { return false } + let last = LiftSlot(exerciseIndex: index, setIndex: plan[index].targetSets) + return !isCompleted(last) && currentSlot != last + } + + /// Drop the last (pending) set of an exercise's line. Returns false when the rule above says no. + @discardableResult + mutating func removeSet(fromExercise index: Int) -> Bool { + guard canRemoveSet(fromExercise: index) else { return false } + pushHistory() + plan[index].targetSets -= 1 + return true + } + /// End the session. The rest that was running is closed out first, so its measured duration is /// not silently lost. mutating func finish(now: Int) { @@ -348,12 +410,14 @@ struct LiftSessionEngine: Equatable { mutating func undo() { guard let previous = history.popLast() else { return } + plan = previous.plan stage = previous.stage sets = previous.sets stageStartedAt = previous.stageStartedAt } private mutating func pushHistory() { - history.append(Snapshot(stage: stage, sets: sets, stageStartedAt: stageStartedAt)) + history.append(Snapshot(plan: plan, stage: stage, + sets: sets, stageStartedAt: stageStartedAt)) } } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index ea5a441b19..824b824319 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -39,6 +39,10 @@ enum LiftSessionPersistence { var targetRpe: Double? var targetWeightKg: Double? var note: String? + /// The program line this was flattened from. Absent from a snapshot written before the + /// set count could be changed mid-session, which decodes to nil and simply skips the + /// write-back — the session itself is unaffected. + var programItemId: String? } /// The stage as a flat, forward-compatible record rather than an encoded enum: a persisted @@ -123,7 +127,8 @@ enum LiftSessionPersistence { targetRepsHigh: $0.targetRepsHigh, targetRpe: $0.targetRpe, targetWeightKg: $0.targetWeightKg, - note: $0.note) + note: $0.note, + programItemId: $0.programItemId) }, stage: box(engine.stage), sets: engine.sets.map { @@ -149,7 +154,8 @@ enum LiftSessionPersistence { targetRepsHigh: $0.targetRepsHigh, targetRpe: $0.targetRpe, targetWeightKg: $0.targetWeightKg, - note: $0.note) + note: $0.note, + programItemId: $0.programItemId) } let sets = s.sets.map { LiftRecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f4542dd421..f8aa0407ea 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,15 @@ { "sourceLanguage": "en", "strings": { + "Add set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add set"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增一組"}} + } }, + "Add a set to %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einen Satz zu %@ hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add a set to %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir una serie a %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série à %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi una serie a %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię do %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar uma série a %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход к %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为 %@ 添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為 %@ 新增一組"}} + } }, + "Remove the last set from %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Letzten Satz von %@ entfernen"}}, "en": {"stringUnit": {"state": "translated", "value": "Remove the last set from %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Quitar la última serie de %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Retirer la dernière série de %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Rimuovi l'ultima serie da %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Usuń ostatnią serię z %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Remover a última série de %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Убрать последний подход из %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "移除 %@ 的最后一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "移除 %@ 的最後一組"}} + } }, "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Der Strich markiert etwa 4 Sätze pro Woche – den Punkt, unter dem die Forschung Wachstum nicht zuverlässig nachweist. Darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze; der Balken hat deshalb kein „voll“."}}, "en": {"stringUnit": {"state": "translated", "value": "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\"."}}, "es": {"stringUnit": {"state": "translated", "value": "La marca señala unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias continúan con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le repère marque environ 4 séries par semaine — le seuil en dessous duquel la recherche ne détecte pas de croissance de façon fiable. Au-dessus, les gains continuent avec de forts rendements décroissants et sans plafond net : la barre n'a donc pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "La tacca segna circa 4 serie a settimana: il punto sotto il quale la ricerca non rileva la crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Znacznik wskazuje około 4 serie tygodniowo — punkt, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej postępy trwają, z silnie malejącymi zyskami i bez wyraźnego pułapu, więc pasek nie ma stanu „pełny”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A marca assinala cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Отметка показывает примерно 4 подхода в неделю — точку, ниже которой исследования не фиксируют рост надёжно. Выше рост продолжается с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "刻度标示每周约 4 组 —— 低于此点,研究无法可靠地检测到增长。高于此点,收益仍在继续,但回报急剧递减且没有明确上限,因此这条进度条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "刻度標示每週約 4 組 —— 低於此點,研究無法可靠地偵測到增長。高於此點,收益仍在持續,但回報急劇遞減且沒有明確上限,因此這條進度條沒有「滿」。"}} } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 5b38810737..a930ed48fb 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -202,7 +202,10 @@ struct LiftLogView: View { targetRepsHigh: item.targetRepsHigh, targetRpe: item.targetRpe, targetWeightKg: item.targetWeightKg, - note: item.note) + note: item.note, + // Carried so a set added or dropped mid-session can be written back + // onto the line it came from, and be there next time. + programItemId: item.id) } // Refuse to start a second session over a running one: two live sessions would both claim // the strap gesture and both write the in-flight snapshot. diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 195335c9a4..43edc8d33c 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -163,11 +163,102 @@ struct LiftSessionView: View { // The rest belongs BETWEEN two sets, because that is where it happens. if isRestingAfter(engine, slot: slot) { restBand(engine) } } + + setCountRow(engine, index: index, item: item) } } .id(index) } + /// Add one more set, or drop the last planned one — at the END of the exercise, because that is + /// where the question comes up: you have done what was written down and have one more in you, or + /// you have not. Until this existed the sheet drew exactly `1...targetSets` and the extra set was + /// performed and then lost. + /// + /// The geometry mirrors a set row: the minus sits in the tick column, under the checks it undoes. + /// + /// **Both buttons also rewrite the program**, which is the point rather than a side effect — a + /// program is a plan for NEXT time, and the sets you actually chose are the better plan. The + /// running session is unaffected either way; the write-back only changes what the program offers + /// when it is started again. + private func setCountRow(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { + let canAdd = item.targetSets < LiftSessionEngine.maxSetsPerExercise + let canRemove = engine.canRemoveSet(fromExercise: index) + + return HStack(spacing: 8) { + Button { + changeSetCount { session.addSet(toExercise: index) } + } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle") + .font(.system(size: 17, weight: .semibold)) + Text("Add set").font(StrandFont.caption) + } + .foregroundStyle(canAdd ? StrandPalette.effortColor : StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .accessibilityLabel(String(localized: "Add a set to \(item.exercise)")) + + Button { + changeSetCount { session.removeSet(fromExercise: index) } + } label: { + Image(systemName: "minus.circle") + .font(.system(size: 17, weight: .semibold)) + // Dimmed rather than gone: the pair reads as one control, and a minus that + // disappears once the last set is done looks like a feature that broke. + .foregroundStyle(canRemove ? StrandPalette.textSecondary + : StrandPalette.textTertiary.opacity(0.4)) + .frame(width: Self.tickColumnWidth) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canRemove) + .accessibilityLabel(String(localized: "Remove the last set from \(item.exercise)")) + } + .padding(.top, 2) + .padding(.horizontal, 8) + } + + /// Run a change to the set count, then make the program match. + /// + /// One funnel for every path that can move a count — the two buttons and the undo — so the + /// program cannot be left behind by a route someone forgot about. + private func changeSetCount(_ change: () -> Bool) { + guard change() else { return } + Task { await writeSetCountsToProgram() } + } + + /// Write the session's set counts back onto the program behind it. + /// + /// Re-reads the lines first and edits only `targetSets`, so a program edited elsewhere while the + /// session runs keeps every other change, and a line that has since been deleted is skipped + /// rather than resurrected. Writes nothing at all when no count actually differs — the store + /// call replaces the program's lines wholesale, and that is not something to do on every tap. + private func writeSetCountsToProgram() async { + guard let programId = session.programId, let plan = session.engine?.plan, + let store = await repo.storeHandle() else { return } + var wanted: [String: Int] = [:] + for line in plan { + if let id = line.programItemId { wanted[id] = line.targetSets } + } + guard !wanted.isEmpty, + let rows = try? await store.liftProgramItems(programId: programId) else { return } + + var changed = false + let rewritten = rows.map { row -> LiftProgramItemRow in + guard let sets = wanted[row.id], row.targetSets != sets else { return row } + var edited = row + edited.targetSets = sets + changed = true + return edited + } + guard changed else { return } + _ = try? await store.replaceLiftProgramItems(programId: programId, items: rewritten) + } + /// Width of the set-number column, shared by the heading and every row so the number sits /// directly under its label. /// @@ -433,7 +524,9 @@ struct LiftSessionView: View { heartRate() Spacer(minLength: 0) Button { - session.undo() + // Through the funnel: undo restores the plan as well as the sets, so taking back + // an added set has to take it back off the program too. + changeSetCount { session.undo(); return true } } label: { Image(systemName: "arrow.uturn.backward") .font(.system(size: 15, weight: .semibold)) diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index babe4e3617..dbf6dfa912 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -479,6 +479,128 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertEqual(e.sets[0].weightKg, 30) } + // MARK: - Adding and dropping a set mid-session + // + // A program is what you INTENDED. Five sets when it says four is ordinary, and so is stopping at + // three — and before this the fifth set was performed and then simply lost, because the sheet + // drew exactly `1...targetSets`. + + func testAnAddedSetBecomesATappableRowAndCountsTowardThePlan() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.plannedWorkingSets, 3) + XCTAssertTrue(e.addSet(toExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2), slot(0, 3)]) + XCTAssertEqual(e.plannedWorkingSets, 4) + XCTAssertFalse(e.allCompleted) + } + + /// The whole point: the extra set has to be recordable, with its own numbers. + func testAnExtraSetIsWhereTheSessionGoesNextAndRecordsWhatItWasDoing() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1, restSec: 60)], + startTs: t0) + e.advance(now: t0) // set 1 + e.advance(now: t0 + 40) // set 1 done, resting + XCTAssertTrue(e.allCompleted, "the plan is finished, as written") + + e.addSet(toExercise: 0) + XCTAssertFalse(e.allCompleted, "and now it is not — there is one more to do") + e.advance(now: t0 + 160) // out of the rest, into set 2 + XCTAssertEqual(e.stage, .working(slot(0, 2))) + e.advance(now: t0 + 200, lastSession: LiftSetCarry(weightKg: 20, reps: 12)) + XCTAssertEqual(e.sets.count, 2) + XCTAssertEqual(e.sets.last?.setIndex, 2) + XCTAssertEqual(e.sets.last?.weightKg, 20, "an added set carries like any other") + } + + func testAddingSetsStopsAtTheBound() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + while e.addSet(toExercise: 0) { } + XCTAssertEqual(e.slots(forExercise: 0).count, LiftSessionEngine.maxSetsPerExercise) + XCTAssertFalse(e.addSet(toExercise: 0), "a stuck finger cannot grow the sheet without end") + } + + func testAddingASetToALineThatIsNotThereDoesNothing() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertFalse(e.addSet(toExercise: 99)) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + func testDroppingTheLastSetTakesItOffTheSheet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertTrue(e.canRemoveSet(fromExercise: 0)) + XCTAssertTrue(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1)]) + XCTAssertEqual(e.plannedWorkingSets, 2) + } + + /// The minus edits a PLAN. A completed set is data — deleting it from here would throw away a + /// set that was actually performed. + func testACompletedSetIsNeverDroppedByTheMinus() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) + e.advance(now: t0 + 40) // set 2 recorded + // Move the session OFF that set, so the only thing protecting it is that it was performed. + e.start(slot(1, 1), now: t0 + 100) + XCTAssertNotEqual(e.currentSlot, slot(0, 2)) + XCTAssertTrue(e.isCompleted(slot(0, 2))) + + XCTAssertFalse(e.canRemoveSet(fromExercise: 0)) + XCTAssertFalse(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2, "what was logged stays on the sheet") + XCTAssertEqual(e.sets.count, 1, "and stays recorded") + } + + func testTheSetTheSessionIsStandingOnIsNeverDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) + XCTAssertEqual(e.stage, .working(slot(0, 2))) + XCTAssertFalse(e.canRemoveSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2) + } + + func testTheLastRemainingSetIsNeverDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertFalse(e.canRemoveSet(fromExercise: 1), "a line with one set has nothing to give up") + XCTAssertFalse(e.removeSet(fromExercise: 1)) + XCTAssertEqual(e.slots(forExercise: 1), [slot(1, 1)]) + } + + func testUndoTakesBackAnAddedSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.addSet(toExercise: 0) + XCTAssertEqual(e.plannedWorkingSets, 4) + e.undo() + XCTAssertEqual(e.plannedWorkingSets, 3, "undo takes back the plan change, not only sets") + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + } + + func testUndoPutsADroppedSetBack() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.removeSet(fromExercise: 0) + e.undo() + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + } + + /// The reason the plan travels in the undo snapshot at all. + /// + /// Start the last set, walk away to another exercise (which leaves it pending), drop it, then + /// undo back past the drop. Without the plan in the snapshot the stage would be restored onto a + /// slot the sheet no longer draws — and completing it would write a set nobody could see. + func testUndoingPastADroppedSetCannotStrandTheSessionOnASlotThatIsGone() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(0, 3), now: t0) // the last set of exercise 0 + e.start(slot(1, 1), now: t0 + 30) // machine busy: move on, 0/3 pending + XCTAssertTrue(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2) + + e.undo() // back past the drop + XCTAssertEqual(e.slots(forExercise: 0).count, 3, "the slot the stage refers to is back") + e.undo() // back onto that very slot + XCTAssertEqual(e.stage, .working(slot(0, 3))) + XCTAssertTrue(e.allSlots.contains(slot(0, 3)), + "the session is never left working a set the sheet does not draw") + } + // MARK: - Degenerate plans func testALineWithNoTargetStillGetsOneTappableSet() { From b852ea479554c098bb6e875783773a062a7f4b7e Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:05:29 +0200 Subject: [PATCH 22/31] lift log: pin that the previous build's in-flight session still reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question that decides whether an update can be installed over the top or needs a full wipe is whether data the PREVIOUS build wrote still reads. The session snapshot in UserDefaults is the only thing this feature persists outside SQLite, so it is the only place an app-layer change can strand it. Three tests, decoding literal JSON written the old way — encoding with today's Snapshot would only prove the build can read itself. They confirm a session started before the update resumes whole, that the new optional programItemId reads as absent rather than garbage (so the write-back is skipped for that one session rather than aimed at an unidentified line), and that today's snapshot round-trips with the added set intact across a relaunch. Co-Authored-By: Claude Opus 5 --- StrandTests/LiftSessionPersistenceTests.swift | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 StrandTests/LiftSessionPersistenceTests.swift diff --git a/StrandTests/LiftSessionPersistenceTests.swift b/StrandTests/LiftSessionPersistenceTests.swift new file mode 100644 index 0000000000..dc9e061cb1 --- /dev/null +++ b/StrandTests/LiftSessionPersistenceTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The crash-safe snapshot of an in-flight session, and the one question that decides whether an +/// update can be installed OVER the previous build or needs a wipe: **does data written by the +/// previous build still read?** +/// +/// The session snapshot is the only thing this feature persists outside SQLite (UserDefaults, +/// `noop.activeLiftSession`), so it is the only place an app-layer change can strand something the +/// old build wrote. A schema change is the other half of that question and is answered by the +/// migration, not here. +final class LiftSessionPersistenceTests: XCTestCase { + + private let t0 = 1_700_000_000 + + /// A snapshot exactly as the PREVIOUS build wrote it: no `programItemId`, because the field did + /// not exist until the set count could be changed mid-session. + /// + /// Written as literal JSON on purpose. Encoding it with today's `Snapshot` would only prove that + /// today's build can read itself — the whole point is to read bytes the old build produced. + private func snapshotJSONWithoutProgramItemId() -> Data { + Data(""" + { + "startSec": \(t0), + "programId": "p1", + "programName": "Upper A", + "plan": [ + {"exercise": "Bench press", "secondaryMuscles": ["triceps"], "primaryMuscle": "chest", + "targetSets": 3, "restSec": 90, "targetRepsLow": 8, "targetWeightKg": 60} + ], + "stage": {"kind": "resting", "item": 0, "set": 1, "endsAt": \(t0 + 130)}, + "sets": [ + {"exerciseIndex": 0, "setIndex": 1, "weightKg": 60, "reps": 8, "isWarmup": false, + "startTs": \(t0 + 10), "endTs": \(t0 + 40)} + ], + "stageStartedAt": \(t0 + 40) + } + """.utf8) + } + + /// A session started on the previous build and still running when the update is installed must + /// come back whole. It is the reason today's change can go on as a plain update rather than a wipe. + func testASessionWrittenByThePreviousBuildStillResumes() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId()), + "a snapshot from the previous build must not read as 'no session'") + let engine = LiftSessionPersistence.engine(from: decoded) + + XCTAssertEqual(engine.startTs, t0) + XCTAssertEqual(engine.plan.count, 1) + XCTAssertEqual(engine.plan[0].exercise, "Bench press") + XCTAssertEqual(engine.plan[0].targetSets, 3) + XCTAssertEqual(engine.plan[0].primaryMuscle, .chest) + XCTAssertEqual(engine.stage, .resting(LiftSlot(exerciseIndex: 0, setIndex: 1), + endsAt: t0 + 130)) + XCTAssertEqual(engine.sets.count, 1, "the set already logged survives the update") + XCTAssertEqual(engine.sets[0].weightKg, 60) + } + + /// The new field is simply absent, not garbage — so the write-back is skipped for that session + /// rather than aimed at a program line that was never identified. + func testAResumedOldSessionCarriesNoProgramLineAndIsStillFullyUsable() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + var engine = LiftSessionPersistence.engine(from: decoded) + XCTAssertNil(engine.plan[0].programItemId) + + // And the set count can still be changed — the session works, the program just is not rewritten. + XCTAssertTrue(engine.addSet(toExercise: 0)) + XCTAssertEqual(engine.plan[0].targetSets, 4) + } + + /// The round trip today's build performs on itself, including the new field. + func testTodaysSnapshotRoundTripsWithTheProgramLine() throws { + let plan = [LiftPlanItem(exercise: "Lat pulldown", primaryMuscle: .lats, + targetSets: 2, restSec: 60, programItemId: "line-7")] + var engine = LiftSessionEngine(plan: plan, startTs: t0) + engine.advance(now: t0) + engine.advance(now: t0 + 30) + engine.addSet(toExercise: 0) + + let encoded = try XCTUnwrap(LiftSessionPersistence.encode( + LiftSessionPersistence.snapshot(engine: engine, programId: "p1", programName: "Pull"))) + let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) + let rebuilt = LiftSessionPersistence.engine(from: back) + + XCTAssertEqual(rebuilt.plan[0].programItemId, "line-7", + "without this the added set could not be written back after a relaunch") + XCTAssertEqual(rebuilt.plan[0].targetSets, 3, "the added set survives a crash") + XCTAssertEqual(rebuilt.sets.count, 1) + } +} From 007c3b48c6412598cf3ee603c35ff84c8d27d638 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:02:28 +0200 Subject: [PATCH 23/31] lift log: let any set's numbers be typed at any time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a real session: "you can't edit the numbers of other sets while in an active set — when I type something during an active set to other sets it refreshes to the empty." It did exactly that. `LiftSessionView.write` bailed out unless the set already had a RECORD, so every keystroke into a pending row was silently dropped; the focused draft made the field look like it had taken until focus left and it fell back to the empty canonical rendering. The comment directly above claimed the opposite — that the value was "held until the set is recorded" — which is what made it invisible for so long. The engine rule it collided with is real and does not move: typing must never append a set, or a set nobody performed becomes data. So the numbers are held in the controller and applied the instant the set is recorded — the same mechanism a warm-up marked in advance has always used. Once applied they BEAT the carried plan, since a number typed for this set outranks the sheet's guess at it, while a field left untouched still carries. The entry is consumed, so a redo shows the ghosts again exactly as before. Held values and warm-up marks now travel in the crash snapshot too: losing them to a relaunch would be the same bug with extra steps. Both are optional in the snapshot, so a session written by the previous build resumes with nothing pending rather than failing to read. 9 controller tests and 2 more persistence tests; the fix was removed and each was watched fail. Verified in the simulator against the database: 62.5 typed into set 3 while set 1 was active survived the keyboard dismissal, and set 3 saved 62.5 x 10 — the typed weight over the carried 45, with the untouched reps still carried. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 86 +++++++++- Strand/Data/LiftSessionPersistence.swift | 53 +++++- Strand/Screens/LiftSessionView.swift | 29 +++- .../LiftSessionPendingInputTests.swift | 162 ++++++++++++++++++ StrandTests/LiftSessionPersistenceTests.swift | 30 +++- 5 files changed, 341 insertions(+), 19 deletions(-) create mode 100644 StrandTests/LiftSessionPendingInputTests.swift diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 3ebc153d6b..4caf746e5a 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -43,6 +43,29 @@ final class LiftSessionController: ObservableObject { /// recorded. Owned by the controller rather than a view so it survives the sheet being minimised. @Published private(set) var pendingWarmups: Set = [] + /// Numbers typed into a set BEFORE it was performed, held exactly the way a warm-up mark is. + /// + /// Reported from a real session: "when I type something during an active set to other sets it + /// refreshes to the empty". It did — `LiftSessionView.write` could only edit a set that already + /// had a record, so every keystroke into a pending row was silently discarded, and the field only + /// LOOKED like it had taken until focus left and the draft was dropped. + /// + /// The engine invariant it ran into is real and stays: typing must never append a set, or a set + /// nobody performed becomes data. So the value is held here instead, shown back on the row, and + /// applied the instant the set is recorded — at which point it BEATS the carried plan, because a + /// number the user typed for this set is better evidence than the one the sheet guessed for it. + @Published private(set) var pendingValues: [LiftSlot: PendingSetValues] = [:] + + /// What a user typed into a set that has not happened yet. All optional: a row where only the + /// weight was typed keeps carrying its reps. + struct PendingSetValues: Equatable { + var weightKg: Double? + var reps: Int? + var rpe: Double? + + var isEmpty: Bool { weightKg == nil && reps == nil && rpe == nil } + } + /// What the store holds for each exercise LAST session, keyed by exercise name then set number — /// the middle layer of `LiftSessionEngine.carry(for:lastSession:)`. /// @@ -94,6 +117,11 @@ final class LiftSessionController: ObservableObject { engine = LiftSessionPersistence.engine(from: snapshot) programId = snapshot.programId programName = snapshot.programName + // Numbers typed for sets not yet performed, and warm-ups marked in advance, come back too: + // they are intent the user already expressed, and losing them is the bug this pair exists + // to prevent, whether it is lost to a blur or to a relaunch. + pendingValues = LiftSessionPersistence.pendingValues(from: snapshot) + pendingWarmups = LiftSessionPersistence.pendingWarmups(from: snapshot) now = Int(Date().timeIntervalSince1970) // Suppress the warning for a rest that is ALREADY inside its final seconds. Without this, // reopening a session mid-rest greets the user with three buzzes for a rest they have been @@ -127,6 +155,7 @@ final class LiftSessionController: ObservableObject { programName = nil warnedFor = nil pendingWarmups = [] + pendingValues = [:] isPresented = false ticker?.cancel() ticker = nil @@ -161,7 +190,7 @@ final class LiftSessionController: ObservableObject { let stamp = Int(Date().timeIntervalSince1970) engine?.advance(now: stamp, lastSession: carryFromLastSession()) - applyPendingWarmup() + applyPendingInput() now = stamp warnedFor = nil persist() @@ -285,12 +314,26 @@ final class LiftSessionController: ObservableObject { return pendingWarmups.contains(slot) } - /// Carry a pre-marked warm-up onto the set that was just recorded. - private func applyPendingWarmup() { - guard let engine, let last = engine.sets.last, pendingWarmups.contains(last.slot), - !last.isWarmup else { return } - self.engine?.updateSet(last.slot, weightKg: last.weightKg, reps: last.reps, - rpe: last.rpe, isWarmup: true) + /// Carry a pre-marked warm-up, and any numbers typed in advance, onto the set just recorded. + /// + /// The typed numbers OVERRIDE what `carry(for:lastSession:)` put there. The carry is the sheet's + /// best guess — this exercise earlier, last session, the program's target — and a value the user + /// typed for this very set outranks all three. A field left untouched keeps its carried value, + /// so typing only the weight does not blank the reps. + /// + /// The entry is CONSUMED. A redo (`start` on a completed slot) drops the record and should show + /// the ghosts again, exactly as it did before; leaving the entry behind would resurrect numbers + /// the user is in the middle of redoing. + private func applyPendingInput() { + guard let engine, let last = engine.sets.last else { return } + let typed = pendingValues.removeValue(forKey: last.slot) + let warmup = last.isWarmup || pendingWarmups.contains(last.slot) + guard typed != nil || warmup != last.isWarmup else { return } + self.engine?.updateSet(last.slot, + weightKg: typed?.weightKg ?? last.weightKg, + reps: typed?.reps ?? last.reps, + rpe: typed?.rpe ?? last.rpe, + isWarmup: warmup) } /// Begin a specific set — the out-of-order path, for when a machine is occupied. @@ -329,11 +372,34 @@ final class LiftSessionController: ObservableObject { return true } + /// Fill in or correct a set's numbers — **any** set, at any time. + /// + /// A set that has been performed is edited in the engine. A set that has NOT been performed + /// cannot be (that would invent it), so its numbers are held in `pendingValues` until it is. + /// From the screen the two are indistinguishable, which is the point: the user asked to be able + /// to type into whichever row they are looking at, and being mid-set somewhere else is not a + /// reason to refuse. func updateSet(_ slot: LiftSlot, weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { - engine?.updateSet(slot, weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup) + if engine?.recordedSet(for: slot) != nil { + engine?.updateSet(slot, weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup) + } else if engine?.planItem(for: slot) != nil { + let values = PendingSetValues(weightKg: weightKg, reps: reps, rpe: rpe) + // Clearing the last field clears the entry rather than leaving an empty one behind, so + // the row goes back to showing the plan's grey ghost instead of a blank it has to keep. + if values.isEmpty { pendingValues.removeValue(forKey: slot) } + else { pendingValues[slot] = values } + } persist() } + /// What a slot is currently showing: what it recorded, or what was typed into it in advance. + func enteredValues(for slot: LiftSlot) -> PendingSetValues { + if let row = engine?.recordedSet(for: slot) { + return PendingSetValues(weightKg: row.weightKg, reps: row.reps, rpe: row.rpe) + } + return pendingValues[slot] ?? PendingSetValues(weightKg: nil, reps: nil, rpe: nil) + } + func undo() { engine?.undo() persist() @@ -361,6 +427,8 @@ final class LiftSessionController: ObservableObject { LiftSessionPersistence.store( LiftSessionPersistence.snapshot(engine: engine, programId: programId, - programName: programName)) + programName: programName, + pendingValues: pendingValues, + pendingWarmups: pendingWarmups)) } } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index 824b824319..6f0dd4c80c 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -27,6 +27,27 @@ enum LiftSessionPersistence { var stage: StageBox var sets: [RecordedSet] var stageStartedAt: Int + /// Numbers typed into sets that have NOT happened yet, and slots marked a warm-up in + /// advance. Both are intent the user has already expressed, so a crash must not cost them — + /// that is the whole point of this snapshot. + /// + /// Optional because a snapshot written before these existed does not carry them: it decodes + /// as absent and the session resumes with nothing pending, which is exactly right. + var pendingValues: [PendingValue]? + var pendingWarmups: [SlotBox]? + + struct PendingValue: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + } + + struct SlotBox: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + } struct PlanItem: Codable, Equatable { var exercise: String @@ -112,7 +133,9 @@ enum LiftSessionPersistence { static func snapshot(engine: LiftSessionEngine, programId: String?, - programName: String?) -> Snapshot { + programName: String?, + pendingValues: [LiftSlot: LiftSessionController.PendingSetValues], + pendingWarmups: Set) -> Snapshot { Snapshot( startSec: engine.startTs, programId: programId, @@ -137,7 +160,33 @@ enum LiftSessionPersistence { isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, restSec: $0.restSec) }, - stageStartedAt: engine.stageStartedAt) + stageStartedAt: engine.stageStartedAt, + // Sorted so the encoded snapshot is stable: a dictionary and a set have no order, and an + // unstable encoding would rewrite the defaults blob on every tick for no reason. + pendingValues: pendingValues + .sorted { ($0.key.exerciseIndex, $0.key.setIndex) < ($1.key.exerciseIndex, $1.key.setIndex) } + .map { slot, values in + Snapshot.PendingValue(exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: values.weightKg, reps: values.reps, rpe: values.rpe) + }, + pendingWarmups: pendingWarmups + .sorted { ($0.exerciseIndex, $0.setIndex) < ($1.exerciseIndex, $1.setIndex) } + .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + } + + /// The numbers typed in advance, back as the controller holds them. + static func pendingValues(from s: Snapshot) -> [LiftSlot: LiftSessionController.PendingSetValues] { + var out: [LiftSlot: LiftSessionController.PendingSetValues] = [:] + for p in s.pendingValues ?? [] { + out[LiftSlot(exerciseIndex: p.exerciseIndex, setIndex: p.setIndex)] = + LiftSessionController.PendingSetValues(weightKg: p.weightKg, reps: p.reps, rpe: p.rpe) + } + return out + } + + /// The warm-up marks made in advance, back as the controller holds them. + static func pendingWarmups(from s: Snapshot) -> Set { + Set((s.pendingWarmups ?? []).map { LiftSlot(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) } /// Rebuild an engine from a snapshot. Unknown muscle tokens are dropped rather than failing the diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 43edc8d33c..013c9d695b 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -456,8 +456,16 @@ struct LiftSessionView: View { // MARK: - Field bindings // // Each field reads and writes THROUGH the controller, so a keystroke lands in the engine and on - // disk immediately. Typing into a set that has not been completed yet is allowed — you may want - // to plan the next one — and is held until the set is recorded. + // disk immediately. + // + // TYPING INTO ANY SET, AT ANY TIME. A set that has already been performed is edited in place; one + // that has not is held in `LiftSessionController.pendingValues` and applied the moment it is + // recorded. The two are indistinguishable from the row, which is the requirement: being mid-set + // on one machine is no reason to refuse a correction to another row you are looking at. + // + // This used to be a claim rather than a behaviour — the comment here said the value was "held + // until the set is recorded" while `write` silently dropped it — and a real session found it: + // "when I type something during an active set to other sets it refreshes to the empty". /// A text binding that does not fight the user while they type: reads the draft if there is one, /// otherwise the canonical rendering of what is stored. @@ -481,7 +489,7 @@ struct LiftSessionView: View { private func weightBinding(_ slot: LiftSlot) -> Binding { fieldBinding(.weight(slot), - formatted: { engine?.recordedSet(for: slot)?.weightKg.map { display($0) } ?? "" }, + formatted: { session.enteredValues(for: slot).weightKg.map { display($0) } ?? "" }, store: { text in let kg = LiftFormat.number(text).map { LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) @@ -492,7 +500,7 @@ struct LiftSessionView: View { private func repsBinding(_ slot: LiftSlot) -> Binding { fieldBinding(.reps(slot), - formatted: { engine?.recordedSet(for: slot)?.reps.map(String.init) ?? "" }, + formatted: { session.enteredValues(for: slot).reps.map(String.init) ?? "" }, store: { text in write(slot) { $0.reps = Int(text.trimmingCharacters(in: .whitespaces)) } }) @@ -500,13 +508,20 @@ struct LiftSessionView: View { private func rpeBinding(_ slot: LiftSlot) -> Binding { fieldBinding(.rpe(slot), - formatted: { engine?.recordedSet(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "" }, + formatted: { session.enteredValues(for: slot).rpe.map { LiftFormat.trim($0) } ?? "" }, store: { text in write(slot) { $0.rpe = LiftFormat.number(text) } }) } - /// Apply one field change to a recorded set, leaving the others as they were. + /// Apply one field change to a set, leaving its other fields as they were. + /// + /// Works whether or not the set has been performed — the controller decides where the value + /// lands. It reads the CURRENT entered values first, so editing the reps cannot blank a weight + /// that was typed a moment ago into the same pending row. private func write(_ slot: LiftSlot, _ mutate: (inout LiftRecordedSet) -> Void) { - guard var row = engine?.recordedSet(for: slot) else { return } + let entered = session.enteredValues(for: slot) + var row = LiftRecordedSet(exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: entered.weightKg, reps: entered.reps, rpe: entered.rpe, + isWarmup: session.isWarmup(slot), startTs: 0, endTs: 0, restSec: nil) mutate(&row) session.updateSet(slot, weightKg: row.weightKg, reps: row.reps, rpe: row.rpe, isWarmup: row.isWarmup) diff --git a/StrandTests/LiftSessionPendingInputTests.swift b/StrandTests/LiftSessionPendingInputTests.swift new file mode 100644 index 0000000000..ddabe1e902 --- /dev/null +++ b/StrandTests/LiftSessionPendingInputTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Typing numbers into a set you are NOT currently doing. +/// +/// Reported from a real session, 11 Sep 2026: *"you can't edit the numbers of other sets while in an +/// active set — when I type something during an active set to other sets it refreshes to the empty."* +/// It did. `LiftSessionView.write` could only edit a set that already had a RECORD, so a keystroke +/// into a pending row went nowhere; the draft made the field look like it had taken until focus left. +/// +/// The engine rule underneath is not the bug and does not move: typing must never append a set, or a +/// set nobody performed becomes data. So the numbers are held in the controller and applied the +/// instant the set is recorded — the same mechanism a warm-up marked in advance already used. +@MainActor +final class LiftSessionPendingInputTests: XCTestCase { + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// Three sets, with targets, so there is a carried plan for a typed value to beat. + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50)] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + // MARK: - The reported bug + + /// The exact report: set 1 is active, the user types into set 3, and it must still be there. + func testTypingIntoAnotherSetWhileOneIsActiveIsKept() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() // set 1 is now active + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + + c.updateSet(slot(0, 3), weightKg: 62.5, reps: 6, rpe: nil, isWarmup: false) + + XCTAssertEqual(c.enteredValues(for: slot(0, 3)).weightKg, 62.5, + "a number typed into another set must not evaporate") + XCTAssertEqual(c.enteredValues(for: slot(0, 3)).reps, 6) + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1)), + "and typing elsewhere must not disturb the set being worked") + XCTAssertEqual(c.engine?.sets.count, 0, "nor invent a set nobody has performed") + } + + /// What was typed in advance is what gets recorded — it beats the carried plan, which is only + /// the sheet's guess (this exercise earlier, last session, the program's target). + func testWhatWasTypedInAdvanceIsWhatTheSetRecords() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.setLastSession(["Bench press": [1: LiftSetCarry(weightKg: 55, reps: 9)]]) + c.advance() // set 1 active + c.updateSet(slot(0, 1), weightKg: 70, reps: 5, rpe: nil, isWarmup: false) + c.advance() // "Set done" + + let recorded = c.engine?.recordedSet(for: slot(0, 1)) + XCTAssertEqual(recorded?.weightKg, 70, "the typed weight wins over the carried 55") + XCTAssertEqual(recorded?.reps, 5) + XCTAssertTrue(c.pendingValues.isEmpty, "and the held entry is consumed once applied") + } + + /// Typing only one field must not blank the others — they keep carrying. + func testAFieldLeftAloneStillCarries() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.setLastSession(["Bench press": [1: LiftSetCarry(weightKg: 55, reps: 9)]]) + c.advance() + c.updateSet(slot(0, 1), weightKg: 70, reps: nil, rpe: nil, isWarmup: false) + c.advance() + + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 70) + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.reps, 9, + "reps nobody typed still come from the carry, not from nowhere") + } + + /// Clearing the field puts the row back to showing the plan's grey ghost, rather than pinning an + /// empty entry that would record a blank set. + func testClearingTheFieldDropsTheHeldEntry() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 2), weightKg: 80, reps: nil, rpe: nil, isWarmup: false) + XCTAssertFalse(c.pendingValues.isEmpty) + + c.updateSet(slot(0, 2), weightKg: nil, reps: nil, rpe: nil, isWarmup: false) + XCTAssertTrue(c.pendingValues.isEmpty) + XCTAssertNil(c.enteredValues(for: slot(0, 2)).weightKg) + } + + // MARK: - What must not change + + /// Editing a set that IS recorded still edits it in the engine, from any stage. + func testACompletedSetIsStillEditedInPlace() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.advance() // set 1 recorded, resting + c.advance() // set 2 active + + c.updateSet(slot(0, 1), weightKg: 47.5, reps: 12, rpe: 8, isWarmup: false) + + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 47.5) + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.rpe, 8) + XCTAssertTrue(c.pendingValues.isEmpty, + "a set with a record is edited, never shadowed by a held entry") + } + + /// The engine's rule is untouched: no amount of typing creates a set. + func testTypingNeverInventsASet() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 1), weightKg: 60, reps: 10, rpe: 9, isWarmup: false) + c.updateSet(slot(0, 3), weightKg: 60, reps: 10, rpe: 9, isWarmup: false) + XCTAssertEqual(c.engine?.sets.count, 0) + XCTAssertEqual(c.engine?.completedWorkingSets, 0) + } + + /// Typing into a slot the plan does not have is ignored rather than held forever. + func testTypingIntoASlotOutsideThePlanIsIgnored() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(9, 1), weightKg: 60, reps: 10, rpe: nil, isWarmup: false) + XCTAssertTrue(c.pendingValues.isEmpty) + } + + /// A warm-up marked in advance still lands, and now travels with the numbers. + func testAWarmUpMarkedInAdvanceStillApplies() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.setWarmup(slot(0, 1), true) + c.updateSet(slot(0, 1), weightKg: 20, reps: 15, rpe: nil, isWarmup: true) + c.advance() + + let recorded = c.engine?.recordedSet(for: slot(0, 1)) + XCTAssertEqual(recorded?.isWarmup, true) + XCTAssertEqual(recorded?.weightKg, 20) + XCTAssertEqual(c.engine?.completedWorkingSets, 0, "a warm-up is still not a working set") + } + + /// A redo drops the record and should show the ghosts again — the consumed entry must not come + /// back and re-fill the row with numbers the user is in the middle of redoing. + func testARedoDoesNotResurrectTheConsumedEntry() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.updateSet(slot(0, 1), weightKg: 70, reps: 5, rpe: nil, isWarmup: false) + c.advance() // recorded with 70 x 5 + c.start(slot(0, 1)) // redo: record dropped + + XCTAssertNil(c.engine?.recordedSet(for: slot(0, 1))) + XCTAssertNil(c.enteredValues(for: slot(0, 1)).weightKg, + "the row is back to its ghosts, as it was before this existed") + } +} diff --git a/StrandTests/LiftSessionPersistenceTests.swift b/StrandTests/LiftSessionPersistenceTests.swift index dc9e061cb1..2625df269a 100644 --- a/StrandTests/LiftSessionPersistenceTests.swift +++ b/StrandTests/LiftSessionPersistenceTests.swift @@ -69,6 +69,33 @@ final class LiftSessionPersistenceTests: XCTestCase { XCTAssertEqual(engine.plan[0].targetSets, 4) } + /// Numbers typed for a set that has not happened yet must survive a crash. Losing them to a + /// relaunch would be the same bug as losing them to a blur, with extra steps. + func testNumbersTypedForASetNotYetPerformedSurviveARelaunch() throws { + let plan = [LiftPlanItem(exercise: "Row", targetSets: 3, restSec: 60)] + let engine = LiftSessionEngine(plan: plan, startTs: t0) + let slot = LiftSlot(exerciseIndex: 0, setIndex: 3) + let typed = LiftSessionController.PendingSetValues(weightKg: 72.5, reps: 6, rpe: nil) + + let encoded = try XCTUnwrap(LiftSessionPersistence.encode( + LiftSessionPersistence.snapshot(engine: engine, programId: nil, programName: nil, + pendingValues: [slot: typed], + pendingWarmups: [LiftSlot(exerciseIndex: 0, setIndex: 1)]))) + let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) + + XCTAssertEqual(LiftSessionPersistence.pendingValues(from: back), [slot: typed]) + XCTAssertEqual(LiftSessionPersistence.pendingWarmups(from: back), + [LiftSlot(exerciseIndex: 0, setIndex: 1)]) + } + + /// A snapshot written before any of this existed carries neither, and must resume with nothing + /// pending rather than failing to read at all. + func testAnOldSnapshotResumesWithNothingPending() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + XCTAssertTrue(LiftSessionPersistence.pendingValues(from: decoded).isEmpty) + XCTAssertTrue(LiftSessionPersistence.pendingWarmups(from: decoded).isEmpty) + } + /// The round trip today's build performs on itself, including the new field. func testTodaysSnapshotRoundTripsWithTheProgramLine() throws { let plan = [LiftPlanItem(exercise: "Lat pulldown", primaryMuscle: .lats, @@ -79,7 +106,8 @@ final class LiftSessionPersistenceTests: XCTestCase { engine.addSet(toExercise: 0) let encoded = try XCTUnwrap(LiftSessionPersistence.encode( - LiftSessionPersistence.snapshot(engine: engine, programId: "p1", programName: "Pull"))) + LiftSessionPersistence.snapshot(engine: engine, programId: "p1", programName: "Pull", + pendingValues: [:], pendingWarmups: []))) let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) let rebuilt = LiftSessionPersistence.engine(from: back) From 1c45b55e6a859fb82c3a2bd6e1caa3287fe1dfd4 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:32:42 +0200 Subject: [PATCH 24/31] lift log: translate the screens that were still rendering English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by auditing before an upstream PR, not by CI — and CI could not have found it. The i18n gate is diff-scoped over the string CATALOG, so a string that never reached the catalog is invisible to it: there is nothing in the diff to fail on. Fifty-one of the feature's 205 localizable strings were in that state. The authority here is the compiler, not a grep: Xcode emits a .stringsdata per source file listing every key it extracted, and comparing that against the catalog is exact where a regex only guesses. Three kinds of gap: Twenty muscle names and three region names were absent entirely, so the muscle picker and the weekly sets-per-muscle card drew English inside an otherwise German app. Twenty-eight more screen strings were absent the same way, including every title and button on the session and program screens. Three keys existed in the catalog under a spelling the compiler never emits. Swift's interpolation produces "%@"/"%lld" KEYS and the positional form belongs in the VALUES, so "%1$@: %2$@ sets, …" and "%lld reps" never once matched a lookup. Renamed to the emitted spelling, their existing translations now apply. "%lld reps" is kept alongside "%@ reps": two call sites legitimately pass an Int and a String, and a value whose specifier disagrees with its key is worse than an untranslated one. The region headers also collided, exactly as "Rest" did before them. The bare "Push" key is the TODAY screen's readiness nudge — 推送 is a push NOTIFICATION in Chinese, "Вперёд" is "forward" in Russian — so the picker's section headers drew words from an unrelated feature. They are now "Push muscles" / "Pull muscles" / "Leg muscles" / "Trunk muscles", the same escape "Rest period" took. Verified in the built bundle (de.lproj now carries Brust, Latissimus, Beinbeuger, Drückmuskulatur) and by running the app in German: the hub reads Trainingsbuch / Sätze pro Muskel, and the picker reads Brust, Vordere Schulter, Trizeps. A full catalog sweep for specifier mismatches found only two, both pre-existing upstream Russian strings, left alone. Five numeric field placeholders stay uncatalogued: they are digits and identical in every locale. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftMuscleNames.swift | 14 +- Strand/Resources/Localizable.xcstrings | 169 ++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 9 deletions(-) diff --git a/Strand/Data/LiftMuscleNames.swift b/Strand/Data/LiftMuscleNames.swift index 78569f2f85..7b4a8abc15 100644 --- a/Strand/Data/LiftMuscleNames.swift +++ b/Strand/Data/LiftMuscleNames.swift @@ -51,12 +51,18 @@ extension LiftMuscle { extension LiftMuscle.Region { /// Localized section title for the muscle picker. Presentation-only, like the region itself. + /// + /// "Push muscles", not "Push" — and for the same reason the rest timer says "Rest period" rather + /// than "Rest". The catalog's bare `"Push"` key is the TODAY screen's readiness nudge ("push + /// yourself"), translated accordingly: 推送 in Chinese is a push NOTIFICATION, and "Вперёд" in + /// Russian is "forward". Reusing it would have rendered this picker's section headers as words + /// from an unrelated feature. A generic English word is never safe as a key here. var displayName: String { switch self { - case .push: return String(localized: "Push") - case .pull: return String(localized: "Pull") - case .legs: return String(localized: "Legs") - case .trunk: return String(localized: "Trunk") + case .push: return String(localized: "Push muscles") + case .pull: return String(localized: "Pull muscles") + case .legs: return String(localized: "Leg muscles") + case .trunk: return String(localized: "Trunk muscles") } } } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f8aa0407ea..984dd18f88 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,168 @@ { "sourceLanguage": "en", "strings": { + "%@ reps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 次"}} + } }, + "%lld reps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 次"}} + } }, + "%@ rest": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ Pause"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ de descanso"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ de repos"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ di recupero"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ przerwy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ de descanso"}}, "ru": {"stringUnit": {"state": "translated", "value": "отдых %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "休息 %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "休息 %@"}} + } }, + "%lld sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Sätze"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld подходов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 組"}} + } }, + "Anything you want to remember": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Alles, was du dir merken willst"}}, "es": {"stringUnit": {"state": "translated", "value": "Cualquier cosa que quieras recordar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tout ce que tu veux retenir"}}, "it": {"stringUnit": {"state": "translated", "value": "Qualsiasi cosa tu voglia ricordare"}}, "pl": {"stringUnit": {"state": "translated", "value": "Cokolwiek chcesz zapamiętać"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tudo o que queiras recordar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Всё, что хочешь запомнить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "任何你想记住的内容"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "任何你想記住的內容"}} + } }, + "Build a program once, then tap through it at the gym. Kept on %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Erstelle ein Programm einmal und tippe dich im Gym hindurch. Bleibt auf %@."}}, "es": {"stringUnit": {"state": "translated", "value": "Crea un programa una vez y ve tocando en el gimnasio. Se guarda en %@."}}, "fr": {"stringUnit": {"state": "translated", "value": "Crée un programme une fois, puis parcours-le à la salle. Conservé sur %@."}}, "it": {"stringUnit": {"state": "translated", "value": "Crea un programma una volta e scorrilo in palestra. Resta su %@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zbuduj program raz i przeklikaj go na siłowni. Zostaje na %@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Cria um programa uma vez e percorre-o no ginásio. Fica em %@."}}, "ru": {"stringUnit": {"state": "translated", "value": "Создай программу один раз и отмечай подходы в зале. Хранится на %@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "创建一次训练计划,然后在健身房逐组点按。保存在 %@ 上。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "建立一次訓練計畫,然後在健身房逐組點按。保存在 %@ 上。"}} + } }, + "Counted once per exercise": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zählt einmal pro Übung"}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta una vez por ejercicio"}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté une fois par exercice"}}, "it": {"stringUnit": {"state": "translated", "value": "Contato una volta per esercizio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone raz na ćwiczenie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado uma vez por exercício"}}, "ru": {"stringUnit": {"state": "translated", "value": "Учитывается один раз на упражнение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每个动作计一次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每個動作計一次"}} + } }, + "Details": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Details"}}, "es": {"stringUnit": {"state": "translated", "value": "Detalles"}}, "fr": {"stringUnit": {"state": "translated", "value": "Détails"}}, "it": {"stringUnit": {"state": "translated", "value": "Dettagli"}}, "pl": {"stringUnit": {"state": "translated", "value": "Szczegóły"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Detalhes"}}, "ru": {"stringUnit": {"state": "translated", "value": "Детали"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "详情"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "詳情"}} + } }, + "Edit exercise": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Übung bearbeiten"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar ejercicio"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier l'exercice"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica esercizio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj ćwiczenie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar exercício"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить упражнение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑动作"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯動作"}} + } }, + "Edit program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm bearbeiten"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier le programme"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить программу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯計畫"}} + } }, + "Finish session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einheit beenden"}}, "es": {"stringUnit": {"state": "translated", "value": "Terminar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Terminer la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Termina sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zakończ sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Terminar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "结束训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "結束訓練"}} + } }, + "In order": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Reihe nach"}}, "es": {"stringUnit": {"state": "translated", "value": "En orden"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans l'ordre"}}, "it": {"stringUnit": {"state": "translated", "value": "In ordine"}}, "pl": {"stringUnit": {"state": "translated", "value": "Po kolei"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Por ordem"}}, "ru": {"stringUnit": {"state": "translated", "value": "По порядку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "按顺序"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "按順序"}} + } }, + "In your words": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "In deinen Worten"}}, "es": {"stringUnit": {"state": "translated", "value": "En tus palabras"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans tes mots"}}, "it": {"stringUnit": {"state": "translated", "value": "Con parole tue"}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoimi słowami"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nas tuas palavras"}}, "ru": {"stringUnit": {"state": "translated", "value": "Своими словами"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "用你的话"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "用你的話"}} + } }, + "Incline dumbbell press": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schrägbankdrücken mit Kurzhanteln"}}, "es": {"stringUnit": {"state": "translated", "value": "Press inclinado con mancuernas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Développé incliné haltères"}}, "it": {"stringUnit": {"state": "translated", "value": "Panca inclinata con manubri"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wyciskanie hantli na skosie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Supino inclinado com halteres"}}, "ru": {"stringUnit": {"state": "translated", "value": "Жим гантелей на наклонной"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上斜哑铃卧推"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上斜啞鈴臥推"}} + } }, + "Lift Log": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trainingsbuch"}}, "es": {"stringUnit": {"state": "translated", "value": "Registro de fuerza"}}, "fr": {"stringUnit": {"state": "translated", "value": "Carnet de force"}}, "it": {"stringUnit": {"state": "translated", "value": "Diario dei pesi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dziennik siłowni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Registo de força"}}, "ru": {"stringUnit": {"state": "translated", "value": "Дневник тренировок"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "力量日志"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "力量日誌"}} + } }, + "No targets set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Keine Ziele gesetzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Sin objetivos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun objectif défini"}}, "it": {"stringUnit": {"state": "translated", "value": "Nessun obiettivo impostato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Brak celów"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sem objetivos definidos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Цели не заданы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未设定目标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未設定目標"}} + } }, + "One number for the whole session, so a leg day can be compared with a run.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Eine Zahl für die ganze Einheit, damit sich ein Beintag mit einem Lauf vergleichen lässt."}}, "es": {"stringUnit": {"state": "translated", "value": "Un número para toda la sesión, para poder comparar un día de piernas con una carrera."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un seul chiffre pour toute la séance, pour comparer un jour de jambes à une course."}}, "it": {"stringUnit": {"state": "translated", "value": "Un solo numero per tutta la sessione, così un giorno gambe si confronta con una corsa."}}, "pl": {"stringUnit": {"state": "translated", "value": "Jedna liczba dla całej sesji, żeby dzień nóg dało się porównać z biegiem."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um número para toda a sessão, para comparar um dia de pernas com uma corrida."}}, "ru": {"stringUnit": {"state": "translated", "value": "Одно число на всю тренировку, чтобы день ног можно было сравнить с пробежкой."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "整场训练一个数字,这样练腿日就能和跑步比较。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "整場訓練一個數字,這樣練腿日就能和跑步比較。"}} + } }, + "Reading your programs…": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programme werden gelesen …"}}, "es": {"stringUnit": {"state": "translated", "value": "Leyendo tus programas…"}}, "fr": {"stringUnit": {"state": "translated", "value": "Lecture de tes programmes…"}}, "it": {"stringUnit": {"state": "translated", "value": "Lettura dei tuoi programmi…"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wczytywanie programów…"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A ler os teus programas…"}}, "ru": {"stringUnit": {"state": "translated", "value": "Читаем твои программы…"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正在读取你的计划…"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正在讀取你的計畫…"}} + } }, + "Rest (seconds)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Pause (Sekunden)"}}, "es": {"stringUnit": {"state": "translated", "value": "Descanso (segundos)"}}, "fr": {"stringUnit": {"state": "translated", "value": "Repos (secondes)"}}, "it": {"stringUnit": {"state": "translated", "value": "Recupero (secondi)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przerwa (sekundy)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descanso (segundos)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отдых (секунды)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "休息(秒)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "休息(秒)"}} + } }, + "Set done": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz fertig"}}, "es": {"stringUnit": {"state": "translated", "value": "Serie hecha"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série terminée"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie completata"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria gotowa"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série concluída"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подход выполнен"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "完成这一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "完成這一組"}} + } }, + "Slow eccentric, pause at the bottom": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Langsam ablassen, unten kurz halten"}}, "es": {"stringUnit": {"state": "translated", "value": "Excéntrica lenta, pausa abajo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Excentrique lente, pause en bas"}}, "it": {"stringUnit": {"state": "translated", "value": "Eccentrica lenta, pausa in basso"}}, "pl": {"stringUnit": {"state": "translated", "value": "Powolna faza ekscentryczna, pauza na dole"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Excêntrica lenta, pausa em baixo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Медленная негативная фаза, пауза внизу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "慢速离心,底部停顿"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "慢速離心,底部停頓"}} + } }, + "Start first set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ersten Satz starten"}}, "es": {"stringUnit": {"state": "translated", "value": "Empezar la primera serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrer la première série"}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia la prima serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozpocznij pierwszą serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Iniciar a primeira série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Начать первый подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始第一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始第一組"}} + } }, + "Start next set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nächsten Satz starten"}}, "es": {"stringUnit": {"state": "translated", "value": "Empezar la siguiente serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrer la série suivante"}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia la serie successiva"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozpocznij następną serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Iniciar a série seguinte"}}, "ru": {"stringUnit": {"state": "translated", "value": "Начать следующий подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始下一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始下一組"}} + } }, + "Type any name you like. NOOP remembers it, with the muscles you give it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gib einen beliebigen Namen ein. NOOP merkt ihn sich, mit den Muskeln, die du zuordnest."}}, "es": {"stringUnit": {"state": "translated", "value": "Escribe el nombre que quieras. NOOP lo recuerda, con los músculos que le asignes."}}, "fr": {"stringUnit": {"state": "translated", "value": "Saisis le nom que tu veux. NOOP le retient, avec les muscles que tu lui attribues."}}, "it": {"stringUnit": {"state": "translated", "value": "Scrivi il nome che preferisci. NOOP lo ricorda, con i muscoli che gli assegni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wpisz dowolną nazwę. NOOP ją zapamięta wraz z mięśniami, które przypiszesz."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escreve o nome que quiseres. O NOOP guarda-o, com os músculos que lhe deres."}}, "ru": {"stringUnit": {"state": "translated", "value": "Введи любое название. NOOP запомнит его вместе с мышцами, которые ты укажешь."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "输入任意名称。NOOP 会连同你指定的肌群一起记住。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "輸入任意名稱。NOOP 會連同你指定的肌群一起記住。"}} + } }, + "Upper A": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oberkörper A"}}, "es": {"stringUnit": {"state": "translated", "value": "Tren superior A"}}, "fr": {"stringUnit": {"state": "translated", "value": "Haut du corps A"}}, "it": {"stringUnit": {"state": "translated", "value": "Parte alta A"}}, "pl": {"stringUnit": {"state": "translated", "value": "Góra A"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tronco superior A"}}, "ru": {"stringUnit": {"state": "translated", "value": "Верх A"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上肢 A"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上肢 A"}} + } }, + "Warm-up": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aufwärmen"}}, "es": {"stringUnit": {"state": "translated", "value": "Calentamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Échauffement"}}, "it": {"stringUnit": {"state": "translated", "value": "Riscaldamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozgrzewka"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Aquecimento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Разминка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "热身"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "熱身"}} + } }, + "What you're aiming for": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Was du anpeilst"}}, "es": {"stringUnit": {"state": "translated", "value": "Lo que buscas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce que tu vises"}}, "it": {"stringUnit": {"state": "translated", "value": "Il tuo obiettivo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Do czego dążysz"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O que procuras"}}, "ru": {"stringUnit": {"state": "translated", "value": "К чему стремишься"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你的目标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你的目標"}} + } }, + "Working sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} + } }, + "Your targets for each exercise. What you actually lift is recorded when you run it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Deine Ziele für jede Übung. Was du tatsächlich hebst, wird beim Durchführen aufgezeichnet."}}, "es": {"stringUnit": {"state": "translated", "value": "Tus objetivos para cada ejercicio. Lo que realmente levantas se registra al ejecutarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Tes objectifs pour chaque exercice. Ce que tu soulèves vraiment est enregistré pendant la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "I tuoi obiettivi per ogni esercizio. Ciò che sollevi davvero viene registrato durante la sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoje cele dla każdego ćwiczenia. To, co faktycznie podnosisz, zapisuje się podczas treningu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os teus objetivos para cada exercício. O que levantas mesmo é registado quando o executas."}}, "ru": {"stringUnit": {"state": "translated", "value": "Твои цели для каждого упражнения. Что ты поднял на самом деле, записывается во время тренировки."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每个动作的目标。你实际举起的重量会在训练时记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每個動作的目標。你實際舉起的重量會在訓練時記錄。"}} + } }, + "measured from heart rate": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "aus der Herzfrequenz gemessen"}}, "es": {"stringUnit": {"state": "translated", "value": "medido a partir de la frecuencia cardíaca"}}, "fr": {"stringUnit": {"state": "translated", "value": "mesuré à partir de la fréquence cardiaque"}}, "it": {"stringUnit": {"state": "translated", "value": "misurato dalla frequenza cardiaca"}}, "pl": {"stringUnit": {"state": "translated", "value": "mierzone z tętna"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "medido a partir da frequência cardíaca"}}, "ru": {"stringUnit": {"state": "translated", "value": "измерено по пульсу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "由心率测得"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "由心率測得"}} + } }, + "Chest": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Brust"}}, "es": {"stringUnit": {"state": "translated", "value": "Pecho"}}, "fr": {"stringUnit": {"state": "translated", "value": "Pectoraux"}}, "it": {"stringUnit": {"state": "translated", "value": "Petto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Klatka piersiowa"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Peito"}}, "ru": {"stringUnit": {"state": "translated", "value": "Грудь"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "胸部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "胸部"}} + } }, + "Front delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Vordere Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides anterior"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes antérieurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi anteriori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki przednie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides anteriores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Передние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "前束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "前束三角肌"}} + } }, + "Side delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Seitliche Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides lateral"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes latéraux"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi laterali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki boczne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides laterais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Средние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "中束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "中束三角肌"}} + } }, + "Rear delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Hintere Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides posterior"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes postérieurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi posteriori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki tylne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides posteriores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Задние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "后束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "後束三角肌"}} + } }, + "Triceps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Tríceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Triceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Tricipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Triceps"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tríceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Трицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "肱三头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "肱三頭肌"}} + } }, + "Lats": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Latissimus"}}, "es": {"stringUnit": {"state": "translated", "value": "Dorsales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dorsaux"}}, "it": {"stringUnit": {"state": "translated", "value": "Dorsali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Najszersze grzbietu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Dorsais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Широчайшие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "背阔肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "背闊肌"}} + } }, + "Upper back": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oberer Rücken"}}, "es": {"stringUnit": {"state": "translated", "value": "Espalda alta"}}, "fr": {"stringUnit": {"state": "translated", "value": "Haut du dos"}}, "it": {"stringUnit": {"state": "translated", "value": "Dorso alto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Górna część pleców"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Costas superiores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Верх спины"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上背"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上背"}} + } }, + "Traps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trapez"}}, "es": {"stringUnit": {"state": "translated", "value": "Trapecios"}}, "fr": {"stringUnit": {"state": "translated", "value": "Trapèzes"}}, "it": {"stringUnit": {"state": "translated", "value": "Trapezi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Czworoboczne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Trapézios"}}, "ru": {"stringUnit": {"state": "translated", "value": "Трапеции"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "斜方肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "斜方肌"}} + } }, + "Biceps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Bíceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Biceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Bicipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Biceps"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bíceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "肱二头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "肱二頭肌"}} + } }, + "Forearms": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unterarme"}}, "es": {"stringUnit": {"state": "translated", "value": "Antebrazos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Avant-bras"}}, "it": {"stringUnit": {"state": "translated", "value": "Avambracci"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przedramiona"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Antebraços"}}, "ru": {"stringUnit": {"state": "translated", "value": "Предплечья"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "前臂"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "前臂"}} + } }, + "Quads": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Quadrizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuádriceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Quadriceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Quadricipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Czworogłowe uda"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Quadríceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Квадрицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "股四头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "股四頭肌"}} + } }, + "Hamstrings": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Beinbeuger"}}, "es": {"stringUnit": {"state": "translated", "value": "Isquiotibiales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ischio-jambiers"}}, "it": {"stringUnit": {"state": "translated", "value": "Femorali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dwugłowe uda"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Isquiotibiais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бицепс бедра"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腘绳肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "膕繩肌"}} + } }, + "Glutes": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gesäß"}}, "es": {"stringUnit": {"state": "translated", "value": "Glúteos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fessiers"}}, "it": {"stringUnit": {"state": "translated", "value": "Glutei"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pośladki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Glúteos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ягодицы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "臀部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "臀部"}} + } }, + "Adductors": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Adduktoren"}}, "es": {"stringUnit": {"state": "translated", "value": "Aductores"}}, "fr": {"stringUnit": {"state": "translated", "value": "Adducteurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Adduttori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przywodziciele"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adutores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Приводящие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "内收肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "內收肌"}} + } }, + "Abductors": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abduktoren"}}, "es": {"stringUnit": {"state": "translated", "value": "Abductores"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abducteurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Abduttori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odwodziciele"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abdutores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отводящие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "外展肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "外展肌"}} + } }, + "Calves": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Waden"}}, "es": {"stringUnit": {"state": "translated", "value": "Gemelos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mollets"}}, "it": {"stringUnit": {"state": "translated", "value": "Polpacci"}}, "pl": {"stringUnit": {"state": "translated", "value": "Łydki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Gémeos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Икры"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "小腿"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "小腿"}} + } }, + "Abs": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bauch"}}, "es": {"stringUnit": {"state": "translated", "value": "Abdominales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abdominaux"}}, "it": {"stringUnit": {"state": "translated", "value": "Addominali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Brzuch"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abdominais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пресс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腹肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腹肌"}} + } }, + "Obliques": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schräge Bauchmuskeln"}}, "es": {"stringUnit": {"state": "translated", "value": "Oblicuos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Obliques"}}, "it": {"stringUnit": {"state": "translated", "value": "Obliqui"}}, "pl": {"stringUnit": {"state": "translated", "value": "Skośne brzucha"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Oblíquos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Косые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腹斜肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腹斜肌"}} + } }, + "Lower back": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unterer Rücken"}}, "es": {"stringUnit": {"state": "translated", "value": "Espalda baja"}}, "fr": {"stringUnit": {"state": "translated", "value": "Bas du dos"}}, "it": {"stringUnit": {"state": "translated", "value": "Lombari"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dolna część pleców"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Lombar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Поясница"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "下背"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "下背"}} + } }, + "Neck": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nacken"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuello"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cou"}}, "it": {"stringUnit": {"state": "translated", "value": "Collo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kark"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Pescoço"}}, "ru": {"stringUnit": {"state": "translated", "value": "Шея"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "颈部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "頸部"}} + } }, + "Push muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Drückmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de empuje"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles de poussée"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli di spinta"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie pchające"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos de empurrar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Жимовые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "推类肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "推類肌群"}} + } }, + "Pull muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zugmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de tracción"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles de tirage"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli di tirata"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie ciągnące"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos de puxar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Тяговые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "拉类肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "拉類肌群"}} + } }, + "Leg muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Beinmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de pierna"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles des jambes"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli delle gambe"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie nóg"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos das pernas"}}, "ru": {"stringUnit": {"state": "translated", "value": "Мышцы ног"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腿部肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腿部肌群"}} + } }, + "Trunk muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Rumpfmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos del tronco"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles du tronc"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli del tronco"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie tułowia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos do tronco"}}, "ru": {"stringUnit": {"state": "translated", "value": "Мышцы корпуса"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "躯干肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "軀幹肌群"}} + } }, "Add set": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Satz hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add set"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增一組"}} } }, @@ -13,10 +175,10 @@ "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Der Strich markiert etwa 4 Sätze pro Woche – den Punkt, unter dem die Forschung Wachstum nicht zuverlässig nachweist. Darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze; der Balken hat deshalb kein „voll“."}}, "en": {"stringUnit": {"state": "translated", "value": "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\"."}}, "es": {"stringUnit": {"state": "translated", "value": "La marca señala unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias continúan con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le repère marque environ 4 séries par semaine — le seuil en dessous duquel la recherche ne détecte pas de croissance de façon fiable. Au-dessus, les gains continuent avec de forts rendements décroissants et sans plafond net : la barre n'a donc pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "La tacca segna circa 4 serie a settimana: il punto sotto il quale la ricerca non rileva la crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Znacznik wskazuje około 4 serie tygodniowo — punkt, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej postępy trwają, z silnie malejącymi zyskami i bez wyraźnego pułapu, więc pasek nie ma stanu „pełny”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A marca assinala cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Отметка показывает примерно 4 подхода в неделю — точку, ниже которой исследования не фиксируют рост надёжно. Выше рост продолжается с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "刻度标示每周约 4 组 —— 低于此点,研究无法可靠地检测到增长。高于此点,收益仍在继续,但回报急剧递减且没有明确上限,因此这条进度条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "刻度標示每週約 4 組 —— 低於此點,研究無法可靠地偵測到增長。高於此點,收益仍在持續,但回報急劇遞減且沒有明確上限,因此這條進度條沒有「滿」。"}} } }, - "%1$@: %2$@ sets, at or above the weekly floor of %3$@": { "localizations": { + "%@: %@ sets, at or above the weekly floor of %@": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, auf oder über dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, at or above the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, en o por encima del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, au niveau ou au-dessus du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, pari o superiori al minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, na poziomie tygodniowego minimum %3$@ lub powyżej"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, no mínimo semanal de %3$@ ou acima"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, на недельном минимуме %3$@ или выше"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,达到或超过每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,達到或超過每週下限 %3$@"}} } }, - "%1$@: %2$@ sets, below the weekly floor of %3$@": { "localizations": { + "%@: %@ sets, below the weekly floor of %@": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, unter dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, below the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, por debajo del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, en dessous du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, sotto il minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, poniżej tygodniowego minimum %3$@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, abaixo do mínimo semanal de %3$@"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, ниже недельного минимума %3$@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,低于每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,低於每週下限 %3$@"}} } }, "Discard session": { "localizations": { @@ -343,9 +505,6 @@ "Set %lld of %lld": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Satz %1$lld von %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld of %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %1$lld de %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %1$lld sur %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %1$lld di %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %1$lld z %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %1$lld de %2$lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %1$lld из %2$lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %1$lld 组,共 %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %1$lld 組,共 %2$lld 組" } } } }, - "%lld reps": { "localizations": { - "de": { "stringUnit": { "state": "translated", "value": "%lld Wdh." } }, "en": { "stringUnit": { "state": "translated", "value": "%lld reps" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld reps" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld répétitions" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld ripetizioni" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld powtórzeń" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld repetições" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld повторений" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 次" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 次" } } - } }, "session RPE %@": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Sitzungs-RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "session RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE de sesión %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE de séance %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE della sessione %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE sesji %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE da sessão %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE сессии %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场 RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場 RPE %@" } } } }, From 3e2d93079d3a62bc095186a031a37b344c747222 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:40:29 +0200 Subject: [PATCH 25/31] lift log: widen the double-tap de-duplication, and stop the estimates reading as measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, two of them from outside. THE DEFECT. `lastDispatchedDoubleTapEventTs` was a single slot, so it suppressed a replay only when the replayed event was the most recent one dispatched. Interleave two taps and each replay looks new again — A, B, replay A (!= B, fires), replay B (!= A, fires). Measured before fixing: two taps replayed together fired FOUR times, and three taps across a re-walked offload fired TWELVE. That is the failure this de-duplication exists to prevent, surviving inside it, and it fits the one ambiguous advance observed in the first hardware session. Now a set, bounded twice: entries outside `liveGestureWindowSeconds` of the incoming event are dropped (past that the freshness guard refuses the replay anyway), plus a hard cap for a strap whose clock jumps. Failing open on a missing timestamp is unchanged. Three tests, including a minted second gesture — the fixture carries one timestamp and the interleaved case needs two reaching ONE router, so the frame is rebuilt with a recomputed CRC32 and a test asserts the mint actually parses. ESTIMATES VS MEASUREMENTS. From community review, and correct: fractional set counts and the 4-sets-a-week tick are modelled, but sat beside "Effort — measured from heart rate" with nothing marking the difference. "7.5 triceps sets" reads as something that came off the body. Both cards now say `· estimated`, and both explainers say the count comes from the muscles the USER assigned, with indirect work at half credit. The tick is named as a research reference over GROUPS of people and explicitly not a target for the reader. The sharpest one: work-vs-rest captioned its figure "under load", when the number is set start to set end — not time under tension. Now "in sets", which is what it measures. No metric is removed and no arithmetic changes. The criticism was that rough models were dressed as precision; the fix for that is to say what each number is, not to delete numbers that are useful when labelled honestly. DESIGN TOKENS. Also from review, and fair — the checklist item should not have been ticked without measuring. Twenty-two raw spacing literals that exactly equal an existing `NoopMetrics` value now use it, so the rendered layout is unchanged and four gym sessions of testing still stand. Colours and text styles were already 100% tokens; the remaining raw values are micro-spacings with no token, at roughly the same density as this codebase's own screens. Co-Authored-By: Claude Opus 5 --- Strand/BLE/FrameRouter.swift | 36 ++++++++- Strand/Resources/Localizable.xcstrings | 15 ++++ Strand/Screens/LiftLogView.swift | 16 ++-- Strand/Screens/LiftProgramEditorSheet.swift | 4 +- Strand/Screens/LiftProgramItemSheet.swift | 6 +- Strand/Screens/LiftSessionBar.swift | 2 +- Strand/Screens/LiftSessionDetailSheet.swift | 18 ++--- Strand/Screens/LiftSessionView.swift | 8 +- .../FrameRouterDoubleTapDedupTests.swift | 74 +++++++++++++++++++ 9 files changed, 149 insertions(+), 30 deletions(-) diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 6054f6beb4..25569f109b 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -725,12 +725,42 @@ public final class FrameRouter { /// The event's OWN timestamp is what distinguishes the two cases: one gesture replayed carries /// one timestamp, while two genuine taps carry two. Nil fails OPEN (dispatch), because a real /// gesture must never be swallowed by a missing field. - private var lastDispatchedDoubleTapEventTs: Int? + /// + /// A SET, not a single slot. Remembering only the last dispatched timestamp suppresses a replay + /// solely when the replayed event is the most recent one dispatched, and a real offload does not + /// oblige. Interleave two taps and each replay looks new again: + /// + /// tap A live -> last = A + /// tap B live -> last = B + /// replay A -> A != B, dispatches + /// replay B -> B != A, dispatches + /// + /// Two phantom advances, and a multi-minute offload that re-walks its banked log repeats the + /// whole pattern — three taps measured as twelve in `FrameRouterDoubleTapDedupTests`. That is the + /// failure this de-duplication exists to prevent, surviving inside it. + /// + /// BOUNDED two ways, because this lives on the BLE path for the lifetime of a connection: entries + /// outside `liveGestureWindowSeconds` of the incoming event are dropped (past that, the freshness + /// guard in `dispatchLiveGestureIfFresh` refuses the replay anyway, so remembering it buys + /// nothing), and a hard cap covers a strap whose clock jumps rather than advances. + private var dispatchedDoubleTapEventTs: [Int] = [] + + /// Far above any plausible number of double-taps inside a 45-second window; a backstop against a + /// clock that jumps, not a working limit. + private static let dispatchedDoubleTapMemory = 32 private func dispatchDoubleTapOnce(eventTimestamp ts: Int?) { if let ts { - guard ts != lastDispatchedDoubleTapEventTs else { return } - lastDispatchedDoubleTapEventTs = ts + guard !dispatchedDoubleTapEventTs.contains(ts) else { return } + // Prune BEFORE appending, so the event just accepted is always the one kept. + dispatchedDoubleTapEventTs.removeAll { + abs(ts - $0) > FrameRouter.liveGestureWindowSeconds + } + dispatchedDoubleTapEventTs.append(ts) + if dispatchedDoubleTapEventTs.count > FrameRouter.dispatchedDoubleTapMemory { + dispatchedDoubleTapEventTs.removeFirst( + dispatchedDoubleTapEventTs.count - FrameRouter.dispatchedDoubleTapMemory) + } } state.onDoubleTap?() } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 984dd18f88..8dad0b8ddd 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,21 @@ { "sourceLanguage": "en", "strings": { + "%@ in sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ in Sätzen"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ en series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ en séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ nelle serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ w seriach"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ em séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ в подходах"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 在组内"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 在組內"}} + } }, + "Last 7 days · estimated": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Letzte 7 Tage · geschätzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Últimos 7 días · estimado"}}, "fr": {"stringUnit": {"state": "translated", "value": "7 derniers jours · estimé"}}, "it": {"stringUnit": {"state": "translated", "value": "Ultimi 7 giorni · stimato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ostatnie 7 dni · szacunkowo"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Últimos 7 dias · estimado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Последние 7 дней · оценка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最近 7 天 · 估算"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最近 7 天 · 估算"}} + } }, + "This session · estimated": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Einheit · geschätzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Esta sesión · estimado"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette séance · estimé"}}, "it": {"stringUnit": {"state": "translated", "value": "Questa sessione · stimato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ta sesja · szacunkowo"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esta sessão · estimado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Эта тренировка · оценка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "本次训练 · 估算"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "本次訓練 · 估算"}} + } }, + "Counted from the muscles you assigned each exercise: direct sets count once, indirect ones count as a half — the method the reference figures were derived under. An estimate from your own labels, not something measured off your body.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gezählt nach den Muskeln, die du jeder Übung zugeordnet hast: direkte Sätze zählen einfach, indirekte als halber Satz — die Methode, unter der die Referenzwerte ermittelt wurden. Eine Schätzung aus deinen eigenen Angaben, nichts an deinem Körper Gemessenes."}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta según los músculos que asignaste a cada ejercicio: las series directas cuentan una vez y las indirectas como media — el método con el que se obtuvieron las cifras de referencia. Es una estimación a partir de tus propias etiquetas, no algo medido en tu cuerpo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté d'après les muscles que tu as attribués à chaque exercice : les séries directes comptent une fois, les indirectes pour moitié — la méthode qui a servi à établir les valeurs de référence. Une estimation issue de tes propres libellés, pas une mesure prise sur ton corps."}}, "it": {"stringUnit": {"state": "translated", "value": "Conteggiato dai muscoli che hai assegnato a ogni esercizio: le serie dirette contano una volta, quelle indirette metà — il metodo con cui sono stati ricavati i valori di riferimento. Una stima dalle tue etichette, non qualcosa misurato sul tuo corpo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone na podstawie mięśni przypisanych do każdego ćwiczenia: serie bezpośrednie liczą się raz, pośrednie za pół — metoda, na której oparto wartości odniesienia. To szacunek z twoich własnych oznaczeń, a nie pomiar na twoim ciele."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado a partir dos músculos que atribuíste a cada exercício: as séries diretas contam uma vez e as indiretas metade — o método com que os valores de referência foram obtidos. É uma estimativa a partir das tuas etiquetas, não algo medido no teu corpo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Считается по мышцам, которые ты назначил каждому упражнению: прямые подходы идут за один, косвенные за половину — метод, по которому получены эталонные значения. Это оценка по твоим же меткам, а не измерение тела."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你为每个动作指定的肌群统计:直接组算一组,间接组算半组——参考数值正是用这个方法得出的。这是基于你自己标注的估算,而非在你身上测得的数据。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你為每個動作指定的肌群統計:直接組算一組,間接組算半組——參考數值正是用這個方法得出的。這是基於你自己標註的估算,而非在你身上測得的數據。"}} + } }, + "Counted from the muscles you assigned each exercise: direct sets count once, indirect ones half. The tick is about 4 sets a week — below that, studies across GROUPS of people stop reliably detecting growth. It is a research reference, not a target for you, and above it gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gezählt nach den Muskeln, die du jeder Übung zugeordnet hast: direkte Sätze zählen einfach, indirekte halb. Der Strich markiert etwa 4 Sätze pro Woche — darunter weisen Studien über GRUPPEN von Menschen Wachstum nicht mehr zuverlässig nach. Das ist ein Forschungsbezug, kein Ziel für dich; darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze, der Balken hat deshalb kein „voll“."}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta según los músculos que asignaste a cada ejercicio: las series directas cuentan una vez y las indirectas media. La marca señala unas 4 series por semana: por debajo, los estudios sobre GRUPOS de personas dejan de detectar crecimiento de forma fiable. Es una referencia de investigación, no un objetivo para ti; por encima se sigue ganando, con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté d'après les muscles que tu as attribués à chaque exercice : les séries directes comptent une fois, les indirectes pour moitié. Le repère marque environ 4 séries par semaine : en dessous, les études portant sur des GROUPES de personnes ne détectent plus la croissance de façon fiable. C'est une référence issue de la recherche, pas un objectif pour toi ; au-dessus, les gains continuent, avec des rendements très décroissants et sans plafond net, donc la barre n'a pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "Conteggiato dai muscoli che hai assegnato a ogni esercizio: le serie dirette contano una volta, quelle indirette metà. La tacca segna circa 4 serie a settimana: sotto quella soglia gli studi su GRUPPI di persone non rilevano più la crescita in modo affidabile. È un riferimento della ricerca, non un obiettivo per te; sopra i guadagni continuano, con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone na podstawie mięśni przypisanych do każdego ćwiczenia: serie bezpośrednie liczą się raz, pośrednie za pół. Znacznik to około 4 serie tygodniowo — poniżej badania na GRUPACH ludzi przestają wiarygodnie wykrywać wzrost. To odniesienie z badań, a nie cel dla ciebie; powyżej przyrosty trwają, przy mocno malejących zyskach i bez wyraźnego sufitu, więc pasek nie ma „pełna”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado a partir dos músculos que atribuíste a cada exercício: as séries diretas contam uma vez e as indiretas metade. A marca assinala cerca de 4 séries por semana — abaixo disso, os estudos sobre GRUPOS de pessoas deixam de detetar crescimento de forma fiável. É uma referência da investigação, não um objetivo para ti; acima, os ganhos continuam, com rendimentos muito decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Считается по мышцам, которые ты назначил каждому упражнению: прямые подходы идут за один, косвенные за половину. Отметка — примерно 4 подхода в неделю: ниже неё исследования на ГРУППАХ людей перестают надёжно фиксировать рост. Это ориентир из исследований, а не цель для тебя; выше прогресс продолжается, с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你为每个动作指定的肌群统计:直接组算一组,间接组算半组。刻度约为每周 4 组——低于此线,针对人群的研究就无法可靠地检测到增长。这是研究参考,不是给你设定的目标;高于它仍会继续增长,但收益显著递减且没有明确上限,所以这个条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你為每個動作指定的肌群統計:直接組算一組,間接組算半組。刻度約為每週 4 組——低於此線,針對人群的研究就無法可靠地偵測到增長。這是研究參考,不是給你設定的目標;高於它仍會繼續增長,但收益顯著遞減且沒有明確上限,所以這個條沒有「滿」。"}} + } }, "%@ reps": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "%@ Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 次"}} } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index a930ed48fb..39b8283dd8 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -69,8 +69,8 @@ struct LiftLogView: View { private var headerCard: some View { NoopCard(tint: StrandPalette.effortColor) { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + HStack(spacing: NoopMetrics.rowSpacing) { Image(systemName: "dumbbell.fill") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(StrandPalette.effortColor) @@ -112,7 +112,7 @@ struct LiftLogView: View { } } - HStack(spacing: 10) { + HStack(spacing: NoopMetrics.rowSpacing) { Button { editing = ProgramEditTarget(id: "new", program: nil) } label: { @@ -148,7 +148,7 @@ struct LiftLogView: View { private func programRow(_ program: LiftProgramRow) -> some View { NoopCard { - HStack(spacing: 12) { + HStack(spacing: NoopMetrics.gap) { Button { editing = ProgramEditTarget(id: program.id, program: program) } label: { @@ -221,7 +221,7 @@ struct LiftLogView: View { private var weekSection: some View { let ordered = LiftMuscle.ordered.filter { (weekCounts[$0] ?? 0) > 0 } return VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Sets per muscle", overline: "Last 7 days") + SectionHeader("Sets per muscle", overline: "Last 7 days · estimated") if ordered.isEmpty { NoopCard { Text("Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.") @@ -231,13 +231,13 @@ struct LiftLogView: View { } } else { NoopCard { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { ForEach(ordered, id: \.self) { muscle in muscleBar(muscle, sets: weekCounts[muscle] ?? 0) } // The band is named and sourced, never phrased as a target NOOP sets for // anyone: this is not a medical device and does not prescribe. - Text("The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".") + Text("Counted from the muscles you assigned each exercise: direct sets count once, indirect ones half. The tick is about 4 sets a week — below that, studies across GROUPS of people stop reliably detecting growth. It is a research reference, not a target for you, and above it gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -335,7 +335,7 @@ struct LiftLogView: View { private func historyRow(_ session: LiftSessionRow) -> some View { NoopCard { - HStack(spacing: 12) { + HStack(spacing: NoopMetrics.gap) { VStack(alignment: .leading, spacing: 3) { Text(session.programName ?? String(localized: "Session")) .font(StrandFont.headline) diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index ab521acf92..790cc0415d 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -135,7 +135,7 @@ struct LiftProgramEditorSheet: View { private func itemRow(_ item: LiftProgramItemRow, index: Int) -> some View { NoopCard { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: NoopMetrics.gap) { Button { editingItem = ItemEditTarget(id: item.id, item: item) } label: { @@ -158,7 +158,7 @@ struct LiftProgramEditorSheet: View { } .buttonStyle(.plain) - VStack(spacing: 10) { + VStack(spacing: NoopMetrics.rowSpacing) { Button { move(from: index, by: -1) } label: { diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index 7be1536cec..bc5959ef6b 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -125,7 +125,7 @@ struct LiftProgramItemSheet: View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Exercise", overline: "Movement") NoopCard { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { TextField("Incline dumbbell press", text: $exercise) .textFieldStyle(.plain) .font(StrandFont.body) @@ -264,7 +264,7 @@ struct LiftProgramItemSheet: View { SectionHeader("Targets", overline: "What you're aiming for") NoopCard { VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 12) { + HStack(spacing: NoopMetrics.gap) { field("Working sets") { numberInput("4", text: $setsText, field: .sets) } @@ -272,7 +272,7 @@ struct LiftProgramItemSheet: View { numberInput("8", text: $repsText, field: .reps) } } - HStack(spacing: 12) { + HStack(spacing: NoopMetrics.gap) { field(weightLabel) { numberInput("60", text: $weightText, field: .weight) } diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index 3b9bbdf8cf..9299882f3c 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -28,7 +28,7 @@ struct LiftSessionBar: View { Button { session.isPresented = true } label: { - HStack(spacing: 12) { + HStack(spacing: NoopMetrics.gap) { Image(systemName: "dumbbell.fill") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint(engine)) diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index 80f623f609..118711c6ba 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -119,8 +119,8 @@ struct LiftSessionDetailSheet: View { private var figuresSection: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("This session", overline: "Figures") - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 10)], - alignment: .leading, spacing: 10) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: NoopMetrics.rowSpacing)], + alignment: .leading, spacing: NoopMetrics.rowSpacing) { tile(String(localized: "Volume"), LiftFormat.weight(LiftMetrics.volumeLoadKg(sets), system: unitSystem), String(localized: "\(workingSetCount) working sets")) @@ -131,7 +131,7 @@ struct LiftSessionDetailSheet: View { tile(String(localized: "Work vs rest"), workRestText, - String(localized: "\(LiftFormat.duration(workRest.workSec)) under load")) + String(localized: "\(LiftFormat.duration(workRest.workSec)) in sets")) tile(String(localized: "Effort"), workout?.strain.map { LiftFormat.trim($0) } ?? "—", @@ -200,7 +200,7 @@ struct LiftSessionDetailSheet: View { private func exerciseCard(_ summary: LiftMetrics.ExerciseSummary) -> some View { let rows = sets.filter { $0.exercise == summary.exercise }.sorted { $0.ord < $1.ord } return NoopCard { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { Text(summary.exercise) .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) @@ -215,7 +215,7 @@ struct LiftSessionDetailSheet: View { Divider().background(StrandPalette.textTertiary.opacity(0.2)) - HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.gap) { VStack(alignment: .leading, spacing: 2) { Text("Best set").strandOverline() Text(bestSetText(summary)) @@ -247,7 +247,7 @@ struct LiftSessionDetailSheet: View { } private func setLine(_ row: LiftSetRow) -> some View { - HStack(spacing: 10) { + HStack(spacing: NoopMetrics.rowSpacing) { Text(row.isWarmup ? String(localized: "W") : "\(row.setIndex)") .font(StrandFont.captionNumber) .foregroundStyle(row.isWarmup ? StrandPalette.textTertiary : StrandPalette.effortColor) @@ -312,7 +312,7 @@ struct LiftSessionDetailSheet: View { let counts = LiftMetrics.muscleCounts(sets) let ordered = LiftMuscle.ordered.filter { (counts.fractional[$0] ?? 0) > 0 } return VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Sets per muscle", overline: "This session") + SectionHeader("Sets per muscle", overline: "This session · estimated") if ordered.isEmpty { NoopCard { Text("None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.") @@ -324,7 +324,7 @@ struct LiftSessionDetailSheet: View { NoopCard { VStack(alignment: .leading, spacing: 8) { ForEach(ordered, id: \.self) { muscle in - HStack(spacing: 10) { + HStack(spacing: NoopMetrics.rowSpacing) { Text(muscle.displayName) .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) @@ -337,7 +337,7 @@ struct LiftSessionDetailSheet: View { .foregroundStyle(StrandPalette.textTertiary) } } - Text("Direct sets count once, indirect sets count as a half — the method the reference figures were derived under.") + Text("Counted from the muscles you assigned each exercise: direct sets count once, indirect ones count as a half — the method the reference figures were derived under. An estimate from your own labels, not something measured off your body.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 013c9d695b..2a80d71a18 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -132,7 +132,7 @@ struct LiftSessionView: View { private func exerciseCard(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { NoopCard { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { VStack(alignment: .leading, spacing: 2) { Text(item.exercise) .font(StrandFont.headline) @@ -530,7 +530,7 @@ struct LiftSessionView: View { // MARK: - The control bar private func controlBar(_ engine: LiftSessionEngine) -> some View { - VStack(spacing: 10) { + VStack(spacing: NoopMetrics.rowSpacing) { HStack(spacing: 14) { clock(String(localized: "Session"), LiftFormat.duration(max(0, session.now - engine.startTs)), @@ -552,7 +552,7 @@ struct LiftSessionView: View { .accessibilityLabel("Undo") } - HStack(spacing: 10) { + HStack(spacing: NoopMetrics.rowSpacing) { Button { session.advance() } label: { Text(actionLabel(engine)).frame(maxWidth: .infinity) } @@ -639,7 +639,7 @@ struct LiftSessionView: View { subtitle: "One number for the whole session, so a leg day can be compared with a run.") { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { NoopCard { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { Text("How hard was the whole session? (1–10)").strandOverline() TextField("7", text: $sessionRpeText) .textFieldStyle(.plain) diff --git a/StrandTests/FrameRouterDoubleTapDedupTests.swift b/StrandTests/FrameRouterDoubleTapDedupTests.swift index a7497e0b74..0bcca6a6e4 100644 --- a/StrandTests/FrameRouterDoubleTapDedupTests.swift +++ b/StrandTests/FrameRouterDoubleTapDedupTests.swift @@ -97,4 +97,78 @@ final class FrameRouterDoubleTapDedupTests: XCTestCase { r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) XCTAssertEqual(fired, 0) } + + /// A second gesture: the captured frame with a DIFFERENT `event_timestamp` and a recomputed CRC. + /// + /// Minted rather than captured because the interleaved case needs two gestures reaching ONE + /// router, and the fixture carries a single timestamp. WHOOP 5 envelope: payload is + /// `frame[8..<20]`, its CRC32 is the trailing four bytes little-endian, and `event_timestamp` + /// sits at payload offset 4. The CRC16 header covers `frame[0..<6]` and is untouched. + private func doubleTapFrame(eventTs: Int) -> [UInt8] { + var f = bytes(doubleTapHex) + let ts = UInt32(eventTs) + for i in 0..<4 { f[12 + i] = UInt8((ts >> (8 * UInt32(i))) & 0xFF) } + let crc = crc32(f, 8, 20) + for i in 0..<4 { f[20 + i] = UInt8((crc >> (8 * UInt32(i))) & 0xFF) } + return f + } + + /// The mint has to produce a frame the parser accepts, or a test built on it proves nothing. + func testTheMintedSecondGestureIsAValidFrame() { + let f = doubleTapFrame(eventTs: doubleTapEventTs + 12) + let check = verifyFrame(f, family: .whoop5) + XCTAssertTrue(check.ok, "minted frame must pass both CRCs or the dedup tests are meaningless") + } + + /// TWO genuine taps, then an offload replaying BOTH — the case a single-slot memory cannot cover. + /// + /// Keeping only the last dispatched timestamp catches a replay solely when the replayed event is + /// the most recent one dispatched. Interleave them and each replay looks new: + /// + /// tap A live -> last = A + /// tap B live -> last = B + /// replay A -> A != B, dispatches again + /// replay B -> B != A, dispatches again + /// + /// Two phantom advances, which in a session is two sets silently lost — the exact failure this + /// de-duplication exists to prevent, surviving inside it. + @MainActor + func testTwoTapsReplayedTogetherStillFireOnlyTwice() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + let tsA = doubleTapEventTs + let tsB = doubleTapEventTs + 12 + r.handle(frame: doubleTapFrame(eventTs: tsA)) + r.handle(frame: doubleTapFrame(eventTs: tsB)) + XCTAssertEqual(fired, 2, "sanity: two genuine taps are two gestures") + + // The strap offloads its banked log a few seconds later, carrying both events. + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: tsA), now: tsB + 5) + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: tsB), now: tsB + 5) + + XCTAssertEqual(fired, 2, "a replay of EITHER tap must be suppressed, not just the most recent") + } + + /// Three taps and a full re-walk, the shape a multi-minute offload actually has. + @MainActor + func testAWholeBatchReplayOfSeveralTapsAddsNothing() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + let stamps = [doubleTapEventTs, doubleTapEventTs + 7, doubleTapEventTs + 19] + for ts in stamps { r.handle(frame: doubleTapFrame(eventTs: ts)) } + XCTAssertEqual(fired, 3) + + for _ in 0..<3 { + for ts in stamps { + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: ts), now: stamps[2] + 3) + } + } + XCTAssertEqual(fired, 3, "re-walking the banked log adds no gestures") + } } From 8257b455bab42d3e3e1a963efe5efc88b1a250e3 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:19:16 +0200 Subject: [PATCH 26/31] lift log: drop work-vs-rest, and say what the volume total can be compared against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A figure earns its place if a reader can act on it and cannot be seriously misled by it. Work-vs-rest fails both halves, so it is gone rather than relabelled. Its numerator was never what the name implied. `workSec` is set start to set end, which includes unracking, setup and the pause before the first rep — it is not time under tension, and the previous caption said "under load", which claimed exactly the thing it could not measure. The denominator is honest: rest IS measured from the taps. More decisive: no decision follows from the ratio. The rest a lifter intends is already set per exercise in the program, and the rest actually taken is on each set row in the session detail, which is where it can be acted on. A session-level 1 : 2.4 informs nothing that those two do not, and invites reading a training quality into a number that only knows timestamps. `LiftMetrics.workRest` and `WorkRest` had this tile as their only consumer, so both are deleted with their three tests, following the precedent that removed `liftRpeProfile` when it stopped being read. Volume STAYS. It is exact arithmetic over logged sets, and this feature is built on repeating a program, which is the case where a session total does compare. But the condition was unstated, so the caption now carries it: 100 kg of leg press is not 100 kg of squat, and the per-exercise figure with its delta against last time — already a section below — is the form that answers "am I progressing". Co-Authored-By: Claude Opus 5 --- .../Sources/StrandAnalytics/LiftMetrics.swift | 28 ------------------- .../LiftMetricsTests.swift | 22 --------------- Strand/Resources/Localizable.xcstrings | 3 ++ Strand/Screens/LiftSessionDetailSheet.swift | 16 ++++------- 4 files changed, 8 insertions(+), 61 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift index 11a7f491af..4c8e5d2ed2 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -60,34 +60,6 @@ public enum LiftMetrics { return rpe * (Double(durationSec) / 60.0) } - // MARK: - Work versus rest - - public struct WorkRest: Equatable { - /// Seconds actually spent performing sets (warm-ups included — a warm-up is still time under - /// load, even though it is not counted as training volume). - public let workSec: Int - /// Seconds spent resting between sets, as MEASURED from the taps rather than as planned. - public let restSec: Int - /// Rest ÷ work. Nil when no work was recorded. A leg day at 1:4 and a circuit at 1:1 are - /// different training even at identical volume, and only a tap-through log can know it. - public let restToWorkRatio: Double? - - public init(workSec: Int, restSec: Int) { - self.workSec = workSec - self.restSec = restSec - self.restToWorkRatio = workSec > 0 ? Double(restSec) / Double(workSec) : nil - } - } - - public static func workRest(_ sets: [LiftSetRow]) -> WorkRest { - var work = 0, rest = 0 - for s in sets { - if let start = s.startTs, let end = s.endTs, end > start { work += end - start } - if let r = s.restSec, r > 0 { rest += r } - } - return WorkRest(workSec: work, restSec: rest) - } - // MARK: - Estimated one-rep max (Epley) /// The rep ceiling above which a 1RM estimate stops being worth showing. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift index 6e41ca78e2..fa4045fb32 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift @@ -61,28 +61,6 @@ final class LiftMetricsTests: XCTestCase { XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 0)) } - // MARK: - Work versus rest - - func testWorkAndRestAreSummedFromTheTaps() { - let wr = LiftMetrics.workRest([ - set(start: 100, end: 140, rest: 120), // 40s work, 120s rest - set(start: 260, end: 290, rest: 90), // 30s work, 90s rest - ]) - XCTAssertEqual(wr.workSec, 70) - XCTAssertEqual(wr.restSec, 210) - XCTAssertEqual(wr.restToWorkRatio!, 3.0, accuracy: 0.001) - } - - func testWarmUpTimeCountsAsWorkEvenThoughItIsNotVolume() { - let wr = LiftMetrics.workRest([set(warmup: true, start: 0, end: 60, rest: 30)]) - XCTAssertEqual(wr.workSec, 60, "a warm-up is still time under load") - } - - func testNoWorkMeansNoRatioRatherThanADivideByZero() { - XCTAssertNil(LiftMetrics.workRest([]).restToWorkRatio) - XCTAssertNil(LiftMetrics.workRest([set(start: nil, end: nil, rest: 90)]).restToWorkRatio) - } - // MARK: - Estimated 1RM (Epley) func testEpleyMatchesTheFormula() { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 8dad0b8ddd..11f8d9e86d 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage": "en", "strings": { + "%lld working sets · compare when you repeat this program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Arbeitssätze · vergleichbar, wenn du dieses Programm wiederholst"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series efectivas · compara al repetir este programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries de travail · à comparer quand tu refais ce programme"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie di lavoro · confronta quando ripeti questo programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii roboczych · porównuj, gdy powtarzasz ten program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries de trabalho · compara quando repetires este programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld рабочих подходов · сравнивай при повторе этой программы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 个正式组 · 重复同一计划时才可比较"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 個正式組 · 重複同一計畫時才可比較"}} + } }, "%@ in sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "%@ in Sätzen"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ en series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ en séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ nelle serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ w seriach"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ em séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ в подходах"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 在组内"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 在組內"}} } }, diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index 118711c6ba..6f41eec456 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -121,18 +121,18 @@ struct LiftSessionDetailSheet: View { SectionHeader("This session", overline: "Figures") LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: NoopMetrics.rowSpacing)], alignment: .leading, spacing: NoopMetrics.rowSpacing) { + // Weight x reps, summed. Exact, but only meaningful against the SAME program run + // again: 100 kg of leg press is not 100 kg of squat, so the total across different + // exercises compares nothing. The per-exercise figure below, with its delta against + // last time, is the form that answers "am I progressing" — this one is the tally. tile(String(localized: "Volume"), LiftFormat.weight(LiftMetrics.volumeLoadKg(sets), system: unitSystem), - String(localized: "\(workingSetCount) working sets")) + String(localized: "\(workingSetCount) working sets · compare when you repeat this program")) tile(String(localized: "Session load"), sessionLoadText, sessionLoadCaption) - tile(String(localized: "Work vs rest"), - workRestText, - String(localized: "\(LiftFormat.duration(workRest.workSec)) in sets")) - tile(String(localized: "Effort"), workout?.strain.map { LiftFormat.trim($0) } ?? "—", String(localized: "measured from heart rate")) @@ -140,14 +140,8 @@ struct LiftSessionDetailSheet: View { } } - private var workRest: LiftMetrics.WorkRest { LiftMetrics.workRest(sets) } private var workingSetCount: Int { sets.filter { !$0.isWarmup }.count } - private var workRestText: String { - guard let ratio = workRest.restToWorkRatio else { return "—" } - return String(format: "1 : %.1f", ratio) - } - private var sessionLoadText: String { guard let load = LiftMetrics.sessionLoad(sessionRpe: session.sessionRpe, durationSec: durationSec) else { return "—" } From 4553eb3818f2f716490da2545dc82213bd98dc79 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:41:47 +0200 Subject: [PATCH 27/31] lift log: drop two APIs nothing in the app calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's parity ledger reports every declaration reached only from tests as a `test-only-callsite` identity, and three of this feature's were exactly that. Each is debt the maintainer would otherwise have to baseline on our behalf, and each breaks this feature's own rule that nothing speculative ships. `ReferenceDose.fractionOfHypertrophyMinimum` clamped a weekly count to 0...1 of the minimum dose. Nothing drew it — the bar uses `weeklySetsBarSpan` — and a value that saturates at 1.0 is precisely the "full bar" shape the feature forbids, since the evidence gives a floor and no ceiling. Removed with its test; the constants and the test pinning them stay. `XlsxSheet.grid(from:)` was a single-sheet wrapper over `grids(from:)`, which is what production reads. Its one test now takes the first sheet through `grids` directly, and still fails loudly on a template it cannot read. The session round-trip tests read back through the store lookup the schema commit removed; they now use `liftSessions`, the read the app actually makes. `XlsxSheet.rawPart` stays, deliberately. It is internal, documented as test support, and is the only way the shipped template's column lock can be asserted — through the same bounded reader production uses, so a copy in the test target would test a weaker path than the one that ships. Co-Authored-By: Claude Opus 5 --- .../Sources/StrandAnalytics/LiftMetrics.swift | 8 -------- .../Tests/StrandAnalyticsTests/LiftMetricsTests.swift | 8 -------- .../StrandImport/Sources/StrandImport/XlsxSheet.swift | 9 --------- .../LiftProgramSheetImporterTests.swift | 2 +- .../Tests/WhoopStoreTests/LiftLogStoreTests.swift | 8 ++++---- 5 files changed, 5 insertions(+), 30 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift index 4c8e5d2ed2..11914b0ced 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -243,13 +243,5 @@ public enum LiftMetrics { /// FOR STRENGTH. Hypertrophy has no identified ceiling — gains continue with strongly /// diminishing returns, and the uncertainty widens as volume rises. public static let strengthPlateauSetsPerWeek = 4.0 - - /// Where a weekly count sits relative to the hypertrophy band, as a 0...1 fraction of the - /// minimum effective dose, clamped. Deliberately NOT a percentage score: a muscle at 9 sets - /// is not "225% complete", it is simply past the point where the evidence thins out. - public static func fractionOfHypertrophyMinimum(_ weeklySets: Double) -> Double { - guard hypertrophyMinimumSetsPerWeek > 0 else { return 0 } - return min(1.0, max(0.0, weeklySets / hypertrophyMinimumSetsPerWeek)) - } } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift index fa4045fb32..c4278551d5 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift @@ -188,14 +188,6 @@ final class LiftMetricsTests: XCTestCase { // MARK: - The reference band - func testTheBandIsAFractionOfTheMinimumEffectiveDoseAndClamps() { - XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(2), 0.5, accuracy: 0.001) - XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(4), 1.0, accuracy: 0.001) - XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(20), 1.0, accuracy: 0.001, - "9 sets is not '225% complete' — past the minimum the evidence just thins out") - XCTAssertEqual(LiftMetrics.ReferenceDose.fractionOfHypertrophyMinimum(0), 0.0, accuracy: 0.001) - } - func testTheReferenceDosesAreTheOnesTheCreditsWereDerivedUnder() { XCTAssertEqual(LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek, 4.0) XCTAssertEqual(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek, 1.0) diff --git a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift index b9849ecd96..e86f5c24a2 100644 --- a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -50,15 +50,6 @@ enum XlsxSheet { return Sheet(headerKeys: Set(keys.filter { !$0.isEmpty }), rows: out) } - /// The FIRST worksheet as a rectangular grid — kept for callers that only want to look at the - /// leading sheet's headers. - static func grid(from data: Data) throws -> [[String]] { - guard let first = try grids(from: data).first else { - throw LiftProgramSheetImporter.ImportError.unreadable - } - return first - } - /// Every worksheet as a grid of strings, in workbook (tab) order. static func grids(from data: Data) throws -> [[[String]]] { guard let archive = try? Archive(data: data, accessMode: .read) else { diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift index 9e551ac0d6..a71024a5e2 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -114,7 +114,7 @@ final class LiftProgramSheetImporterTests: XCTestCase { // The header row is what the importer matches on, so it is what must not drift. (`rows` // is empty for an unfilled template by design — every data row is blank.) - let grid = try XlsxSheet.grid(from: data) + let grid = try XlsxSheet.grids(from: data).first ?? [] let headers = grid.first.map { $0.map { HeaderNorm.normalize($0) } } ?? [] for expected in ["program", "program_note", "exercise", "primary_muscle", "secondary_muscles", "sets", "reps", "weight_kg", "rest_sec", "note"] { diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index 5d97858995..f85dec111b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -107,8 +107,8 @@ final class LiftLogStoreTests: XCTestCase { sessionRpe: 7.5, note: nil) _ = try await store.upsertLiftSessions([row]) - let back = try await store.liftSession(deviceId: "dev", startTs: 1_700_000_000, - sport: "Strength Training") + let back = try await store.liftSessions(deviceId: "dev", fromTs: 1_700_000_000, + toTs: 1_700_000_000).first XCTAssertEqual(back?.sessionRpe, 7.5) } @@ -120,8 +120,8 @@ final class LiftLogStoreTests: XCTestCase { sessionRpe: nil, note: nil) _ = try await store.upsertLiftSessions([row]) - let back = try await store.liftSession(deviceId: "dev", startTs: 1_700_000_500, - sport: "Strength Training") + let back = try await store.liftSessions(deviceId: "dev", fromTs: 1_700_000_500, + toTs: 1_700_000_500).first XCTAssertNil(back?.sessionRpe, "a skipped rating must stay nil — a 0 would read as 'effortless' and corrupt the load") } From 2d1a33425b39f1879bc6bf1ab4fd3e2336abb178 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:07:26 +0200 Subject: [PATCH 28/31] lift log: dropping a set drops what was typed into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LiftSessionController.removeSet` already cleared a warm-up mark made in advance on the set it dropped, so adding the set back would not return it marked. Numbers typed into that set in advance were not cleared, so the re-added set came back showing, and would have recorded, values given to the set the user had just removed. Both are now cleared together. The new test fails without the fix, with 80.0 on the re-added set. Also corrects text that went stale as the feature moved on: the engine and a test message no longer cite work-vs-rest, `LiftMetrics` no longer counts six figures, `FrameRouter` documents recent timestamps rather than the last one, the item sheet no longer names a migration number that has since changed, and the import guide and `XlsxSheet`'s header no longer say only the first sheet is read — the importer takes the first sheet in tab order with an Exercise column. Co-Authored-By: Claude Opus 5 --- .../Sources/StrandAnalytics/LiftMetrics.swift | 2 +- .../Sources/StrandImport/XlsxSheet.swift | 4 ++-- Strand/BLE/FrameRouter.swift | 2 +- Strand/Data/LiftSessionController.swift | 5 +++-- Strand/Data/LiftSessionEngine.swift | 4 ++-- Strand/Screens/LiftProgramItemSheet.swift | 4 ++-- StrandTests/LiftSessionEngineTests.swift | 2 +- StrandTests/LiftSessionPendingInputTests.swift | 17 +++++++++++++++++ docs/LIFT_LOG_PROGRAM_IMPORT.md | 3 ++- 9 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift index 11914b0ced..2d7fb96855 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -4,7 +4,7 @@ import WhoopStore // Training metrics for the Lift Log. // // Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the -// whole design constraint: NOOP shows six honest numbers rather than one invented score, because a +// whole design constraint: NOOP shows a few honest numbers rather than one invented score, because a // composite "workout score out of 100" feels satisfying and tells you nothing about what to change. // // PURE. No store, no clock, no UI — the inputs are rows and the outputs are numbers, so the whole diff --git a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift index e86f5c24a2..eb1bd11baf 100644 --- a/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -3,8 +3,8 @@ import ZIPFoundation // A deliberately small `.xlsx` reader: enough to read a filled-in template, and nothing else. // -// An .xlsx is a ZIP of XML. Reading one properly — styles, number formats, dates, formulas, multiple -// sheets, streaming — is a library's worth of work. This reads the FIRST worksheet as text, which is +// An .xlsx is a ZIP of XML. Reading one properly — styles, number formats, dates, formulas, +// streaming — is a library's worth of work. This reads each worksheet as text, in tab order, which is // all a program sheet needs, and is honest about that: no formula evaluation (a cell's last cached // value is used, which is what Excel wrote), no date coercion, no styling. // diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 25569f109b..374a7c2f7f 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -710,7 +710,7 @@ public final class FrameRouter { // MARK: - Double-tap de-duplication - /// `event_timestamp` of the last DOUBLE_TAP handed to the app. + /// `event_timestamp`s of the DOUBLE_TAPs recently handed to the app. /// /// ONE physical gesture can reach us TWICE. It arrives live through `handle(frame:)`, and then /// again when the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 4caf746e5a..e801681d55 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -365,9 +365,10 @@ final class LiftSessionController: ObservableObject { guard let engine, engine.canRemoveSet(fromExercise: index) else { return false } let dropped = LiftSlot(exerciseIndex: index, setIndex: engine.plan[index].targetSets) self.engine?.removeSet(fromExercise: index) - // A slot that no longer exists must not keep a warm-up mark: adding the set back would - // return it silently marked, from a tap the user made against a different set. + // A slot that no longer exists must not keep a warm-up mark or typed numbers: adding the set + // back would return them silently, from input the user gave a set they then removed. pendingWarmups.remove(dropped) + pendingValues.removeValue(forKey: dropped) persist() return true } diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 22cd5eae8e..451fb73add 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -318,8 +318,8 @@ struct LiftSessionEngine: Equatable { case .resting(let slot, _): pushHistory() - // Record what was ACTUALLY rested — the figure the work-vs-rest split is built from, and - // the one thing only the taps can know. + // Record what was ACTUALLY rested — the figure each set row shows, and the one thing + // only the taps can know. if let i = sets.firstIndex(where: { $0.slot == slot }) { sets[i].restSec = max(0, now - stageStartedAt) } diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index bc5959ef6b..a9f7f66327 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -435,8 +435,8 @@ struct LiftProgramItemSheet: View { ord: item?.ord ?? 0, exercise: name, targetSets: Int(setsText.trimmingCharacters(in: .whitespaces)), - // ONE rep count. `targetRepsHigh`/`targetRpe` stay nil: they are v40 columns kept for - // compatibility, not part of the plan any more. + // ONE rep count. `targetRepsHigh`/`targetRpe` stay nil: they are schema columns the + // editor no longer fills, not part of the plan any more. targetRepsLow: Int(repsText.trimmingCharacters(in: .whitespaces)), targetRepsHigh: nil, targetRpe: nil, diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index dbf6dfa912..8d9d38a728 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -295,7 +295,7 @@ final class LiftSessionEngineTests: XCTestCase { e.advance(now: t0 + 10) // planned 90s e.advance(now: t0 + 210) // actually rested 200s XCTAssertEqual(e.sets[0].restSec, 200, - "work-vs-rest is measured from the taps, not assumed from the plan") + "rest is measured from the taps, not assumed from the plan") } func testASetCarriesTheDurationItWasPerformedOver() { diff --git a/StrandTests/LiftSessionPendingInputTests.swift b/StrandTests/LiftSessionPendingInputTests.swift index ddabe1e902..e30917b69f 100644 --- a/StrandTests/LiftSessionPendingInputTests.swift +++ b/StrandTests/LiftSessionPendingInputTests.swift @@ -159,4 +159,21 @@ final class LiftSessionPendingInputTests: XCTestCase { XCTAssertNil(c.enteredValues(for: slot(0, 1)).weightKg, "the row is back to its ghosts, as it was before this existed") } + + /// Removing a set takes what was entered for it along: adding the set back starts it fresh + /// rather than returning numbers and a warm-up mark given to a set the user dropped. + func testRemovingASetDropsWhatWasEnteredForIt() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 3), weightKg: 80, reps: 4, rpe: nil, isWarmup: false) + c.setWarmup(slot(0, 3), true) + + XCTAssertTrue(c.removeSet(fromExercise: 0)) + XCTAssertTrue(c.addSet(toExercise: 0)) + + XCTAssertNil(c.enteredValues(for: slot(0, 3)).weightKg, + "the re-added set shows its ghosts, not the dropped set's numbers") + XCTAssertFalse(c.isWarmup(slot(0, 3))) + XCTAssertTrue(c.pendingValues.isEmpty) + } } diff --git a/docs/LIFT_LOG_PROGRAM_IMPORT.md b/docs/LIFT_LOG_PROGRAM_IMPORT.md index dce5591a3d..0e3f93c93b 100644 --- a/docs/LIFT_LOG_PROGRAM_IMPORT.md +++ b/docs/LIFT_LOG_PROGRAM_IMPORT.md @@ -42,7 +42,8 @@ and the per-muscle rollup resolves the same name the same way. ## Formats and quirks -- **`.xlsx`** (the template) and **`.csv`** both work. Everything is read from the FIRST sheet. +- **`.xlsx`** (the template) and **`.csv`** both work. In a workbook, the first sheet in tab order that + has an `Exercise` column is imported, so an instructions tab in front of it is fine. - **CSV delimiters** are sniffed — `,`, `;` or tab. Excel in most of Europe writes `;`, which is fine. - **Decimal commas** are understood: `40,5` and `40.5` both mean 40.5. - **Unit suffixes** are tolerated: `40,5 kg` reads as 40.5. From 7ad582290af48b8e6d4a3ca7416aea7c2dc33561 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:39:40 +0200 Subject: [PATCH 29/31] lift log: log every point where a double-tap can be dropped A gym session on 15 Sep 2026 had 2-3 double-taps that did not register, and nothing on the strap log could say why. A tap can be dropped in three places, and none of them left a line: - the de-duplication holds back an event it has already dispatched; - `dispatchLiveGestureIfFresh` refuses a gesture that reaches the app only through a sync, more than 45 s after it happened; - `AppModel.handleDoubleTap` ignores a tap within 1.2 s of the previous one. Each now appends one line, carrying the strap's own event timestamp so it can be matched against the "Double-tap -> ..." line a dispatched tap leaves. A late tap is only logged within ten minutes of its timestamp, so offloading a day of ordinary history stays silent. A reported miss with none of these lines was never sent by the strap. Two tests pin the lines, and each fails without its line. Co-Authored-By: Claude Opus 5 --- Strand/App/AppModel.swift | 6 +++- Strand/BLE/FrameRouter.swift | 23 ++++++++++++-- .../FrameRouterDoubleTapDedupTests.swift | 30 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 54a4116940..815dc3e145 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1645,7 +1645,11 @@ final class AppModel: ObservableObject { private func handleDoubleTap() { let now = Date() - guard now.timeIntervalSince(lastDoubleTapAt) > 1.2 else { return } // debounce repeats + let since = now.timeIntervalSince(lastDoubleTapAt) + guard since > 1.2 else { // debounce repeats + live.append(log: String(format: "Double-tap ignored: %.1f s after the previous one (debounce 1.2 s)", since)) + return + } lastDoubleTapAt = now if let override = strapDoubleTapOverride { live.append(log: "Double-tap → Lift Log: next") diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 374a7c2f7f..d0f1fb618f 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -653,6 +653,10 @@ public final class FrameRouter { /// backfill offload (old ts) is ignored, but a real-time one fires even mid-sync. static let liveGestureWindowSeconds = 45 + /// How far back a DOUBLE_TAP arriving through a sync still earns a log line. Ten minutes covers a + /// gym session's syncs; anything older is ordinary history being offloaded, and stays silent. + static let lateGestureLogSeconds = 600 + /// Parse an EVENT frame and fire ONLY the live physical-gesture handlers (double-tap / wrist) iff the /// event is recent. Called for offload frames during backfill — where `handle(frame:)` is skipped — /// so a real-time gesture still works mid-offload (#69: the 5/MG offload runs for minutes). `now` @@ -698,7 +702,17 @@ public final class FrameRouter { guard parsed.ok, parsed.crcOK != false else { return } guard parsed.typeName == "EVENT", let ev = parsed.parsed["event"]?.stringValue else { return } guard let ts = parsed.parsed["event_timestamp"]?.intValue, ts > 0 else { return } // fail closed - guard abs(now - ts) <= FrameRouter.liveGestureWindowSeconds else { return } + let age = now - ts + guard abs(age) <= FrameRouter.liveGestureWindowSeconds else { + // A recent double-tap reaching us through a sync gets a line, so a tap reported as "did not + // register" can be checked: a live dispatch leaves "Double-tap → …" at that moment, and a + // tap that only ever came through a sync leaves just this. It asserts only the delivery seen. + if ev.hasPrefix("DOUBLE_TAP"), age > 0, age <= FrameRouter.lateGestureLogSeconds { + state.append(log: "Double-tap (strap time \(ts)) arrived \(age) s late during a sync; " + + "not acted on (live window \(FrameRouter.liveGestureWindowSeconds) s)") + } + return + } if ev.hasPrefix("DOUBLE_TAP") { dispatchDoubleTapOnce(eventTimestamp: ts) } else if ev.hasPrefix("WRIST_ON") { @@ -751,7 +765,12 @@ public final class FrameRouter { private func dispatchDoubleTapOnce(eventTimestamp ts: Int?) { if let ts { - guard !dispatchedDoubleTapEventTs.contains(ts) else { return } + guard !dispatchedDoubleTapEventTs.contains(ts) else { + // Only a gesture actually held back leaves a line, so a tap reported as missing can be + // told apart from a replay being suppressed. + state.append(log: "Double-tap (strap time \(ts)) not dispatched: that event was already handled") + return + } // Prune BEFORE appending, so the event just accepted is always the one kept. dispatchedDoubleTapEventTs.removeAll { abs(ts - $0) > FrameRouter.liveGestureWindowSeconds diff --git a/StrandTests/FrameRouterDoubleTapDedupTests.swift b/StrandTests/FrameRouterDoubleTapDedupTests.swift index 0bcca6a6e4..1daba46e42 100644 --- a/StrandTests/FrameRouterDoubleTapDedupTests.swift +++ b/StrandTests/FrameRouterDoubleTapDedupTests.swift @@ -171,4 +171,34 @@ final class FrameRouterDoubleTapDedupTests: XCTestCase { } XCTAssertEqual(fired, 3, "re-walking the banked log adds no gestures") } + + // MARK: - Evidence for a tap that "did not register" + + /// A held-back replay leaves a line, so a missed tap can be told apart from a suppressed replay. + @MainActor + func testASuppressedReplayLeavesALogLine() { + let live = LiveState() + let r = router(live) + let frame = bytes(doubleTapHex) + r.handle(frame: frame) + let before = live.log.count + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 10) + XCTAssertTrue(live.log.dropFirst(before).contains { + $0.contains("\(doubleTapEventTs)") && $0.contains("already handled") + }) + } + + /// A recent double-tap that only reaches the app through a sync is logged; old history is not. + @MainActor + func testALateDoubleTapIsLoggedButOldHistoryIsNot() { + let live = LiveState() + let r = router(live) + let before = live.log.count + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 120) + XCTAssertTrue(live.log.dropFirst(before).contains { $0.contains("arrived 120 s late") }) + + let count = live.log.count + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) + XCTAssertEqual(live.log.count, count, "a replay from hours ago is history, not a missed tap") + } } From 45caa744a8e6076cf5c815325d0506140bcf84b8 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:39:40 +0200 Subject: [PATCH 30/31] lift log: finish a session by asking, and let a finished one be edited From a gym session on 15 Sep 2026, three changes to how a session ends: Grey numbers stay grey. Finishing a set records its timing only; a set's numbers are its own only once typed, and one chain (`carry(for:lastSession:)`) supplies the grey numbers the sheet, the minimised bar and the Lock Screen all show. Writing the grey numbers in at "set done" made the user type over guesses. Finishing asks instead of assuming. If some sets have nothing typed in (never started, or finished without typing), one question completes them all with their grey numbers or leaves them out; a set with anything typed always saves, its blank fields taking grey values, so nothing saves empty. If a set count was changed with the plus/minus row, a second question asks whether the program keeps it; the row no longer rewrites the program on every tap. Save waits for both answers. A finished session can be edited: weights, reps, RPE, warm-up marks and session RPE. Fields are text parsed on Save, and only a field that changed is written, so an untouched pound value is not nudged by a round trip. Two gaps found while checking this in the simulator are fixed too: the Lift Log list did not show a session saved from the sheet until the screen was reopened (the sheet lives above the screen and a save does not always bump the refresh counter), and a disabled primary button looked pressable because the shared style does not dim; the Lift Log's save buttons now dim with the design system's disabled opacity. Tests cover the grey chain, what finishing saves either way, the program comparison, the edit parsing and the saved-session signal; the key ones were each seen to fail with their fix removed. All 13 new strings are in the ten locales. Co-Authored-By: Claude Opus 5 --- Strand/Data/LiftSessionController.swift | 143 ++++++++--- Strand/Data/LiftSessionEngine.swift | 74 +++--- Strand/Resources/Localizable.xcstrings | 39 +++ Strand/Screens/LiftLogView.swift | 4 +- Strand/Screens/LiftProgramEditorSheet.swift | 1 + Strand/Screens/LiftProgramItemSheet.swift | 1 + Strand/Screens/LiftSessionDetailSheet.swift | 39 ++- Strand/Screens/LiftSessionEditSheet.swift | 218 ++++++++++++++++ Strand/Screens/LiftSessionView.swift | 240 +++++++++++------- StrandTests/LiftSessionEditTests.swift | 50 ++++ StrandTests/LiftSessionEngineTests.swift | 88 ++++--- StrandTests/LiftSessionFinishTests.swift | 163 ++++++++++++ .../LiftSessionPendingInputTests.swift | 5 +- 13 files changed, 860 insertions(+), 205 deletions(-) create mode 100644 Strand/Screens/LiftSessionEditSheet.swift create mode 100644 StrandTests/LiftSessionEditTests.swift create mode 100644 StrandTests/LiftSessionFinishTests.swift diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index e801681d55..922116bf77 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -31,6 +31,10 @@ final class LiftSessionController: ObservableObject { /// True while the full sheet is presented; false when minimised to the bottom bar. @Published var isPresented = false + /// Bumped each time a finished session is written. The session sheet is presented above every + /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. + @Published private(set) var savedSessions = 0 + var isActive: Bool { engine != nil && engine?.isFinished == false } /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so @@ -69,12 +73,11 @@ final class LiftSessionController: ObservableObject { /// What the store holds for each exercise LAST session, keyed by exercise name then set number — /// the middle layer of `LiftSessionEngine.carry(for:lastSession:)`. /// - /// It lives here rather than in the sheet because the strap can advance a set while the sheet is - /// minimised or closed, and a set recorded from the strap must carry the same numbers the sheet - /// would have shown. `LiftSessionView` loads it and hands it over; until it does (a session - /// resumed straight into the bar after a relaunch, say) the carry falls through to the program's - /// target, which is the layer below. - private var lastSession: [String: [Int: LiftSetCarry]] = [:] + /// It lives here rather than in the sheet because the minimised bar and the Lock Screen show a + /// set's numbers too, and they must be the grey numbers the sheet shows. `LiftSessionView` loads it + /// and hands it over; until it does (a session resumed straight into the bar after a relaunch, say) + /// the grey numbers fall through to the program's target, which is the layer below. + @Published private var lastSession: [String: [Int: LiftSetCarry]] = [:] private var ticker: AnyCancellable? /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. @@ -147,6 +150,7 @@ final class LiftSessionController: ObservableObject { func finishedSaving() { teardown() LiftSessionPersistence.clear() + savedSessions += 1 } private func teardown() { @@ -189,7 +193,7 @@ final class LiftSessionController: ObservableObject { if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } let stamp = Int(Date().timeIntervalSince1970) - engine?.advance(now: stamp, lastSession: carryFromLastSession()) + engine?.advance(now: stamp) applyPendingInput() now = stamp warnedFor = nil @@ -253,21 +257,17 @@ final class LiftSessionController: ObservableObject { } } - /// Reps x weight for a slot, as "8 x 30 kg". - /// - /// While RESTING these are what the set actually recorded; while WORKING the set does not exist - /// yet, so they are what completing it would record — the same numbers the sheet shows in grey. + /// Reps x weight for a slot, as "8 x 30 kg": what the set counts as — typed numbers, else the grey + /// ones the sheet shows. func setNumbers(for slot: LiftSlot, system: UnitSystem) -> String? { - guard let engine else { return nil } - let values = engine.recordedSet(for: slot).map { - LiftSetCarry(weightKg: $0.weightKg, reps: $0.reps) - } ?? carry(for: slot) + guard engine != nil else { return nil } + let shown = values(of: slot) - let weight = values.weightKg.map { + let weight = shown.weightKg.map { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) + " " + LiftFormat.weightUnit(system) } - switch (values.reps, weight) { + switch (shown.reps, weight) { case (let r?, let w?): return "\(r) x \(w)" case (let r?, nil): return String(localized: "\(r) reps") case (nil, let w?): return w @@ -281,21 +281,19 @@ final class LiftSessionController: ObservableObject { lastSession = values } - /// What a slot will record (or did record) without anything typed — the sheet's grey numbers, - /// resolved through the same chain, for callers that only have the controller. Used by the - /// minimised bar, which has no access to the store's last-session values on its own. + /// The grey numbers a slot shows — the one chain every surface reads, so the sheet, the minimised + /// bar and the Lock Screen cannot disagree about them. func carry(for slot: LiftSlot) -> LiftSetCarry { - guard let engine else { return .none } - let exercise = engine.planItem(for: slot)?.exercise - let last = exercise.flatMap { lastSession[$0]?[slot.setIndex] } ?? .none - return engine.carry(for: slot, lastSession: last) + engine?.carry(for: slot, lastSession: lastSessionSets(for: slot)) ?? .none + } + + /// What a slot counts as: typed numbers, else its grey ones. + func values(of slot: LiftSlot) -> LiftSetCarry { + engine?.values(of: slot, lastSession: lastSessionSets(for: slot)) ?? .none } - /// The last-session carry for the slot currently being worked, if any. - private func carryFromLastSession() -> LiftSetCarry { - guard let engine, case .working(let slot) = engine.stage, - let exercise = engine.planItem(for: slot)?.exercise else { return .none } - return lastSession[exercise]?[slot.setIndex] ?? .none + private func lastSessionSets(for slot: LiftSlot) -> [Int: LiftSetCarry] { + engine?.planItem(for: slot).flatMap { lastSession[$0.exercise] } ?? [:] } /// Mark a slot as a warm-up (or not). Applies immediately when the set already exists, and is @@ -316,10 +314,8 @@ final class LiftSessionController: ObservableObject { /// Carry a pre-marked warm-up, and any numbers typed in advance, onto the set just recorded. /// - /// The typed numbers OVERRIDE what `carry(for:lastSession:)` put there. The carry is the sheet's - /// best guess — this exercise earlier, last session, the program's target — and a value the user - /// typed for this very set outranks all three. A field left untouched keeps its carried value, - /// so typing only the weight does not blank the reps. + /// The set is recorded with its timing only, so typed numbers become its own and a field left + /// untouched stays grey — typing only the weight does not blank the reps. /// /// The entry is CONSUMED. A redo (`start` on a completed slot) drops the record and should show /// the ghosts again, exactly as it did before; leaving the entry behind would resurrect numbers @@ -411,6 +407,87 @@ final class LiftSessionController: ObservableObject { persist() } + // MARK: - Finishing + + /// Slots with no number typed in — never started, or finished without typing. Finishing asks once + /// whether to complete all of them with their grey numbers or leave them out. + var unfinishedSlots: [LiftSlot] { engine?.unenteredSlots ?? [] } + + /// One set as the finished session saves it. Timing is nil for a set completed at finish without + /// ever being started: there is no moment to record, and inventing one would give it a rest and a + /// heart-rate window it never had. + struct FinishedSet: Equatable { + var slot: LiftSlot + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int? + var endTs: Int? + var restSec: Int? + } + + /// The sets the session saves. + /// + /// A set with anything typed always saves, and a number left blank takes its grey value, so a set + /// that was rated but never weighed does not save empty. Unfinished sets are saved with their grey + /// numbers (and anything typed in advance) when `completingUnfinished`, and left out otherwise. + /// Performed sets keep the order they happened in; sets completed at finish follow in plan order. + func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { + guard let engine else { return [] } + let unfinished = Set(engine.unenteredSlots) + var out = engine.sets + .filter { completingUnfinished || !unfinished.contains($0.slot) } + .map { set -> FinishedSet in + let shown = values(of: set.slot) + return FinishedSet(slot: set.slot, weightKg: shown.weightKg, reps: shown.reps, + rpe: set.rpe, isWarmup: set.isWarmup, startTs: set.startTs, + endTs: set.endTs, restSec: set.restSec) + } + guard completingUnfinished else { return out } + for slot in engine.allSlots where !engine.isCompleted(slot) { + let typed = pendingValues[slot] + let grey = carry(for: slot) + out.append(FinishedSet(slot: slot, weightKg: typed?.weightKg ?? grey.weightKg, + reps: typed?.reps ?? grey.reps, rpe: typed?.rpe, + isWarmup: pendingWarmups.contains(slot), + startTs: nil, endTs: nil, restSec: nil)) + } + return out + } + + /// A program line whose set count this session changed. + struct SetCountChange: Equatable { + var itemId: String + var exercise: String + var from: Int + var to: Int + } + + /// Lines whose set count in this session differs from the program's current one. A line with no + /// count counts as one set, as it does when a session starts, and a line deleted from the program + /// since is skipped rather than resurrected. + static func setCountChanges(plan: [LiftPlanItem], program items: [LiftProgramItemRow]) -> [SetCountChange] { + let byId = Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return plan.compactMap { line in + guard let id = line.programItemId, let row = byId[id] else { return nil } + let saved = max(1, row.targetSets ?? 1) + guard saved != line.targetSets else { return nil } + return SetCountChange(itemId: id, exercise: line.exercise, from: saved, to: line.targetSets) + } + } + + /// The program's lines with `changes` applied. Only `targetSets` moves. + static func applying(_ changes: [SetCountChange], to items: [LiftProgramItemRow]) -> [LiftProgramItemRow] { + let counts = Dictionary(changes.map { ($0.itemId, $0.to) }, uniquingKeysWith: { first, _ in first }) + return items.map { row in + guard let sets = counts[row.id] else { return row } + var edited = row + edited.targetSets = sets + return edited + } + } + // MARK: - The rest warning private func fireRestWarningIfDue() { diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 451fb73add..8134b7a54f 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -40,9 +40,9 @@ struct LiftPlanItem: Equatable { /// The weight the program plans, in kilograms. var targetWeightKg: Double? var note: String? - /// The `liftProgramItem.id` this line was flattened from, so a set added or dropped during the - /// session can be written back onto the program it came from. Nil for a line with no program - /// behind it, and the write-back is then simply skipped. + /// The `liftProgramItem.id` this line was flattened from, so a set count changed during the session + /// can be offered back to the program when it is finished. Nil for a line with no program behind + /// it, which is then never offered. var programItemId: String? /// Rest used when a program line does not specify one. Two minutes sits in the middle of the @@ -92,7 +92,7 @@ struct LiftRecordedSet: Equatable { var slot: LiftSlot { LiftSlot(exerciseIndex: exerciseIndex, setIndex: setIndex) } } -/// What a set records when it is completed without anything typed into it. +/// The numbers a set shows in grey: what it would save if completed without anything typed. /// /// Only weight and reps. RPE is deliberately absent — see `carry(for:lastSession:)`. struct LiftSetCarry: Equatable { @@ -239,44 +239,57 @@ struct LiftSessionEngine: Equatable { return max(0, endsAt - now) } - /// What was lifted for the PREVIOUS set of this exercise in THIS session — the ghost values a - /// set row shows before anything is typed. Falls back to nil, and the UI then falls back to the - /// plan's target or to what was lifted last session. + /// The nearest earlier set of this exercise already performed in THIS session. Nil for the first + /// set, or when nothing before it has been done yet. func previousSetInSession(for slot: LiftSlot) -> LiftRecordedSet? { (1.. LiftSetCarry { - let previous = previousSetInSession(for: slot) + /// `lastSession` is this exercise's previous session by set number — the one layer the engine + /// cannot know. The caller supplies it; a set number it lacks falls through to the target. + func carry(for slot: LiftSlot, lastSession: [Int: LiftSetCarry]) -> LiftSetCarry { + let previous = previousSetInSession(for: slot).map { values(of: $0.slot, lastSession: lastSession) } + let last = lastSession[slot.setIndex] let item = planItem(for: slot) return LiftSetCarry( - weightKg: previous?.weightKg ?? lastSession.weightKg ?? item?.targetWeightKg, - reps: previous?.reps ?? lastSession.reps ?? item?.targetRepsLow) + weightKg: previous?.weightKg ?? last?.weightKg ?? item?.targetWeightKg, + reps: previous?.reps ?? last?.reps ?? item?.targetRepsLow) + } + + /// What a slot counts as: each number typed into it, else its grey number. + func values(of slot: LiftSlot, lastSession: [Int: LiftSetCarry]) -> LiftSetCarry { + let grey = carry(for: slot, lastSession: lastSession) + guard let set = recordedSet(for: slot) else { return grey } + return LiftSetCarry(weightKg: set.weightKg ?? grey.weightKg, reps: set.reps ?? grey.reps) + } + + /// Slots with no number typed in: never performed, or performed without typing. Finishing the + /// session asks once what happens to all of them. + var unenteredSlots: [LiftSlot] { + allSlots.filter { slot in + guard let set = recordedSet(for: slot) else { return true } + return set.weightKg == nil && set.reps == nil && set.rpe == nil + } } // MARK: - Actions @@ -296,10 +309,7 @@ struct LiftSessionEngine: Equatable { } /// The big button. Context decides what it means. - /// - /// `lastSession` is what the store holds for the slot being completed, used only as the middle - /// layer of `carry(for:lastSession:)`. Callers without it pass `.none`. - mutating func advance(now: Int, lastSession: LiftSetCarry = .none) { + mutating func advance(now: Int) { switch stage { case .warmup: guard let next = nextPendingSlot else { return } @@ -307,10 +317,10 @@ struct LiftSessionEngine: Equatable { case .working(let slot): pushHistory() - let carried = carry(for: slot, lastSession: lastSession) + // Timing only: the numbers stay grey until typed (see `carry(for:lastSession:)`). sets.append(LiftRecordedSet( exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, - weightKg: carried.weightKg, reps: carried.reps, rpe: nil, isWarmup: false, + weightKg: nil, reps: nil, rpe: nil, isWarmup: false, startTs: stageStartedAt, endTs: now, restSec: nil)) let rest = planItem(for: slot)?.restSec ?? LiftPlanItem.defaultRestSec stage = .resting(slot, endsAt: now + rest) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 11f8d9e86d..5027d56ce2 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -184,6 +184,45 @@ "Add set": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Satz hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add set"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增一組"}} } }, + "Unfinished sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unvollständige Sätze"}}, "en": {"stringUnit": {"state": "translated", "value": "Unfinished sets"}}, "es": {"stringUnit": {"state": "translated", "value": "Series sin terminar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries non terminées"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie incomplete"}}, "pl": {"stringUnit": {"state": "translated", "value": "Niedokończone serie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries por terminar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Незавершённые подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未完成的组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未完成的組"}} + } }, + "%lld sets have no numbers typed in — sets you did not start, or finished without typing.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Sätze haben keine eingetragenen Werte – nicht begonnene oder ohne Eingabe beendete Sätze."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld sets have no numbers typed in — sets you did not start, or finished without typing."}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series no tienen números: series que no empezaste o que terminaste sin escribir nada."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries n'ont aucun chiffre saisi — des séries non commencées ou terminées sans saisie."}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie non hanno numeri inseriti: serie non iniziate o finite senza scrivere nulla."}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie bez wpisanych liczb: %lld – nierozpoczęte lub zakończone bez wpisywania."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries não têm números: séries que não começaste ou que terminaste sem escrever nada."}}, "ru": {"stringUnit": {"state": "translated", "value": "Подходов без введённых чисел: %lld — не начатые или завершённые без ввода."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "有 %lld 组没有输入数字:未开始的组,或完成时没有输入的组。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "有 %lld 組沒有輸入數字:未開始的組,或完成時沒有輸入的組。"}} + } }, + "Complete them": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abschließen"}}, "en": {"stringUnit": {"state": "translated", "value": "Complete them"}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter"}}, "it": {"stringUnit": {"state": "translated", "value": "Completale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnij"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全"}} + } }, + "Discard them": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard them"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartarlas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Les abandonner"}}, "it": {"stringUnit": {"state": "translated", "value": "Scartale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartá-las"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отбросить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄"}} + } }, + "Completing saves them with the grey numbers shown. Discarding leaves them out of the session.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abschließen speichert sie mit den grau angezeigten Werten. Verwerfen lässt sie aus der Einheit weg."}}, "en": {"stringUnit": {"state": "translated", "value": "Completing saves them with the grey numbers shown. Discarding leaves them out of the session."}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas las guarda con los números en gris. Descartarlas las deja fuera de la sesión."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter les enregistre avec les chiffres affichés en gris. Les abandonner les retire de la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "Completarle le salva con i numeri in grigio. Scartarle le lascia fuori dalla sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnienie zapisze je z liczbami pokazanymi na szaro. Odrzucenie pominie je w sesji."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las guarda-as com os números a cinzento. Descartá-las deixa-as fora da sessão."}}, "ru": {"stringUnit": {"state": "translated", "value": "Если завершить, они сохранятся с серыми числами. Если отбросить, в сессию они не попадут."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全会用灰色显示的数字保存它们;放弃则不计入本次训练。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全會用灰色顯示的數字儲存它們;放棄則不計入本次訓練。"}} + } }, + "You changed the number of sets. Keep the new counts in the program for next time?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Du hast die Anzahl der Sätze geändert. Die neuen Zahlen fürs nächste Mal ins Programm übernehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You changed the number of sets. Keep the new counts in the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has cambiado el número de series. ¿Guardar las nuevas cifras en el programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as modifié le nombre de séries. Garder les nouveaux nombres dans le programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai cambiato il numero di serie. Tenere i nuovi numeri nel programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zmieniono liczbę serii. Zachować nowe liczby w programie na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alteraste o número de séries. Guardar os novos números no programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Количество подходов изменено. Сохранить новые значения в программе на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你更改了组数。要把新的组数保存到计划里,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你更改了組數。要把新的組數儲存到計畫裡,下次使用嗎?"}} + } }, + "%@: %lld → %lld sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld Sätze"}}, "en": {"stringUnit": {"state": "translated", "value": "%@: %lld → %lld sets"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$lld → %3$lld séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld serii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld подх."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$lld → %3$lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$lld → %3$lld 組"}} + } }, + "Update program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm aktualisieren"}}, "en": {"stringUnit": {"state": "translated", "value": "Update program"}}, "es": {"stringUnit": {"state": "translated", "value": "Actualizar programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mettre à jour"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiorna programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zaktualizuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Atualizar programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Обновить программу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "更新计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "更新計畫"}} + } }, + "Keep as it was": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unverändert lassen"}}, "en": {"stringUnit": {"state": "translated", "value": "Keep as it was"}}, "es": {"stringUnit": {"state": "translated", "value": "Dejar como estaba"}}, "fr": {"stringUnit": {"state": "translated", "value": "Laisser tel quel"}}, "it": {"stringUnit": {"state": "translated", "value": "Lascia com'era"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zostaw bez zmian"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deixar como estava"}}, "ru": {"stringUnit": {"state": "translated", "value": "Оставить как было"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "保持不变"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "保持不變"}} + } }, + "Choose an option above to save.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wähle oben eine Option, um zu speichern."}}, "en": {"stringUnit": {"state": "translated", "value": "Choose an option above to save."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige una opción arriba para guardar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisis une option ci-dessus pour enregistrer."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un'opzione qui sopra per salvare."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz opcję powyżej, aby zapisać."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolhe uma opção acima para guardar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Чтобы сохранить, выбери вариант выше."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "请先在上方选择一项再保存。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "請先在上方選擇一項再儲存。"}} + } }, + "Edit sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sätze bearbeiten"}}, "en": {"stringUnit": {"state": "translated", "value": "Edit sets"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar series"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier les séries"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj serie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑各组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯各組"}} + } }, + "Fix a number you missed or mistyped. The session's figures follow when you save.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Korrigiere einen vergessenen oder falsch eingegebenen Wert. Die Kennzahlen der Einheit folgen beim Speichern."}}, "en": {"stringUnit": {"state": "translated", "value": "Fix a number you missed or mistyped. The session's figures follow when you save."}}, "es": {"stringUnit": {"state": "translated", "value": "Corrige un número que olvidaste o escribiste mal. Las cifras de la sesión se actualizan al guardar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Corrige un chiffre oublié ou mal saisi. Les chiffres de la séance suivent à l'enregistrement."}}, "it": {"stringUnit": {"state": "translated", "value": "Correggi un numero dimenticato o sbagliato. I dati della sessione si aggiornano quando salvi."}}, "pl": {"stringUnit": {"state": "translated", "value": "Popraw pominiętą lub błędnie wpisaną liczbę. Wskaźniki sesji zaktualizują się po zapisaniu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Corrige um número que esqueceste ou escreveste mal. Os valores da sessão atualizam-se ao guardar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Исправь пропущенное или неверно введённое число. Показатели сессии обновятся при сохранении."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "修正漏填或填错的数字。保存后,本次训练的数据会随之更新。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "修正漏填或填錯的數字。儲存後,本次訓練的數據會隨之更新。"}} + } }, + "Save changes": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Änderungen speichern"}}, "en": {"stringUnit": {"state": "translated", "value": "Save changes"}}, "es": {"stringUnit": {"state": "translated", "value": "Guardar cambios"}}, "fr": {"stringUnit": {"state": "translated", "value": "Enregistrer"}}, "it": {"stringUnit": {"state": "translated", "value": "Salva modifiche"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisz zmiany"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Guardar alterações"}}, "ru": {"stringUnit": {"state": "translated", "value": "Сохранить изменения"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "保存更改"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "儲存變更"}} + } }, "Add a set to %@": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Einen Satz zu %@ hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add a set to %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir una serie a %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série à %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi una serie a %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię do %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar uma série a %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход к %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为 %@ 添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為 %@ 新增一組"}} } }, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index 39b8283dd8..ce72933b6f 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -51,7 +51,9 @@ struct LiftLogView: View { historySection } } - .task(id: repo.refreshSeq) { await load() } + // Also on a saved session: the session sheet lives above this screen and cannot tell it + // directly, and a save does not always bump `refreshSeq`. + .task(id: "\(repo.refreshSeq)-\(session.savedSessions)") { await load() } .sheet(item: $editing) { target in LiftProgramEditorSheet(program: target.program) { await load() diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift index 790cc0415d..4b9bf5fe1c 100644 --- a/Strand/Screens/LiftProgramEditorSheet.swift +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -242,6 +242,7 @@ struct LiftProgramEditorSheet: View { .buttonStyle(.noopPrimary) .frame(maxWidth: 160) .disabled(!canSave) + .opacity(canSave ? 1 : NoopButtonMetrics.disabledOpacity) .accessibilityLabel("Save program") } } diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift index a9f7f66327..d30ae1cbe9 100644 --- a/Strand/Screens/LiftProgramItemSheet.swift +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -327,6 +327,7 @@ struct LiftProgramItemSheet: View { .buttonStyle(.noopPrimary) .frame(maxWidth: 160) .disabled(!canSave) + .opacity(canSave ? 1 : NoopButtonMetrics.disabledOpacity) .accessibilityLabel("Save exercise") } } diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index 6f41eec456..d7a9ef26ee 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -3,7 +3,7 @@ import StrandDesign import StrandAnalytics import WhoopStore -// One finished session, read back in full: every set as performed, the six session figures, and how +// One finished session, read back in full: every set as performed, the session figures, and how // each exercise compares with the last time you did it. // // This is the screen the whole feature exists to produce. A log book that cannot show you what you @@ -18,8 +18,8 @@ import WhoopStore struct LiftSessionDetailSheet: View { let session: LiftSessionRow - /// Called after the session is deleted, so the hub can reload its list. - var onDeleted: () async -> Void = {} + /// Called after the session is edited or deleted, so the hub can reload its list. + var onChanged: () async -> Void = {} @EnvironmentObject var repo: Repository @Environment(\.dismiss) private var dismiss @@ -32,6 +32,9 @@ struct LiftSessionDetailSheet: View { @State private var loaded = false @State private var confirmingDelete = false @State private var deleting = false + @State private var editing = false + /// Session RPE as stored now. The edit sheet can correct it, and the session load must follow. + @State private var sessionRpe: Double? @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } @@ -52,6 +55,7 @@ struct LiftSessionDetailSheet: View { muscleSection rpeSection footnote + NoopButton("Edit sets", systemImage: "pencil", kind: .secondary) { editing = true } deleteSection } } @@ -63,6 +67,19 @@ struct LiftSessionDetailSheet: View { #endif .background(StrandPalette.surfaceBase) .task { await load() } + .sheet(isPresented: $editing) { + LiftSessionEditSheet(session: storedSession, sets: sets) { + await load() + await onChanged() + } + } + } + + /// The session as stored now, with any corrected RPE. + private var storedSession: LiftSessionRow { + var row = session + row.sessionRpe = sessionRpe + return row } /// Remove a session that should not have been recorded — a mis-tap, or a test. @@ -101,7 +118,7 @@ struct LiftSessionDetailSheet: View { _ = try? await store.deleteLiftSession(id: session.id) // cascades to its sets if let workout { await repo.deleteWorkout(workout) } - await onDeleted() + await onChanged() dismiss() } @@ -143,7 +160,7 @@ struct LiftSessionDetailSheet: View { private var workingSetCount: Int { sets.filter { !$0.isWarmup }.count } private var sessionLoadText: String { - guard let load = LiftMetrics.sessionLoad(sessionRpe: session.sessionRpe, + guard let load = LiftMetrics.sessionLoad(sessionRpe: sessionRpe, durationSec: durationSec) else { return "—" } return String(Int(load.rounded())) } @@ -155,7 +172,7 @@ struct LiftSessionDetailSheet: View { /// "× 2 min" invites the reader to check 8 × 2 = 16 against a displayed 21 and conclude the app /// is making numbers up. private var sessionLoadCaption: String { - guard let rpe = session.sessionRpe else { + guard let rpe = sessionRpe else { return String(localized: "not rated") } let minutes = Double(durationSec) / 60.0 @@ -398,8 +415,16 @@ struct LiftSessionDetailSheet: View { // MARK: - Load private func load() async { - guard let store = await repo.storeHandle() else { loaded = true; return } + guard let store = await repo.storeHandle() else { sessionRpe = session.sessionRpe; loaded = true; return } sets = (try? await store.liftSets(sessionId: session.id)) ?? [] + // Re-read rather than trusting the row this sheet was opened with: the edit sheet can change it. + let stored = try? await store.liftSessions(deviceId: session.deviceId, + fromTs: session.startTs, toTs: session.startTs) + if let row = stored?.first(where: { $0.id == session.id }) { + sessionRpe = row.sessionRpe + } else { + sessionRpe = session.sessionRpe + } // The workout row this session is pinned to, by that table's own natural key. let rows = (try? await store.workouts(deviceId: repo.deviceId, diff --git a/Strand/Screens/LiftSessionEditSheet.swift b/Strand/Screens/LiftSessionEditSheet.swift new file mode 100644 index 0000000000..b70576ed1c --- /dev/null +++ b/Strand/Screens/LiftSessionEditSheet.swift @@ -0,0 +1,218 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Correct a finished session: a number missed or mistyped at the gym, a warm-up not marked, or the +// session's RPE. Only the numbers move — which sets were done, when and in what order stays as it +// happened — and every figure on the session screen is recomputed from these rows. +// +// Fields hold plain text and are parsed once, on Save, so a field is never rewritten while it is being +// typed into (the bug that stored 45.5 kg as 455). Only a field whose text changed is written back: +// re-parsing an untouched pound value into kilograms would otherwise nudge it by a rounding error. + +struct LiftSessionEditSheet: View { + let session: LiftSessionRow + let sets: [LiftSetRow] + /// Called after the changes are written, so the session screen can reload. + let onSaved: () async -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + /// The text of every field, by set id, and what it held when the sheet opened. + @State private var form: [String: SetForm] = [:] + @State private var original: [String: SetForm] = [:] + @State private var sessionRpeText = "" + @State private var originalSessionRpe = "" + @State private var saving = false + + @FocusState private var focused: Field? + private enum Field: Hashable { case weight(String), reps(String), rpe(String), sessionRpe } + + /// One set's fields, as typed. + struct SetForm: Equatable { + var weight: String + var reps: String + var rpe: String + var isWarmup: Bool + } + + private var hasChanges: Bool { form != original || sessionRpeText != originalSessionRpe } + + /// Exercises in the order they were first performed. + private var exercises: [String] { + var seen = Set() + return sets.sorted { $0.ord < $1.ord }.map(\.exercise).filter { seen.insert($0).inserted } + } + + private var weightHeading: LocalizedStringKey { + unitSystem == .imperial ? "Lb" : "Kg" + } + + var body: some View { + ScreenScaffold(title: "Edit sets", + subtitle: "Fix a number you missed or mistyped. The session's figures follow when you save.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + sessionRpeCard + ForEach(exercises, id: \.self) { exerciseCard($0) } + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 780) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .onAppear(perform: fill) + } + + private var sessionRpeCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("How hard was the whole session? (1–10)").strandOverline() + field(.sessionRpe, text: Binding( + get: { sessionRpeText }, + set: { sessionRpeText = $0.replacingOccurrences(of: ",", with: ".") })) + } + } + } + + private func exerciseCard(_ exercise: String) -> some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Text(exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + HStack(spacing: 8) { + Text("Set").strandOverline() + .frame(width: LiftSessionView.setColumnWidth, alignment: .center) + Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ForEach(sets.filter { $0.exercise == exercise }.sorted { $0.ord < $1.ord }, id: \.id) { + setRow($0) + } + } + } + } + + /// The set number toggles a warm-up, as on the session sheet. + private func setRow(_ row: LiftSetRow) -> some View { + let warmup = form[row.id]?.isWarmup ?? row.isWarmup + return HStack(spacing: 8) { + Button { form[row.id]?.isWarmup.toggle() } label: { + Text(warmup ? String(localized: "W") : "\(row.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(warmup ? StrandPalette.metricAmber : StrandPalette.textSecondary) + .frame(width: LiftSessionView.setColumnWidth, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(warmup + ? String(localized: "Warm-up set — tap to make it a working set") + : String(localized: "Set \(row.setIndex) — tap to mark it a warm-up")) + field(.weight(row.id), text: binding(row.id, \.weight)) + field(.reps(row.id), text: binding(row.id, \.reps)) + field(.rpe(row.id), text: binding(row.id, \.rpe)) + } + .padding(.vertical, 6) + } + + private func field(_ target: Field, text: Binding) -> some View { + TextField(Self.empty, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: target) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// A typed comma becomes a point, as on the session sheet: iOS labels the decimal key from the + /// device's region, and the field reads back in the notation the app displays. + private func binding(_ id: String, _ key: WritableKeyPath) -> Binding { + Binding(get: { form[id]?[keyPath: key] ?? "" }, + set: { form[id]?[keyPath: key] = $0.replacingOccurrences(of: ",", with: ".") }) + } + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save changes") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 180) + .disabled(saving || !hasChanges) + .opacity(saving || !hasChanges ? NoopButtonMetrics.disabledOpacity : 1) + } + } + + private func fill() { + guard form.isEmpty else { return } + for row in sets { form[row.id] = Self.form(for: row, system: unitSystem) } + original = form + sessionRpeText = session.sessionRpe.map { LiftFormat.trim($0) } ?? "" + originalSessionRpe = sessionRpeText + } + + private func save() async { + guard !saving, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + let edited = sets.compactMap { row -> LiftSetRow? in + guard let after = form[row.id], let before = original[row.id], after != before else { return nil } + return Self.applying(after, over: before, to: row, system: unitSystem) + } + if !edited.isEmpty { _ = try? await store.upsertLiftSets(edited) } + if sessionRpeText != originalSessionRpe { + var row = session + row.sessionRpe = LiftFormat.number(sessionRpeText) + _ = try? await store.upsertLiftSessions([row]) + } + await onSaved() + dismiss() + } + + /// A set's fields as the sheet opens, in the unit the app displays. + static func form(for row: LiftSetRow, system: UnitSystem) -> SetForm { + SetForm(weight: row.weightKg.map { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) } ?? "", + reps: row.reps.map(String.init) ?? "", + rpe: row.rpe.map { LiftFormat.trim($0) } ?? "", + isWarmup: row.isWarmup) + } + + /// `row` with every field that differs between `before` and `after` parsed back in. A blank field + /// clears its value. + static func applying(_ after: SetForm, over before: SetForm, to row: LiftSetRow, + system: UnitSystem) -> LiftSetRow { + var edited = row + if after.weight != before.weight { + edited.weightKg = LiftFormat.number(after.weight).map { + LiftFormat.kilograms(fromDisplay: $0, system: system) + } + } + if after.reps != before.reps { + edited.reps = Int(after.reps.trimmingCharacters(in: .whitespaces)) + } + if after.rpe != before.rpe { + edited.rpe = LiftFormat.number(after.rpe) + } + edited.isWarmup = after.isWarmup + return edited + } + + private static let empty = "—" +} diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 2a80d71a18..d2a52263f7 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -12,7 +12,7 @@ import WhoopStore // COLOUR CARRIES STATE, so you can find your place at a glance from arm's length: // green the set you are working now // amber the rest that follows it -// done a completed set, with a check and the numbers you entered +// done a completed set, with a check; numbers nobody typed stay grey // // The session itself lives in `LiftSessionController`, ABOVE this view. Swiping this sheet away // minimises it to the bottom bar; the clock, the strap gesture and the buzzes all keep running, @@ -27,12 +27,19 @@ struct LiftSessionView: View { /// Called once the session has been written, so the hub can reload. let onFinished: () async -> Void - /// What the user did for each exercise LAST session — the fallback ghost values, loaded once. - @State private var lastTime: [String: [Int: LiftRecordedSet]] = [:] @State private var showingFinish = false @State private var confirmingDiscard = false @State private var sessionRpeText = "" @State private var saving = false + /// The two questions finishing can ask. Nil until answered: saving waits for an answer rather than + /// deciding for the user. + @State private var unfinishedChoice: UnfinishedChoice? + @State private var programChoice: ProgramChoice? + /// Program lines whose set count this session changed, read when the finish sheet opens. + @State private var setCountChanges: [LiftSessionController.SetCountChange] = [] + + private enum UnfinishedChoice: Hashable { case complete, discard } + private enum ProgramChoice: Hashable { case update, keep } /// For the live heart rate on the control bar. `AppModel.bpm` is the smoothed, spike-filtered /// value every screen is supposed to show — never the raw per-beat number, which swings with HRV. @@ -159,7 +166,7 @@ struct LiftSessionView: View { columnHeadings ForEach(engine.slots(forExercise: index), id: \.self) { slot in - setRow(engine, slot: slot, item: item) + setRow(engine, slot: slot) // The rest belongs BETWEEN two sets, because that is where it happens. if isRestingAfter(engine, slot: slot) { restBand(engine) } } @@ -177,17 +184,16 @@ struct LiftSessionView: View { /// /// The geometry mirrors a set row: the minus sits in the tick column, under the checks it undoes. /// - /// **Both buttons also rewrite the program**, which is the point rather than a side effect — a - /// program is a plan for NEXT time, and the sets you actually chose are the better plan. The - /// running session is unaffected either way; the write-back only changes what the program offers - /// when it is started again. + /// **Both buttons change this session only.** Whether the program keeps the new count is asked + /// when the session is finished: a program is a plan for next time, and one extra set on a good + /// day is not always a new plan. private func setCountRow(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { let canAdd = item.targetSets < LiftSessionEngine.maxSetsPerExercise let canRemove = engine.canRemoveSet(fromExercise: index) return HStack(spacing: 8) { Button { - changeSetCount { session.addSet(toExercise: index) } + session.addSet(toExercise: index) } label: { HStack(spacing: 6) { Image(systemName: "plus.circle") @@ -203,7 +209,7 @@ struct LiftSessionView: View { .accessibilityLabel(String(localized: "Add a set to \(item.exercise)")) Button { - changeSetCount { session.removeSet(fromExercise: index) } + session.removeSet(fromExercise: index) } label: { Image(systemName: "minus.circle") .font(.system(size: 17, weight: .semibold)) @@ -222,43 +228,6 @@ struct LiftSessionView: View { .padding(.horizontal, 8) } - /// Run a change to the set count, then make the program match. - /// - /// One funnel for every path that can move a count — the two buttons and the undo — so the - /// program cannot be left behind by a route someone forgot about. - private func changeSetCount(_ change: () -> Bool) { - guard change() else { return } - Task { await writeSetCountsToProgram() } - } - - /// Write the session's set counts back onto the program behind it. - /// - /// Re-reads the lines first and edits only `targetSets`, so a program edited elsewhere while the - /// session runs keeps every other change, and a line that has since been deleted is skipped - /// rather than resurrected. Writes nothing at all when no count actually differs — the store - /// call replaces the program's lines wholesale, and that is not something to do on every tap. - private func writeSetCountsToProgram() async { - guard let programId = session.programId, let plan = session.engine?.plan, - let store = await repo.storeHandle() else { return } - var wanted: [String: Int] = [:] - for line in plan { - if let id = line.programItemId { wanted[id] = line.targetSets } - } - guard !wanted.isEmpty, - let rows = try? await store.liftProgramItems(programId: programId) else { return } - - var changed = false - let rewritten = rows.map { row -> LiftProgramItemRow in - guard let sets = wanted[row.id], row.targetSets != sets else { return row } - var edited = row - edited.targetSets = sets - changed = true - return edited - } - guard changed else { return } - _ = try? await store.replaceLiftProgramItems(programId: programId, items: rewritten) - } - /// Width of the set-number column, shared by the heading and every row so the number sits /// directly under its label. /// @@ -266,7 +235,7 @@ struct LiftSessionView: View { /// mid-word — a real session photographed it reading "SE / T" over two lines. The headings are /// also `lineLimit(1)` with a scale floor: this row is four short labels across a phone width in /// ten languages, and a wrapped heading breaks the column alignment for every row beneath it. - private static let setColumnWidth: CGFloat = 34 + static let setColumnWidth: CGFloat = 34 /// Width of the trailing tick column. Mirrored by a clear spacer in the heading row so the four /// labels sit over the four things they name. @@ -291,7 +260,7 @@ struct LiftSessionView: View { // MARK: - One set row - private func setRow(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> some View { + private func setRow(_ engine: LiftSessionEngine, slot: LiftSlot) -> some View { let recorded = engine.recordedSet(for: slot) let isWorking = engine.stage == .working(slot) @@ -318,10 +287,10 @@ struct LiftSessionView: View { numberField(slot: slot, field: .weight(slot), text: weightBinding(slot), - ghost: ghostWeight(engine, slot: slot, item: item)) + ghost: ghostWeight(slot)) numberField(slot: slot, field: .reps(slot), text: repsBinding(slot), - ghost: ghostReps(engine, slot: slot, item: item)) + ghost: ghostReps(slot)) numberField(slot: slot, field: .rpe(slot), text: rpeBinding(slot), ghost: ghostRpe(engine, slot: slot)) @@ -419,34 +388,26 @@ struct LiftSessionView: View { // MARK: - Ghost values // - // The placeholder shows what you'd most likely repeat, in priority order: the PREVIOUS SET OF - // THIS EXERCISE IN THIS SESSION first (set 2 almost always mirrors set 1), then the same set - // number last session, then the program's target — the same order, from the same source, as - // `LiftSessionEngine.carry(for:lastSession:)`. + // The grey numbers come from ONE chain, `LiftSessionController.carry(for:)`: this exercise earlier + // in the session (set 2 almost always mirrors set 1), then the same set last session, then the + // program's target. The minimised bar and the Lock Screen read the same chain. // - // These are shown only for a set that has NOT been completed yet: a plan, not a record. Once the - // set is completed the carried numbers become a real entry and the binding below returns them, - // so the row shows what was actually logged rather than a grey suggestion of it. Keep the two - // chains in step — a ghost that does not match what completing the set records is worse than no - // ghost at all. + // A set keeps its grey numbers after it is done, until something is typed over them — grey means + // "not entered". What they are worth is decided when the session is finished: every set without + // typed numbers is completed with them, or discarded, in one choice. - private func ghostWeight(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> String { - if let prev = engine.previousSetInSession(for: slot)?.weightKg { return display(prev) } - if let last = lastTime[item.exercise]?[slot.setIndex]?.weightKg { return display(last) } - if let target = item.targetWeightKg { return display(target) } - return "—" + private func ghostWeight(_ slot: LiftSlot) -> String { + session.carry(for: slot).weightKg.map { display($0) } ?? "—" } - private func ghostReps(_ engine: LiftSessionEngine, slot: LiftSlot, item: LiftPlanItem) -> String { - if let prev = engine.previousSetInSession(for: slot)?.reps { return String(prev) } - if let last = lastTime[item.exercise]?[slot.setIndex]?.reps { return String(last) } - if let target = item.targetRepsLow { return String(target) } - return "—" + private func ghostReps(_ slot: LiftSlot) -> String { + session.carry(for: slot).reps.map(String.init) ?? "—" } + /// RPE is never carried, so its ghost is only the previous set's own rating — a reminder, never a + /// value any set will save. private func ghostRpe(_ engine: LiftSessionEngine, slot: LiftSlot) -> String { - if let prev = engine.previousSetInSession(for: slot)?.rpe { return LiftFormat.trim(prev) } - return "—" + engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" } private func display(_ kg: Double) -> String { @@ -538,11 +499,7 @@ struct LiftSessionView: View { stageClock(engine) heartRate() Spacer(minLength: 0) - Button { - // Through the funnel: undo restores the plan as well as the sets, so taking back - // an added set has to take it back off the program too. - changeSetCount { session.undo(); return true } - } label: { + Button { session.undo() } label: { Image(systemName: "arrow.uturn.backward") .font(.system(size: 15, weight: .semibold)) } @@ -558,7 +515,12 @@ struct LiftSessionView: View { } .buttonStyle(.noopPrimary) - Button { showingFinish = true } label: { + Button { + unfinishedChoice = nil + programChoice = nil + setCountChanges = [] + showingFinish = true + } label: { Text("Finish") } .buttonStyle(NoopButtonStyle(.secondary)) @@ -635,8 +597,11 @@ struct LiftSessionView: View { // MARK: - Finish private var finishSheet: some View { - ScreenScaffold(title: "Finish session", - subtitle: "One number for the whole session, so a leg day can be compared with a run.") { + let unfinished = session.unfinishedSlots.count + let answered = (unfinished == 0 || unfinishedChoice != nil) + && (setCountChanges.isEmpty || programChoice != nil) + return ScreenScaffold(title: "Finish session", + subtitle: "One number for the whole session, so a leg day can be compared with a run.") { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.gap) { @@ -653,16 +618,26 @@ struct LiftSessionView: View { .fixedSize(horizontal: false, vertical: true) } } + if unfinished > 0 { unfinishedCard(count: unfinished) } + if !setCountChanges.isEmpty { programCard } + HStack { Button("Skip") { Task { await save() } } .buttonStyle(.plain) .font(StrandFont.body) .foregroundStyle(StrandPalette.textSecondary) + .disabled(saving || !answered) Spacer() Button("Save session") { Task { await save() } } .buttonStyle(.noopPrimary) .frame(maxWidth: 180) - .disabled(saving) + .disabled(saving || !answered) + .opacity(saving || !answered ? NoopButtonMetrics.disabledOpacity : 1) + } + if !answered { + Text("Choose an option above to save.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) } // A way OUT that records nothing. Until this existed, every route off this screen @@ -699,14 +674,65 @@ struct LiftSessionView: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) + .task { await loadSetCountChanges() } + } + + /// Sets nobody typed a number into. One choice covers all of them, because what matters at the end + /// of a session is simply whether they happened: complete them with the numbers the sheet showed, + /// or leave them out. + private func unfinishedCard(count: Int) -> some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("Unfinished sets").strandOverline() + Text("\(count) sets have no numbers typed in — sets you did not start, or finished without typing.") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Picker("Unfinished sets", selection: $unfinishedChoice) { + Text("Complete them").tag(UnfinishedChoice?.some(.complete)) + Text("Discard them").tag(UnfinishedChoice?.some(.discard)) + } + .pickerStyle(.segmented) + .labelsHidden() + Text("Completing saves them with the grey numbers shown. Discarding leaves them out of the session.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// Set counts changed with ⊕/⊖ during the session. The program keeps them only if asked to. + private var programCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("Program").strandOverline() + Text("You changed the number of sets. Keep the new counts in the program for next time?") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + ForEach(setCountChanges, id: \.itemId) { change in + Text("\(change.exercise): \(change.from) → \(change.to) sets") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textSecondary) + } + Picker("Program", selection: $programChoice) { + Text("Update program").tag(ProgramChoice?.some(.update)) + Text("Keep as it was").tag(ProgramChoice?.some(.keep)) + } + .pickerStyle(.segmented) + .labelsHidden() + } + } } // MARK: - Loading and saving - /// What was lifted for each of this session's exercises LAST time, indexed by set number. + /// What was lifted for each of this session's exercises LAST time, by set number — the middle + /// layer of the grey numbers, handed to the controller that owns the chain. private func loadLastTime() async { guard let engine, let store = await repo.storeHandle() else { return } - var out: [String: [Int: LiftRecordedSet]] = [:] + var out: [String: [Int: LiftSetCarry]] = [:] // One query per DISTINCT exercise, not per plan line. A program that programs the same // movement twice — or an imported one with many lines — would otherwise re-ask the store the // same question, and this runs when the sheet opens. @@ -714,21 +740,13 @@ struct LiftSessionView: View { let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, exercise: exercise, before: engine.startTs)) ?? [] - var bySet: [Int: LiftRecordedSet] = [:] + var bySet: [Int: LiftSetCarry] = [:] for r in rows where !r.isWarmup { - bySet[r.setIndex] = LiftRecordedSet( - exerciseIndex: 0, setIndex: r.setIndex, weightKg: r.weightKg, reps: r.reps, - rpe: r.rpe, isWarmup: r.isWarmup, startTs: r.startTs ?? 0, - endTs: r.endTs ?? 0, restSec: r.restSec) + bySet[r.setIndex] = LiftSetCarry(weightKg: r.weightKg, reps: r.reps) } out[exercise] = bySet } - lastTime = out - // The controller needs this too: the strap can complete a set while this sheet is minimised, - // and a set recorded that way must carry the same numbers the sheet was showing. - session.setLastSession(out.mapValues { bySet in - bySet.mapValues { LiftSetCarry(weightKg: $0.weightKg, reps: $0.reps) } - }) + session.setLastSession(out) } private func save() async { @@ -740,6 +758,8 @@ struct LiftSessionView: View { guard let engine = session.engine else { return } let endTs = Int(Date().timeIntervalSince1970) let sessionId = UUID().uuidString + // After `finish`, which closes out the running rest: that set's measured rest belongs to it. + let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) let row = LiftSessionRow( id: sessionId, deviceId: repo.deviceId, @@ -752,8 +772,9 @@ struct LiftSessionView: View { _ = try? await store.upsertLiftSessions([row]) // `ord` is COMPLETION order, which with out-of-order work is not the plan's order — and it - // is the order that actually happened, which is what a session should read back as. - let rows = engine.sets.enumerated().map { ord, s -> LiftSetRow in + // is the order that actually happened, which is what a session should read back as. Sets + // completed at finish without being started come last. + let rows = finished.enumerated().map { ord, s -> LiftSetRow in let item = engine.planItem(for: s.slot) return LiftSetRow( id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, @@ -762,11 +783,14 @@ struct LiftSessionView: View { // past weeks were counted as. primaryMuscle: item?.primaryMuscle, secondaryMuscles: item?.secondaryMuscles ?? [], - setIndex: s.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, + setIndex: s.slot.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, isWarmup: s.isWarmup, startTs: s.startTs, endTs: s.endTs, restSec: s.restSec, note: nil) } _ = try? await store.upsertLiftSets(rows) + if programChoice == .update { + await writeSetCountsToProgram(store: store, plan: engine.plan) + } // Through the SAME path a manual workout takes, so it inherits overlap dedup, the engine's // HR-derived strain fill and delete/merge. `strain` stays nil deliberately: the engine fills @@ -785,6 +809,28 @@ struct LiftSessionView: View { dismiss() } + /// The program lines whose set count this session changed, for the finish sheet to ask about. + private func loadSetCountChanges() async { + guard let programId = session.programId, let plan = session.engine?.plan, + let store = await repo.storeHandle(), + let rows = try? await store.liftProgramItems(programId: programId) else { return } + setCountChanges = LiftSessionController.setCountChanges(plan: plan, program: rows) + } + + /// Save this session's set counts onto its program — only when the user chose to. + /// + /// Re-reads the lines and moves only `targetSets`, so a program edited elsewhere while the session + /// ran keeps every other change and a line deleted since is not resurrected. The store call + /// replaces the lines wholesale, so nothing is written when no count differs. + private func writeSetCountsToProgram(store: WhoopStore, plan: [LiftPlanItem]) async { + guard let programId = session.programId, + let rows = try? await store.liftProgramItems(programId: programId) else { return } + let changes = LiftSessionController.setCountChanges(plan: plan, program: rows) + guard !changes.isEmpty else { return } + _ = try? await store.replaceLiftProgramItems( + programId: programId, items: LiftSessionController.applying(changes, to: rows)) + } + /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer /// uses, so a typed session and an imported one land in one bucket with one icon. static let sport = "Strength Training" diff --git a/StrandTests/LiftSessionEditTests.swift b/StrandTests/LiftSessionEditTests.swift new file mode 100644 index 0000000000..deab70005a --- /dev/null +++ b/StrandTests/LiftSessionEditTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Correcting a finished session. The parsing is the part that can quietly store the wrong number. +@MainActor +final class LiftSessionEditTests: XCTestCase { + + private func row(weightKg: Double? = 60, reps: Int? = 8, rpe: Double? = nil) -> LiftSetRow { + LiftSetRow(id: "s1", deviceId: "d", sessionId: "x", ord: 0, exercise: "Squat", + primaryMuscle: .quads, setIndex: 1, weightKg: weightKg, reps: reps, rpe: rpe, + isWarmup: false, startTs: 100, endTs: 140, restSec: 90, note: nil) + } + + /// Opening and saving an untouched pound value must not nudge the stored kilograms. + func testAnUntouchedFieldIsNotWrittenBack() { + let before = LiftSessionEditSheet.form(for: row(), system: .imperial) + var after = before + after.reps = "10" + let edited = LiftSessionEditSheet.applying(after, over: before, to: row(), system: .imperial) + XCTAssertEqual(edited.weightKg, 60, "exactly the stored kilograms, not a round trip through pounds") + XCTAssertEqual(edited.reps, 10) + } + + func testAChangedWeightIsReadInTheDisplayUnit() { + let before = LiftSessionEditSheet.form(for: row(), system: .imperial) + var after = before + after.weight = "135" + let edited = LiftSessionEditSheet.applying(after, over: before, to: row(), system: .imperial) + XCTAssertEqual(edited.weightKg ?? 0, LiftFormat.kilograms(fromDisplay: 135, system: .imperial), + accuracy: 1e-9) + } + + func testADecimalCommaAndAClearedFieldAreHonoured() { + let before = LiftSessionEditSheet.form(for: row(rpe: 8), system: .metric) + var after = before + after.weight = "62,5" + after.rpe = "" + let edited = LiftSessionEditSheet.applying(after, over: before, to: row(rpe: 8), system: .metric) + XCTAssertEqual(edited.weightKg, 62.5) + XCTAssertNil(edited.rpe, "a cleared field clears the value rather than keeping the old one") + } + + func testMarkingAWarmUpAfterTheFactApplies() { + let before = LiftSessionEditSheet.form(for: row(), system: .metric) + var after = before + after.isWarmup = true + XCTAssertTrue(LiftSessionEditSheet.applying(after, over: before, to: row(), system: .metric).isWarmup) + } +} diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift index 8d9d38a728..c1edd8837f 100644 --- a/StrandTests/LiftSessionEngineTests.swift +++ b/StrandTests/LiftSessionEngineTests.swift @@ -98,45 +98,61 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertEqual(e.slotAfter(slot(2, 2)), slot(0, 1), "exhausted -> first pending in plan order") } - // MARK: - Carrying the shown numbers onto a completed set + // MARK: - Grey numbers + // + // A finished set records its timing only. Its numbers stay grey until typed, and what a set without + // typed numbers saves is decided when the session is finished (`LiftSessionFinishTests`). - /// A set completed with nothing typed records the numbers the sheet was showing in grey. Before - /// this, 19 sets from a real session saved with weight and reps NIL — the sheet displayed - /// "50 x 10" the whole time and stored nothing, so the session's volume was zero. - func testCompletingASetWithoutTypingRecordsTheProgramTarget() { + /// Finishing a set without typing writes nothing into it: the program's target stays a grey + /// suggestion the user can type straight over, and still counts as the numbers the sheet showed. + func testFinishingASetWithoutTypingLeavesItsNumbersGrey() { var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) e.advance(now: t0 + 10) // warm-up -> working set 1 e.advance(now: t0 + 70) // set done let row = e.recordedSet(for: slot(0, 1)) - XCTAssertEqual(row?.weightKg, 50) - XCTAssertEqual(row?.reps, 10) + XCTAssertNil(row?.weightKg, "a grey number is not an entry") + XCTAssertNil(row?.reps) + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry(weightKg: 50, reps: 10)) + XCTAssertEqual(e.unenteredSlots.first, slot(0, 1)) } - /// The second set carries what the FIRST set actually was, not the plan — if you dropped to - /// 45 kg, set 2 follows you down rather than snapping back to the program. - func testASetCarriesWhatTheExerciseActuallyDidEarlierInTheSession() { + /// The second set follows what the FIRST set counts as — if you dropped to 45 kg, set 2 follows + /// you down rather than snapping back to the program. + func testGreyNumbersFollowWhatTheExerciseDidEarlierInTheSession() { var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) e.advance(now: t0 + 10) e.advance(now: t0 + 70) e.updateSet(slot(0, 1), weightKg: 45, reps: 8, rpe: 9, isWarmup: false) - e.advance(now: t0 + 130) // rest done -> set 2 - e.advance(now: t0 + 190) // set 2 done - let row = e.recordedSet(for: slot(0, 2)) - XCTAssertEqual(row?.weightKg, 45, "the session's own history outranks the program's plan") - XCTAssertEqual(row?.reps, 8) + XCTAssertEqual(e.carry(for: slot(0, 2), lastSession: [:]), LiftSetCarry(weightKg: 45, reps: 8), + "the session's own history outranks the program's plan") } - /// The store's answer sits between this session and the program target. - func testLastSessionIsUsedWhenTheSessionHasNoEarlierSetForTheExercise() { + /// An untyped set 1 still leads set 2, and correcting set 1 later moves set 2's grey numbers with it: + /// nothing was written into set 2 that would have to be typed over. + func testCorrectingAnEarlierSetMovesTheGreyNumbersAfterIt() { var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) e.advance(now: t0 + 10) - e.advance(now: t0 + 70, lastSession: LiftSetCarry(weightKg: 52.5, reps: 9)) + e.advance(now: t0 + 70) // set 1 done, untyped + e.advance(now: t0 + 130) + e.advance(now: t0 + 190) // set 2 done, untyped + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 50) - let row = e.recordedSet(for: slot(0, 1)) - XCTAssertEqual(row?.weightKg, 52.5, "last session beats the program's target") - XCTAssertEqual(row?.reps, 9) + e.updateSet(slot(0, 1), weightKg: 42.5, reps: nil, rpe: nil, isWarmup: false) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 42.5) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).reps, 10, + "a field left alone keeps following its own chain") + } + + /// The store's answer sits between this session and the program target. + func testLastSessionIsUsedWhenTheSessionHasNoEarlierSetForTheExercise() { + let e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + let last = [1: LiftSetCarry(weightKg: 52.5, reps: 9)] + XCTAssertEqual(e.carry(for: slot(0, 1), lastSession: last), LiftSetCarry(weightKg: 52.5, reps: 9), + "last session beats the program's target") + XCTAssertEqual(e.carry(for: slot(0, 3), lastSession: last), LiftSetCarry(weightKg: 50, reps: 10), + "a set number last session did not have falls through to the target") } /// RPE is never carried: it is how hard a set FELT, which nothing can know in advance, and @@ -149,7 +165,7 @@ final class LiftSessionEngineTests: XCTestCase { e.advance(now: t0 + 130) e.advance(now: t0 + 190) - XCTAssertEqual(e.recordedSet(for: slot(0, 2))?.weightKg, 50, "weight carries") + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 50, "weight carries") XCTAssertNil(e.recordedSet(for: slot(0, 2))?.rpe, "the felt effort of a set does not") } @@ -160,21 +176,26 @@ final class LiftSessionEngineTests: XCTestCase { e.advance(now: t0 + 10) e.advance(now: t0 + 70) - XCTAssertNil(e.recordedSet(for: slot(0, 1))?.weightKg) - XCTAssertNil(e.recordedSet(for: slot(0, 1))?.reps) + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry.none) } - /// A carried value is a normal entry: typing over it wins, including typing a 0 for a set that - /// was planned but not actually performed. - func testTypingZeroOverAcarriedValueSticks() { + /// A typed value beats the grey one, including a 0 for a set that was planned but not performed. + func testTypingZeroOverAGreyValueSticks() { var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) e.advance(now: t0 + 10) e.advance(now: t0 + 70) - XCTAssertEqual(e.recordedSet(for: slot(0, 1))?.weightKg, 50) - e.updateSet(slot(0, 1), weightKg: 0, reps: 0, rpe: nil, isWarmup: false) - XCTAssertEqual(e.recordedSet(for: slot(0, 1))?.weightKg, 0) - XCTAssertEqual(e.recordedSet(for: slot(0, 1))?.reps, 0) + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry(weightKg: 0, reps: 0)) + } + + /// Only a set with nothing typed at all is unentered; a rating alone counts as an entry. + func testUnenteredSlotsAreTheOnesNobodyTypedInto() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 1), now: t0); e.advance(now: t0 + 40) // done, untyped + e.start(slot(0, 2), now: t0 + 100); e.advance(now: t0 + 140) + e.updateSet(slot(0, 2), weightKg: nil, reps: nil, rpe: 8, isWarmup: false) + XCTAssertEqual(e.unenteredSlots, [slot(0, 1), slot(1, 1)], + "an untyped finished set and a never-started one; the rated set is entered") } // MARK: - The default in-order path @@ -506,10 +527,11 @@ final class LiftSessionEngineTests: XCTestCase { XCTAssertFalse(e.allCompleted, "and now it is not — there is one more to do") e.advance(now: t0 + 160) // out of the rest, into set 2 XCTAssertEqual(e.stage, .working(slot(0, 2))) - e.advance(now: t0 + 200, lastSession: LiftSetCarry(weightKg: 20, reps: 12)) + e.advance(now: t0 + 200) XCTAssertEqual(e.sets.count, 2) XCTAssertEqual(e.sets.last?.setIndex, 2) - XCTAssertEqual(e.sets.last?.weightKg, 20, "an added set carries like any other") + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [2: LiftSetCarry(weightKg: 20, reps: 12)]).reps, 12, + "an added set shows grey numbers like any other") } func testAddingSetsStopsAtTheBound() { diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift new file mode 100644 index 0000000000..3746387650 --- /dev/null +++ b/StrandTests/LiftSessionFinishTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Finishing a session: what saves, and whether the program keeps a changed set count. +/// +/// Asked for after a real session, 15 Sep 2026: grey numbers stay grey during the session, finishing +/// asks once whether the sets without typed numbers are completed with them or left out ("the user +/// might not complete all the workout, just a couple of exercises"), and a set count changed with ⊕/⊖ +/// reaches the program only if the user says so. +@MainActor +final class LiftSessionFinishTests: XCTestCase { + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// Bench 3 sets and rows 2 sets, both with targets and program lines behind them. + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50, programItemId: "bench"), + LiftPlanItem(exercise: "Row", primaryMuscle: .lats, targetSets: 2, + restSec: 60, targetRepsLow: 12, targetWeightKg: 40, programItemId: "row")] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + /// Bench set 1 done with only its weight typed, bench set 2 done untyped, the rest never started. + private func halfDoneSession() -> LiftSessionController { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + c.advance() // bench 1 working + c.updateSet(slot(0, 1), weightKg: 55, reps: nil, rpe: nil, isWarmup: false) + c.advance() // bench 1 done, resting + c.advance() // bench 2 working + c.advance() // bench 2 done, untyped + return c + } + + func testUnfinishedSetsAreTheUntypedAndTheNeverStarted() { + XCTAssertEqual(halfDoneSession().unfinishedSlots, + [slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + } + + /// A set with anything typed always saves, and a blank field takes its grey number rather than + /// saving empty. + func testATypedSetSavesEitherWayWithItsBlanksFilled() { + let c = halfDoneSession() + for completing in [false, true] { + let bench1 = c.setsToSave(completingUnfinished: completing).first { $0.slot == slot(0, 1) } + XCTAssertEqual(bench1?.weightKg, 55) + XCTAssertEqual(bench1?.reps, 10, "the untyped reps take the grey target, not nil") + XCTAssertNotNil(bench1?.startTs) + } + } + + func testDiscardingLeavesEveryUnfinishedSetOut() { + let saved = halfDoneSession().setsToSave(completingUnfinished: false) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1)]) + } + + /// Completing saves the untyped and the never-started sets with the grey numbers the sheet showed: + /// bench follows bench set 1, rows take their target. Performed sets keep their order and timing; + /// the never-started ones follow, with no timing to invent. + func testCompletingSavesThemWithTheirGreyNumbers() { + let saved = halfDoneSession().setsToSave(completingUnfinished: true) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1), slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + + let bench2 = saved[1] + XCTAssertEqual(bench2.weightKg, 55, "set 2 follows what set 1 counts as") + XCTAssertEqual(bench2.reps, 10) + XCTAssertNotNil(bench2.endTs, "it was performed, so its timing is real") + + let row1 = saved[3] + XCTAssertEqual(row1.weightKg, 40) + XCTAssertEqual(row1.reps, 12) + XCTAssertNil(row1.startTs, "never started: no moment to record") + XCTAssertNil(row1.restSec) + XCTAssertNil(row1.rpe, "RPE is never invented") + } + + /// Numbers typed in advance and a warm-up mark still count for a set completed at finish. + func testCompletingUsesWhatWasTypedAndMarkedInAdvance() { + let c = halfDoneSession() + c.updateSet(slot(1, 1), weightKg: 42.5, reps: nil, rpe: 7, isWarmup: false) + c.setWarmup(slot(1, 2), true) + + let saved = c.setsToSave(completingUnfinished: true) + let row1 = saved.first { $0.slot == slot(1, 1) } + XCTAssertEqual(row1?.weightKg, 42.5) + XCTAssertEqual(row1?.reps, 12, "the field left alone is still grey") + XCTAssertEqual(row1?.rpe, 7) + XCTAssertEqual(saved.first { $0.slot == slot(1, 2) }?.isWarmup, true) + } + + /// Nothing unfinished means nothing to ask, and every set saves. + func testAFullyTypedSessionHasNothingUnfinished() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], programId: nil, programName: nil) + c.advance() + c.updateSet(slot(0, 1), weightKg: 12, reps: 12, rpe: nil, isWarmup: false) + c.advance() + XCTAssertTrue(c.unfinishedSlots.isEmpty) + XCTAssertEqual(c.setsToSave(completingUnfinished: false).count, 1) + } + + /// The minimised bar and the Lock Screen show a finished, untyped set's grey numbers, not a blank. + func testTheBarShowsGreyNumbersForAnUntypedSet() { + let c = halfDoneSession() + XCTAssertEqual(c.setNumbers(for: slot(0, 2), system: .metric), "10 x 55 kg") + } + + // MARK: - The program's set counts + + private func item(_ id: String, _ exercise: String, sets: Int?) -> LiftProgramItemRow { + LiftProgramItemRow(id: id, deviceId: "d", programId: "p", ord: 0, exercise: exercise, + targetSets: sets, targetRepsLow: 10, targetRepsHigh: nil, targetRpe: nil, + targetWeightKg: 50, restSec: 60, note: "keep me") + } + + func testOnlyLinesWhoseCountMovedAreOffered() { + var lines = plan() + lines[0].targetSets = 4 // bench 3 -> 4 + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + XCTAssertEqual(LiftSessionController.setCountChanges(plan: lines, program: rows), + [.init(itemId: "bench", exercise: "Bench press", from: 3, to: 4)]) + } + + /// A line with no count starts a session with one set, so one set is no change; a line deleted + /// from the program since the session began is never offered back. + func testAMissingCountIsOneAndADeletedLineIsSkipped() { + let lines = [LiftPlanItem(exercise: "Curl", targetSets: nil, programItemId: "curl"), + LiftPlanItem(exercise: "Gone", targetSets: 5, programItemId: "gone")] + XCTAssertTrue(LiftSessionController.setCountChanges( + plan: lines, program: [item("curl", "Curl", sets: nil)]).isEmpty) + } + + func testApplyingMovesOnlyTheSetCount() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let changed = LiftSessionController.applying( + [.init(itemId: "bench", exercise: "Bench press", from: 3, to: 5)], to: rows) + XCTAssertEqual(changed[0].targetSets, 5) + XCTAssertEqual(changed[0].note, "keep me") + XCTAssertEqual(changed[0].targetWeightKg, 50) + XCTAssertEqual(changed[1].targetSets, 2, "an unchanged line stays as it was") + } + + /// The hub reloads on this: a saved session must announce itself, and a discarded one must not. + func testSavingASessionIsAnnouncedButDiscardingIsNot() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: nil) + c.discard() + XCTAssertEqual(c.savedSessions, 0) + c.start(plan: plan(), programId: nil, programName: nil) + c.finishedSaving() + XCTAssertEqual(c.savedSessions, 1) + } +} diff --git a/StrandTests/LiftSessionPendingInputTests.swift b/StrandTests/LiftSessionPendingInputTests.swift index e30917b69f..6db2ea7e2a 100644 --- a/StrandTests/LiftSessionPendingInputTests.swift +++ b/StrandTests/LiftSessionPendingInputTests.swift @@ -77,8 +77,9 @@ final class LiftSessionPendingInputTests: XCTestCase { c.advance() XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 70) - XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.reps, 9, - "reps nobody typed still come from the carry, not from nowhere") + XCTAssertNil(c.engine?.recordedSet(for: slot(0, 1))?.reps, "reps nobody typed stay grey") + XCTAssertEqual(c.values(of: slot(0, 1)).reps, 9, + "and still count as the carried value, not as nothing") } /// Clearing the field puts the row back to showing the plan's grey ghost, rather than pinning an From fed714cb59388d5666678bb5029ab76ed2b99082 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 15 Sep 2026 16:07:05 +1200 Subject: [PATCH 31/31] lift log: do not file a session that saved no sets Discarding the unentered sets can empty a session completely. A session run face-down and advanced entirely on the strap has nothing typed, so every slot is unentered and "Discard them" leaves no set behind. That path still wrote a LiftSessionRow with no sets AND a manual workout, and the engine fills a workout's strain from the heart rate the strap measured, so an hour that recorded nothing read back as a workout with a strain on it. Nothing to file, so file nothing: the session row, the set rows and the workout are all skipped when no set survives. The program's set counts are a separate choice the user made explicitly on the second question, so those still apply, and the session is torn down and dismissed exactly as it is after a normal save. That teardown now lives in one place so the two paths cannot drift. Test pins the precondition the guard reads: a five-set session advanced entirely on the strap with nothing typed saves nothing when discarding, and still saves all five when completing. --- Strand/Screens/LiftSessionView.swift | 20 ++++++++++++++++++++ StrandTests/LiftSessionFinishTests.swift | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index d2a52263f7..28b9bc44b0 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -761,6 +761,20 @@ struct LiftSessionView: View { // After `finish`, which closes out the running rest: that set's measured rest belongs to it. let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) + // Nothing to file, so file nothing. Discarding can empty a session completely: a session run + // face-down and advanced entirely on the strap has nothing typed, so every slot is unentered + // and "Discard them" leaves no set behind. Filing it anyway wrote a session row with no sets + // AND a manual workout, and the engine fills that workout's strain from the heart rate the + // strap measured — so an hour that recorded nothing still read back as a workout. The + // program's set counts are a separate thing the user chose explicitly, so those still apply. + guard !finished.isEmpty else { + if programChoice == .update { + await writeSetCountsToProgram(store: store, plan: engine.plan) + } + await finishAndDismiss() + return + } + let row = LiftSessionRow( id: sessionId, deviceId: repo.deviceId, startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, @@ -802,6 +816,12 @@ struct LiftSessionView: View { distanceM: nil, zonesJSON: nil, notes: session.programName, steps: nil) await repo.saveManualWorkout(workout) + await finishAndDismiss() + } + + /// Close the session down and leave the sheet. Shared by the normal save and the nothing-to-file + /// path above, so the two cannot drift about what ending a session means. + private func finishAndDismiss() async { session.finishedSaving() await repo.refresh() await onFinished() diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift index 3746387650..65fa330ba8 100644 --- a/StrandTests/LiftSessionFinishTests.swift +++ b/StrandTests/LiftSessionFinishTests.swift @@ -42,6 +42,21 @@ final class LiftSessionFinishTests: XCTestCase { return c } + /// A session run face-down, every set advanced on the strap and nothing typed. Discarding then + /// leaves NOTHING: this is the precondition `LiftSessionView.save` guards on, because filing it + /// wrote a session with no sets and a manual workout the engine would fill strain into, so an + /// hour that recorded nothing read back as a workout. Completing still saves all five. + func testAFaceDownSessionDiscardingSavesNothingAtAll() { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + for _ in 0..<10 { c.advance() } // every set worked, none typed + XCTAssertEqual(c.unfinishedSlots.count, 5, "nothing typed, so every slot is unentered") + XCTAssertTrue(c.setsToSave(completingUnfinished: false).isEmpty, + "discarding an all-untyped session must leave no set to file") + XCTAssertEqual(c.setsToSave(completingUnfinished: true).count, 5, + "completing still files every set with its grey numbers") + } + func testUnfinishedSetsAreTheUntypedAndTheNeverStarted() { XCTAssertEqual(halfDoneSession().unfinishedSlots, [slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)])