diff --git a/Sources/RunCoach/DemoMode.swift b/Sources/RunCoach/DemoMode.swift index d8fb906..4dbca95 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 | widget. 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/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/RunCoachApp.swift b/Sources/RunCoach/RunCoachApp.swift index 279cb1d..bf47294 100644 --- a/Sources/RunCoach/RunCoachApp.swift +++ b/Sources/RunCoach/RunCoachApp.swift @@ -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 } 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 + } +} 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 a4fd200..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") +declare -a SCREENS=("home" "recommendation" "weekly" "plan" "goal" "chat" "data" "progress" "season" "thresholds" "widget") for screen in "${SCREENS[@]}"; do echo "Capturing $screen..."