Skip to content

Commit f62f30f

Browse files
schwaaampclaude
andcommitted
Complete personalized health snapshot: sync pipeline, wins, daily history, dismiss tracking, profile editing
R1: Wire compute-baselines into sync-all-devices orchestrator. After each successful device sync, baselines are recomputed for that user. Non-fatal — sync succeeds even if baseline computation fails. R2: Add daily_history and days_of_data to starter pack response. The engine already fetches daily data but was discarding it after averaging. Now preserves per-metric daily values for trend computation and personal best detection on the client. R3: Build computeWins() client-side function. Detects personal bests and all-in-range events from the enriched scorecard. Capped at 3 wins, ordered by significance. Wired into my-body.tsx. R4: CycleInsightCard dismiss persistence via AsyncStorage. Card reappears after 30 days, permanently dismissed after 2 dismissals. R5: CycleConfirmationPrompt dismiss tracking. Stops asking after 3 consecutive dismissals of the monthly period check-in. R6: Retroactive phase correction. When user confirms a period date (via CycleInsightCard or CycleConfirmationPrompt), compute-baselines is re-triggered to re-tag historical phase labels and recompute baselines. R7: Profile cycle editing. Female users can now edit cycle preferences (regular/irregular/none/prefer not to say) and turn off cycle tracking from the Profile screen edit modal. Tests: 2807 mobile (0 fail) + 88 Edge Function (0 fail) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3268fba commit f62f30f

File tree

19 files changed

+1956
-24
lines changed

19 files changed

+1956
-24
lines changed

docs/planning/personalized-health-snapshot.md

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,288 @@ Note: Red is never used. Amber is the strongest signal. The personal baseline ap
824824

825825
---
826826

