Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions Sources/RunCoach/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?" : {

},
Expand All @@ -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
Expand Down
135 changes: 1 addition & 134 deletions Sources/RunCoach/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<AthleteThresholds, Int?>, default def: Int) -> Binding<Int> {
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 {
Expand Down
143 changes: 143 additions & 0 deletions Sources/RunCoach/ThresholdsView.swift
Original file line number Diff line number Diff line change
@@ -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<AthleteThresholds, Int?>, default def: Int) -> Binding<Int> {
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
}
}
Loading