Skip to content

Commit 5cba87d

Browse files
schwaaampclaude
andcommitted
Personalized health snapshot: replace population ranges with personal baselines
Replace static age/sex population ranges with a three-layer personalized scoring system for the health snapshot tab: Layer 1 - Personal Baseline: P10/P50/P90 computed from each user's own 90-day data window (30-day minimum). Population ranges become background context, not the primary scoring signal. Layer 2 - Cycle-Aware Baselines: For women who opt in, separate follicular and luteal baselines prevent false alarms when metrics naturally shift with the menstrual cycle. In-context Cycle Insight Card (not onboarding) collects cycle data after 30 days of data, when the user can see why it matters. Layer 3 - Trend Direction: Cycle-aware trend computation compares like-to- like phases (luteal vs luteal) to avoid false shifting signals at phase transitions. Key components: - compute-baselines Edge Function with upsert-based persistence - Direction-aware color logic (outside_good=green, outside_bad=amber, never red) - Phase-specific personal bests (Luteal Personal Best) - Population safety net when personal P50 is outside population range - HeroSummary, WinsSection, ExpertContext, CycleInsightCard, CycleConfirmationPrompt - Respiratory rate added as 7th metric (WHOOP/Oura) - Privacy commitment: cycle data used only for range personalization New files: 19 created, 15 modified Tests: 2773 mobile (0 fail) + 53 Edge Function (0 fail) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9ebf2a6 commit 5cba87d

34 files changed

+6384
-68
lines changed

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Active feature planning and architectural strategy documents.
4242
| [Insight Engine v3](./planning/insight-engine-v3.md) | Dynamic metric discovery, auto-blacklisting, intraday analyzers | 📋 Planning |
4343
| [Timezone Strategy](./planning/timezone-strategy.md) | Cross-system timezone normalization for all data sources | 📋 Planning (prerequisite for Insight v3) |
4444
| [Unified Experiment Analysis](./planning/unified-experiment-analysis.md) | Single analysis engine, diagnostics, admin dashboard | ✅ Complete |
45+
| [Personalized Health Snapshot](./planning/personalized-health-snapshot.md) | Replace population ranges with personal baselines, cycle-aware scoring, trend direction | 📋 Planning |
4546

4647
---
4748

docs/planning/personalized-health-snapshot.md

