diff --git a/.codeyam/scenarios/accessibility-large-text-tour.json b/.codeyam/scenarios/accessibility-large-text-tour.json new file mode 100644 index 0000000..d96434b --- /dev/null +++ b/.codeyam/scenarios/accessibility-large-text-tour.json @@ -0,0 +1,54 @@ +{ + "name": "Accessibility - Large Text Tour", + "id": "085146b6-3a6b-480d-bb82-c9ed005a4efd", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbWorkoutType": "", + "rbDistanceMiles": 4.3, + "rbCoachType": "walk", + "rbGoalSteps": 10000, + "rbShowKeyProviderPicker": false, + "rbStartScreen": "tour", + "rbActiveEnergyKcal": 210, + "rbMinutesSinceMovement": 18, + "otterpaceTodayTourSeen": false, + "rbShowHistory": false, + "rbTheme": "default", + "rbStartTab": "today", + "rbAskSeedQuestion": "", + "rbDate": "2026-06-22", + "rbCoachConnected": true, + "rbCoachPreviewScroll": "", + "rbShowJournalEditor": false, + "rbShowCoachDataPreview": false, + "rbSteps": 9180, + "rbCoachHeadline": "So close, 820 to go!", + "rbContentSize": "accessibility3", + "rbDaysRunThisWeek": 2, + "rbLongestRunMiles": 4.2, + "rbConnected": true, + "rbOpenHint": "", + "rbShowWeeklyReview": false, + "rbPreviewMode": "", + "rbCoachSafety": false, + "rbShowSettings": false, + "rbWeeklyMileage": 12.4, + "rbCoachBody": "You're 820 steps from 10K and it's nearly 9:30pm. One short podcast lap around the block and today's goal is yours.", + "otterpaceRacePromptDismissed": true, + "rbLoadTrend": "steady", + "rbBuddyMood": "cheering", + "rbRestDaysThisWeek": 2, + "rbTourStep": 1, + "rbRacesJSON": "", + "rbShowRacePrompt": false, + "rbActiveMinutes": 54 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/ask-coach-starter-day-one.json b/.codeyam/scenarios/ask-coach-starter-day-one.json new file mode 100644 index 0000000..a7c2787 --- /dev/null +++ b/.codeyam/scenarios/ask-coach-starter-day-one.json @@ -0,0 +1,54 @@ +{ + "name": "Ask Coach - Starter Day One", + "id": "112f41b9-0c03-499e-93f4-32480f7b7afb", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbActiveEnergyKcal": 210, + "rbShowWeeklyReview": false, + "rbWorkoutType": "", + "rbShowCoachDataPreview": false, + "rbAskSeedQuestion": "", + "rbRestDaysThisWeek": 2, + "rbWeeklyMileage": 0.0, + "rbTourStep": 0, + "rbLongestRunMiles": 0.0, + "rbSteps": 1200, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbCoachHeadline": "Nice steady day", + "rbMinutesSinceMovement": 25, + "rbShowKeyProviderPicker": false, + "rbStartScreen": "", + "rbBuddyMood": "ready", + "rbGoalSteps": 10000, + "rbRacesJSON": "", + "rbStartTab": "coach", + "rbShowSettings": false, + "rbDaysRunThisWeek": 0, + "rbActiveMinutes": 6, + "otterpaceRacePromptDismissed": true, + "rbCoachPreviewScroll": "", + "rbCoachSafety": false, + "rbDistanceMiles": 0.5, + "rbShowJournalEditor": false, + "otterpaceTodayTourSeen": true, + "rbCoachConnected": true, + "rbConnected": true, + "rbContentSize": "", + "rbCoachType": "walk", + "rbOpenHint": "", + "rbPreviewMode": "", + "rbDate": "2026-06-22", + "rbLoadTrend": "steady", + "rbShowHistory": false, + "rbShowRacePrompt": false, + "rbTheme": "default" + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/AskCoachView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/ask-coach-starter-race-week.json b/.codeyam/scenarios/ask-coach-starter-race-week.json new file mode 100644 index 0000000..90f3ccf --- /dev/null +++ b/.codeyam/scenarios/ask-coach-starter-race-week.json @@ -0,0 +1,59 @@ +{ + "name": "Ask Coach - Starter Race Week", + "id": "2fb21e0e-358b-44d6-97d8-581d39892f8b", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbCoachHeadline": "Nice steady day", + "rbMinutesSinceMovement": 40, + "rbOpenHint": "", + "rbStartScreen": "", + "rbShowCoachDataPreview": false, + "rbShowKeyProviderPicker": false, + "rbGoalSteps": 10000, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbTourStep": 0, + "rbStartTab": "coach", + "rbBuddyMood": "ready", + "rbShowHistory": false, + "rbAskSeedQuestion": "", + "rbShowRacePrompt": false, + "rbCoachType": "walk", + "rbWorkoutType": "run", + "rbContentSize": "", + "rbCoachPreviewScroll": "", + "rbWeeklyMileage": 12.4, + "rbDaysRunThisWeek": 2, + "rbWorkoutDistanceMiles": 4.2, + "otterpaceTodayTourSeen": true, + "rbSteps": 6400, + "rbLoadTrend": "steady", + "rbActiveEnergyKcal": 210, + "rbCoachConnected": true, + "rbDistanceMiles": 3.1, + "rbLongestRunMiles": 4.2, + "rbCoachSafety": false, + "rbPreviewMode": "", + "rbShowWeeklyReview": false, + "rbDate": "2026-06-22", + "rbRestDaysThisWeek": 2, + "otterpaceRacePromptDismissed": true, + "rbTheme": "default", + "rbWorkoutDate": "2026-06-21", + "rbShowSettings": false, + "rbWorkoutPace": "10:28/mi", + "rbWorkoutSource": "healthkit", + "rbConnected": true, + "rbRacesJSON": "[{\"id\":\"11111111-1111-1111-1111-111111111111\",\"name\":\"October Trail Half\",\"distanceMiles\":13.1,\"date\":\"2026-10-10\",\"location\":\"Bend, OR\"}]", + "rbWorkoutDurationMinutes": 44, + "rbShowJournalEditor": false, + "rbActiveMinutes": 28 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/AskCoachView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/ask-coach-tutorial-intro.json b/.codeyam/scenarios/ask-coach-tutorial-intro.json new file mode 100644 index 0000000..6c29a6a --- /dev/null +++ b/.codeyam/scenarios/ask-coach-tutorial-intro.json @@ -0,0 +1,59 @@ +{ + "name": "Ask Coach - Tutorial Intro", + "id": "31f5b268-03d9-464b-8898-73bc5ad4f64e", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbWorkoutType": "run", + "rbTourStep": 0, + "rbDaysRunThisWeek": 2, + "rbMinutesSinceMovement": 40, + "rbStartScreen": "", + "rbStartTab": "coach", + "rbWorkoutDate": "2026-06-21", + "rbPreviewMode": "", + "rbShowJournalEditor": false, + "rbWorkoutSource": "healthkit", + "rbBuddyMood": "ready", + "rbCoachHeadline": "Nice steady day", + "rbCoachPreviewScroll": "", + "rbCoachSafety": false, + "rbDistanceMiles": 3.1, + "rbShowCoachDataPreview": false, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbGoalSteps": 10000, + "rbShowRacePrompt": false, + "rbWorkoutPace": "10:28/mi", + "rbConnected": true, + "rbActiveEnergyKcal": 210, + "otterpaceTodayTourSeen": true, + "rbActiveMinutes": 28, + "rbAskSeedQuestion": "", + "rbShowWeeklyReview": false, + "rbOpenHint": "", + "rbShowKeyProviderPicker": false, + "rbDate": "2026-06-22", + "rbLongestRunMiles": 4.2, + "rbRacesJSON": "", + "rbShowSettings": false, + "rbSteps": 6400, + "rbWeeklyMileage": 12.4, + "rbContentSize": "", + "rbCoachConnected": true, + "rbCoachType": "walk", + "rbLoadTrend": "steady", + "otterpaceRacePromptDismissed": true, + "rbRestDaysThisWeek": 2, + "rbTheme": "default", + "rbWorkoutDistanceMiles": 4.2, + "rbShowHistory": false, + "rbWorkoutDurationMinutes": 44 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/AskCoachView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/ask-coach-tutorial-locked.json b/.codeyam/scenarios/ask-coach-tutorial-locked.json new file mode 100644 index 0000000..c3bef04 --- /dev/null +++ b/.codeyam/scenarios/ask-coach-tutorial-locked.json @@ -0,0 +1,54 @@ +{ + "name": "Ask Coach - Tutorial Locked", + "id": "58a80956-eafa-4edd-886a-26db8afb5f0d", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "otterpaceRacePromptDismissed": true, + "rbMinutesSinceMovement": 40, + "rbTheme": "default", + "rbRestDaysThisWeek": 2, + "rbGoalSteps": 10000, + "rbSteps": 6400, + "rbCoachSafety": false, + "rbPreviewMode": "", + "rbAskSeedQuestion": "", + "rbStartTab": "coach", + "rbRacesJSON": "", + "rbLongestRunMiles": 4.2, + "rbCoachHeadline": "Nice steady day", + "rbDaysRunThisWeek": 2, + "rbShowRacePrompt": false, + "rbConnected": true, + "rbShowSettings": false, + "rbWeeklyMileage": 12.4, + "rbShowKeyProviderPicker": false, + "rbCoachPreviewScroll": "", + "rbLoadTrend": "steady", + "rbTourStep": 0, + "rbShowJournalEditor": false, + "rbCoachType": "walk", + "rbContentSize": "", + "rbDate": "2026-06-22", + "rbShowHistory": false, + "rbStartScreen": "", + "rbWorkoutType": "", + "rbShowWeeklyReview": false, + "rbBuddyMood": "ready", + "rbOpenHint": "", + "rbShowCoachDataPreview": false, + "rbActiveMinutes": 28, + "otterpaceTodayTourSeen": true, + "rbCoachConnected": false, + "rbDistanceMiles": 3.1, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbActiveEnergyKcal": 210 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/AskCoachView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/screenshots/accessibility-large-text-tour--iphone-16.png b/.codeyam/scenarios/screenshots/accessibility-large-text-tour--iphone-16.png new file mode 100644 index 0000000..11c4144 Binary files /dev/null and b/.codeyam/scenarios/screenshots/accessibility-large-text-tour--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/ask-coach-starter-day-one--iphone-16.png b/.codeyam/scenarios/screenshots/ask-coach-starter-day-one--iphone-16.png new file mode 100644 index 0000000..945ce6e Binary files /dev/null and b/.codeyam/scenarios/screenshots/ask-coach-starter-day-one--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/ask-coach-starter-race-week--iphone-16.png b/.codeyam/scenarios/screenshots/ask-coach-starter-race-week--iphone-16.png new file mode 100644 index 0000000..2d7ce24 Binary files /dev/null and b/.codeyam/scenarios/screenshots/ask-coach-starter-race-week--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/ask-coach-tutorial-intro--iphone-16.png b/.codeyam/scenarios/screenshots/ask-coach-tutorial-intro--iphone-16.png new file mode 100644 index 0000000..0363b96 Binary files /dev/null and b/.codeyam/scenarios/screenshots/ask-coach-tutorial-intro--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/ask-coach-tutorial-locked--iphone-16.png b/.codeyam/scenarios/screenshots/ask-coach-tutorial-locked--iphone-16.png new file mode 100644 index 0000000..66cb6ca Binary files /dev/null and b/.codeyam/scenarios/screenshots/ask-coach-tutorial-locked--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/today-day-one-no-data--iphone-16.png b/.codeyam/scenarios/screenshots/today-day-one-no-data--iphone-16.png new file mode 100644 index 0000000..5a9ed50 Binary files /dev/null and b/.codeyam/scenarios/screenshots/today-day-one-no-data--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/today-hint-active-minutes-open--iphone-16.png b/.codeyam/scenarios/screenshots/today-hint-active-minutes-open--iphone-16.png new file mode 100644 index 0000000..a1c8b93 Binary files /dev/null and b/.codeyam/scenarios/screenshots/today-hint-active-minutes-open--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/today-hint-buddy-mood-open--iphone-16.png b/.codeyam/scenarios/screenshots/today-hint-buddy-mood-open--iphone-16.png new file mode 100644 index 0000000..5468616 Binary files /dev/null and b/.codeyam/scenarios/screenshots/today-hint-buddy-mood-open--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/today-race-prompt-explained--iphone-16.png b/.codeyam/scenarios/screenshots/today-race-prompt-explained--iphone-16.png new file mode 100644 index 0000000..3181c91 Binary files /dev/null and b/.codeyam/scenarios/screenshots/today-race-prompt-explained--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/tour-step-1-buddy--iphone-16.png b/.codeyam/scenarios/screenshots/tour-step-1-buddy--iphone-16.png new file mode 100644 index 0000000..11c4144 Binary files /dev/null and b/.codeyam/scenarios/screenshots/tour-step-1-buddy--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/tour-step-2-stats--iphone-16.png b/.codeyam/scenarios/screenshots/tour-step-2-stats--iphone-16.png new file mode 100644 index 0000000..49e24db Binary files /dev/null and b/.codeyam/scenarios/screenshots/tour-step-2-stats--iphone-16.png differ diff --git a/.codeyam/scenarios/screenshots/tour-step-3-coach-card--iphone-16.png b/.codeyam/scenarios/screenshots/tour-step-3-coach-card--iphone-16.png new file mode 100644 index 0000000..4c4cc0e Binary files /dev/null and b/.codeyam/scenarios/screenshots/tour-step-3-coach-card--iphone-16.png differ diff --git a/.codeyam/scenarios/today-day-one-no-data.json b/.codeyam/scenarios/today-day-one-no-data.json new file mode 100644 index 0000000..1042596 --- /dev/null +++ b/.codeyam/scenarios/today-day-one-no-data.json @@ -0,0 +1,54 @@ +{ + "name": "Today - Day One No Data", + "id": "e2de689a-0dd6-4af9-ac1b-1aa6d8026fed", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbPreviewMode": "", + "rbShowWeeklyReview": false, + "rbShowCoachDataPreview": false, + "rbShowKeyProviderPicker": false, + "rbStartScreen": "", + "rbCoachBody": "Nothing recorded yet today. Whenever you're ready, a short walk is a friendly first step toward your 10K.", + "rbSteps": 0, + "rbWeeklyMileage": 0.0, + "rbCoachConnected": true, + "rbDate": "2026-06-22", + "rbActiveEnergyKcal": 0, + "rbMinutesSinceMovement": 0, + "rbShowJournalEditor": false, + "rbBuddyMood": "ready", + "rbCoachSafety": false, + "rbShowSettings": false, + "rbCoachHeadline": "Welcome to Otterpace", + "rbRacesJSON": "", + "rbWorkoutType": "", + "rbConnected": true, + "rbCoachType": "move", + "rbRestDaysThisWeek": 0, + "rbTheme": "default", + "rbCoachPreviewScroll": "", + "rbActiveMinutes": 0, + "rbContentSize": "", + "rbDistanceMiles": 0.0, + "rbDaysRunThisWeek": 0, + "rbGoalSteps": 10000, + "rbLongestRunMiles": 0.0, + "otterpaceTodayTourSeen": true, + "rbLoadTrend": "recovering", + "rbOpenHint": "", + "rbShowHistory": false, + "rbShowRacePrompt": false, + "rbTourStep": 0, + "rbAskSeedQuestion": "", + "otterpaceRacePromptDismissed": true, + "rbStartTab": "today" + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/today-hint-active-minutes-open.json b/.codeyam/scenarios/today-hint-active-minutes-open.json new file mode 100644 index 0000000..af111c4 --- /dev/null +++ b/.codeyam/scenarios/today-hint-active-minutes-open.json @@ -0,0 +1,54 @@ +{ + "name": "Today - Hint Active Minutes Open", + "id": "7ae73266-25b1-438f-9dfa-0a64da3c7ac7", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbShowCoachDataPreview": false, + "rbContentSize": "", + "rbBuddyMood": "cheering", + "rbCoachHeadline": "Nice steady day", + "rbShowWeeklyReview": false, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbShowKeyProviderPicker": false, + "rbCoachSafety": false, + "otterpaceTodayTourSeen": true, + "rbAskSeedQuestion": "", + "rbStartScreen": "", + "rbMinutesSinceMovement": 18, + "rbCoachPreviewScroll": "", + "rbCoachConnected": true, + "rbShowSettings": false, + "otterpaceRacePromptDismissed": true, + "rbWorkoutType": "", + "rbPreviewMode": "", + "rbLoadTrend": "steady", + "rbActiveMinutes": 54, + "rbTourStep": 0, + "rbLongestRunMiles": 4.2, + "rbConnected": true, + "rbActiveEnergyKcal": 210, + "rbDistanceMiles": 4.3, + "rbOpenHint": "activeMinutes", + "rbRestDaysThisWeek": 2, + "rbCoachType": "walk", + "rbShowJournalEditor": false, + "rbDate": "2026-06-22", + "rbSteps": 9180, + "rbShowHistory": false, + "rbRacesJSON": "", + "rbTheme": "default", + "rbGoalSteps": 10000, + "rbStartTab": "today", + "rbDaysRunThisWeek": 2, + "rbWeeklyMileage": 12.4, + "rbShowRacePrompt": false + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/today-hint-buddy-mood-open.json b/.codeyam/scenarios/today-hint-buddy-mood-open.json new file mode 100644 index 0000000..1659b5a --- /dev/null +++ b/.codeyam/scenarios/today-hint-buddy-mood-open.json @@ -0,0 +1,54 @@ +{ + "name": "Today - Hint Buddy Mood Open", + "id": "17fce989-418b-4bb3-9dd5-a8b8bdd56763", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbCoachPreviewScroll": "", + "rbDaysRunThisWeek": 2, + "rbOpenHint": "buddyMood", + "rbDate": "2026-06-22", + "rbRacesJSON": "", + "rbShowCoachDataPreview": false, + "rbShowHistory": false, + "rbAskSeedQuestion": "", + "rbTourStep": 0, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbCoachHeadline": "Nice steady day", + "rbShowKeyProviderPicker": false, + "rbTheme": "default", + "rbCoachType": "walk", + "rbStartTab": "today", + "otterpaceTodayTourSeen": true, + "rbCoachSafety": false, + "rbMinutesSinceMovement": 18, + "rbStartScreen": "", + "rbRestDaysThisWeek": 2, + "rbActiveEnergyKcal": 210, + "rbActiveMinutes": 54, + "rbBuddyMood": "cheering", + "rbShowSettings": false, + "rbShowWeeklyReview": false, + "rbSteps": 9180, + "rbLoadTrend": "steady", + "rbShowJournalEditor": false, + "rbWeeklyMileage": 12.4, + "rbPreviewMode": "", + "rbDistanceMiles": 4.3, + "otterpaceRacePromptDismissed": true, + "rbConnected": true, + "rbShowRacePrompt": false, + "rbCoachConnected": true, + "rbGoalSteps": 10000, + "rbWorkoutType": "", + "rbContentSize": "", + "rbLongestRunMiles": 4.2 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/today-race-prompt-explained.json b/.codeyam/scenarios/today-race-prompt-explained.json new file mode 100644 index 0000000..cd1ce7a --- /dev/null +++ b/.codeyam/scenarios/today-race-prompt-explained.json @@ -0,0 +1,54 @@ +{ + "name": "Today - Race Prompt Explained", + "id": "73e10866-5ca8-4846-9c61-1619b112427a", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbShowJournalEditor": false, + "rbOpenHint": "", + "rbPreviewMode": "", + "otterpaceRacePromptDismissed": false, + "rbShowSettings": false, + "rbTourStep": 0, + "otterpaceTodayTourSeen": true, + "rbActiveMinutes": 28, + "rbBuddyMood": "ready", + "rbShowCoachDataPreview": false, + "rbShowRacePrompt": true, + "rbRacesJSON": "", + "rbDate": "2026-06-22", + "rbCoachPreviewScroll": "", + "rbSteps": 6400, + "rbShowHistory": false, + "rbWorkoutType": "", + "rbContentSize": "", + "rbLoadTrend": "steady", + "rbCoachHeadline": "Nice steady day", + "rbCoachType": "walk", + "rbGoalSteps": 10000, + "rbShowKeyProviderPicker": false, + "rbDaysRunThisWeek": 2, + "rbCoachConnected": true, + "rbTheme": "default", + "rbLongestRunMiles": 4.2, + "rbRestDaysThisWeek": 2, + "rbWeeklyMileage": 12.4, + "rbActiveEnergyKcal": 210, + "rbMinutesSinceMovement": 40, + "rbStartTab": "today", + "rbShowWeeklyReview": false, + "rbStartScreen": "", + "rbAskSeedQuestion": "", + "rbConnected": true, + "rbCoachBody": "You're at 6,400 steps with easy movement in the bank. A short evening walk would top you off without adding training stress.", + "rbCoachSafety": false, + "rbDistanceMiles": 3.1 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/tour-step-1-buddy.json b/.codeyam/scenarios/tour-step-1-buddy.json new file mode 100644 index 0000000..ae67f91 --- /dev/null +++ b/.codeyam/scenarios/tour-step-1-buddy.json @@ -0,0 +1,54 @@ +{ + "name": "Tour - Step 1 Buddy", + "id": "4fbc8f23-c69e-4b44-9bbc-cca7d7c0383c", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbDistanceMiles": 4.3, + "rbMinutesSinceMovement": 18, + "rbWeeklyMileage": 12.4, + "rbShowHistory": false, + "rbActiveMinutes": 54, + "rbBuddyMood": "cheering", + "rbSteps": 9180, + "rbPreviewMode": "", + "rbStartScreen": "tour", + "rbWorkoutType": "", + "rbShowSettings": false, + "rbCoachConnected": true, + "rbOpenHint": "", + "rbConnected": true, + "rbDate": "2026-06-22", + "rbShowJournalEditor": false, + "rbCoachType": "walk", + "rbLongestRunMiles": 4.2, + "otterpaceTodayTourSeen": false, + "rbStartTab": "today", + "rbActiveEnergyKcal": 210, + "rbCoachHeadline": "So close, 820 to go!", + "rbDaysRunThisWeek": 2, + "rbRestDaysThisWeek": 2, + "rbCoachBody": "You're 820 steps from 10K and it's nearly 9:30pm. One short podcast lap around the block and today's goal is yours.", + "rbShowCoachDataPreview": false, + "rbAskSeedQuestion": "", + "rbContentSize": "", + "rbRacesJSON": "", + "rbShowKeyProviderPicker": false, + "rbCoachPreviewScroll": "", + "rbShowRacePrompt": false, + "rbTourStep": 0, + "otterpaceRacePromptDismissed": true, + "rbCoachSafety": false, + "rbLoadTrend": "steady", + "rbShowWeeklyReview": false, + "rbGoalSteps": 10000, + "rbTheme": "default" + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/tour-step-2-stats.json b/.codeyam/scenarios/tour-step-2-stats.json new file mode 100644 index 0000000..63b41a9 --- /dev/null +++ b/.codeyam/scenarios/tour-step-2-stats.json @@ -0,0 +1,54 @@ +{ + "name": "Tour - Step 2 Stats", + "id": "16f512be-7f0e-408e-8bef-db8602f4cc2d", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbLongestRunMiles": 4.2, + "rbCoachSafety": false, + "rbDate": "2026-06-22", + "rbStartTab": "today", + "rbShowKeyProviderPicker": false, + "rbDistanceMiles": 4.3, + "rbSteps": 9180, + "rbTheme": "default", + "rbDaysRunThisWeek": 2, + "otterpaceTodayTourSeen": false, + "rbWeeklyMileage": 12.4, + "rbWorkoutType": "", + "rbRestDaysThisWeek": 2, + "rbShowWeeklyReview": false, + "rbCoachType": "walk", + "rbCoachHeadline": "So close, 820 to go!", + "rbPreviewMode": "", + "rbShowRacePrompt": false, + "rbActiveMinutes": 54, + "rbConnected": true, + "rbGoalSteps": 10000, + "rbCoachPreviewScroll": "", + "rbStartScreen": "tour", + "rbCoachConnected": true, + "rbMinutesSinceMovement": 18, + "rbTourStep": 1, + "rbShowSettings": false, + "rbAskSeedQuestion": "", + "otterpaceRacePromptDismissed": true, + "rbShowCoachDataPreview": false, + "rbActiveEnergyKcal": 210, + "rbBuddyMood": "cheering", + "rbRacesJSON": "", + "rbShowJournalEditor": false, + "rbCoachBody": "You're 820 steps from 10K and it's nearly 9:30pm. One short podcast lap around the block and today's goal is yours.", + "rbContentSize": "", + "rbOpenHint": "", + "rbLoadTrend": "steady", + "rbShowHistory": false + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/.codeyam/scenarios/tour-step-3-coach-card.json b/.codeyam/scenarios/tour-step-3-coach-card.json new file mode 100644 index 0000000..bd73445 --- /dev/null +++ b/.codeyam/scenarios/tour-step-3-coach-card.json @@ -0,0 +1,54 @@ +{ + "name": "Tour - Step 3 Coach Card", + "id": "96a25325-7c4d-4d16-811f-0d0f05382574", + "url": "/", + "dimensions": [ + "iPhone 16" + ], + "mocks": {}, + "deviceState": { + "preferences": { + "rbCoachHeadline": "So close, 820 to go!", + "rbShowCoachDataPreview": false, + "rbShowHistory": false, + "rbWeeklyMileage": 12.4, + "rbLoadTrend": "steady", + "rbPreviewMode": "", + "rbConnected": true, + "rbCoachPreviewScroll": "", + "otterpaceTodayTourSeen": false, + "rbStartTab": "today", + "rbShowKeyProviderPicker": false, + "rbDaysRunThisWeek": 2, + "otterpaceRacePromptDismissed": true, + "rbContentSize": "", + "rbActiveMinutes": 54, + "rbBuddyMood": "cheering", + "rbMinutesSinceMovement": 18, + "rbRacesJSON": "", + "rbDistanceMiles": 4.3, + "rbWorkoutType": "", + "rbAskSeedQuestion": "", + "rbCoachConnected": true, + "rbStartScreen": "tour", + "rbTheme": "default", + "rbCoachSafety": false, + "rbOpenHint": "", + "rbShowSettings": false, + "rbRestDaysThisWeek": 2, + "rbShowRacePrompt": false, + "rbShowWeeklyReview": false, + "rbCoachBody": "You're 820 steps from 10K and it's nearly 9:30pm. One short podcast lap around the block and today's goal is yours.", + "rbLongestRunMiles": 4.2, + "rbDate": "2026-06-22", + "rbGoalSteps": 10000, + "rbSteps": 9180, + "rbTourStep": 2, + "rbCoachType": "walk", + "rbShowJournalEditor": false, + "rbActiveEnergyKcal": 210 + } + }, + "scenarioType": "application", + "pageFilePath": "Sources/AppCore/TodayView.swift" +} \ No newline at end of file diff --git a/Sources/AppCore/AskCoachEmptyState.swift b/Sources/AppCore/AskCoachEmptyState.swift index 7adc21d..786b13c 100644 --- a/Sources/AppCore/AskCoachEmptyState.swift +++ b/Sources/AppCore/AskCoachEmptyState.swift @@ -1,22 +1,42 @@ import SwiftUI -// The Ask Coach first-open state: a friendly Buddy and a prompt inviting the -// user to ask, shown whenever the conversation has no messages yet. +// The Ask Coach first-open state, shown whenever the conversation has no messages +// yet. It replaces the old single static prompt with Buddy's scripted intro plus +// tappable starter questions, so a first-time user learns who they're talking to, +// what Buddy can actually see, and what a good question looks like. +// +// The intro turns render as coach `ChatBubble`s, which is honest: they are +// app-authored copy that never claims to be a generated reply. struct AskCoachEmptyState: View { + let context: TodayState + let connected: Bool + var onPick: (String) -> Void = { _ in } + + private var turns: [String] { + CoachTutorial.openingTurns(for: context, connected: connected) + } + + private var suggestions: [StarterQuestion] { + StarterQuestions.suggestions(for: context) + } + var body: some View { - VStack(spacing: 14) { - Spacer() - BuddyView(mood: .ready, size: 96) - Text("What should we do today?") - .font(Typography.title2) - .foregroundColor(Palette.ink) - Text("Ask me about running, rest, hitting your step goal, or how training's going. I'll keep it practical and easy on your body.") - .font(Typography.body) - .foregroundColor(Palette.subtle) - .multilineTextAlignment(.center) - .padding(.horizontal, 36) - Spacer() - Spacer() + ScrollView { + VStack(alignment: .leading, spacing: Layout.md) { + ForEach(Array(turns.enumerated()), id: \.offset) { _, text in + ChatBubble(message: ChatMessage(id: 0, role: .coach, text: text, mood: .ready)) + } + + Text("Try asking") + .font(Typography.captionStrong) + .foregroundColor(Palette.subtle) + .padding(.top, Layout.xs) + + StarterQuestionChips(questions: suggestions, onPick: onPick) + } + .padding(.horizontal, Layout.screenGutter) + .padding(.top, Layout.md) + .padding(.bottom, Layout.screenBottom) } .frame(maxWidth: .infinity) } diff --git a/Sources/AppCore/AskCoachLockedState.swift b/Sources/AppCore/AskCoachLockedState.swift index d2557f7..a828df0 100644 --- a/Sources/AppCore/AskCoachLockedState.swift +++ b/Sources/AppCore/AskCoachLockedState.swift @@ -3,33 +3,55 @@ import SwiftUI // Shown in the Ask Coach tab when no AI key is connected. The Today insights and // weekly review are always free; the conversational chat needs the user's own // key, so this invites them to connect one in Settings rather than faking a reply. +// +// Below the CTA it shows the SAME starter questions the unlocked state offers, as +// non-tappable examples. An unconnected user still learns what a good question +// looks like, and because the chips are inert the screen never implies the app +// will answer one without a key. struct AskCoachLockedState: View { + var context: TodayState var onAddKey: () -> Void = {} + private var suggestions: [StarterQuestion] { + StarterQuestions.suggestions(for: context) + } + var body: some View { - VStack(spacing: 14) { - Spacer() - BuddyView(mood: .ready, size: 96) - Text("Chat with Buddy") - .font(Typography.title2) - .foregroundColor(Palette.ink) - Text("Your Today insights and weekly review are always free. To chat with Buddy for conversational coaching, connect your own AI key.") - .font(Typography.body) - .foregroundColor(Palette.subtle) - .multilineTextAlignment(.center) - .padding(.horizontal, 36) - Button(action: onAddKey) { - Text("Connect your AI key") - .font(Typography.headline) - .foregroundColor(Palette.onAccent) - .padding(.horizontal, 22).padding(.vertical, 12) - .background(Capsule().fill(Palette.brand)) + ScrollView { + VStack(spacing: 14) { + BuddyView(mood: .ready, size: 96) + Text("Chat with Buddy") + .font(Typography.title2) + .foregroundColor(Palette.ink) + Text("Your Today insights and weekly review are always free. To chat with Buddy for conversational coaching, connect your own AI key.") + .font(Typography.body) + .foregroundColor(Palette.subtle) + .multilineTextAlignment(.center) + .padding(.horizontal, 36) + Button(action: onAddKey) { + Text("Connect your AI key") + .font(Typography.headline) + .foregroundColor(Palette.onAccent) + .padding(.horizontal, 22).padding(.vertical, 12) + .background(Capsule().fill(Palette.brand)) + } + .padding(.top, 4) + .accessibilityLabel("Connect your AI key in Settings") + + VStack(alignment: .leading, spacing: Layout.sm) { + Text("Once you connect a key, you can ask things like") + .font(Typography.caption) + .foregroundColor(Palette.subtle) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + StarterQuestionChips(questions: suggestions) + } + .padding(.top, Layout.md) + .padding(.horizontal, Layout.screenGutter) } - .padding(.top, 4) - .accessibilityLabel("Connect your AI key in Settings") - Spacer() - Spacer() + .padding(.top, Layout.xl) + .padding(.bottom, Layout.screenBottom) + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) } } diff --git a/Sources/AppCore/AskCoachView.swift b/Sources/AppCore/AskCoachView.swift index 21a2cbe..54432f9 100644 --- a/Sources/AppCore/AskCoachView.swift +++ b/Sources/AppCore/AskCoachView.swift @@ -78,9 +78,22 @@ public struct AskCoachView: View { onWeeklyReview: { withAnimation(Motion.overlay) { showReview = true } }) Divider().opacity(0.4) if !chatUnlocked { - AskCoachLockedState(onAddKey: onOpenSettings) + AskCoachLockedState(context: model.today, onAddKey: onOpenSettings) } else if messages.isEmpty { - AskCoachEmptyState() + // A tapped starter question goes through `submit` exactly like + // a typed one: real coach when a key is connected, deterministic + // `CoachEngine` fallback otherwise. No separate answer path. + AskCoachEmptyState( + context: model.today, + connected: chatUnlocked, + onPick: { question in + Analytics.shared.capture( + "starter_question_tapped", + ["intent": CoachIntent.classify(question).rawValue] + ) + submit(question) + } + ) } else { ChatThread(messages: messages) } @@ -150,6 +163,11 @@ public struct AskCoachView: View { reply = CoachReply(intent: .general, text: "Your AI coach key was rejected. Reconnect it in Settings, then ask again.", mood: .concerned) + } catch CoachError.upstream(let message) { + // A provider-side configuration fault. Saying "check your + // connection" here sent the user chasing a network problem that + // did not exist, so state what actually happened. + reply = CoachReply(intent: .general, text: message, mood: .concerned) } catch { reply = CoachReply(intent: .general, text: "I couldn't reach Buddy just now. Check your connection and try again.", diff --git a/Sources/AppCore/BuddySummaryCard.swift b/Sources/AppCore/BuddySummaryCard.swift index 6e98c4e..e3419f0 100644 --- a/Sources/AppCore/BuddySummaryCard.swift +++ b/Sources/AppCore/BuddySummaryCard.swift @@ -14,22 +14,33 @@ struct BuddySummaryCard: View { } var body: some View { - HStack(spacing: 14) { - VStack(spacing: 6) { - BuddyView(mood: mood, size: 92) - MoodChip(mood: mood) + // The caption gives the mood word a referent. Without it the chip just + // says "Ready" under a mascot, and a first-time user has no idea ready + // for WHAT. The ⓘ answers the follow-up (what makes the mood change). + // + // The caption is NOT repeated for VoiceOver: the Buddy column below + // already speaks "Buddy the mascot, feeling ready", so an extra spoken + // "Buddy's read on today" would just double up. It stays visual, and the + // ⓘ carries the explanation for screen-reader users. + VStack(alignment: .leading, spacing: Layout.xs) { + InfoHint(topic: .buddyMood, label: "Buddy's read on today") + HStack(spacing: 14) { + VStack(spacing: 6) { + BuddyView(mood: mood, size: 92) + MoodChip(mood: mood) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Buddy the mascot, feeling \(mood.caption.lowercased())") + StepRing( + progress: model.goalProgress, + steps: model.today.steps, + goal: model.today.goalSteps, + remaining: model.stepsRemaining, + reached: model.goalReached, + exceeded: model.goalExceeded + ) + .frame(maxWidth: .infinity) } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Buddy the mascot, feeling \(mood.caption.lowercased())") - StepRing( - progress: model.goalProgress, - steps: model.today.steps, - goal: model.today.goalSteps, - remaining: model.stepsRemaining, - reached: model.goalReached, - exceeded: model.goalExceeded - ) - .frame(maxWidth: .infinity) } .padding(Layout.cardPadding) .cardStyle() diff --git a/Sources/AppCore/Coach/CoachTutorial.swift b/Sources/AppCore/Coach/CoachTutorial.swift new file mode 100644 index 0000000..1abee0e --- /dev/null +++ b/Sources/AppCore/Coach/CoachTutorial.swift @@ -0,0 +1,79 @@ +import Foundation + +// MARK: - Buddy's scripted opening +// +// The Coach tab used to open on a blank text field, which answered neither "who +// am I talking to?" nor "what can it actually see?". This is the short, scripted +// welcome that answers both. +// +// It is app-authored copy, NOT a fabricated model reply, and it never claims to +// be a generated answer — so it is honest to render in the locked (no key) state +// too. That preserves the rule `AskCoachLockedState` already follows: invite the +// user to connect a key rather than faking a reply. +// +// Pure and testable, like `CoachEngine`. No em dashes in user-facing copy. + +public enum CoachTutorial { + /// Buddy's opening turns, rendered as coach chat bubbles. + /// + /// - Parameters: + /// - context: the day's state, so the copy never promises run analysis on a + /// day-one install that has no runs to analyze. + /// - connected: whether an AI key is connected. When false the closing turn + /// names the connect step instead of inviting a question. + public static func openingTurns(for context: TodayState, connected: Bool) -> [String] { + var turns: [String] = [ + "Hi, I'm Buddy, your running coach. I'm here to help you move more consistently and build running fitness without getting hurt." + ] + + turns.append(dataTurn(for: context)) + + turns.append( + "I can't see how you're feeling unless you tell me, so mention sore legs, bad sleep, or a stressful week and I'll factor it in. I'm not a doctor, so anything sharp or persistent is one for a professional." + ) + + turns.append( + connected + ? "Tap one of the questions below to get started, or just type your own." + : "Connect your own AI key in Settings and we can talk properly. Here are the kinds of things you'll be able to ask." + ) + + return turns + } + + /// What Buddy can actually see today. Adapts to a day-one state so it never + /// promises analysis of runs that do not exist yet. + private static func dataTurn(for context: TodayState) -> String { + var visible: [String] = ["today's steps and movement"] + + if !context.workouts.isEmpty || context.latestWorkout != nil { + visible.append("your recent runs and walks") + } + if context.weeklyLoad != nil || !context.loadHistory.isEmpty { + visible.append("how hard your week has been") + } + if !context.journal.isEmpty { + visible.append("your check ins") + } + if !context.races.isEmpty { + visible.append("any races you've added") + } + + let list = sentenceList(visible) + + if context.workouts.isEmpty && context.latestWorkout == nil { + return "Right now I can see \(list). Once you log a few runs I'll be able to talk about pace, mileage, and recovery too." + } + return "I can see \(list), so my answers are about your actual week, not generic advice." + } + + /// "a", "a and b", "a, b, and c" — plain English, no trailing separator. + private static func sentenceList(_ items: [String]) -> String { + switch items.count { + case 0: return "nothing yet" + case 1: return items[0] + case 2: return "\(items[0]) and \(items[1])" + default: return items.dropLast().joined(separator: ", ") + ", and " + items[items.count - 1] + } + } +} diff --git a/Sources/AppCore/Coach/RemoteCoach.swift b/Sources/AppCore/Coach/RemoteCoach.swift index 273f256..6e30563 100644 --- a/Sources/AppCore/Coach/RemoteCoach.swift +++ b/Sources/AppCore/Coach/RemoteCoach.swift @@ -213,6 +213,12 @@ public enum CoachError: Error, Equatable { case rateLimited case server case network + /// The backend reached the provider and the provider refused for a reason the + /// user can act on (an unavailable model, an exhausted token budget). Carries + /// the backend's own message: collapsing these into `.server` is what made a + /// misconfigured provider read as "check your connection" in the chat, which + /// pointed the user at the one thing that was not wrong. + case upstream(String) } /// Calls the backend coach proxy. Stateless; safe to construct per request. @@ -245,6 +251,24 @@ public struct RemoteCoach { let safetyFlag: Bool } + /// The backend's error envelope: `{ "error": "", "message": "" }`. + private struct ErrorBody: Decodable { + let error: String + let message: String? + } + + /// The actionable reason from a non-2xx body, when the backend supplied one. + /// Only codes the user can actually do something about are surfaced; a bare + /// `upstream_error` stays a generic failure so a real outage still reads as one. + static func errorMessage(from data: Data) -> String? { + guard let body = try? JSONDecoder().decode(ErrorBody.self, from: data) else { return nil } + let actionable: Set = ["model_unavailable", "token_budget_exhausted"] + guard actionable.contains(body.error), let message = body.message, !message.isEmpty else { + return nil + } + return message + } + /// The context actually put on the wire. The journal is projected down to its /// bounded recent slice here — at the single point where the payload leaves /// the device — so an unbounded diary can never push `loadHistory` out of the @@ -291,7 +315,14 @@ public struct RemoteCoach { case 200: break case 401: throw CoachError.invalidKey // only a rejected key surfaces to the user; a 400 (bad request) is our bug, not theirs case 429: throw CoachError.rateLimited - default: throw CoachError.server + default: + // The backend distinguishes a provider misconfiguration (unavailable + // model, exhausted token budget) from an outage. Carry that reason + // through rather than flattening it into a generic failure. + if let message = Self.errorMessage(from: data) { + throw CoachError.upstream(message) + } + throw CoachError.server } guard let decoded = try? JSONDecoder().decode(ResponseBody.self, from: data) else { diff --git a/Sources/AppCore/ContentView.swift b/Sources/AppCore/ContentView.swift index 1d45009..787f3d9 100644 --- a/Sources/AppCore/ContentView.swift +++ b/Sources/AppCore/ContentView.swift @@ -31,6 +31,12 @@ public struct ContentView: View { @State private var showOnboarding: Bool private let startOnboardingPage = OnboardingState.startPage() + // Replaying the Today spotlight tour. `TodayDashboard` seeds its tour state in + // `init` (so a launch-seeded capture renders on the first frame), which means + // clearing the flag alone would not restart a dashboard that already exists. + // Bumping this token re-keys the dashboard so it re-inits and picks the tour up. + @State private var todayTourReplay = 0 + // Scenario-only override: a scenario can seed `rbContentSize` (e.g. "xxxl", // "accessibility3") to force a Dynamic Type size for the whole app, so the // large-text accessibility states render in a capture. Empty (production) => @@ -110,7 +116,13 @@ public struct ContentView: View { if showSettings && previewMode.isEmpty && session.state != .undecided { SettingsView(model: model, session: session, onClose: { withAnimation(Motion.overlay) { showSettings = false } }, - onReplayTour: { withAnimation(Motion.overlay) { showSettings = false; showOnboarding = true } }) + onReplayTour: { withAnimation(Motion.overlay) { showSettings = false; showOnboarding = true } }, + onReplayTodayTour: { + TourState.clearSeen() + tab = .today + todayTourReplay += 1 + withAnimation(Motion.overlay) { showSettings = false } + }) .overlayTransition() .zIndex(2) } @@ -169,6 +181,7 @@ public struct ContentView: View { TabView(selection: $tab) { TodayDashboard(model: model, onAskCoach: { tab = .coach }, onSettings: { withAnimation(Motion.overlay) { showSettings = true } }) + .id(todayTourReplay) .tag(MainTab.today) .tabItem { Label("Today", systemImage: "sun.max.fill") } diff --git a/Sources/AppCore/Formatters.swift b/Sources/AppCore/Formatters.swift index 83de8cd..18c7012 100644 --- a/Sources/AppCore/Formatters.swift +++ b/Sources/AppCore/Formatters.swift @@ -25,6 +25,43 @@ func movementLabel(_ minutes: Int) -> String { return m == 0 ? "\(h)h" : "\(h)h\(m)m" } +// MARK: - Honest empty state +// +// A fresh install has no Health data at all, and rendering that as `0` reads like +// a judgment ("you did nothing") rather than the truth ("nothing recorded yet"). +// These helpers draw the line between "no data" and a genuine zero: a real +// zero-step morning must keep reading `0`, because that IS the day's data. + +/// The placeholder shown when the day genuinely has no recorded data. +let noDataDash = "—" + +/// Whether Health has recorded anything at all for the day. +/// +/// `minutesSinceLastMovement` is load-bearing, not decorative: a genuine +/// zero-step morning seeds 0 steps, 0 active minutes and 0 miles, yet Health HAS +/// observed the user (it knows they moved N minutes ago). Judging on the three +/// metrics alone would collapse that real morning into the same bucket as a fresh +/// install and swallow a true zero behind a dash. A never-observed install has no +/// movement timestamp at all, which is the difference this reads. +func hasDayData(steps: Int, activeMinutes: Int, distanceMiles: Double, + minutesSinceLastMovement: Int = 0) -> Bool { + steps > 0 || activeMinutes > 0 || distanceMiles > 0 || minutesSinceLastMovement > 0 +} + +/// A stat tile's value: the real value when the day has data, else `—`. +/// Note this keys off the WHOLE day, not the individual metric, so a day with +/// 4,000 steps and 0 active minutes still honestly shows `0` active minutes. +func statValue(_ value: String, hasData: Bool) -> String { + hasData ? value : noDataDash +} + +/// "Time since you moved", but honest on a fresh install. `movementLabel(0)` +/// renders the cheerful-but-wrong "now" when nothing has ever been recorded; +/// with no data to measure from there is no answer, so show `—` instead. +func movementDisplay(minutes: Int, hasData: Bool) -> String { + hasData ? movementLabel(minutes) : noDataDash +} + /// Caption shown under the step count in the goal ring. Stays warm and /// celebratory once the goal is met — with extra cheer when it's been passed — /// and otherwise frames the goal the user is working toward. diff --git a/Sources/AppCore/HintCopy.swift b/Sources/AppCore/HintCopy.swift new file mode 100644 index 0000000..7d1efae --- /dev/null +++ b/Sources/AppCore/HintCopy.swift @@ -0,0 +1,72 @@ +import Foundation + +// MARK: - Explain-this-element copy +// +// The plain-language answers to "what am I looking at?" for the Today dashboard's +// ambiguous elements. Pure and SwiftUI-free (same spirit as `Formatters.swift`) so +// the copy is unit-testable without rendering a view, and so one table owns every +// explanation instead of scattering strings through the section components. +// +// House style: no em dashes in user-facing strings (the convention applied across +// the app's copy), and never shame-based. + +/// Scenario hook: which hint (if any) renders already expanded at launch. +/// +/// A hint is a tap-to-reveal disclosure, and a simulator capture cannot tap. This +/// is the same trick `rbShowHistory` / `rbShowJournalEditor` use for the overlays: +/// seed the open state so the first frame renders it complete, never mid-animation. +public enum HintSeed { + public static let key = "rbOpenHint" + + public static func isOpen(_ topic: HintTopic, _ d: UserDefaults = .standard) -> Bool { + (d.string(forKey: key) ?? "") == topic.rawValue + } +} + +public enum HintTopic: String, CaseIterable { + case buddyMood + case stepRing + case activeMinutes + case distance + case sinceMoving + case coachCard + case checkIn + case weeklyLoad + + /// Short name of the thing being explained. Also builds the hint button's + /// accessibility label ("Explain "). + public var title: String { + switch self { + case .buddyMood: return "Buddy's mood" + case .stepRing: return "your step ring" + case .activeMinutes: return "active minutes" + case .distance: return "miles" + case .sinceMoving: return "time since you moved" + case .coachCard: return "Buddy's suggestion" + case .checkIn: return "the daily check in" + case .weeklyLoad: return "your weekly load" + } + } + + /// One or two sentences answering the literal question the element raises. + public var body: String { + switch self { + case .buddyMood: + return "Buddy's read on today. It changes with your steps, your latest run, and how hard your week has been. \"Ready\" means nothing is holding you back." + case .stepRing: + return "Your steps so far today against your daily goal. The ring fills as you move, and you can change the goal in Settings." + case .activeMinutes: + return "Minutes Apple Health scored as brisk movement today. It fills in through the day, so it starts at zero every morning." + case .distance: + return "Miles Apple Health recorded from walking and running today, including your workouts." + case .sinceMoving: + return "How long since Apple Health last recorded you moving. Useful for spotting a long sit." + case .coachCard: + return "Buddy's suggestion for today, based on your steps, your recent runs, and how much you have trained this week. Tap it to ask a follow up." + case .checkIn: + return "How today felt, in your own words. One tap logs it, and Buddy reads your recent check ins when it advises you." + case .weeklyLoad: + return "How much running you have done this week compared with the weeks before it. It is how Buddy spots a jump in mileage early." + } + } +} diff --git a/Sources/AppCore/InfoHint.swift b/Sources/AppCore/InfoHint.swift new file mode 100644 index 0000000..288f22c --- /dev/null +++ b/Sources/AppCore/InfoHint.swift @@ -0,0 +1,101 @@ +import SwiftUI + +// MARK: - Tap-to-reveal hint +// +// A small ⓘ button that toggles an inline explanatory caption beneath its host. +// +// Deliberately NOT a `.popover` or `.help()`: the macOS test build targets macOS +// 12 where `.help()` is macOS-only and popovers need anchor math, while an inline +// disclosure compiles everywhere, captures deterministically in a scenario, and +// reflows correctly at accessibility text sizes. + +/// The ⓘ affordance itself. Pair it with `hintCaption(_:expanded:)` on the host, +/// or use the `hinted(_:)` modifier when the host has no room for its own row. +struct InfoHintButton: View { + let topic: HintTopic + @Binding var expanded: Bool + + var body: some View { + Button { + withAnimation(Motion.overlay) { expanded.toggle() } + } label: { + Image(systemName: expanded ? "info.circle.fill" : "info.circle") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Palette.subtle) + // A 13pt glyph is far below a comfortable tap target, so pad the + // hit area out without pushing the glyph around in the layout. + .padding(6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Explain \(topic.title)") + .accessibilityHint(expanded ? "Hides the explanation" : "Shows a short explanation") + } +} + +/// The revealed caption. Rendered in the host's own layout so it never overlaps +/// neighboring cards and never needs an anchor. +struct InfoHintCaption: View { + let topic: HintTopic + + var body: some View { + Text(topic.body) + .font(Typography.caption) + .foregroundColor(Palette.subtle) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Layout.sm) + .padding(.vertical, Layout.xs) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Palette.subtle.opacity(0.10)) + ) + .accessibilityLabel("\(topic.title): \(topic.body)") + } +} + +/// Self-contained ⓘ + caption pair for hosts that can spare a row: the button sits +/// inline, and tapping it reveals the caption directly beneath. +struct InfoHint: View { + let topic: HintTopic + /// Optional lead-in text shown to the left of the ⓘ (e.g. a card caption). + var label: String? = nil + + // Seeded in `init` (not `.onAppear`) so a launch-seeded capture renders the + // revealed caption on the very first frame. + @State private var expanded: Bool + + init(topic: HintTopic, label: String? = nil) { + self.topic = topic + self.label = label + _expanded = State(initialValue: HintSeed.isOpen(topic)) + } + + var body: some View { + VStack(alignment: .leading, spacing: Layout.xs) { + HStack(spacing: 2) { + if let label { + Text(label) + .font(Typography.caption) + .foregroundColor(Palette.subtle) + } + InfoHintButton(topic: topic, expanded: $expanded) + Spacer(minLength: 0) + } + if expanded { + InfoHintCaption(topic: topic) + } + } + } +} + +extension View { + /// Attach a hint beneath any view without restructuring its layout. + func hinted(_ topic: HintTopic, label: String? = nil) -> some View { + VStack(alignment: .leading, spacing: Layout.xs) { + self + InfoHint(topic: topic, label: label) + } + } +} diff --git a/Sources/AppCore/RaceGoals.swift b/Sources/AppCore/RaceGoals.swift index 77356ba..64322c0 100644 --- a/Sources/AppCore/RaceGoals.swift +++ b/Sources/AppCore/RaceGoals.swift @@ -195,15 +195,103 @@ public enum RaceStore { // MARK: - "Add a race" Today banner dismissal -/// Whether the user has dismissed the Today "add a race" prompt. Modeled on -/// `OnboardingState`: a single UserDefaults flag with injectable defaults. +/// Whether to show the Today "add a race" prompt, and what dismissing it means. +/// +/// This used to be a single forever-flag: the first ✕ killed the banner for good. +/// That is exactly what a confused first-run user does by reflex, so the offer was +/// being destroyed by the very confusion it caused. Now dismissal is a snooze: +/// two 14-day snoozes, then permanent, and the banner does not appear on day one +/// at all. Pure + injectable defaults, like `OnboardingState`. public enum RacePromptState { static let dismissedKey = "otterpaceRacePromptDismissed" + static let snoozedUntilKey = "otterpaceRacePromptSnoozedUntil" + static let dismissCountKey = "otterpaceRacePromptDismissCount" + static let firstEligibleKey = "otterpaceRacePromptFirstEligible" + /// Dismissals after which the banner stops coming back. + public static let maxDismissals = 2 + + /// Days a single dismissal buys. + public static let snoozeDays = 14 + + /// Back-compat: the pre-snooze forever-flag. Still honored so an existing + /// install that already dismissed the banner does not see it return. public static func isDismissed(_ d: UserDefaults = .standard) -> Bool { d.bool(forKey: dismissedKey) } + public static func markDismissed(_ d: UserDefaults = .standard) { d.set(true, forKey: dismissedKey) } + + public static func dismissCount(_ d: UserDefaults = .standard) -> Int { + d.integer(forKey: dismissCountKey) + } + + /// Whether the banner should be visible. False when: + /// • an upcoming race already exists (nothing to ask for), + /// • the legacy forever-flag was set by an older build, + /// • it has been dismissed `maxDismissals` times, + /// • a snooze is still running, + /// • or the user became eligible less than a day ago (so it never lands in + /// the confusing first session). + /// + /// Stamps the first-eligible date on the first call that qualifies, which is + /// what makes the day-one suppression work without a separate install date. + public static func shouldShow(asOf today: String, + races: [RaceGoal], + defaults d: UserDefaults = .standard) -> Bool { + if RaceGoal.hasUpcoming(in: races, asOf: today) { return false } + if isDismissed(d) { return false } + if dismissCount(d) >= maxDismissals { return false } + + if let snoozedUntil = d.string(forKey: snoozedUntilKey), !snoozedUntil.isEmpty { + // ISO yyyy-MM-dd strings sort lexicographically, so a plain compare is + // correct here (same trick `RaceGoal.upcoming` relies on). + if today < snoozedUntil { return false } + } + + guard let firstEligible = d.string(forKey: firstEligibleKey), !firstEligible.isEmpty else { + d.set(today, forKey: firstEligibleKey) + return false // day one: record eligibility, stay quiet + } + return today > firstEligible + } + + /// Dismiss for now: bump the count and snooze for `snoozeDays`. On the final + /// allowed dismissal it becomes permanent. + public static func snooze(asOf today: String, defaults d: UserDefaults = .standard) { + let count = dismissCount(d) + 1 + d.set(count, forKey: dismissCountKey) + + if count >= maxDismissals { + markDismissed(d) + return + } + if let until = addDays(snoozeDays, to: today) { + d.set(until, forKey: snoozedUntilKey) + } + } + + /// ISO date `days` after `iso`, or nil on unparseable input. + static func addDays(_ days: Int, to iso: String) -> String? { + guard let date = isoParser.date(from: iso), + let moved = utcCalendar.date(byAdding: .day, value: days, to: date) + else { return nil } + return isoParser.string(from: moved) + } + + private static let isoParser: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + + private static let utcCalendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC") ?? .current + return c + }() } diff --git a/Sources/AppCore/RacePromptBanner.swift b/Sources/AppCore/RacePromptBanner.swift index 98154ca..2913773 100644 --- a/Sources/AppCore/RacePromptBanner.swift +++ b/Sources/AppCore/RacePromptBanner.swift @@ -1,48 +1,68 @@ import SwiftUI -// A dismissable Today callout, in Buddy's voice, inviting the user to add a race. -// Shown only when the user has no races yet and hasn't dismissed it. Tapping opens -// Settings (where the Races card lives); the ✕ dismisses it for good. +// A Today callout, in Buddy's voice, inviting the user to add a race. +// +// The old version led with a bare ✕ that killed the banner permanently on the +// first tap, and body copy that never said what adding a race would DO. Both are +// fixed here: the copy names the actual outcome, and dismissal is an explicit +// "Not now" text button (a considered tap, not a reflex) wired to a snooze. The +// ✕-equivalent survives as an accessibility action, so the one-gesture dismissal +// screen-reader users expect is still available. struct RacePromptBanner: View { var onTap: () -> Void var onDismiss: () -> Void var body: some View { - ZStack(alignment: .topTrailing) { - Button(action: onTap) { - HStack(spacing: 12) { - Image(systemName: "flag.checkered") - .font(.system(size: 20, weight: .bold)) + VStack(alignment: .leading, spacing: Layout.sm) { + HStack(spacing: 12) { + Image(systemName: "flag.checkered") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(Palette.onAccent) + VStack(alignment: .leading, spacing: 3) { + Text("Got a race coming up?") + .font(Typography.headline) .foregroundColor(Palette.onAccent) - VStack(alignment: .leading, spacing: 3) { - Text("Got a race coming up?") - .font(Typography.headline) - .foregroundColor(Palette.onAccent) - Text("Tell Buddy and I'll tailor your training toward it.") - .font(Typography.caption) - .foregroundColor(Palette.onAccent.opacity(0.92)) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 24) + Text("Add your race and I will shape your weeks around it: long runs, taper, and race week.") + .font(Typography.caption) + .foregroundColor(Palette.onAccent.opacity(0.92)) + .fixedSize(horizontal: false, vertical: true) } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - LinearGradient(colors: [Palette.brand, Palette.brandDeep], - startPoint: .leading, endPoint: .trailing) - ) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + Spacer(minLength: 0) } - .buttonStyle(.plain) - .accessibilityLabel("Got a race coming up? Tap to add a race.") - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(Palette.onAccent.opacity(0.9)) - .padding(10) + HStack(spacing: Layout.sm) { + Button(action: onDismiss) { + Text("Not now") + .font(Typography.caption) + .foregroundColor(Palette.onAccent.opacity(0.92)) + .padding(.horizontal, 14) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Not now") + .accessibilityHint("Hides this for a couple of weeks") + + Spacer(minLength: 0) + + Button(action: onTap) { + Text("Add a race") + .font(Typography.headline) + .foregroundColor(Palette.brandDeep) + .padding(.horizontal, 18).padding(.vertical, 10) + .background(Capsule().fill(Palette.onAccent)) + } + .buttonStyle(.plain) + .accessibilityLabel("Add a race") } - .accessibilityLabel("Dismiss race prompt") } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + LinearGradient(colors: [Palette.brand, Palette.brandDeep], + startPoint: .leading, endPoint: .trailing) + ) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .accessibilityAction(named: Text("Dismiss race prompt"), onDismiss) } } diff --git a/Sources/AppCore/SettingsView.swift b/Sources/AppCore/SettingsView.swift index c047515..a6eb548 100644 --- a/Sources/AppCore/SettingsView.swift +++ b/Sources/AppCore/SettingsView.swift @@ -13,6 +13,7 @@ public struct SettingsView: View { @ObservedObject var session: SessionStore var onClose: () -> Void var onReplayTour: () -> Void = {} + var onReplayTodayTour: () -> Void = {} @State private var confirmDelete = false @@ -95,11 +96,13 @@ public struct SettingsView: View { } public init(model: OtterpaceModel, session: SessionStore, onClose: @escaping () -> Void = {}, - onReplayTour: @escaping () -> Void = {}) { + onReplayTour: @escaping () -> Void = {}, + onReplayTodayTour: @escaping () -> Void = {}) { self.model = model self.session = session self.onClose = onClose self.onReplayTour = onReplayTour + self.onReplayTodayTour = onReplayTodayTour } public var body: some View { @@ -901,6 +904,7 @@ public struct SettingsView: View { card("About") { row(icon: "pawprint.fill", tint: Palette.brand, title: "Otterpace", detail: "Version \(appVersion)") actionRow("Show welcome tour again", icon: "sparkles", tint: Palette.sky) { onReplayTour() } + actionRow("Show the Today tour again", icon: "hand.point.up.left.fill", tint: Palette.go) { onReplayTodayTour() } } } diff --git a/Sources/AppCore/StarterQuestionChips.swift b/Sources/AppCore/StarterQuestionChips.swift new file mode 100644 index 0000000..5fa8b60 --- /dev/null +++ b/Sources/AppCore/StarterQuestionChips.swift @@ -0,0 +1,57 @@ +import SwiftUI + +// The ice-breaker chip row shared by the Ask Coach empty and locked states. +// +// When `onPick` is nil the chips render as non-tappable examples. That is how the +// locked state uses them: an unconnected user still learns the shape of a good +// question, without the app implying it will answer one. +struct StarterQuestionChips: View { + let questions: [StarterQuestion] + var onPick: ((String) -> Void)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: Layout.sm) { + ForEach(questions) { q in + if let onPick { + Button { onPick(q.text) } label: { chip(q.text, tappable: true) } + .buttonStyle(.plain) + .accessibilityLabel("Ask: \(q.text)") + .accessibilityHint("Sends this question to Buddy") + } else { + chip(q.text, tappable: false) + .accessibilityLabel("Example question: \(q.text)") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func chip(_ text: String, tappable: Bool) -> some View { + HStack(spacing: 8) { + Text(text) + .font(Typography.callout) + .foregroundColor(tappable ? Palette.ink : Palette.subtle) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + if tappable { + Image(systemName: "arrow.up.right") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(Palette.brand) + } + } + .padding(.horizontal, Layout.md) + .padding(.vertical, Layout.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 44) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(tappable ? Palette.brand.opacity(0.10) : Palette.subtle.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke((tappable ? Palette.brand : Palette.subtle).opacity(0.22), lineWidth: 1) + ) + .contentShape(Rectangle()) + } +} diff --git a/Sources/AppCore/StarterQuestions.swift b/Sources/AppCore/StarterQuestions.swift new file mode 100644 index 0000000..22e91ab --- /dev/null +++ b/Sources/AppCore/StarterQuestions.swift @@ -0,0 +1,65 @@ +import Foundation + +// MARK: - Ask Coach ice-breakers +// +// A blank text field tells a new user nothing about what Buddy can actually +// answer. These are the tappable examples that show the shape of a good question. +// +// Each suggestion declares the `CoachIntent` it was written for, and the wording +// is chosen so `CoachIntent.classify` genuinely routes it there — a test asserts +// that round-trip, so a suggestion can never quietly drift into the wrong intent +// (or, worse, into `.injuryPain`) as the classifier's keywords evolve. + +public struct StarterQuestion: Equatable, Identifiable { + public let text: String + /// The intent this question is written to reach. + public let intent: CoachIntent + + public var id: String { text } + + public init(text: String, intent: CoachIntent) { + self.text = text + self.intent = intent + } +} + +public enum StarterQuestions { + /// The most chips we ever offer, so the row never dominates the screen. + public static let maxCount = 4 + + /// Ice-breakers for the current day's state, most useful first. + /// + /// Conditional suggestions only appear when the data behind them exists: a + /// day-one user with no runs is not offered "how did my last run go?", and a + /// user with no races is not offered race planning. Offering a question the + /// app cannot answer well is exactly the first-run confusion this fixes. + public static func suggestions(for context: TodayState, asOf today: String = "") -> [StarterQuestion] { + var out: [StarterQuestion] = [ + StarterQuestion(text: "Should I run today or rest?", intent: .runOrRest), + StarterQuestion(text: "How do I get to 10,000 steps today?", intent: .hit10K), + ] + + if context.latestWorkout != nil { + out.append(StarterQuestion(text: "How did my last run go?", intent: .postRunReflection)) + } + + let day = today.isEmpty ? context.date : today + if !day.isEmpty, RaceGoal.hasUpcoming(in: context.races, asOf: day) { + out.append(StarterQuestion(text: "How should I train for my race?", intent: .raceGoal)) + } + + if context.loadHistory.count >= 2 { + out.append(StarterQuestion(text: "Am I building mileage too fast?", intent: .mileageTooFast)) + } + + // Always available, and the natural catch-all, so it backfills whenever + // the conditional suggestions did not fire. + out.append(StarterQuestion(text: "What should I focus on this week?", intent: .general)) + + // De-duplicate defensively (by text) before capping, so a future edit that + // repeats a question can't spend a scarce chip slot twice. + var seen = Set<String>() + let unique = out.filter { seen.insert($0.text).inserted } + return Array(unique.prefix(maxCount)) + } +} diff --git a/Sources/AppCore/StatTile.swift b/Sources/AppCore/StatTile.swift index 884c599..98404d4 100644 --- a/Sources/AppCore/StatTile.swift +++ b/Sources/AppCore/StatTile.swift @@ -6,8 +6,36 @@ struct StatTile: View { let tint: Color let value: String let label: String + /// Optional ⓘ explaining what the metric actually measures. + var hint: HintTopic? = nil + /// Optional line under the label, used to say "no data yet" out loud rather + /// than leaving a bare `—` to read as a broken tile. + var subtitle: String? = nil + /// Spoken form for VoiceOver. Without it a no-data tile reads as + /// "— active min", which is meaningless aloud. + var accessibilityText: String? = nil - var body: some View { + // Seeded from the scenario so a capture can render an open hint on the first + // frame — a simulator screenshot cannot tap the ⓘ itself. + @State private var hintExpanded: Bool + + init(icon: String, tint: Color, value: String, label: String, + hint: HintTopic? = nil, subtitle: String? = nil, accessibilityText: String? = nil) { + self.icon = icon + self.tint = tint + self.value = value + self.label = label + self.hint = hint + self.subtitle = subtitle + self.accessibilityText = accessibilityText + _hintExpanded = State(initialValue: hint.map { HintSeed.isOpen($0) } ?? false) + } + + /// The metric itself: icon, value, label, and the optional "no data yet" + /// line, collapsed into ONE VoiceOver element exactly as before. The ⓘ is + /// deliberately kept outside this group so it stays separately focusable + /// and its "Explain …" label is reachable. + private var metric: some View { VStack(spacing: 6) { Image(systemName: icon) .font(Typography.headline) @@ -21,11 +49,31 @@ struct StatTile: View { .font(Typography.caption) .foregroundColor(Palette.subtle) .multilineTextAlignment(.center) + if let subtitle { + Text(subtitle) + .font(Typography.caption) + .foregroundColor(Palette.subtle.opacity(0.8)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText ?? "\(value) \(label)") + } + + var body: some View { + VStack(spacing: 4) { + metric + if let hint { + InfoHintButton(topic: hint, expanded: $hintExpanded) + if hintExpanded { + InfoHintCaption(topic: hint) + .padding(.horizontal, Layout.xs) + } + } } .frame(maxWidth: .infinity) .padding(.vertical, 14) .cardStyle() - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(value) \(label)") } } diff --git a/Sources/AppCore/StatsRow.swift b/Sources/AppCore/StatsRow.swift index d5b7a76..fa672ae 100644 --- a/Sources/AppCore/StatsRow.swift +++ b/Sources/AppCore/StatsRow.swift @@ -3,19 +3,52 @@ import SwiftUI // The three at-a-glance metric tiles: active minutes, distance, and time since // the user last moved. The 3-up row reflows to a vertical stack at accessibility // text sizes so the tiles never clip or truncate on large type / small screens. +// +// On a day Health has recorded nothing at all, the tiles read `—` with a "no data +// yet" line instead of three zeroes: on a fresh install a row of `0`s reads as a +// judgment, and `movementLabel(0)` would cheerfully claim you moved "now". A +// genuine zero-step morning still shows `0` — that IS the day's data. struct StatsRow: View { let today: TodayState @Environment(\.dynamicTypeSize) private var typeSize + private var hasData: Bool { + hasDayData(steps: today.steps, + activeMinutes: today.activeMinutes, + distanceMiles: today.distanceMiles, + minutesSinceLastMovement: today.minutesSinceLastMovement) + } + + private var noDataCaption: String? { hasData ? nil : "no data yet" } + private var tiles: [StatTile] { [ StatTile(icon: "flame.fill", tint: Palette.brand, - value: "\(today.activeMinutes)", label: "active min"), + value: statValue("\(today.activeMinutes)", hasData: hasData), + label: "active min", + hint: .activeMinutes, + subtitle: noDataCaption, + accessibilityText: hasData + ? "\(today.activeMinutes) active minutes" + : "No active minutes recorded yet"), StatTile(icon: "figure.walk", tint: Palette.go, - value: String(format: "%.1f", today.distanceMiles), label: "miles"), + value: statValue(String(format: "%.1f", today.distanceMiles), hasData: hasData), + label: "miles", + hint: .distance, + subtitle: noDataCaption, + accessibilityText: hasData + ? "\(String(format: "%.1f", today.distanceMiles)) miles" + : "No distance recorded yet"), + // "since moving" read as a fragment, so the tile now says who moved. StatTile(icon: "clock.fill", tint: Palette.sky, - value: movementLabel(today.minutesSinceLastMovement), label: "since moving"), + value: movementDisplay(minutes: today.minutesSinceLastMovement, hasData: hasData), + label: "since you moved", + hint: .sinceMoving, + subtitle: noDataCaption, + accessibilityText: hasData + ? "\(movementLabel(today.minutesSinceLastMovement)) since you last moved" + : "No movement recorded yet"), ] } @@ -23,7 +56,7 @@ struct StatsRow: View { if typeSize.isAccessibilitySize { VStack(spacing: 12) { ForEach(0..<tiles.count, id: \.self) { tiles[$0] } } } else { - HStack(spacing: 12) { ForEach(0..<tiles.count, id: \.self) { tiles[$0] } } + HStack(alignment: .top, spacing: 12) { ForEach(0..<tiles.count, id: \.self) { tiles[$0] } } } } } diff --git a/Sources/AppCore/TodayView.swift b/Sources/AppCore/TodayView.swift index 09abb20..44e63fd 100644 --- a/Sources/AppCore/TodayView.swift +++ b/Sources/AppCore/TodayView.swift @@ -28,6 +28,12 @@ public struct TodayDashboard: View { // scenario would otherwise hide it. private let forceRacePrompt = UserDefaults.standard.bool(forKey: "rbShowRacePrompt") + // The spotlight tour's current step, or nil when the tour isn't running. + // Seeded in `init` (like `showHistory` / `showJournalEditor`) so a + // launch-seeded capture renders the tour complete on the very first frame + // rather than mid-transition. + @State private var tourStep: Int? + public init(model: OtterpaceModel, onAskCoach: @escaping () -> Void = {}, onSettings: @escaping () -> Void = {}) { self.model = model self.onAskCoach = onAskCoach @@ -36,6 +42,12 @@ public struct TodayDashboard: View { _racePromptDismissed = State(initialValue: RacePromptState.isDismissed()) _showJournalEditor = State(initialValue: UserDefaults.standard.bool(forKey: "rbShowJournalEditor")) _journalEditorIsPostRun = State(initialValue: UserDefaults.standard.bool(forKey: "rbJournalEditorPostRun")) + + let showTour = TourState.shouldShow( + startScreen: UserDefaults.standard.string(forKey: "rbStartScreen") ?? "", + healthConnected: model.today.healthKitConnected + ) + _tourStep = State(initialValue: showTour ? TourState.startStep() : nil) } // The "today" used for race math: the seeded/observed dashboard date when set @@ -66,8 +78,12 @@ public struct TodayDashboard: View { return Journal.entry(forWorkoutOn: workout.date, in: model.today.journal) } + // Dismissal is a snooze now, not a self-destruct, and the banner stays quiet + // through the first session — see `RacePromptState.shouldShow`. private var showRacePrompt: Bool { - forceRacePrompt || (!RaceGoal.hasUpcoming(in: model.today.races, asOf: todayISO) && !racePromptDismissed) + forceRacePrompt + || (!racePromptDismissed + && RacePromptState.shouldShow(asOf: todayISO, races: model.today.races)) } public var body: some View { @@ -76,13 +92,18 @@ public struct TodayDashboard: View { VStack(spacing: Layout.cardSpacing) { TodayHeader(date: model.today.date, onSettings: onSettings) BuddySummaryCard(model: model) + .tourAnchor(.buddy) StatsRow(today: model.today) + .tourAnchor(.stats) if showRacePrompt { RacePromptBanner( onTap: onSettings, onDismiss: { - RacePromptState.markDismissed() - Analytics.shared.capture("race_prompt_dismissed") + RacePromptState.snooze(asOf: todayISO) + Analytics.shared.capture( + "race_prompt_snoozed", + ["count": "\(RacePromptState.dismissCount())"] + ) withAnimation(Motion.overlay) { racePromptDismissed = true } } ) @@ -91,6 +112,7 @@ public struct TodayDashboard: View { // compute the honest nudge from the day's data (no key needed). CoachCard(coach: model.today.coach ?? CoachEngine.dailyNudge(for: model.today), onAskCoach: onAskCoach) + .tourAnchor(.coachCard) CheckInCard( entry: todayCheckIn, onQuickFeel: { feel in @@ -104,6 +126,7 @@ public struct TodayDashboard: View { withAnimation(Motion.overlay) { showJournalEditor = true } } ) + .tourAnchor(.checkIn) if let workout = model.today.latestWorkout { WorkoutCard( workout: workout, @@ -118,6 +141,7 @@ public struct TodayDashboard: View { WeeklyLoadCard(load: load) } ActivityHistoryButton(onTap: { withAnimation(Motion.overlay) { showHistory = true } }) + .tourAnchor(.history) } .screenScrollContent() } @@ -146,5 +170,47 @@ public struct TodayDashboard: View { .zIndex(2) } } + // The tour sits above every other overlay, and reads the anchor bounds + // collected from the tagged sections. When no anchor has resolved yet the + // callout centers itself, so a launch-seeded capture is never blank. + .overlayPreferenceValue(TourAnchorKey.self) { anchors in + GeometryReader { proxy in + if let index = tourStep, index < TourStep.allCases.count { + let step = TourStep.allCases[index] + TodayTourOverlay( + step: step, + index: index, + highlight: anchors[step.anchorID].map { proxy[$0] }, + onNext: { advanceTour(from: index) }, + onSkip: { endTour(step: step, completed: false) } + ) + } + } + } + .onAppear { + if let index = tourStep, index < TourStep.allCases.count { + Analytics.shared.capture("tour_started") + Analytics.shared.capture("tour_step_viewed", ["step": TourStep.allCases[index].rawValue]) + } + } + } + + // MARK: Tour + + private func advanceTour(from index: Int) { + let next = index + 1 + guard next < TourStep.allCases.count else { + endTour(step: TourStep.allCases[index], completed: true) + return + } + Analytics.shared.capture("tour_step_viewed", ["step": TourStep.allCases[next].rawValue]) + withAnimation(Motion.overlay) { tourStep = next } + } + + private func endTour(step: TourStep, completed: Bool) { + TourState.markSeen() + Analytics.shared.capture(completed ? "tour_completed" : "tour_skipped", + ["step": step.rawValue]) + withAnimation(Motion.overlay) { tourStep = nil } } } diff --git a/Sources/AppCore/Tour/TodayTourOverlay.swift b/Sources/AppCore/Tour/TodayTourOverlay.swift new file mode 100644 index 0000000..50a0449 --- /dev/null +++ b/Sources/AppCore/Tour/TodayTourOverlay.swift @@ -0,0 +1,144 @@ +import SwiftUI + +// MARK: - Today spotlight tour +// +// A scrim plus a themed callout card naming the highlighted element, with a +// step-dot indicator, a primary "Next" / "Got it", and a secondary "Skip tour". +// +// Positioning uses `anchorPreference` / `overlayPreferenceValue`, which are +// available on the macOS 12 test build. When no anchor resolves (a launch-seeded +// capture can render the overlay before the anchored section has reported its +// bounds) the callout falls back to a centered card, so a seeded capture is never +// blank. + +/// Collects the bounds of each tour-anchored section, keyed by `TourStep.anchorID`. +struct TourAnchorKey: PreferenceKey { + static var defaultValue: [String: Anchor<CGRect>] = [:] + static func reduce(value: inout [String: Anchor<CGRect>], nextValue: () -> [String: Anchor<CGRect>]) { + value.merge(nextValue()) { _, new in new } + } +} + +extension View { + /// Tag a Today section as the target of a tour step. + func tourAnchor(_ step: TourStep) -> some View { + anchorPreference(key: TourAnchorKey.self, value: .bounds) { [step.anchorID: $0] } + } +} + +struct TodayTourOverlay: View { + let step: TourStep + let index: Int + /// Resolved bounds of the highlighted section, when the anchor has reported. + var highlight: CGRect? = nil + var onNext: () -> Void + var onSkip: () -> Void + + private var isLast: Bool { index == TourStep.allCases.count - 1 } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + Color.black.opacity(0.55) + .ignoresSafeArea() + // Tapping the scrim advances, matching the callout's primary + // action, so a stray tap never feels like a dead end. + .onTapGesture(perform: onNext) + .accessibilityHidden(true) + + if let highlight { + RoundedRectangle(cornerRadius: Layout.cardCorner, style: .continuous) + .stroke(Palette.onAccent.opacity(0.9), lineWidth: 2) + .background( + RoundedRectangle(cornerRadius: Layout.cardCorner, style: .continuous) + .fill(Palette.onAccent.opacity(0.12)) + ) + .frame(width: highlight.width + 8, height: highlight.height + 8) + .position(x: highlight.midX, y: highlight.midY) + .allowsHitTesting(false) + } + + callout + .frame(maxWidth: 380) + .position(calloutCenter(in: geo.size)) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Today tour, step \(index + 1) of \(TourStep.allCases.count)") + } + + // MARK: Callout + + private var callout: some View { + VStack(alignment: .leading, spacing: Layout.sm) { + Text(step.title) + .font(Typography.title3) + .foregroundColor(Palette.ink) + Text(step.body) + .font(Typography.body) + .foregroundColor(Palette.subtle) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + ForEach(0..<TourStep.allCases.count, id: \.self) { i in + Circle() + .fill(i == index ? Palette.brand : Palette.subtle.opacity(0.3)) + .frame(width: 6, height: 6) + } + } + .padding(.top, 2) + .accessibilityHidden(true) + + HStack { + if !isLast { + Button(action: onSkip) { + Text("Skip tour") + .font(Typography.caption) + .foregroundColor(Palette.subtle) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Skip the tour") + } + Spacer() + Button(action: onNext) { + Text(step.advanceTitle) + .font(Typography.headline) + .foregroundColor(Palette.onAccent) + .padding(.horizontal, 22).padding(.vertical, 12) + .background( + Capsule().fill( + LinearGradient(colors: [Palette.brand, Palette.brandDeep], + startPoint: .leading, endPoint: .trailing) + ) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(isLast ? "Finish the tour" : "Next tour step") + } + .padding(.top, 2) + } + .padding(Layout.cardPadding) + .background( + RoundedRectangle(cornerRadius: Layout.cardCorner, style: .continuous) + .fill(Palette.card) + ) + .padding(.horizontal, Layout.screenGutter) + } + + /// Place the callout under the highlighted section when there's room, above it + /// otherwise, and dead center when no anchor has resolved. + private func calloutCenter(in size: CGSize) -> CGPoint { + guard let highlight else { + return CGPoint(x: size.width / 2, y: size.height / 2) + } + let estimatedHeight: CGFloat = 220 + let below = highlight.maxY + estimatedHeight / 2 + Layout.md + let above = highlight.minY - estimatedHeight / 2 - Layout.md + let y = below < size.height - Layout.md + ? below + : max(estimatedHeight / 2 + Layout.md, above) + return CGPoint(x: size.width / 2, y: y) + } +} diff --git a/Sources/AppCore/Tour/TourState.swift b/Sources/AppCore/Tour/TourState.swift new file mode 100644 index 0000000..fc1bb61 --- /dev/null +++ b/Sources/AppCore/Tour/TourState.swift @@ -0,0 +1,57 @@ +import Foundation + +// MARK: - Today tour persistence + launch gating +// +// Remembers whether the Today spotlight tour has been seen and decides whether to +// show it at launch. Modeled directly on `OnboardingState` so the two behave +// alike: UserDefaults-backed with injectable defaults, and a pure `shouldShow` so +// the launch decision is unit-testable rather than only observable in a capture. +public enum TourState { + static let seenKey = "otterpaceTodayTourSeen" + + /// Number of steps in the tour. Single source of truth shared with the + /// overlay's pager and with `startStep`'s clamp. + public static var stepCount: Int { TourStep.allCases.count } + + public static func hasSeen(_ d: UserDefaults = .standard) -> Bool { + d.bool(forKey: seenKey) + } + + public static func markSeen(_ d: UserDefaults = .standard) { + d.set(true, forKey: seenKey) + } + + /// Lets Settings offer a "show the tour again" action. + public static func clearSeen(_ d: UserDefaults = .standard) { + d.removeObject(forKey: seenKey) + } + + /// Whether to show the Today tour at launch. Pure + deterministic: + /// • `startScreen == "tour"` → always show (preview/replay opt-in, + /// regardless of `hasSeen`). + /// • already seen → don't show. + /// • Health not connected → don't show, so the tour can never land on top + /// of the `ConnectHero` and point at cards that aren't rendered. + /// • scenario-seeded run → don't show (scenarios skip by default, matching + /// `OnboardingState.shouldShow`); a tour scenario opts in via + /// `rbStartScreen = "tour"`. + /// • otherwise (production first launch, Health connected) → show. + public static func shouldShow(defaults d: UserDefaults = .standard, + seeded: Bool = HealthSource.isScenarioSeeded(), + startScreen: String = "", + healthConnected: Bool = true) -> Bool { + if startScreen == "tour" { return true } + if hasSeen(d) { return false } + if !healthConnected { return false } + if seeded { return false } + return true + } + + /// Scenario hook: which step to start on (`rbTourStep`), clamped to the valid + /// range so a capture can target any single step. Defaults to 0. Mirrors + /// `OnboardingState.startPage`. + public static func startStep(_ d: UserDefaults = .standard) -> Int { + let raw = d.integer(forKey: "rbTourStep") + return min(max(0, raw), stepCount - 1) + } +} diff --git a/Sources/AppCore/Tour/TourStep.swift b/Sources/AppCore/Tour/TourStep.swift new file mode 100644 index 0000000..e517eb3 --- /dev/null +++ b/Sources/AppCore/Tour/TourStep.swift @@ -0,0 +1,54 @@ +import Foundation + +// MARK: - Today tour script +// +// The ordered steps of the Today spotlight tour, as pure data. Mirrors the +// `HintCopy` style so the copy is testable without rendering a view. +// +// This is deliberately NOT a new onboarding page. `OnboardingScenarioIndexTests` +// pins every `rbOnboardingPage` seed against the onboarding step order, so adding +// a step there would shift every later index and silently recapture the wrong +// screens. The tour ships as its own overlay with its own gate instead. + +public enum TourStep: String, CaseIterable { + case buddy + case stats + case coachCard + case checkIn + case history + + /// Short headline naming the highlighted element. + public var title: String { + switch self { + case .buddy: return "This is Buddy" + case .stats: return "Your day at a glance" + case .coachCard: return "Today's suggestion" + case .checkIn: return "Tell Buddy how it felt" + case .history: return "Your history" + } + } + + /// One or two sentences on what the element is for. + public var body: String { + switch self { + case .buddy: + return "Buddy's mood reflects your day, and the ring shows your steps against your goal. Tap any ⓘ on this screen for a short explanation." + case .stats: + return "Active minutes, miles, and how long since you last moved. On a brand new install these read as a dash until Apple Health records something." + case .coachCard: + return "One practical suggestion for today, based on your steps and your recent training. Tap it to ask Buddy a follow up." + case .checkIn: + return "One tap logs how today felt. Buddy reads your recent check ins, so this is how it learns what a hard week looks like for you." + case .history: + return "Your past weeks: mileage, runs, and rest days. That's the tour. You can replay it any time from Settings." + } + } + + /// Stable identifier used to anchor the callout to the highlighted section. + public var anchorID: String { rawValue } + + /// Label on the advance button. The final step commits rather than continues. + public var advanceTitle: String { + self == TourStep.allCases[TourStep.allCases.count - 1] ? "Got it" : "Next" + } +} diff --git a/Tests/AppCoreTests/CoachErrorSurfacingTests.swift b/Tests/AppCoreTests/CoachErrorSurfacingTests.swift new file mode 100644 index 0000000..4d66abd --- /dev/null +++ b/Tests/AppCoreTests/CoachErrorSurfacingTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import AppCore + +// How a failed coach call reaches the user. +// +// Regression: a working OpenAI key produced "I couldn't reach Buddy just now. +// Check your connection and try again." The key was valid and the network was +// fine — the backend had rejected the request for a provider-side reason — but +// every non-401 collapsed into `.server`, so the chat pointed the user at the +// one thing that was not wrong. These pin the reason surviving the trip. +final class CoachErrorSurfacingTests: XCTestCase { + + private func body(_ json: String) -> Data { Data(json.utf8) } + + func testActionableUpstreamReasonSurvives() { + let data = body(""" + {"error":"model_unavailable","message":"OpenAI rejected the request: model \\"gpt-5\\" may be unavailable to this key."} + """) + XCTAssertEqual(RemoteCoach.errorMessage(from: data), + "OpenAI rejected the request: model \"gpt-5\" may be unavailable to this key.") + } + + func testExhaustedBudgetIsSurfaced() { + let data = body(""" + {"error":"token_budget_exhausted","message":"OpenAI used its entire token budget before answering."} + """) + XCTAssertEqual(RemoteCoach.errorMessage(from: data), + "OpenAI used its entire token budget before answering.") + } + + /// A genuine outage stays generic — the caller falls back to the offline + /// coach rather than showing the user backend jargon they cannot act on. + func testGenericUpstreamErrorIsNotSurfaced() { + XCTAssertNil(RemoteCoach.errorMessage(from: body(#"{"error":"upstream_error"}"#))) + XCTAssertNil(RemoteCoach.errorMessage(from: body(#"{"error":"no_text"}"#))) + } + + func testMalformedOrEmptyBodiesAreIgnored() { + XCTAssertNil(RemoteCoach.errorMessage(from: body("not json"))) + XCTAssertNil(RemoteCoach.errorMessage(from: Data())) + XCTAssertNil(RemoteCoach.errorMessage(from: body(#"{"error":"model_unavailable"}"#))) + XCTAssertNil(RemoteCoach.errorMessage(from: body(#"{"error":"model_unavailable","message":""}"#))) + } + + /// The error the chat renders must not be the connection copy. + func testUpstreamErrorIsDistinctFromNetworkError() { + XCTAssertNotEqual(CoachError.upstream("model unavailable"), CoachError.server) + XCTAssertNotEqual(CoachError.upstream("model unavailable"), CoachError.network) + XCTAssertEqual(CoachError.upstream("same"), CoachError.upstream("same")) + } +} diff --git a/Tests/AppCoreTests/CoachTutorialTests.swift b/Tests/AppCoreTests/CoachTutorialTests.swift new file mode 100644 index 0000000..784581e --- /dev/null +++ b/Tests/AppCoreTests/CoachTutorialTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import AppCore + +// Buddy's scripted opening in the Coach tab. +// +// The copy is app-authored, so the honesty constraints are testable: it must not +// promise run analysis on a day-one install, and the locked variant must point at +// the connect step rather than inviting a question the app cannot answer. +final class CoachTutorialTests: XCTestCase { + + private func dayOne() -> TodayState { + TodayState(healthKitConnected: true, date: "2026-06-22", steps: 1200) + } + + private func rich() -> TodayState { + let w = LatestWorkout(type: "run", distanceMiles: 4.2, durationMinutes: 44, + pace: "10:28/mi", date: "2026-06-21", source: "healthkit") + return TodayState(healthKitConnected: true, + date: "2026-06-22", + steps: 9180, + latestWorkout: w, + weeklyLoad: WeeklyLoad(weeklyMileage: 12.4, daysRunThisWeek: 3, + longestRunMiles: 5.0, restDaysThisWeek: 2, + loadTrend: "steady"), + workouts: [w], + loadHistory: [WeeklyLoadPoint(weekStartISO: "2026-06-15", miles: 12.4, daysRun: 3)], + races: [RaceGoal(name: "October Trail Half", distanceMiles: 13.1, date: "2026-10-10")]) + } + + func testOpeningTurnsAreNonEmptyForBothStates() { + for connected in [true, false] { + for context in [dayOne(), rich()] { + let turns = CoachTutorial.openingTurns(for: context, connected: connected) + XCTAssertGreaterThanOrEqual(turns.count, 2) + for t in turns { + XCTAssertFalse(t.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + func testIntroducesBuddyByName() { + let turns = CoachTutorial.openingTurns(for: rich(), connected: true) + XCTAssertTrue(turns.contains { $0.contains("Buddy") }, + "The intro never says who the user is talking to.") + } + + /// The whole point of the data turn: say what Buddy can actually see. + func testNamesWhatItCanSee() { + let turns = CoachTutorial.openingTurns(for: rich(), connected: true) + let all = turns.joined(separator: " ") + XCTAssertTrue(all.contains("steps")) + XCTAssertTrue(all.contains("runs")) + } + + /// A day-one install has no runs, so the intro must not claim to analyze them. + func testDayOneDoesNotPromiseRunAnalysis() { + let all = CoachTutorial.openingTurns(for: dayOne(), connected: true).joined(separator: " ") + XCTAssertTrue(all.contains("Once you log"), + "Day one should set expectations about runs it cannot see yet.") + XCTAssertFalse(all.contains("your recent runs and walks"), + "Day one claims to see runs that do not exist.") + } + + func testRichStateDescribesRealData() { + let all = CoachTutorial.openingTurns(for: rich(), connected: true).joined(separator: " ") + XCTAssertTrue(all.contains("your recent runs and walks")) + XCTAssertFalse(all.contains("Once you log")) + } + + /// Locked: point at the connect step, never at "ask me something". + func testLockedVariantNamesTheConnectStep() { + let locked = CoachTutorial.openingTurns(for: rich(), connected: false).joined(separator: " ") + XCTAssertTrue(locked.contains("Settings"), "Locked intro should name where to connect a key.") + + let unlocked = CoachTutorial.openingTurns(for: rich(), connected: true).joined(separator: " ") + XCTAssertTrue(unlocked.contains("Tap one of the questions"), + "Unlocked intro should invite a question.") + } + + /// Coaching is not medical advice, and the intro is where that is set. + func testStatesTheSafetyBoundary() { + let all = CoachTutorial.openingTurns(for: rich(), connected: true).joined(separator: " ") + XCTAssertTrue(all.lowercased().contains("not a doctor")) + } + + func testNoEmDashes() { + for connected in [true, false] { + for context in [dayOne(), rich()] { + for t in CoachTutorial.openingTurns(for: context, connected: connected) { + XCTAssertFalse(t.contains("—"), "\"\(t)\" uses an em dash") + } + } + } + } + + /// No dangling separators from the list builder. + func testDataTurnReadsAsASentence() { + for context in [dayOne(), rich()] { + for t in CoachTutorial.openingTurns(for: context, connected: true) { + XCTAssertFalse(t.contains(" ,"), "\"\(t)\" has a stray separator") + XCTAssertFalse(t.contains(",."), "\"\(t)\" has a stray separator") + XCTAssertFalse(t.contains("and ,"), "\"\(t)\" has a stray separator") + } + } + } + + func testDeterministic() { + XCTAssertEqual(CoachTutorial.openingTurns(for: rich(), connected: true), + CoachTutorial.openingTurns(for: rich(), connected: true)) + } +} diff --git a/Tests/AppCoreTests/FormattersTests.swift b/Tests/AppCoreTests/FormattersTests.swift index 2227e04..506277d 100644 --- a/Tests/AppCoreTests/FormattersTests.swift +++ b/Tests/AppCoreTests/FormattersTests.swift @@ -153,4 +153,66 @@ final class FormattersTests: XCTestCase { let s = weekRollupSpoken(miles: 14.7, runCount: 3, restDays: 3, daysElapsed: 7) XCTAssertEqual(s, "14.7 miles, 3 runs, 3 rest days") } + + // MARK: - Honest empty state + // + // The distinction that matters: "no data recorded" is not the same as "you did + // zero". A fresh install must not be told it walked zero miles; a real + // zero-step morning must still read 0, because that IS the day's data. + + func testHasDayDataIsFalseOnlyWhenNothingWasObserved() { + XCTAssertFalse(hasDayData(steps: 0, activeMinutes: 0, distanceMiles: 0, + minutesSinceLastMovement: 0)) + XCTAssertTrue(hasDayData(steps: 120, activeMinutes: 0, distanceMiles: 0)) + XCTAssertTrue(hasDayData(steps: 0, activeMinutes: 4, distanceMiles: 0)) + XCTAssertTrue(hasDayData(steps: 0, activeMinutes: 0, distanceMiles: 0.2)) + } + + // A movement timestamp is evidence Health observed the user, even when every + // metric is still zero. This is what keeps a real zero-step morning honest. + func testMovementTimestampCountsAsObservation() { + XCTAssertTrue(hasDayData(steps: 0, activeMinutes: 0, distanceMiles: 0, + minutesSinceLastMovement: 8)) + } + + func testStatValueDashesOnlyWithNoData() { + XCTAssertEqual(statValue("54", hasData: true), "54") + XCTAssertEqual(statValue("54", hasData: false), "—") + } + + // The fresh-install bug this fixes: movementLabel(0) renders the + // cheerful-but-wrong "now" when nothing has ever been recorded. + func testMovementDisplayDoesNotClaimYouJustMovedOnAFreshInstall() { + XCTAssertEqual(movementLabel(0), "now") // the raw helper, unchanged + XCTAssertEqual(movementDisplay(minutes: 0, hasData: false), "—") // but not what we show + } + + func testMovementDisplayDelegatesWhenThereIsData() { + XCTAssertEqual(movementDisplay(minutes: 0, hasData: true), "now") + XCTAssertEqual(movementDisplay(minutes: 18, hasData: true), "18m") + XCTAssertEqual(movementDisplay(minutes: 92, hasData: true), "1h32m") + } + + // A genuine zero-step morning is data, not the absence of it. These are the + // exact seeds of the `today-zero-step-morning` scenario, so the honest-empty + // change cannot silently swallow a true zero. + func testGenuineZeroStepMorningStillReadsZero() { + let observed = hasDayData(steps: 0, activeMinutes: 0, distanceMiles: 0.0, + minutesSinceLastMovement: 8) + XCTAssertTrue(observed, "a real zero-step morning has been observed by Health") + + XCTAssertEqual(statValue("0", hasData: observed), "0") + XCTAssertEqual(statValue("0.0", hasData: observed), "0.0") + XCTAssertEqual(movementDisplay(minutes: 8, hasData: observed), "8m") + } + + // The fresh install: nothing recorded at all, so nothing to report. + func testFreshInstallShowsDashesNotZeroes() { + let observed = hasDayData(steps: 0, activeMinutes: 0, distanceMiles: 0, + minutesSinceLastMovement: 0) + XCTAssertFalse(observed) + + XCTAssertEqual(statValue("0", hasData: observed), "—") + XCTAssertEqual(movementDisplay(minutes: 0, hasData: observed), "—") + } } diff --git a/Tests/AppCoreTests/HintCopyTests.swift b/Tests/AppCoreTests/HintCopyTests.swift new file mode 100644 index 0000000..2c27f44 --- /dev/null +++ b/Tests/AppCoreTests/HintCopyTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import AppCore + +// The ⓘ explanations. Every topic must actually have copy: a hint that renders an +// empty caption is worse than no hint, because the user taps and gets nothing. +final class HintCopyTests: XCTestCase { + + func testEveryTopicHasTitleAndBody() { + for topic in HintTopic.allCases { + XCTAssertFalse(topic.title.trimmingCharacters(in: .whitespaces).isEmpty, + "\(topic.rawValue) has no title") + XCTAssertFalse(topic.body.trimmingCharacters(in: .whitespaces).isEmpty, + "\(topic.rawValue) has no body") + } + } + + func testNoEmDashes() { + for topic in HintTopic.allCases { + XCTAssertFalse(topic.title.contains("—"), "\(topic.rawValue) title uses an em dash") + XCTAssertFalse(topic.body.contains("—"), "\(topic.rawValue) body uses an em dash") + } + } + + /// The body should explain, not just restate the label. + func testBodiesAreExplanatory() { + for topic in HintTopic.allCases { + XCTAssertGreaterThan(topic.body.count, 40, + "\(topic.rawValue) body is too short to explain anything") + } + } + + func testTitlesAreDistinct() { + let titles = HintTopic.allCases.map(\.title) + XCTAssertEqual(Set(titles).count, titles.count, "two hint topics share a title") + } + + func testBodiesAreDistinct() { + let bodies = HintTopic.allCases.map(\.body) + XCTAssertEqual(Set(bodies).count, bodies.count, "two hint topics share a body") + } + + /// The three confusions that prompted this feature must each be answered. + func testAnswersTheReportedConfusions() { + XCTAssertTrue(HintTopic.buddyMood.body.contains("Ready"), + "The mood hint should explain what the default 'Ready' chip means.") + XCTAssertTrue(HintTopic.activeMinutes.body.lowercased().contains("zero"), + "The active-minutes hint should explain why it starts at zero.") + XCTAssertTrue(HintTopic.sinceMoving.body.lowercased().contains("last recorded"), + "The since-you-moved hint should say what it measures from.") + } +} diff --git a/Tests/AppCoreTests/OnboardingStateTests.swift b/Tests/AppCoreTests/OnboardingStateTests.swift index adc96c9..850e0e9 100644 --- a/Tests/AppCoreTests/OnboardingStateTests.swift +++ b/Tests/AppCoreTests/OnboardingStateTests.swift @@ -62,4 +62,16 @@ final class OnboardingStateTests: XCTestCase { XCTAssertEqual(OnboardingState.stepCount, 9) XCTAssertEqual(OnboardingState.pageCount, OnboardingState.introPageCount) // back-compat alias } + + // The Today spotlight tour ships as its own overlay with its own gate, + // deliberately NOT as an onboarding step. If it ever gets folded in here, every + // `rbOnboardingPage` seed at or after the insertion point shifts and those + // scenarios silently start capturing the wrong screen. This asserts the two + // flows stay independent. + func testTodayTourDidNotBecomeAnOnboardingStep() { + XCTAssertEqual(OnboardingState.stepCount, 9, + "The Today tour must not add an onboarding step — it has its own TourState gate.") + XCTAssertNotEqual(OnboardingState.seenKey, TourState.seenKey, + "The two flows must persist independently, so replaying one does not replay the other.") + } } diff --git a/Tests/AppCoreTests/RaceGoalsTests.swift b/Tests/AppCoreTests/RaceGoalsTests.swift index 8f584f1..05ece48 100644 --- a/Tests/AppCoreTests/RaceGoalsTests.swift +++ b/Tests/AppCoreTests/RaceGoalsTests.swift @@ -152,4 +152,87 @@ final class RaceGoalsTests: XCTestCase { XCTAssertTrue(review.safetyFlag) // spiking still wins XCTAssertTrue(review.nextWeek.lowercased().contains("ease off")) } + + // MARK: - Race prompt: snooze instead of self-destruct + // + // The old banner set one forever-flag on the first ✕, which is exactly what a + // confused first-run user taps by reflex — the offer was being destroyed by + // the very confusion it caused. It now stays quiet on day one, snoozes twice, + // and only then goes away for good. + + private func promptDefaults(_ name: String = #function) -> UserDefaults { + let suite = "RacePromptStateTests.\(name)" + UserDefaults().removePersistentDomain(forName: suite) + return UserDefaults(suiteName: suite)! + } + + /// Day one records eligibility and stays quiet, so the banner never lands in + /// the confusing first session. + func testBannerIsSilentOnTheFirstDay() { + let d = promptDefaults() + XCTAssertFalse(RacePromptState.shouldShow(asOf: today, races: [], defaults: d)) + } + + func testBannerAppearsTheNextDay() { + let d = promptDefaults() + _ = RacePromptState.shouldShow(asOf: today, races: [], defaults: d) // stamps eligibility + XCTAssertTrue(RacePromptState.shouldShow(asOf: "2026-06-25", races: [], defaults: d)) + } + + /// Nothing to ask for while a race is already on the calendar. + func testBannerSuppressedWhileAnUpcomingRaceExists() { + let d = promptDefaults() + _ = RacePromptState.shouldShow(asOf: today, races: [], defaults: d) + XCTAssertFalse(RacePromptState.shouldShow(asOf: "2026-06-25", + races: [race("October Half", "2026-10-10")], + defaults: d)) + } + + /// A finished race does not suppress it — there is a next goal to set. + func testPastRaceDoesNotSuppressTheBanner() { + let d = promptDefaults() + _ = RacePromptState.shouldShow(asOf: today, races: [], defaults: d) + XCTAssertTrue(RacePromptState.shouldShow(asOf: "2026-06-25", + races: [race("Spring 10K", "2026-04-01", miles: 6.2)], + defaults: d)) + } + + func testFirstDismissSnoozesRatherThanKilling() { + let d = promptDefaults() + _ = RacePromptState.shouldShow(asOf: today, races: [], defaults: d) + XCTAssertTrue(RacePromptState.shouldShow(asOf: "2026-06-25", races: [], defaults: d)) + + RacePromptState.snooze(asOf: "2026-06-25", defaults: d) + XCTAssertEqual(RacePromptState.dismissCount(d), 1) + XCTAssertFalse(RacePromptState.isDismissed(d), "one tap must not be permanent") + + // Still snoozed a week later... + XCTAssertFalse(RacePromptState.shouldShow(asOf: "2026-07-02", races: [], defaults: d)) + // ...and back after the window. + XCTAssertTrue(RacePromptState.shouldShow(asOf: "2026-07-10", races: [], defaults: d)) + } + + func testSecondDismissIsPermanent() { + let d = promptDefaults() + _ = RacePromptState.shouldShow(asOf: today, races: [], defaults: d) + RacePromptState.snooze(asOf: "2026-06-25", defaults: d) + RacePromptState.snooze(asOf: "2026-07-10", defaults: d) + + XCTAssertEqual(RacePromptState.dismissCount(d), RacePromptState.maxDismissals) + XCTAssertTrue(RacePromptState.isDismissed(d)) + XCTAssertFalse(RacePromptState.shouldShow(asOf: "2026-12-01", races: [], defaults: d)) + } + + /// An install that already dismissed the banner under the old forever-flag + /// must not see it come back. + func testLegacyDismissalIsStillHonored() { + let d = promptDefaults() + RacePromptState.markDismissed(d) + XCTAssertFalse(RacePromptState.shouldShow(asOf: "2026-06-25", races: [], defaults: d)) + } + + func testSnoozeWindowIsFourteenDays() { + XCTAssertEqual(RacePromptState.addDays(RacePromptState.snoozeDays, to: "2026-06-25"), + "2026-07-09") + } } diff --git a/Tests/AppCoreTests/StarterQuestionsTests.swift b/Tests/AppCoreTests/StarterQuestionsTests.swift new file mode 100644 index 0000000..d8617f0 --- /dev/null +++ b/Tests/AppCoreTests/StarterQuestionsTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import AppCore + +// The Ask Coach ice-breakers. +// +// The load-bearing assertion here is the intent round-trip: each suggestion +// declares the intent it targets, and `CoachIntent.classify` must actually route +// it there. Without that, a keyword change in the classifier could silently send +// "Should I run today or rest?" somewhere else — worst case into `.injuryPain`, +// where a friendly ice-breaker returns an injury-caution reply. +final class StarterQuestionsTests: XCTestCase { + + private func state(latestWorkout: LatestWorkout? = nil, + workouts: [LatestWorkout] = [], + loadHistory: [WeeklyLoadPoint] = [], + races: [RaceGoal] = [], + date: String = "2026-06-22") -> TodayState { + TodayState(healthKitConnected: true, + date: date, + steps: 6000, + latestWorkout: latestWorkout, + workouts: workouts, + loadHistory: loadHistory, + races: races) + } + + private func workout(date: String = "2026-06-21") -> LatestWorkout { + LatestWorkout(type: "run", distanceMiles: 4.2, durationMinutes: 44, + pace: "10:28/mi", date: date, source: "healthkit") + } + + private func loadPoint(_ weekStart: String, _ miles: Double) -> WeeklyLoadPoint { + WeeklyLoadPoint(weekStartISO: weekStart, miles: miles, daysRun: 3) + } + + // MARK: Round-trip + + /// Every suggestion, in every state, must classify to the intent it declares. + func testEverySuggestionClassifiesToItsDeclaredIntent() { + let states: [TodayState] = [ + state(), + state(latestWorkout: workout(), workouts: [workout()]), + state(loadHistory: [loadPoint("2026-06-15", 12), loadPoint("2026-06-08", 9)]), + state(races: [RaceGoal(name: "October Trail Half", distanceMiles: 13.1, date: "2026-10-10")]), + ] + + for s in states { + for q in StarterQuestions.suggestions(for: s) { + XCTAssertEqual(CoachIntent.classify(q.text), q.intent, + "\"\(q.text)\" declares \(q.intent) but classifies as \(CoachIntent.classify(q.text)).") + } + } + } + + /// A friendly ice-breaker must never trip the injury-caution path. + func testNoSuggestionRoutesToInjury() { + let s = state(latestWorkout: workout(), workouts: [workout()], + races: [RaceGoal(name: "Half", distanceMiles: 13.1, date: "2026-10-10")]) + for q in StarterQuestions.suggestions(for: s) { + XCTAssertNotEqual(CoachIntent.classify(q.text), .injuryPain, + "\"\(q.text)\" routes to the injury reply.") + } + } + + // MARK: Day one vs. rich state + + /// A day-one user has no runs, so offering "how did my last run go?" would + /// point at data the app does not have. + func testDayOneOffersNoPostRunOrRaceQuestion() { + let intents = StarterQuestions.suggestions(for: state()).map(\.intent) + XCTAssertFalse(intents.contains(.postRunReflection)) + XCTAssertFalse(intents.contains(.raceGoal)) + XCTAssertFalse(intents.contains(.mileageTooFast)) + } + + func testAlwaysOffersTheUniversalQuestions() { + for s in [state(), state(latestWorkout: workout(), workouts: [workout()])] { + let intents = StarterQuestions.suggestions(for: s).map(\.intent) + XCTAssertTrue(intents.contains(.runOrRest)) + XCTAssertTrue(intents.contains(.hit10K)) + } + } + + func testPostRunQuestionAppearsOnceThereIsARun() { + let intents = StarterQuestions.suggestions(for: state(latestWorkout: workout(), + workouts: [workout()])).map(\.intent) + XCTAssertTrue(intents.contains(.postRunReflection)) + } + + func testRaceQuestionAppearsOnlyForAnUpcomingRace() { + let upcoming = state(races: [RaceGoal(name: "October Trail Half", distanceMiles: 13.1, date: "2026-10-10")]) + XCTAssertTrue(StarterQuestions.suggestions(for: upcoming).map(\.intent).contains(.raceGoal)) + + // A finished race is not something to train toward. + let past = state(races: [RaceGoal(name: "Spring 10K", distanceMiles: 6.2, date: "2026-04-01")]) + XCTAssertFalse(StarterQuestions.suggestions(for: past).map(\.intent).contains(.raceGoal)) + } + + func testMileageQuestionNeedsTwoLoadPoints() { + let one = state(loadHistory: [loadPoint("2026-06-15", 12)]) + XCTAssertFalse(StarterQuestions.suggestions(for: one).map(\.intent).contains(.mileageTooFast)) + + let two = state(loadHistory: [loadPoint("2026-06-15", 12), loadPoint("2026-06-08", 9)]) + XCTAssertTrue(StarterQuestions.suggestions(for: two).map(\.intent).contains(.mileageTooFast)) + } + + // MARK: Shape + + func testCappedAndDuplicateFree() { + // The richest possible state, where every conditional suggestion fires. + let rich = state(latestWorkout: workout(), + workouts: [workout()], + loadHistory: [loadPoint("2026-06-15", 12), loadPoint("2026-06-08", 9)], + races: [RaceGoal(name: "October Trail Half", distanceMiles: 13.1, date: "2026-10-10")]) + let suggestions = StarterQuestions.suggestions(for: rich) + + XCTAssertLessThanOrEqual(suggestions.count, StarterQuestions.maxCount) + XCTAssertEqual(Set(suggestions.map(\.text)).count, suggestions.count, "duplicate suggestion text") + XCTAssertEqual(Set(suggestions.map(\.intent)).count, suggestions.count, "two suggestions target the same intent") + } + + func testNeverEmpty() { + XCTAssertFalse(StarterQuestions.suggestions(for: state()).isEmpty) + } + + func testCopyHasNoEmDashes() { + let rich = state(latestWorkout: workout(), workouts: [workout()], + races: [RaceGoal(name: "Half", distanceMiles: 13.1, date: "2026-10-10")]) + for q in StarterQuestions.suggestions(for: rich) { + XCTAssertFalse(q.text.contains("—"), "\"\(q.text)\" uses an em dash") + } + } + + func testDeterministic() { + let s = state(latestWorkout: workout(), workouts: [workout()]) + XCTAssertEqual(StarterQuestions.suggestions(for: s), StarterQuestions.suggestions(for: s)) + } +} diff --git a/Tests/AppCoreTests/TourStateTests.swift b/Tests/AppCoreTests/TourStateTests.swift new file mode 100644 index 0000000..1664eee --- /dev/null +++ b/Tests/AppCoreTests/TourStateTests.swift @@ -0,0 +1,182 @@ +import XCTest +@testable import AppCore + +// The Today spotlight tour's launch gate and its scenario seeds. +// +// The gate matters because the tour draws a full-screen scrim: showing it at the +// wrong moment (over the Connect hero, or on a scenario capture that never asked +// for it) does not just look wrong, it hides the whole screen underneath. +final class TourStateTests: XCTestCase { + + /// A defaults instance isolated from the real app domain, so the gate's + /// persistence can be exercised without leaking into other tests. + private func freshDefaults(_ name: String = #function) -> UserDefaults { + let suite = "TourStateTests.\(name)" + UserDefaults().removePersistentDomain(forName: suite) + return UserDefaults(suiteName: suite)! + } + + // MARK: Gating matrix + + func testShowsOnFirstRunWhenHealthConnected() { + let d = freshDefaults() + XCTAssertTrue(TourState.shouldShow(defaults: d, seeded: false, healthConnected: true)) + } + + func testDoesNotShowOnceSeen() { + let d = freshDefaults() + TourState.markSeen(d) + XCTAssertFalse(TourState.shouldShow(defaults: d, seeded: false, healthConnected: true)) + } + + /// The tour points at the Buddy card, the stats row, and the coach card. None + /// of those are on screen before Health is connected — `ConnectHero` is. So + /// the tour must stay down until there is something to point at. + func testDoesNotShowBeforeHealthIsConnected() { + let d = freshDefaults() + XCTAssertFalse(TourState.shouldShow(defaults: d, seeded: false, healthConnected: false)) + } + + /// Scenarios skip the tour by default, matching `OnboardingState.shouldShow`. + /// Otherwise every seeded Today capture would render behind a scrim. + func testDoesNotShowUnderAScenarioSeed() { + let d = freshDefaults() + XCTAssertFalse(TourState.shouldShow(defaults: d, seeded: true, healthConnected: true)) + } + + /// The opt-in that lets a tour scenario capture the overlay on purpose. It + /// wins over every other suppression, including `hasSeen`. + func testStartScreenTourForcesItEvenWhenSeenAndSeeded() { + let d = freshDefaults() + TourState.markSeen(d) + XCTAssertTrue(TourState.shouldShow(defaults: d, seeded: true, + startScreen: "tour", healthConnected: true)) + } + + func testClearSeenMakesItShowAgain() { + let d = freshDefaults() + TourState.markSeen(d) + XCTAssertFalse(TourState.shouldShow(defaults: d, seeded: false, healthConnected: true)) + TourState.clearSeen(d) + XCTAssertTrue(TourState.shouldShow(defaults: d, seeded: false, healthConnected: true)) + } + + // MARK: Step index + + func testStartStepDefaultsToZero() { + XCTAssertEqual(TourState.startStep(freshDefaults()), 0) + } + + func testStartStepReadsTheSeed() { + let d = freshDefaults() + d.set(2, forKey: "rbTourStep") + XCTAssertEqual(TourState.startStep(d), 2) + } + + func testStartStepClampsOutOfRangeSeeds() { + let d = freshDefaults() + d.set(99, forKey: "rbTourStep") + XCTAssertEqual(TourState.startStep(d), TourState.stepCount - 1) + + d.set(-3, forKey: "rbTourStep") + XCTAssertEqual(TourState.startStep(d), 0) + } + + func testStepCountMatchesTheScript() { + XCTAssertEqual(TourState.stepCount, TourStep.allCases.count) + XCTAssertGreaterThan(TourState.stepCount, 0) + } + + // MARK: Copy + + func testEveryStepHasCopy() { + for step in TourStep.allCases { + XCTAssertFalse(step.title.isEmpty, "\(step.rawValue) has no title") + XCTAssertFalse(step.body.isEmpty, "\(step.rawValue) has no body") + XCTAssertFalse(step.title.contains("—"), "\(step.rawValue) title uses an em dash") + XCTAssertFalse(step.body.contains("—"), "\(step.rawValue) body uses an em dash") + } + } + + /// Only the last step commits; the rest continue. + func testAdvanceTitleMarksTheFinalStep() { + let steps = TourStep.allCases + for step in steps.dropLast() { + XCTAssertEqual(step.advanceTitle, "Next", "\(step.rawValue) should continue the tour") + } + XCTAssertEqual(steps[steps.count - 1].advanceTitle, "Got it") + } + + // MARK: Scenario index guard + // + // Same failure mode `OnboardingScenarioIndexTests` guards against: a seeded + // index silently pointing at a different step than the scenario's name claims. + // A capture built from a drifted seed is "correct" in that it renders what the + // seed asked for, so nothing else catches it. + + private static let expectedStep: [String: Int] = [ + "tour-step-1-buddy": 0, + "tour-step-2-stats": 1, + "tour-step-3-coach-card": 2, + ] + + private var scenariosDir: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // AppCoreTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent(".codeyam/scenarios") + } + + private func preferences(_ slug: String) throws -> [String: Any]? { + let url = scenariosDir.appendingPathComponent("\(slug).json") + let data = try Data(contentsOf: url) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + return (json?["deviceState"] as? [String: Any])?["preferences"] as? [String: Any] + } + + func testSeededTourStepsMatchNamedSteps() throws { + for (slug, expected) in Self.expectedStep { + let seeded = try preferences(slug)?["rbTourStep"] as? Int + XCTAssertEqual(seeded, expected, + "\(slug) seeds rbTourStep=\(seeded.map(String.init) ?? "nil") but is named for step \(expected). Its capture renders a different step than its name claims.") + } + } + + /// A tour scenario must also opt in, or the gate suppresses the overlay under + /// the scenario seed and the capture comes back with no tour at all. + func testTourScenariosOptInViaStartScreen() throws { + for slug in Self.expectedStep.keys { + let startScreen = try preferences(slug)?["rbStartScreen"] as? String + XCTAssertEqual(startScreen, "tour", + "\(slug) must seed rbStartScreen=\"tour\"; without it the tour is suppressed under a scenario seed and the capture shows no overlay.") + } + } + + /// Every seeded step must be addressable, so a stale seed can't be silently + /// clamped onto a neighbouring step by `startStep`. + func testSeededStepsAreInRange() throws { + for (slug, _) in Self.expectedStep { + guard let step = try preferences(slug)?["rbTourStep"] as? Int else { + XCTFail("\(slug) has no rbTourStep seed"); continue + } + XCTAssertTrue((0..<TourState.stepCount).contains(step), + "\(slug) seeds step \(step), outside 0..<\(TourState.stepCount) — startStep would clamp it to a different screen.") + } + } + + /// A new tour scenario must be registered here too, so it can't be added with + /// an unchecked step index. + func testEveryTourScenarioIsCovered() throws { + let files = try FileManager.default.contentsOfDirectory(atPath: scenariosDir.path) + let slugs = files + .filter { $0.hasSuffix(".json") } + .map { String($0.dropLast(5)) } + .filter { $0.hasPrefix("tour-") } + + for slug in slugs { + XCTAssertNotNil(Self.expectedStep[slug], + "Scenario \(slug) has no expected step index — add it to expectedStep so its seed is guarded against step-order drift.") + } + } +} diff --git a/api/_lib/llm.ts b/api/_lib/llm.ts index c54d909..b85b4e2 100644 --- a/api/_lib/llm.ts +++ b/api/_lib/llm.ts @@ -150,16 +150,49 @@ function parseJsonReply(text: string): LlmResult { } } -/** Map any provider's HTTP status onto the status the app already handles. */ -function throwForStatus(status: number | undefined, provider: LlmProvider): never { +/** + * Map any provider's HTTP status onto the status the app already handles. + * + * `detail` carries the provider's own error text when we have it. Without it a + * misconfigured model id and a genuine outage both surfaced as "could not be + * reached", which is what made a broken OpenAI key impossible to diagnose from + * the app: the user saw a network-sounding message for a config bug. + */ +function throwForStatus( + status: number | undefined, + provider: LlmProvider, + detail?: string, +): never { const name = provider === "anthropic" ? "Anthropic" : provider === "openai" ? "OpenAI" : "Gemini"; + const suffix = detail ? ` ${detail}` : ""; if (status === 401 || status === 403) { throw new LlmError(401, "invalid_key", `That API key was rejected by ${name}.`); } if (status === 429) { throw new LlmError(429, "rate_limited", `Your ${name} account is rate limited. Try again shortly.`); } - throw new LlmError(502, "upstream_error", `${name} could not be reached.`); + // 400/404 are OUR bug (unknown model, malformed request), not an outage, and + // not something the user can retry past. Say so, and pass the provider's + // reason through so the cause is visible without reproducing locally. + if (status === 400 || status === 404) { + throw new LlmError( + 502, + "model_unavailable", + `${name} rejected the request: model "${modelFor(provider)}" may be unavailable to this key.${suffix}`, + ); + } + throw new LlmError(502, "upstream_error", `${name} could not be reached.${suffix}`); +} + +/** Best-effort extraction of a provider's error message from its response body. */ +async function errorDetail(response: Response): Promise<string | undefined> { + const body = (await response.json().catch(() => null)) as + | { error?: { message?: string } | string } + | null; + if (!body) return undefined; + const err = body.error; + const message = typeof err === "string" ? err : err?.message; + return typeof message === "string" && message ? message.slice(0, 300) : undefined; } // MARK: - Anthropic @@ -192,6 +225,19 @@ async function completeAnthropic(apiKey: string, request: LlmRequest): Promise<L // fetch rather than the SDK: it is one request shape, and every extra dependency // is weight in a serverless bundle that cold-starts on each user's key. +/** + * Headroom for the reply itself, on top of whatever the model spends thinking. + * + * `max_completion_tokens` is a budget for reasoning tokens AND visible output. + * On a reasoning model the thinking is invoiced against the same allowance, so a + * budget sized for a 2-4 sentence answer (1024) can be consumed entirely before + * a single visible character is emitted — the call then returns `content: ""` + * with `finish_reason: "length"`, which read as "no text" and surfaced to the + * user as a connection failure. The reply is small; the thinking is not, so this + * is sized for the thinking. + */ +const OPENAI_MIN_COMPLETION_TOKENS = 16000; + async function completeOpenAI(apiKey: string, request: LlmRequest): Promise<LlmResult> { let response: Response; try { @@ -203,7 +249,7 @@ async function completeOpenAI(apiKey: string, request: LlmRequest): Promise<LlmR }, body: JSON.stringify({ model: MODELS.openai, - max_completion_tokens: request.maxTokens ?? 1024, + max_completion_tokens: Math.max(request.maxTokens ?? 1024, OPENAI_MIN_COMPLETION_TOKENS), messages: [ { role: "system", content: request.system }, ...request.messages.map((t) => ({ role: t.role, content: t.content })), @@ -218,15 +264,30 @@ async function completeOpenAI(apiKey: string, request: LlmRequest): Promise<LlmR throw new LlmError(502, "upstream_error", "OpenAI could not be reached."); } - if (!response.ok) throwForStatus(response.status, "openai"); + if (!response.ok) throwForStatus(response.status, "openai", await errorDetail(response)); const body = (await response.json().catch(() => null)) as { - choices?: Array<{ message?: { content?: string | null; refusal?: string | null } }>; + choices?: Array<{ + finish_reason?: string; + message?: { content?: string | null; refusal?: string | null }; + }>; } | null; - const choice = body?.choices?.[0]?.message; + const first = body?.choices?.[0]; + const choice = first?.message; // A strict-schema refusal comes back in its own field, with content null. if (choice?.refusal) return { kind: "refusal" }; - if (typeof choice?.content !== "string" || !choice.content) return { kind: "empty" }; + if (typeof choice?.content !== "string" || !choice.content) { + // Empty output because the budget ran out is a fixable configuration fault, + // not an upstream outage — name it so it can't hide behind "no text" again. + if (first?.finish_reason === "length") { + throw new LlmError( + 502, + "token_budget_exhausted", + `OpenAI used its entire token budget before answering (model "${MODELS.openai}"). Raise max_completion_tokens.`, + ); + } + return { kind: "empty" }; + } return parseJsonReply(choice.content); } @@ -274,7 +335,7 @@ async function completeGemini(apiKey: string, request: LlmRequest): Promise<LlmR throw new LlmError(502, "upstream_error", "Gemini could not be reached."); } - if (!response.ok) throwForStatus(response.status, "gemini"); + if (!response.ok) throwForStatus(response.status, "gemini", await errorDetail(response)); const body = (await response.json().catch(() => null)) as { candidates?: Array<{ finishReason?: string; content?: { parts?: Array<{ text?: string }> } }>; diff --git a/test/api/llm.test.ts b/test/api/llm.test.ts index 9bd7639..5d926fe 100644 --- a/test/api/llm.test.ts +++ b/test/api/llm.test.ts @@ -201,6 +201,65 @@ describe("complete — OpenAI", () => { })); await expect(complete({ provider: "openai", apiKey: "sk-x" }, request())).rejects.toBeInstanceOf(LlmError); }); + + // Regression: a connected OpenAI key produced "I couldn't reach Buddy just + // now. Check your connection and try again." The key was fine and the network + // was fine — the reply budget was being consumed by reasoning tokens, and the + // resulting empty completion was indistinguishable from an outage. + + it("gives the reply real headroom beyond the reasoning budget", async () => { + const fetchMock = stubFetch({ choices: [{ message: { content: '{"text":"Rest day."}' } }] }); + await complete({ provider: "openai", apiKey: "sk-openai-1" }, request()); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body as string); + // `max_completion_tokens` covers reasoning AND visible output, so a budget + // sized for a 2-4 sentence answer can be spent before any text is emitted. + expect(sent.max_completion_tokens).toBeGreaterThanOrEqual(16000); + expect(sent.max_tokens).toBeUndefined(); + }); + + it("reports an exhausted token budget as its own fault, not an outage", async () => { + stubFetch({ choices: [{ finish_reason: "length", message: { content: "" } }] }); + await expect( + complete({ provider: "openai", apiKey: "sk-x" }, request()), + ).rejects.toMatchObject({ status: 502, code: "token_budget_exhausted" }); + }); + + it("still reports a plain empty completion as empty", async () => { + stubFetch({ choices: [{ finish_reason: "stop", message: { content: "" } }] }); + expect(await complete({ provider: "openai", apiKey: "sk-x" }, request())).toEqual({ + kind: "empty", + }); + }); + + it("names an unavailable model instead of claiming the provider is unreachable", async () => { + stubFetch( + { error: { message: "The model `gpt-5` does not exist or you do not have access to it." } }, + { ok: false, status: 404 }, + ); + const err = await complete({ provider: "openai", apiKey: "sk-x" }, request()).catch((e) => e); + expect(err).toMatchObject({ status: 502, code: "model_unavailable" }); + // The provider's own reason must survive, or this is undiagnosable from the app. + expect(err.message).toMatch(/does not exist or you do not have access/); + expect(err.message).not.toMatch(/could not be reached/); + }); + + it("passes the provider's reason through on a 400", async () => { + stubFetch( + { error: { message: "Unsupported parameter: 'max_tokens'." } }, + { ok: false, status: 400 }, + ); + await expect( + complete({ provider: "openai", apiKey: "sk-x" }, request()), + ).rejects.toThrow(/Unsupported parameter/); + }); + + // A genuine outage must still read as one, so the new codes stay meaningful. + it("still reports a 500 as an unreachable provider", async () => { + stubFetch({}, { ok: false, status: 500 }); + await expect( + complete({ provider: "openai", apiKey: "sk-x" }, request()), + ).rejects.toMatchObject({ code: "upstream_error" }); + }); }); describe("complete — Gemini", () => {