827+
## Remaining Implementation: Detailed Strategy
828+
829+
The following items complete the feature. Each takes a TDD approach: write failing tests, then implement to make them pass. Items are ordered by dependency — later items depend on earlier ones.
830+
831+
### R1: Wire compute-baselines into sync-all-devices (server)
832+
833+
**Why:** Without this, baselines are never recomputed after the initial backfill. Users who sync new data see stale personal ranges.
834+
835+
**File:** `supabase/functions/sync-all-devices/index.ts`
836+
837+
**Change:** After all device sync batches complete (after the current results summary loop), collect unique user IDs from successful syncs and call `compute-baselines` for each via HTTP (same pattern as `callSyncFunction`).
838+
839+
```typescript
840+
// After line ~305 (after all batches complete, before logging summary)
841+
const usersToRecompute = [...new Set(
842+
results.filter(r => r.success).map(r => r.user_id)
843+
)];
844+
845+
for (const userId of usersToRecompute) {
846+
try {
847+
await fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/compute-baselines`, {
848+
method: 'POST',
849+
headers: {
850+
'Authorization': `Bearer ${serviceRoleKey}`,
851+
'Content-Type': 'application/json',
852+
},
853+
body: JSON.stringify({ user_id: userId }),
854+
});
855+
} catch (err) {
856+
logger.warn('baseline', `Failed to recompute baselines for ${userId}: ${err}`);
857+
// Non-fatal — sync still succeeded
858+
}
859+
}
860+
```
861+
862+
**Tests (add to `sync-all-devices/index.test.ts` or `orchestrator.test.ts`):**
863+
- After successful device syncs, compute-baselines is called for each unique user ID
864+
- Failed device syncs do not trigger baseline computation for that user
865+
- Compute-baselines failure does not fail the overall sync response
866+
- Compute-baselines is called once per user even if user has multiple devices
867+
868+
**Test count:** ~4
869+
870+
---
871+
872+
### R2: Add daily history and days_of_data to starter pack response (server)
873+
874+
**Why:** The mobile app needs `days_of_data` for the CycleInsightCard and baseline progress text, and `dailyHistory` for trend computation and personal best detection. The starter pack engine already fetches the raw daily data (lines 157-172 of `starter-pack.ts`) but discards it after computing averages.
875+
876+
**File:** `supabase/functions/ai-engine/engines/starter-pack.ts`
877+
878+
**Changes:**
879+
880+
1. **Preserve daily values** instead of discarding after averaging. Build a `dailyHistory` map: `Record<string, { date: string; value: number }[]>` keyed by metric_key. Each entry is a day's value for that metric.
881+
882+
2. **Count days_of_data** from the total unique dates across daily_summary and sleep_sessions.
883+
884+
3. **Return both in the response:**
885+
```typescript
886+
return {
887+
...existingFields,
888+
days_of_data: uniqueDates.size,
889+
daily_history: dailyHistoryMap, // new field
890+
};
891+
```
892+
893+
4. **Update mobile type:** Add `daily_history?: Record<string, { date: string; value: number }[]>` to `StarterPackResult` in `types.ts`.
894+
895+
5. **Wire in my-body.tsx:** Pass `starterPack.daily_history` to `buildMetricScorecard` as the `dailyHistory` parameter.
896+
897+
**Tests (add to starter pack test file or create new):**
898+
- `days_of_data` equals the number of unique dates in the data
899+
- `daily_history` contains entries for each metric with actual daily values
900+
- `daily_history.resting_hr` has one entry per day, not duplicates
901+
- Empty data returns `days_of_data: 0` and empty `daily_history`
902+
903+
**Test count:** ~4
904+
905+
---
906+
907+
### R3: Build wins computation (client-side)
908+
909+
**Why:** The `WinsSection` component exists but nothing produces `WinEvent[]`. Wins need to be computed from the scorecard + daily history on the client side, since they depend on the enriched scorecard (with personal baselines) which is built client-side.
910+
911+
**File (new):** `mobile/src/utils/experiments/computeWins.ts`
912+
913+
**Function signature:**
914+
```typescript
915+
export function computeWins(
916+
scorecard: MetricScore[],
917+
dailyHistory: Record<string, { date: string; value: number }[]> | undefined,
918+
previousWinDismissals: Record<string, string>, // metric_key → last dismissed date
919+
): WinEvent[]
920+
```
921+
922+
**Logic:**
923+
1. **Personal best:** For each metric where `is_personal_best === true`, create a win. Skip if the same metric had a personal best win in the last 30 days (cooldown check against `previousWinDismissals`).
924+
2. **Back in range:** For each metric where `personal_status === 'within'`, check if the prior day's status was `'outside_bad'`. If so, create a "Back in your range" win.
925+
3. **Streak detection:** For each metric, check if `trend_direction === 'improving'` and count consecutive weeks. If 3+, create a streak win. Show at week 3, 5, 8 (not every week).
926+
4. **All in range:** If every metric with a personal baseline has `personal_status === 'within'` or `'outside_good'`, create an "all in range" win.
927+
5. **Baselines ready:** One-time win when personal baselines first appear. Track via AsyncStorage key `baselines_ready_shown`.
928+
6. **Cap at 3 wins**, ordered by significance: personal_best > streak > back_in_range > all_in_range.
929+
930+
**File (new):** `mobile/src/utils/experiments/__tests__/computeWins.test.ts`
931+
932+
**Tests:**
933+
- Returns empty array when no scorecard has personal baselines
934+
- Returns personal best win when `is_personal_best` is true
935+
- Respects 30-day cooldown per metric for personal best
936+
- Returns back-in-range win when metric transitions from outside_bad to within
937+
- Returns streak win at 3 consecutive improving weeks
938+
- Does not return streak win at week 2 (below threshold)
939+
- Returns all-in-range win when all personalized metrics are within/outside_good
940+
- Caps at 3 wins max
941+
- Orders wins by significance
942+
943+
**Wire in my-body.tsx:** Call `computeWins(enrichedScorecard, starterPack?.daily_history, dismissals)` and pass the result as `wins` to `HealthSnapshot`.
944+
945+
**Test count:** ~9
946+
947+
---
948+
949+
### R4: CycleInsightCard dismiss persistence (client-side)
950+
951+
**Why:** The "Not now" button currently does nothing persistent. The plan says the card reappears once after ~30 days, then stops after two dismissals.
952+
953+
**File:** `mobile/src/components/Experiments/CycleInsightCard.tsx`
954+
955+
**Changes:**
956+
957+
Add two new props:
958+
```typescript
959+
dismissCount: number; // how many times dismissed so far
960+
lastDismissedAt: string | null; // ISO date of last dismissal
961+
```
962+
963+
Update the gating logic:
964+
- If `dismissCount >= 2`: return null (permanently dismissed)
965+
- If `lastDismissedAt` is within 30 days: return null (cooling off)
966+
- Otherwise: show the card
967+
968+
**File:** `mobile/src/app/(tabs)/my-body.tsx`
969+
970+
Store dismiss state in AsyncStorage:
971+
- Key: `cycle_insight_dismiss_count`number
972+
- Key: `cycle_insight_last_dismissed`ISO date string
973+
974+
Read on mount, pass to `CycleInsightCard`. On dismiss, increment count and set date.
975+
976+
**Tests (add to integration test or CycleInsightCard unit test):**
977+
- Card does not render when `dismissCount >= 2`
978+
- Card does not render when `lastDismissedAt` is within 30 days
979+
- Card renders when `lastDismissedAt` is >30 days ago and `dismissCount < 2`
980+
- Pressing "Not now" calls onDismiss
981+
982+
**Test count:** ~4
983+
984+
---
985+
986+
### R5: Cycle confirmation dismiss tracking (client-side)
987+
988+
**Why:** The monthly "Did your period start?" prompt currently has no persistence for dismissals. The plan says stop asking after 3 consecutive dismissals.
989+
990+
**File:** `mobile/src/components/Experiments/CycleConfirmationPrompt.tsx`
991+
992+
**Changes:** Add a `consecutiveDismissals` prop. If >= 3, return null.
993+
994+
**File:** `mobile/src/app/(tabs)/my-body.tsx`
995+
996+
Store in AsyncStorage:
997+
- Key: `cycle_confirm_consecutive_dismissals`number
998+
999+
On confirm: reset to 0. On dismiss: increment. On "Not yet": increment. Pass count to `CycleConfirmationPrompt`.
1000+
1001+
**Tests:**
1002+
- Prompt does not render when `consecutiveDismissals >= 3`
1003+
- Prompt renders when `consecutiveDismissals < 3`
1004+
- Confirming resets the counter (verify via callback)
1005+
1006+
**Test count:** ~3
1007+
1008+
---
1009+
1010+
### R6: Retroactive phase correction on confirm (client-side trigger)
1011+
1012+
**Why:** When a user confirms a period start date via the CycleConfirmationPrompt, the baselines should be recomputed with corrected phase tags. Currently `updateProfile` is called but `compute-baselines` is not re-triggered.
1013+
1014+
**File:** `mobile/src/app/(tabs)/my-body.tsx`
1015+
1016+
**Change:** After calling `updateProfile({ last_period_date })` in `onCycleConfirm`, also call the `compute-baselines` Edge Function for the current user to re-tag and recompute.
1017+
1018+
```typescript
1019+
onCycleConfirm={async (periodStartDate) => {
1020+
await updateProfile({ last_period_date: periodStartDate });
1021+
// Trigger baseline recomputation with corrected phase tags
1022+
try {
1023+
await supabase.functions.invoke('compute-baselines', {
1024+
body: { user_id: user?.id },
1025+
});
1026+
// Invalidate the baselines cache so the UI refreshes
1027+
queryClient.invalidateQueries({ queryKey: ['personalBaselines'] });
1028+
} catch {
1029+
// Non-fatal — baselines will be recomputed on next sync
1030+
}
1031+
}}
1032+
```
1033+
1034+
**Tests (integration test):**
1035+
- After onCycleConfirm fires, compute-baselines is invoked with the correct user_id
1036+
- After recomputation, personalBaselines query is invalidated
1037+
1038+
**Test count:** ~2
1039+
1040+
---
1041+
1042+
### R7: Profile cycle editing (client-side)
1043+
1044+
**Why:** The profile screen shows cycle status as read-only text. Users need to be able to change their cycle preferences (e.g., turning cycle tracking off, or updating status from regular to irregular).
1045+
1046+
**File:** `mobile/src/app/(tabs)/profile.tsx`
1047+
1048+
**Change:** Add an edit flow for the cycle row (for female users only). When the user taps the cycle row, open an inline editor or modal with the same 4 options from the CycleInsightCard (regular, irregular, no, prefer not to say) + a date picker for last period date. On save, call `updateProfile` with the new values. Include a "Turn off cycle tracking" option that sets `cycle_status` to null, `last_period_date` to null, and `cycle_length_days` to nulland triggers a baseline recomputation (which will remove phase-specific baselines and rebuild all-phase only).
1049+
1050+
**Tests:**
1051+
- Female user sees cycle row with edit affordance
1052+
- Tapping edit shows cycle status options
1053+
- Selecting a new status and saving calls updateProfile
1054+
- "Turn off" clears all cycle fields
1055+
- Male user does not see cycle row
1056+
1057+
**Test count:** ~5
1058+
1059+
---
1060+
1061+
### R8: Privacy policy update (non-code)
1062+
1063+
**Why:** The plan's Cycle Data Privacy section commits to specific principles. The app's privacy policy must be updated before shipping cycle tracking to users.
1064+
1065+
**Action items:**
1066+
1. Add a section to the privacy policy covering menstrual cycle data
1067+
2. State: cycle data is collected solely to personalize health metric ranges
1068+
3. State: cycle data is never sold, shared with third parties, or used for advertising
1069+
4. State: cycle data never leaves our infrastructure (no external API calls)
1070+
5. State: cycle data is deletableturning off cycle tracking in Profile deletes all cycle data
1071+
6. Reference the in-app privacy commitment shown on the Cycle Insight Card
1072+
1073+
**No tests needed**this is a legal/content update.
1074+
1075+
---
1076+
1077+
### Implementation Order
1078+
1079+
```
1080+
R1 (sync pipeline) — no dependencies, can ship immediately
1081+
R2 (daily history) — no dependencies, enables R3
1082+
R3 (wins computation) — depends on R2 for daily history
1083+
R4 (dismiss persist) — no dependencies
1084+
R5 (confirm tracking) — no dependencies
1085+
R6 (retroactive) — no dependencies
1086+
R7 (profile editing) — no dependencies
1087+
R8 (privacy policy) — no dependencies, non-code
1088+
```
1089+
1090+
R1, R4, R5, R6, R7, R8 are all independent and can be done in parallel.
1091+
R2 must complete before R3.
1092+
1093+
### Total New Tests
1094+
1095+
| Item | Tests |
1096+
|------|-------|
1097+
| R1: Sync pipeline wiring | ~4 |
1098+
| R2: Daily history + days_of_data | ~4 |
1099+
| R3: Wins computation | ~9 |
1100+
| R4: CycleInsightCard dismiss | ~4 |
1101+
| R5: Cycle confirmation tracking | ~3 |
1102+
| R6: Retroactive recomputation | ~2 |
1103+
| R7: Profile cycle editing | ~5 |
1104+
| R8: Privacy policy | 0 |
1105+
| **Total** | **~31 new tests** |
1106+
1107+
---
1108+
8271109
## Migration Strategy
8281110
8291111
### Transition period

mobile/__tests__/components/MyBody.test.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { render, screen } from '@testing-library/react-native';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
34

45
// ---------------------------------------------------------------------------
56
// Mocks
@@ -112,13 +113,30 @@ jest.mock('lucide-react-native', () => ({
112113
WifiOff: () => null,
113114
}));
114115

116+
jest.mock('@/utils/supabaseClient', () => ({
117+
supabase: {
118+
functions: {
119+
invoke: jest.fn().mockResolvedValue({ data: null, error: null }),
120+
},
121+
},
122+
}));
123+
115124
// Import after mocks
116125
const MyBodyScreen = require('@/app/(tabs)/my-body').default;
117126

118127
// ---------------------------------------------------------------------------
119128
// Tests
120129
// ---------------------------------------------------------------------------
121130

131+
function renderWithProviders(ui: React.ReactElement) {
132+
const queryClient = new QueryClient({
133+
defaultOptions: { queries: { retry: false } },
134+
});
135+
return render(
136+
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
137+
);
138+
}
139+
122140
describe('My Body Tab', () => {
123141
beforeEach(() => {
124142
jest.clearAllMocks();
@@ -130,21 +148,21 @@ describe('My Body Tab', () => {
130148
});
131149

132150
it('renders "My Body" header', () => {
133-
render(<MyBodyScreen />);
151+
renderWithProviders(<MyBodyScreen />);
134152
expect(screen.getByText('My Body')).toBeTruthy();
135153
});
136154

137155
it('shows "Connect a Wearable" when no wearable connected', () => {
138156
mockDevices = [];
139-
render(<MyBodyScreen />);
157+
renderWithProviders(<MyBodyScreen />);
140158
expect(screen.getByText('Connect a Wearable')).toBeTruthy();
141159
expect(screen.getByText(/Connect a fitness tracker/)).toBeTruthy();
142160
});
143161

144162
it('shows loading state when data is being fetched', () => {
145163
mockDevices = [{ provider: 'whoop', is_active: true }];
146164
mockStarterPackLoading = true;
147-
render(<MyBodyScreen />);
165+
renderWithProviders(<MyBodyScreen />);
148166
expect(screen.getByText(/Analyzing your health data/)).toBeTruthy();
149167
});
150168

@@ -158,7 +176,7 @@ describe('My Body Tab', () => {
158176
baseline_quality: 'excellent',
159177
metric_gaps: [],
160178
};
161-
render(<MyBodyScreen />);
179+
renderWithProviders(<MyBodyScreen />);
162180
expect(screen.getByTestId('health-snapshot')).toBeTruthy();
163181
});
164182

@@ -170,13 +188,13 @@ describe('My Body Tab', () => {
170188
baseline_quality: 'insufficient',
171189
metric_gaps: [],
172190
};
173-
render(<MyBodyScreen />);
191+
renderWithProviders(<MyBodyScreen />);
174192
expect(screen.getByText('Building Your Baseline')).toBeTruthy();
175193
expect(screen.getByText(/at least 14 days/)).toBeTruthy();
176194
});
177195

178196
it('shows medical disclaimer', () => {
179-
render(<MyBodyScreen />);
197+
renderWithProviders(<MyBodyScreen />);
180198
expect(screen.getByTestId('medical-disclaimer')).toBeTruthy();
181199
});
182200
});

0 commit comments

Comments
 (0)