Skip to content
Closed
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
68 changes: 66 additions & 2 deletions Sources/RunCoach/DemoMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ enum DemoMode {
}

/// Optional screen to open directly (for scripted screenshot capture):
/// COACHSAGE_SCREEN = home | weekly | goal | chat. Defaults to home.
/// COACHSAGE_SCREEN = home | recommendation | weekly | plan | goal | chat
/// | data | progress | season | thresholds | widget. Defaults to home.
static var screen: String {
ProcessInfo.processInfo.environment["COACHSAGE_SCREEN"] ?? "home"
}
Expand All @@ -34,7 +35,27 @@ enum DemoMode {
text: "Olympic distance — first triathlon, aiming to finish strong.",
priority: .a
)
EventsStore.save(goals)
// A full A/B/C season (build 56 multi-event model) so the Season editor
// shows the multi-race planning that's the headline differentiator.
let bEvent = SeasonEvent(
eventName: "Foreshore Sprint Tri",
targetDate: Calendar.current.date(byAdding: .day, value: 35, to: Date())!,
sports: ["swimming", "cycling", "running"],
eventType: .sprintTri,
triLegs: EventType.sprintTri.presetLegs,
text: "Tune-up race to rehearse transitions before City Tri.",
priority: .b
)
let cEvent = SeasonEvent(
eventName: "Riverside 10K",
targetDate: Calendar.current.date(byAdding: .day, value: 21, to: Date())!,
sports: ["running"],
eventType: .single,
raceDistanceKm: 10,
text: "Parkrun-style tune-up to check run fitness.",
priority: .c
)
EventsStore.save([goals, bEvent, cEvent])

TrainingPlanStore.save(trainingPlan(goalDate: goalDate))
WeeklyPlanStore.save(weeklyPlan())
Expand Down Expand Up @@ -68,6 +89,49 @@ enum DemoMode {
]
}

/// 12 weeks of workout history for the Progress screen — weekly volume by
/// sport, consistency streak, fitness trend and personal bests. The
/// Simulator has no HealthKit history, so `refreshProgress` uses this in
/// demo mode. Volume builds across the block toward the goal race.
static var progressHistory: [WorkoutSummary] {
let cal = Calendar.current
func at(_ ago: Int) -> Date { cal.date(byAdding: .day, value: -ago, to: Date())! }
// avgHR drives the app's sRPE load model (round(avgHR/thresholdHR·10)·min),
// so easy sessions carry genuinely easy heart rates — otherwise CTL/ATL
// balloon into elite territory for a first-timer's volume.
func run(_ ago: Int, km: Double, paceSecPerKm: Int, avgHR: Int) -> WorkoutSummary {
WorkoutSummary(id: UUID(), name: "Run", distance: km * 1000, movingTime: Int(km * Double(paceSecPerKm)),
avgHR: avgHR, maxHR: avgHR + 16, kind: .run, type: "Running", date: at(ago))
}
func ride(_ ago: Int, km: Double, avgHR: Int) -> WorkoutSummary {
WorkoutSummary(id: UUID(), name: "Ride", distance: km * 1000, movingTime: Int(km / 29 * 3600),
avgHR: avgHR, maxHR: avgHR + 14, avgPower: 155, cadence: 86, kind: .ride, type: "Cycling", date: at(ago))
}
func swim(_ ago: Int, m: Double) -> WorkoutSummary {
WorkoutSummary(id: UUID(), name: "Swim", distance: m, movingTime: Int(m / 100 * 118),
avgHR: 120, maxHR: 138, kind: .swim, type: "Swimming", date: at(ago))
}
var out: [WorkoutSummary] = []
// week 0 = this week (most recent) ... 11 = 12 weeks ago. build ∈ (0,1],
// larger in recent weeks so volume ramps toward the goal race.
for week in 0..<12 {
let base = week * 7
let build = Double(12 - week) / 12
out.append(run(base + 6, km: 5 + build * 1.5, paceSecPerKm: 410, avgHR: 122)) // easy
out.append(ride(base + 4, km: 26 + build * 14, avgHR: 120)) // endurance ride
out.append(swim(base + 3, m: 1200 + build * 600)) // swim
out.append(run(base + 0, km: 8 + build * 6, paceSecPerKm: 400, avgHR: 130)) // long run
if week % 2 == 0 { // threshold on alt weeks
out.append(run(base + 2, km: 6, paceSecPerKm: 340, avgHR: 158))
}
}
// Standout efforts so Personal Bests populate.
out.append(run(9, km: 5, paceSecPerKm: 264, avgHR: 172)) // 22:00 5K
out.append(run(23, km: 10, paceSecPerKm: 279, avgHR: 168)) // 46:30 10K
out.append(run(37, km: 21.1, paceSecPerKm: 300, avgHR: 160)) // 1:45 half
return out
}

/// Seeded "My Data" content (the Simulator has no HealthKit data).
static var diagnostics: HealthKitManager.HealthDiagnostics {
let cal = Calendar.current
Expand Down
16 changes: 16 additions & 0 deletions Sources/RunCoach/HomeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,22 @@ final class HomeModel: ObservableObject {
isLoadingProgress = true
progressError = nil
defer { isLoadingProgress = false }
#if DEBUG
if DemoMode.isActive {
let history = DemoMode.progressHistory
let now = Date()
progressWeeklyVolume = ProgressMath.weeklyVolume(workouts: history, weeks: 12, now: now)
progressConsistency = ProgressMath.consistency(workouts: history, weeks: 10, minDaysPerWeek: 3, now: now)
progressPersonalBests = ProgressMath.personalBests(workouts: history)
fitnessSnapshot = FitnessMath.snapshot(
workouts: history, now: now, days: 28,
thresholdHR: AthleteThresholdsStore.load().thresholdHR,
effortFor: { self.efforts[$0.uuidString] }
)
rebuildHealthContext()
return
}
#endif
do {
let history = try await healthKit.fetchWorkoutHistory(limit: 500)
let now = Date()
Expand Down
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
19 changes: 19 additions & 0 deletions Sources/RunCoach/RunCoachApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,28 @@ struct RunCoachApp: App {
NavigationStack { CoachingChatView(coach: CoachingService()) }
case "data":
NavigationStack { MyDataView() }
case "progress":
DemoProgressScreen()
case "season":
NavigationStack { SeasonEditorView() }
case "thresholds":
NavigationStack { Form { ThresholdsView(model: HomeModel()) }.navigationTitle("Thresholds") }
case "widget":
WidgetMockupView()
default:
EmptyView()
}
}

/// Progress relies on an async `.task` to load history into its HomeModel,
/// unlike the plan routes which get their data at init. An inline
/// `HomeModel()` is replaced whenever the demo root re-renders, discarding
/// the just-loaded data before capture — so own it via `@StateObject`.
private struct DemoProgressScreen: View {
@StateObject private var model = HomeModel()
var body: some View {
NavigationStack { CoachProgressView(model: model) }
}
}
#endif
}
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
Loading
Loading