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
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
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
}
67 changes: 67 additions & 0 deletions Sources/RunCoach/WidgetMockupView.swift
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions Sources/RunCoachWatch/RunCoachWatchApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion scripts/capture-website-screenshots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
Loading