From ff3462624e0301492981a6ab0bca5ae8619b6e3a Mon Sep 17 00:00:00 2001 From: Andrew Leslie Date: Tue, 21 Jul 2026 19:57:10 +1000 Subject: [PATCH 1/3] Extract Training thresholds section into standalone ThresholdsView Moves the ~130-line thresholds block (run pace / FTP / swim CSS / threshold HR, the confirm flow, and the stale-set re-estimate nudge) out of the large SettingsView into its own view. Its body is a Section, so it renders identically whether embedded in Settings' Form or shown on its own. Pure, behaviour-preserving refactor; SettingsView now calls ThresholdsView(model:). Co-Authored-By: Claude Opus 4.8 --- Sources/RunCoach/Localizable.xcstrings | 20 ++-- Sources/RunCoach/SettingsView.swift | 135 +---------------------- Sources/RunCoach/ThresholdsView.swift | 143 +++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 146 deletions(-) create mode 100644 Sources/RunCoach/ThresholdsView.swift 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 + } +} From 963124a28fc84b1a20a3ed1ca581f343513ab50e Mon Sep 17 00:00:00 2001 From: Andrew Leslie Date: Tue, 21 Jul 2026 19:57:20 +1000 Subject: [PATCH 2/3] Add demo routes + seed data for Progress, Season & Thresholds shots DEBUG/COACHSAGE_DEMO-gated only: - DemoMode: seed a full A/B/C season and 12 weeks of progressHistory (with realistic easy-session heart rates so the sRPE-based CTL/ATL land in a credible range rather than elite figures). - HomeModel.refreshProgress: use the seeded history in demo mode so the Progress screen renders without HealthKit data. - RunCoachApp: add progress/season/thresholds COACHSAGE_SCREEN routes. The progress route owns its HomeModel via @StateObject so the async history load survives a demo-root re-render (inline models were captured empty). - capture script: add the three new screens. Co-Authored-By: Claude Opus 4.8 --- Sources/RunCoach/DemoMode.swift | 68 +++++++++++++++++++++++++- Sources/RunCoach/HomeModel.swift | 16 ++++++ Sources/RunCoach/RunCoachApp.swift | 17 +++++++ scripts/capture-website-screenshots.sh | 2 +- 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Sources/RunCoach/DemoMode.swift b/Sources/RunCoach/DemoMode.swift index d8fb906..f0b6d5c 100644 --- a/Sources/RunCoach/DemoMode.swift +++ b/Sources/RunCoach/DemoMode.swift @@ -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. Defaults to home. static var screen: String { ProcessInfo.processInfo.environment["COACHSAGE_SCREEN"] ?? "home" } @@ -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()) @@ -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 diff --git a/Sources/RunCoach/HomeModel.swift b/Sources/RunCoach/HomeModel.swift index 1263513..f3d9912 100644 --- a/Sources/RunCoach/HomeModel.swift +++ b/Sources/RunCoach/HomeModel.swift @@ -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() diff --git a/Sources/RunCoach/RunCoachApp.swift b/Sources/RunCoach/RunCoachApp.swift index 279cb1d..d786239 100644 --- a/Sources/RunCoach/RunCoachApp.swift +++ b/Sources/RunCoach/RunCoachApp.swift @@ -134,9 +134,26 @@ 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") } 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 } diff --git a/scripts/capture-website-screenshots.sh b/scripts/capture-website-screenshots.sh index a4fd200..38a3509 100755 --- a/scripts/capture-website-screenshots.sh +++ b/scripts/capture-website-screenshots.sh @@ -16,7 +16,7 @@ mkdir -p "$TMPDIR" xcrun simctl install "$DEVICE" "$APP" >/dev/null # Ordered list of screens to capture. "home" uses the default Today tab. -declare -a SCREENS=("home" "recommendation" "weekly" "plan" "goal" "chat" "data") +declare -a SCREENS=("home" "recommendation" "weekly" "plan" "goal" "chat" "data" "progress" "season" "thresholds") for screen in "${SCREENS[@]}"; do echo "Capturing $screen..." From 53d04094cad4c11c2f33a627817a1f67a3db5948 Mon Sep 17 00:00:00 2001 From: Andrew Leslie Date: Tue, 21 Jul 2026 20:50:37 +1000 Subject: [PATCH 3/3] Add widget + Apple Watch demo capture paths - WidgetMockupView + COACHSAGE_SCREEN=widget route: renders the medium Home Screen widget on a wallpaper for marketing capture (re-created for capture like LiveActivityScreenshotView, since a widget extension can't be screenshotted from the app). - RunCoachWatchApp: seed a demo WidgetSnapshot into the App-Group store under COACHSAGE_DEMO so the standalone watch app renders real content on a watchOS sim (no phone to sync from). DEBUG-only. - capture script: add the widget screen. Co-Authored-By: Claude Opus 4.8 --- Sources/RunCoach/DemoMode.swift | 2 +- Sources/RunCoach/RunCoachApp.swift | 2 + Sources/RunCoach/WidgetMockupView.swift | 67 ++++++++++++++++++++ Sources/RunCoachWatch/RunCoachWatchApp.swift | 15 +++++ scripts/capture-website-screenshots.sh | 2 +- 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Sources/RunCoach/WidgetMockupView.swift diff --git a/Sources/RunCoach/DemoMode.swift b/Sources/RunCoach/DemoMode.swift index f0b6d5c..4dbca95 100644 --- a/Sources/RunCoach/DemoMode.swift +++ b/Sources/RunCoach/DemoMode.swift @@ -16,7 +16,7 @@ enum DemoMode { /// Optional screen to open directly (for scripted screenshot capture): /// COACHSAGE_SCREEN = home | recommendation | weekly | plan | goal | chat - /// | data | progress | season | thresholds. Defaults to home. + /// | data | progress | season | thresholds | widget. Defaults to home. static var screen: String { ProcessInfo.processInfo.environment["COACHSAGE_SCREEN"] ?? "home" } diff --git a/Sources/RunCoach/RunCoachApp.swift b/Sources/RunCoach/RunCoachApp.swift index d786239..bf47294 100644 --- a/Sources/RunCoach/RunCoachApp.swift +++ b/Sources/RunCoach/RunCoachApp.swift @@ -140,6 +140,8 @@ struct RunCoachApp: App { NavigationStack { SeasonEditorView() } case "thresholds": NavigationStack { Form { ThresholdsView(model: HomeModel()) }.navigationTitle("Thresholds") } + case "widget": + WidgetMockupView() default: EmptyView() } diff --git a/Sources/RunCoach/WidgetMockupView.swift b/Sources/RunCoach/WidgetMockupView.swift new file mode 100644 index 0000000..09a6f48 --- /dev/null +++ b/Sources/RunCoach/WidgetMockupView.swift @@ -0,0 +1,67 @@ +#if DEBUG +import SwiftUI + +/// Marketing mockup of the Home Screen medium widget sitting on a wallpaper — +/// DEBUG/demo only, reached via COACHSAGE_SCREEN=widget and captured with the +/// screenshot pipeline. Mirrors `DailyRecommendationWidgetView.homeScreenView`; +/// it is a re-creation for capture (like `LiveActivityScreenshotView`), not the +/// live widget code path, because a widget extension can't be screenshotted +/// directly from the app. +struct WidgetMockupView: View { + var body: some View { + ZStack { + LinearGradient( + colors: [Color(red: 0.10, green: 0.15, blue: 0.24), Color(red: 0.04, green: 0.06, blue: 0.10)], + startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + + VStack(spacing: 44) { + VStack(spacing: 0) { + Text("9:41") + .font(.system(size: 72, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Text("Tuesday, 14 July") + .font(.title3) + .foregroundStyle(.white.opacity(0.85)) + } + .padding(.top, 40) + + widgetCard + .frame(width: 360, height: 170) + + Spacer() + } + .padding(.horizontal, 20) + } + } + + private var widgetCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Circle().fill(Color.green).frame(width: 11, height: 11) + Text("Today's Recommendation").font(.caption.bold()) + } + Text("Threshold run — 7 km with 4 km continuous at 5:30/km. Green light: HRV above baseline and sleep solid.") + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + Text("Rate today").font(.caption2).foregroundStyle(.secondary) + ForEach(["Easy", "Moderate", "Hard"], id: \.self) { label in + Text(label) + .font(.caption2.bold()) + .padding(.horizontal, 9).padding(.vertical, 4) + .background(Color.green.opacity(0.16)) + .foregroundStyle(.green) + .clipShape(Capsule()) + } + } + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .shadow(color: .black.opacity(0.35), radius: 20, y: 10) + } +} +#endif diff --git a/Sources/RunCoachWatch/RunCoachWatchApp.swift b/Sources/RunCoachWatch/RunCoachWatchApp.swift index 86c633a..e3a850c 100644 --- a/Sources/RunCoachWatch/RunCoachWatchApp.swift +++ b/Sources/RunCoachWatch/RunCoachWatchApp.swift @@ -8,6 +8,21 @@ import SwiftUI /// what the phone already computed. @main struct RunCoachWatchApp: App { + init() { + #if DEBUG + // Screenshot/demo seeding: the watch has no data of its own until the + // phone syncs one over, so for marketing capture we pre-load the same + // App-Group snapshot store `WatchSessionManager` reads on init. Gated + // on COACHSAGE_DEMO exactly like the phone app; never in a release. + if ProcessInfo.processInfo.environment["COACHSAGE_DEMO"] == "1" { + WidgetSnapshotStore.save(WidgetSnapshot( + recommendationSummary: "Threshold run — 7 km with 4 km continuous at 5:30/km. Green light: HRV above baseline and sleep solid.", + recoveryLabel: "Green", + updatedAt: Date())) + } + #endif + } + var body: some Scene { WindowGroup { ContentView() diff --git a/scripts/capture-website-screenshots.sh b/scripts/capture-website-screenshots.sh index 38a3509..7c89118 100755 --- a/scripts/capture-website-screenshots.sh +++ b/scripts/capture-website-screenshots.sh @@ -16,7 +16,7 @@ mkdir -p "$TMPDIR" xcrun simctl install "$DEVICE" "$APP" >/dev/null # Ordered list of screens to capture. "home" uses the default Today tab. -declare -a SCREENS=("home" "recommendation" "weekly" "plan" "goal" "chat" "data" "progress" "season" "thresholds") +declare -a SCREENS=("home" "recommendation" "weekly" "plan" "goal" "chat" "data" "progress" "season" "thresholds" "widget") for screen in "${SCREENS[@]}"; do echo "Capturing $screen..."