Lines changed: 889 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* TDD tests for ExpertContext component.
3+
*
4+
* Validates the collapsible expert quote section that provides context
5+
* on why personal ranges are used instead of population averages.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
10+
11+
// ---------- Mocks ----------
12+
13+
jest.mock('@/components/useColors', () => ({
14+
useColors: () => ({
15+
background: '#FFFFFF',
16+
cardBackground: '#FFFFFF',
17+
text: '#000000',
18+
textSecondary: '#757575',
19+
primary: '#6B4EE6',
20+
primaryUltraLight: '#F0ECFD',
21+
outline: '#E0E0E0',
22+
}),
23+
}));
24+
25+
// Mock AsyncStorage — the global mock from jest.setup.js handles this,
26+
// but we need to control the return value for "seen" state.
27+
const mockAsyncStorage = require('@react-native-async-storage/async-storage');
28+
29+
// Import after mocks
30+
const ExpertContext = require('@/components/Experiments/ExpertContext').default;
31+
32+
// ---------- Tests ----------
33+
34+
describe('ExpertContext', () => {
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
// Default: user has seen the section before (collapsed by default)
38+
mockAsyncStorage.getItem.mockResolvedValue('true');
39+
});
40+
41+
it('renders "Why personal ranges?" title after loading', async () => {
42+
render(<ExpertContext />);
43+
await waitFor(() => {
44+
expect(screen.getByText('Why personal ranges?')).toBeTruthy();
45+
});
46+
});
47+
48+
it('contains Huberman quote text when expanded', async () => {
49+
render(<ExpertContext />);
50+
await waitFor(() => {
51+
expect(screen.getByText('Why personal ranges?')).toBeTruthy();
52+
});
53+
// Expand the section
54+
fireEvent.press(screen.getByText('Why personal ranges?'));
55+
expect(
56+
screen.getByText(/Individual trends matter more than absolute numbers/),
57+
).toBeTruthy();
58+
});
59+
60+
it('contains Patrick quote text when expanded', async () => {
61+
render(<ExpertContext />);
62+
await waitFor(() => {
63+
expect(screen.getByText('Why personal ranges?')).toBeTruthy();
64+
});
65+
fireEvent.press(screen.getByText('Why personal ranges?'));
66+
expect(
67+
screen.getByText(/relative changes over time with respect to your normal range/),
68+
).toBeTruthy();
69+
});
70+
71+
it('contains Sims quote text when expanded', async () => {
72+
render(<ExpertContext />);
73+
await waitFor(() => {
74+
expect(screen.getByText('Why personal ranges?')).toBeTruthy();
75+
});
76+
fireEvent.press(screen.getByText('Why personal ranges?'));
77+
expect(
78+
screen.getByText(/recovery metrics in the late luteal phase/),
79+
).toBeTruthy();
80+
});
81+
82+
it('toggles expanded/collapsed on press', async () => {
83+
render(<ExpertContext />);
84+
await waitFor(() => {
85+
expect(screen.getByText('Why personal ranges?')).toBeTruthy();
86+
});
87+
// Initially collapsed (user has seen before)
88+
expect(screen.queryByText(/Individual trends matter/)).toBeNull();
89+
90+
// Expand
91+
fireEvent.press(screen.getByText('Why personal ranges?'));
92+
expect(screen.getByText(/Individual trends matter/)).toBeTruthy();
93+
94+
// Collapse
95+
fireEvent.press(screen.getByText('Why personal ranges?'));
96+
expect(screen.queryByText(/Individual trends matter/)).toBeNull();
97+
});
98+
99+
it('renders nothing before AsyncStorage resolves', () => {
100+
// Make AsyncStorage never resolve
101+
mockAsyncStorage.getItem.mockReturnValue(new Promise(() => {}));
102+
const { toJSON } = render(<ExpertContext />);
103+
expect(toJSON()).toBeNull();
104+
});
105+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* TDD tests for HeroSummary component.
3+
*
4+
* Validates the dynamic hero sentence that summarizes the user's current
5+
* health snapshot status using positive, personal language.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen } from '@testing-library/react-native';
10+
import { MetricScore } from '@/utils/experiments/types';
11+
12+
// ---------- Mocks ----------
13+
14+
jest.mock('@/components/useColors', () => ({
15+
useColors: () => ({
16+
background: '#FFFFFF',
17+
cardBackground: '#FFFFFF',
18+
text: '#000000',
19+
textSecondary: '#757575',
20+
primary: '#6B4EE6',
21+
primaryUltraLight: '#F0ECFD',
22+
outline: '#E0E0E0',
23+
}),
24+
}));
25+
26+
// Import after mocks
27+
const HeroSummary = require('@/components/Experiments/HeroSummary').default;
28+
29+
// ---------- Test data ----------
30+
31+
const makeScore = (overrides: Partial<MetricScore> = {}): MetricScore => ({
32+
metric_key: 'resting_hr',
33+
metric_label: 'Resting Heart Rate',
34+
current_value: 58,
35+
optimal_min: 52,
36+
optimal_max: 72,
37+
gap_status: 'within_optimal',
38+
improvement_potential: 0,
39+
unit: 'bpm',
40+
description: 'Reflects cardiovascular fitness.',
41+
insight: 'Your resting heart rate of 58 bpm is within the optimal range.',
42+
citation: 'Ostchega et al. 2011 (NHANES)',
43+
is_personalized: true,
44+
direction: 'lower_is_better',
45+
personal_p10: 55,
46+
personal_p50: 60,
47+
personal_p90: 66,
48+
personal_status: 'within',
49+
cycle_phase: null,
50+
phase_label: null,
51+
trend_direction: 'stable',
52+
trend_pct_change: null,
53+
is_personal_best: false,
54+
personal_best_label: null,
55+
population_safety_flag: false,
56+
...overrides,
57+
});
58+
59+
// ---------- Tests ----------
60+
61+
describe('HeroSummary', () => {
62+
it('renders count of metrics in personal range', () => {
63+
const scorecard = [
64+
makeScore({ metric_key: 'resting_hr', personal_status: 'within' }),
65+
makeScore({ metric_key: 'hrv', metric_label: 'HRV', personal_status: 'within' }),
66+
makeScore({ metric_key: 'sleep_duration', metric_label: 'Sleep Duration', personal_status: 'outside_good' }),
67+
makeScore({ metric_key: 'steps', metric_label: 'Steps', personal_status: 'outside_bad' }),
68+
makeScore({ metric_key: 'deep_sleep', metric_label: 'Deep Sleep', personal_status: 'within' }),
69+
makeScore({ metric_key: 'rem_sleep', metric_label: 'REM Sleep', personal_status: 'within' }),
70+
makeScore({ metric_key: 'resp_rate', metric_label: 'Respiratory Rate', personal_status: 'within' }),
71+
];
72+
render(<HeroSummary scorecard={scorecard} />);
73+
// 5 within + 1 outside_good = 6 in personal range, out of 7 total
74+
expect(screen.getByText(/6 of 7 metrics are in your personal range/)).toBeTruthy();
75+
});
76+
77+
it('uses dynamic metric count (not hardcoded)', () => {
78+
const scorecard = [
79+
makeScore({ metric_key: 'resting_hr', personal_status: 'within' }),
80+
makeScore({ metric_key: 'hrv', metric_label: 'HRV', personal_status: 'within' }),
81+
makeScore({ metric_key: 'steps', metric_label: 'Steps', personal_status: 'outside_bad' }),
82+
];
83+
render(<HeroSummary scorecard={scorecard} />);
84+
expect(screen.getByText(/2 of 3 metrics are in your personal range/)).toBeTruthy();
85+
});
86+
87+
it('highlights improving metric in sentence', () => {
88+
const scorecard = [
89+
makeScore({ metric_key: 'resting_hr', personal_status: 'within' }),
90+
makeScore({
91+
metric_key: 'deep_sleep',
92+
metric_label: 'Deep Sleep',
93+
personal_status: 'within',
94+
trend_direction: 'improving',
95+
}),
96+
];
97+
render(<HeroSummary scorecard={scorecard} />);
98+
expect(screen.getByText(/Deep Sleep has been improving/)).toBeTruthy();
99+
});
100+
101+
it('includes follicular phase context', () => {
102+
const scorecard = [
103+
makeScore({ metric_key: 'resting_hr', personal_status: 'within' }),
104+
];
105+
render(
106+
<HeroSummary
107+
scorecard={scorecard}
108+
cyclePhase={{ phase: 'follicular', dayOfCycle: 8 }}
109+
/>,
110+
);
111+
expect(
112+
screen.getByText(/Follicular phase, Day 8/),
113+
).toBeTruthy();
114+
expect(
115+
screen.getByText(/your metrics are in their strongest window/),
116+
).toBeTruthy();
117+
});
118+
119+
it('includes luteal phase context with positive framing', () => {
120+
const scorecard = [
121+
makeScore({ metric_key: 'resting_hr', personal_status: 'within' }),
122+
];
123+
render(
124+
<HeroSummary
125+
scorecard={scorecard}
126+
cyclePhase={{ phase: 'luteal', dayOfCycle: 21 }}
127+
/>,
128+
);
129+
expect(
130+
screen.getByText(/Luteal phase, Day 21/),
131+
).toBeTruthy();
132+
expect(
133+
screen.getByText(/your body is doing exactly what it should right now/),
134+
).toBeTruthy();
135+
});
136+
137+
it('handles zero metrics in range without deficit language', () => {
138+
const scorecard = [
139+
makeScore({ metric_key: 'resting_hr', personal_status: 'outside_bad' }),
140+
makeScore({ metric_key: 'hrv', metric_label: 'HRV', personal_status: 'outside_bad' }),
141+
makeScore({ metric_key: 'steps', metric_label: 'Steps', personal_status: 'outside_bad' }),
142+
];
143+
render(<HeroSummary scorecard={scorecard} />);
144+
// Should say "0 of 3" but never use words like "below", "declining", "suboptimal"
145+
expect(screen.getByText(/0 of 3 metrics are in your personal range/)).toBeTruthy();
146+
// Ensure no deficit language
147+
const allText = screen.getByText(/0 of 3/).props.children;
148+
const textStr = Array.isArray(allText) ? allText.join('') : String(allText);
149+
expect(textStr).not.toMatch(/below|declining|suboptimal|bad|poor|worse/i);
150+
});
151+
152+
it('renders legacy header when no personal baselines exist (all personal_status null)', () => {
153+
const scorecard = [
154+
makeScore({ metric_key: 'resting_hr', personal_status: null }),
155+
makeScore({ metric_key: 'hrv', metric_label: 'HRV', personal_status: null }),
156+
makeScore({ metric_key: 'steps', metric_label: 'Steps', personal_status: null }),
157+
];
158+
render(<HeroSummary scorecard={scorecard} />);
159+
expect(screen.getByText('Your Health Snapshot')).toBeTruthy();
160+
});
161+
});

0 commit comments

Comments
 (0)