diff --git a/Sources/RunCoach/Localizable.xcstrings b/Sources/RunCoach/Localizable.xcstrings index 936da48..f1e618e 100644 --- a/Sources/RunCoach/Localizable.xcstrings +++ b/Sources/RunCoach/Localizable.xcstrings @@ -33,18 +33,6 @@ } } }, - "%@ · %@ week%@ to go" : { - "comment" : "A date and a count of weeks remaining.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ · %2$@ week%3$@ to go" - } - } - } - }, "%@ (%@)" : { "comment" : "A label that describes the meaning of a fitness value. The first argument is the string “Fitness”, the string “Fatigue” or the string “Form”. The second argument is the string “CTL”, the string “ATL” or the string “TSB”.", "isCommentAutoGenerated" : true, @@ -451,6 +439,10 @@ "comment" : "A button to regenerate the current week's plan.", "isCommentAutoGenerated" : true }, + "Regenerating your race plan…" : { + "comment" : "A", + "isCommentAutoGenerated" : true + }, "Remember this?" : { }, @@ -461,6 +453,10 @@ "comment" : "A button that restores purchases.", "isCommentAutoGenerated" : true }, + "Retry" : { + "comment" : "A button to refresh the page.", + "isCommentAutoGenerated" : true + }, "RPE %@" : { "comment" : "A label that shows the rate of perceived exertion for a workout.", "isCommentAutoGenerated" : true diff --git a/Sources/RunCoach/SettingsView.swift b/Sources/RunCoach/SettingsView.swift index 693f041..7ae155c 100644 --- a/Sources/RunCoach/SettingsView.swift +++ b/Sources/RunCoach/SettingsView.swift @@ -17,7 +17,6 @@ struct SettingsView: View { @State private var showDeleteConfirm = false @State private var deleteError: String? @State private var showSignIn = false - @State private var thresholds = AthleteThresholdsStore.load() @State private var showTrainingStatusSheet = false // Build 50 (R2, coach memory v1): editable list of remembered constraints. @State private var memory: CoachMemory = CoachMemoryStore.load() @@ -92,7 +91,7 @@ struct SettingsView: View { Text("Pro unlocks goal-length macro plans, race-day plans, season recaps, and on-demand weekly-plan regeneration.") } - thresholdsSection + ThresholdsView(model: model) injuryStatusSection @@ -279,138 +278,6 @@ struct SettingsView: View { return f }() - // MARK: - Training thresholds - - private static func mmss(_ sec: Int) -> String { "\(sec / 60):\(String(format: "%02d", sec % 60))" } - - /// Binding over an optional-Int threshold field: any adjustment marks the - /// set confirmed (so it's no longer re-estimated) and backs it up. - private func thresholdBinding(_ keyPath: WritableKeyPath, default def: Int) -> Binding { - Binding( - get: { thresholds[keyPath: keyPath] ?? def }, - set: { - thresholds[keyPath: keyPath] = $0 - thresholds.confirmed = true - thresholds.confirmedAt = Date() - AthleteThresholdsStore.save(thresholds) - StateSync.shared.requestPush() - } - ) - } - - @ViewBuilder private var thresholdsSection: some View { - Section { - if thresholds.isEmpty { - Text("We'll estimate these from your recent training — check back after a few logged sessions.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - if thresholds.runThresholdPaceSecPerKm != nil { - Stepper(value: thresholdBinding(\.runThresholdPaceSecPerKm, default: 300), in: 150...600, step: 5) { - LabeledContent("Run threshold pace", value: "\(Self.mmss(thresholds.runThresholdPaceSecPerKm!)) /km") - } - } - if thresholds.ftpWatts != nil { - Stepper(value: thresholdBinding(\.ftpWatts, default: 200), in: 60...600, step: 5) { - LabeledContent("Cycling FTP", value: "\(thresholds.ftpWatts!) W") - } - } - if thresholds.swimCSSPer100mSec != nil { - Stepper(value: thresholdBinding(\.swimCSSPer100mSec, default: 120), in: 45...240, step: 1) { - LabeledContent("Swim CSS", value: "\(Self.mmss(thresholds.swimCSSPer100mSec!)) /100m") - } - } - if thresholds.thresholdHR != nil { - Stepper(value: thresholdBinding(\.thresholdHR, default: 160), in: 90...220, step: 1) { - LabeledContent("Threshold HR", value: "\(thresholds.thresholdHR!) bpm") - } - } - if thresholds.confirmed { - Label("Confirmed", systemImage: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - .font(.subheadline) - } else { - Button("These look right — confirm") { - thresholds.confirmed = true - thresholds.confirmedAt = Date() - AthleteThresholdsStore.save(thresholds) - StateSync.shared.requestPush() - } - } - - // Build 41 (H2 #11): a confirmed set otherwise freezes - // forever — this surfaces once it's stale AND recent - // training suggests it's meaningfully off, in either - // direction (fitness regresses as well as improves). - if let suggestion = model.thresholdUpdateSuggestion { - thresholdUpdateNudge(suggestion) - } - } - } header: { - Text("Training thresholds") - } footer: { - Text("Your coach builds every pace, power and HR target from these. We estimate them from your recent training — adjust any that look off, then confirm.") - } - } - - private func thresholdUpdateNudge(_ suggestion: AthleteThresholds) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("Your recent training suggests these may have changed.") - .font(.subheadline) - // Build 42, review fix #3: filter rows by the SAME per-field noise - // floor `HomePolicy.shouldSuggestThresholdUpdate` uses to trigger - // the nudge in the first place — otherwise a field that didn't - // clear the floor (and so wasn't why the nudge fired) could still - // show a sub-noise "165 → 166" row here, a change smaller than - // the athlete could even dial in by hand with the Stepper below. - VStack(alignment: .leading, spacing: 2) { - if let current = thresholds.runThresholdPaceSecPerKm, let new = suggestion.runThresholdPaceSecPerKm, abs(new - current) >= 5 { - Text("Run threshold: \(Self.mmss(current)) → \(Self.mmss(new)) /km").font(.caption).foregroundStyle(.secondary) - } - if let current = thresholds.ftpWatts, let new = suggestion.ftpWatts, abs(new - current) >= 5 { - Text("Cycling FTP: \(current) → \(new) W").font(.caption).foregroundStyle(.secondary) - } - if let current = thresholds.swimCSSPer100mSec, let new = suggestion.swimCSSPer100mSec, abs(new - current) >= 2 { - Text("Swim CSS: \(Self.mmss(current)) → \(Self.mmss(new)) /100m").font(.caption).foregroundStyle(.secondary) - } - if let current = thresholds.thresholdHR, let new = suggestion.thresholdHR, abs(new - current) >= 3 { - Text("Threshold HR: \(current) → \(new) bpm").font(.caption).foregroundStyle(.secondary) - } - } - HStack { - Button("Update") { applyThresholdSuggestion(suggestion) } - .buttonStyle(.borderedProminent) - Button("Keep current") { keepCurrentThresholds() } - .buttonStyle(.bordered) - } - } - .padding(.vertical, 4) - } - - /// Applies the re-estimate and re-confirms — same save path as adjusting - /// a field by hand, so it stays in sync with StateSync the same way. - /// Build 42, review fix #1: merges field-by-field (`merging`) rather than - /// replacing the whole struct, so a field the re-estimate has no recent - /// data for isn't silently nulled out. - private func applyThresholdSuggestion(_ suggestion: AthleteThresholds) { - thresholds = thresholds.merging(suggestion) - thresholds.confirmed = true - thresholds.confirmedAt = Date() - AthleteThresholdsStore.save(thresholds) - StateSync.shared.requestPush() - model.thresholdUpdateSuggestion = nil - } - - /// "I looked at this, current values are still fine" — resets the - /// staleness clock without changing any values, so the nudge won't - /// reappear for another `AthleteThresholds.staleAfterDays`. - private func keepCurrentThresholds() { - thresholds.confirmedAt = Date() - AthleteThresholdsStore.save(thresholds) - StateSync.shared.requestPush() - model.thresholdUpdateSuggestion = nil - } - private var injuryStatusSection: some View { Section { Button { diff --git a/Sources/RunCoach/ThresholdsView.swift b/Sources/RunCoach/ThresholdsView.swift new file mode 100644 index 0000000..5d64d9c --- /dev/null +++ b/Sources/RunCoach/ThresholdsView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +/// The "Training thresholds" section — run pace / cycling FTP / swim CSS / +/// threshold HR, each estimated from recent training and adjustable by hand. +/// Extracted from `SettingsView` (build 74): its `body` is a `Section`, so it +/// renders identically whether embedded in Settings' `Form` or shown on its +/// own (the screenshot demo route wraps it in a bare `Form`). Owns its own +/// `thresholds` @State; reads the stale-set re-estimate nudge from the shared +/// `HomeModel`. +struct ThresholdsView: View { + @ObservedObject var model: HomeModel + @State private var thresholds = AthleteThresholdsStore.load() + + var body: some View { + Section { + if thresholds.isEmpty { + Text("We'll estimate these from your recent training — check back after a few logged sessions.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + if thresholds.runThresholdPaceSecPerKm != nil { + Stepper(value: thresholdBinding(\.runThresholdPaceSecPerKm, default: 300), in: 150...600, step: 5) { + LabeledContent("Run threshold pace", value: "\(Self.mmss(thresholds.runThresholdPaceSecPerKm!)) /km") + } + } + if thresholds.ftpWatts != nil { + Stepper(value: thresholdBinding(\.ftpWatts, default: 200), in: 60...600, step: 5) { + LabeledContent("Cycling FTP", value: "\(thresholds.ftpWatts!) W") + } + } + if thresholds.swimCSSPer100mSec != nil { + Stepper(value: thresholdBinding(\.swimCSSPer100mSec, default: 120), in: 45...240, step: 1) { + LabeledContent("Swim CSS", value: "\(Self.mmss(thresholds.swimCSSPer100mSec!)) /100m") + } + } + if thresholds.thresholdHR != nil { + Stepper(value: thresholdBinding(\.thresholdHR, default: 160), in: 90...220, step: 1) { + LabeledContent("Threshold HR", value: "\(thresholds.thresholdHR!) bpm") + } + } + if thresholds.confirmed { + Label("Confirmed", systemImage: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + .font(.subheadline) + } else { + Button("These look right — confirm") { + thresholds.confirmed = true + thresholds.confirmedAt = Date() + AthleteThresholdsStore.save(thresholds) + StateSync.shared.requestPush() + } + } + + // Build 41 (H2 #11): a confirmed set otherwise freezes + // forever — this surfaces once it's stale AND recent + // training suggests it's meaningfully off, in either + // direction (fitness regresses as well as improves). + if let suggestion = model.thresholdUpdateSuggestion { + thresholdUpdateNudge(suggestion) + } + } + } header: { + Text("Training thresholds") + } footer: { + Text("Your coach builds every pace, power and HR target from these. We estimate them from your recent training — adjust any that look off, then confirm.") + } + } + + private static func mmss(_ sec: Int) -> String { "\(sec / 60):\(String(format: "%02d", sec % 60))" } + + /// Binding over an optional-Int threshold field: any adjustment marks the + /// set confirmed (so it's no longer re-estimated) and backs it up. + private func thresholdBinding(_ keyPath: WritableKeyPath, default def: Int) -> Binding { + Binding( + get: { thresholds[keyPath: keyPath] ?? def }, + set: { + thresholds[keyPath: keyPath] = $0 + thresholds.confirmed = true + thresholds.confirmedAt = Date() + AthleteThresholdsStore.save(thresholds) + StateSync.shared.requestPush() + } + ) + } + + private func thresholdUpdateNudge(_ suggestion: AthleteThresholds) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Your recent training suggests these may have changed.") + .font(.subheadline) + // Build 42, review fix #3: filter rows by the SAME per-field noise + // floor `HomePolicy.shouldSuggestThresholdUpdate` uses to trigger + // the nudge in the first place — otherwise a field that didn't + // clear the floor (and so wasn't why the nudge fired) could still + // show a sub-noise "165 → 166" row here, a change smaller than + // the athlete could even dial in by hand with the Stepper below. + VStack(alignment: .leading, spacing: 2) { + if let current = thresholds.runThresholdPaceSecPerKm, let new = suggestion.runThresholdPaceSecPerKm, abs(new - current) >= 5 { + Text("Run threshold: \(Self.mmss(current)) → \(Self.mmss(new)) /km").font(.caption).foregroundStyle(.secondary) + } + if let current = thresholds.ftpWatts, let new = suggestion.ftpWatts, abs(new - current) >= 5 { + Text("Cycling FTP: \(current) → \(new) W").font(.caption).foregroundStyle(.secondary) + } + if let current = thresholds.swimCSSPer100mSec, let new = suggestion.swimCSSPer100mSec, abs(new - current) >= 2 { + Text("Swim CSS: \(Self.mmss(current)) → \(Self.mmss(new)) /100m").font(.caption).foregroundStyle(.secondary) + } + if let current = thresholds.thresholdHR, let new = suggestion.thresholdHR, abs(new - current) >= 3 { + Text("Threshold HR: \(current) → \(new) bpm").font(.caption).foregroundStyle(.secondary) + } + } + HStack { + Button("Update") { applyThresholdSuggestion(suggestion) } + .buttonStyle(.borderedProminent) + Button("Keep current") { keepCurrentThresholds() } + .buttonStyle(.bordered) + } + } + .padding(.vertical, 4) + } + + /// Applies the re-estimate and re-confirms — same save path as adjusting + /// a field by hand, so it stays in sync with StateSync the same way. + /// Build 42, review fix #1: merges field-by-field (`merging`) rather than + /// replacing the whole struct, so a field the re-estimate has no recent + /// data for isn't silently nulled out. + private func applyThresholdSuggestion(_ suggestion: AthleteThresholds) { + thresholds = thresholds.merging(suggestion) + thresholds.confirmed = true + thresholds.confirmedAt = Date() + AthleteThresholdsStore.save(thresholds) + StateSync.shared.requestPush() + model.thresholdUpdateSuggestion = nil + } + + /// "I looked at this, current values are still fine" — resets the + /// staleness clock without changing any values, so the nudge won't + /// reappear for another `AthleteThresholds.staleAfterDays`. + private func keepCurrentThresholds() { + thresholds.confirmedAt = Date() + AthleteThresholdsStore.save(thresholds) + StateSync.shared.requestPush() + model.thresholdUpdateSuggestion = nil + } +}