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/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/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..."