diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 9912a2c8c..5ef74d250 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -147,6 +147,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport { band: "green", bandSetter: null, composition: pillars.map((p) => p.id), + configured: false, noiseFloor: 3, scoreVersion: 2, }, diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index b879bdcf3..9938c6ca1 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -239,6 +239,7 @@ function scoredReport( band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, @@ -368,6 +369,7 @@ describe(" pillars", () => { band: "red", bandSetter: null, composition: ["BLOOD_PRESSURE", "GLYCAEMIA"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 1785b8175..dc4c21e75 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -114,6 +114,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport { band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE"], + configured: false, noiseFloor: 3, scoreVersion: 2, }, diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts index e26683bf1..864f07476 100644 --- a/src/lib/analytics/score/__tests__/breadth.test.ts +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -144,6 +144,7 @@ describe("the scorer refuses exactly what the write refuses", () => { pillars: ids.map((id) => pillar(id)), availablePillars: ids, asOf: NOW, + configured: false, }); const verdict = evaluateScoreBreadth(ids); expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient"); diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts index 412d37525..4c281957b 100644 --- a/src/lib/analytics/score/__tests__/composite.test.ts +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -83,6 +83,7 @@ function evaluation( pillars: entries, availablePillars: available, asOf, + configured: false, }); } diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts index c9609b154..d9d475202 100644 --- a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -114,6 +114,7 @@ function composite(score: number, asOf: Date): Derived { band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + configured: false, noiseFloor: 1, scoreVersion: 2, }, @@ -188,6 +189,7 @@ describe("the recipe boundary", () => { band: "green", bandSetter: null, composition: ["BLOOD_PRESSURE", "ACTIVITY"], + configured: false, noiseFloor: 1, scoreVersion: 2, }, diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 8791d125c..9840cea82 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -97,6 +97,7 @@ function report( pillars, availablePillars: available, asOf: NOW, + configured: false, }); return { composite, diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 3522d8d47..5df5488b1 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -35,6 +35,14 @@ export interface CompositeInput { pillars: ScorePillarResult[]; availablePillars: ScorePillarId[]; asOf: Date; + /** + * Whether `availablePillars` is an authored recipe rather than the + * account's defaults. Resolved by `resolveScoreConfigured` before the + * pillar list gets here, because the comparison needs the config and + * the modules and the composite has neither. Required, so a caller + * cannot stay silent and publish `false` for a configured account. + */ + configured: boolean; } export function computeComposite( @@ -139,6 +147,7 @@ export function computeComposite( ? composition[bandSetterIndex] : null, composition, + configured: input.configured, noiseFloor, scoreVersion: SCORE_VERSION, }, diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts index af358a7b4..5db900a11 100644 --- a/src/lib/analytics/score/config.ts +++ b/src/lib/analytics/score/config.ts @@ -170,6 +170,63 @@ export function resolveHealthScoreConfig( }; } +/** + * Does this account's score run on an authored recipe? + * + * One boolean, resolved here so that no client ever reads a config blob + * to answer it. The web hero, the iOS widget, the watch complication and + * the public API all read the same value off the wire. + * + * **The definition, in one sentence:** the score is configured when the + * composition it resolves to differs from the composition the account's + * defaults would resolve to today. Both sides are narrowed by the same + * modules, so the comparison is about the person's choice and nothing + * else. + * + * Three consequences, each deliberate: + * + * * An account that opened the surface and kept every pillar is NOT + * configured. It has a selection, and that selection is the default; + * nothing about its number is attributable to an authored recipe. + * This is why `hasSelection` is not this flag — that one answers + * "did the person write something", which is a different question + * and the wrong one to put in front of a widget. + * * An account whose modules alone narrow the set is NOT configured. + * The modules say what is being recorded, not what should count, and + * the defaults resolve through them identically. + * * An account that took out a pillar no module is feeding IS + * configured, because its resolved composition is genuinely + * narrower than the default one. Data availability narrows further + * still, and that narrowing is not a choice — so the comparison runs + * before it, and the flag does not flicker as readings arrive. + * + * The mirror of the third: taking out a pillar whose module is already + * off changes nothing, and the flag says so. The recipe is stored and + * comes back the moment the module returns; today it makes no difference + * to the number, and claiming otherwise would be a surface claiming more + * than it does. + * + * `recordedPillars` is what `pillarsWithModuleData(modules)` returns. + * Required, and deliberately not defaulted: a caller that left it out + * would compare against every pillar in the catalogue and report a + * module-narrowed account as configured, which is precisely the answer + * this flag exists to avoid. + */ +export function resolveScoreConfigured(args: { + config: Pick; + recordedPillars: readonly ScorePillarId[]; +}): boolean { + const recorded = new Set(args.recordedPillars); + const resolved = args.config.pillars.filter((id) => recorded.has(id)); + const byDefault = DEFAULT_HEALTH_SCORE_CONFIG.pillars.filter((id) => + recorded.has(id), + ); + return ( + resolved.length !== byDefault.length || + resolved.some((id, index) => id !== byDefault[index]) + ); +} + /** * The recipe's identity, reduced to the two things a comparison needs: * which version of the person's own recipe produced a number, and when diff --git a/src/lib/analytics/score/index.ts b/src/lib/analytics/score/index.ts index 59ac59640..11ec16ca5 100644 --- a/src/lib/analytics/score/index.ts +++ b/src/lib/analytics/score/index.ts @@ -24,6 +24,12 @@ import { computeWellbeingPillar } from "./wellbeing"; export interface ComputeHealthScoreInput { asOf: Date; availablePillars: ScorePillarId[]; + /** + * Whether `availablePillars` is an authored recipe rather than the + * account's defaults, resolved by `resolveScoreConfigured`. Rides to + * the composite unchanged; nothing here re-derives it. + */ + configured: boolean; pillars: PillarInputs; weightGoal: Derived; delta?: number | null; @@ -84,6 +90,7 @@ export function computeHealthScore( pillars, availablePillars: input.availablePillars, asOf: input.asOf, + configured: input.configured, }), pillars, delta: input.delta ?? null, diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 0caf068ba..052fb4fad 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -27,7 +27,11 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { ACTIVITY_WINDOW_DAYS } from "./activity"; import { SLEEP_WINDOW_DAYS } from "./sleep"; import { attachScoreDelta } from "./composite"; -import { resolveHealthScoreConfig, scoreConfigBoundary } from "./config"; +import { + resolveHealthScoreConfig, + resolveScoreConfigured, + scoreConfigBoundary, +} from "./config"; import { pillarsWithModuleData, type ScoreReaderModules } from "./modules"; import { computeHealthScore } from "./index"; import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; @@ -785,10 +789,17 @@ export async function computeUserHealthScore( // recording would be nagging in the voice of their own settings. The // place that pillar comes back is the modules screen. const config = resolveHealthScoreConfig(input.healthScoreConfigJson); - const recorded = new Set(pillarsWithModuleData(input.modules)); + const recordedPillars = pillarsWithModuleData(input.modules); + const recorded = new Set(recordedPillars); const availablePillars: ScorePillarId[] = config.pillars.filter((id) => recorded.has(id), ); + // v1.35.0 — the one place the "this score is configured" boolean is + // decided, from the same config and the same module map the + // composition came from. Resolved here rather than anywhere a client + // can reach, because a client that re-derived it would be a second + // place deciding one thing. + const configured = resolveScoreConfigured({ config, recordedPillars }); const previousAt = new Date(input.now.getTime() - 7 * DAY_MS); const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS); const currentBp = scoreBpEnvelope({ @@ -841,6 +852,7 @@ export async function computeUserHealthScore( const current = computeHealthScore({ asOf: input.now, availablePillars, + configured, pillars: scoreInputsFor({ ...common, asOf: input.now, @@ -851,6 +863,7 @@ export async function computeUserHealthScore( const previous = computeHealthScore({ asOf: previousAt, availablePillars, + configured, pillars: scoreInputsFor({ ...common, asOf: previousAt, @@ -861,6 +874,7 @@ export async function computeUserHealthScore( const previousPrevious = computeHealthScore({ asOf: previousPreviousAt, availablePillars, + configured, pillars: scoreInputsFor({ ...common, asOf: previousPreviousAt, diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index c71d0fa1e..d6b31de86 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -119,6 +119,14 @@ export interface CompositeValue { bandSetter: ScorePillarId | null; /** Registry-ordered eligible pillar ids. Part of the number's identity. */ composition: ScorePillarId[]; + /** + * v1.35.0 — the resolved "this score is configured" flag: true when the + * account's composition differs from the one its defaults would resolve + * to today. Server-resolved so no client interprets a config blob, and + * never a per-pillar detail. `resolveScoreConfigured` in `./config` + * owns the definition and the reasons behind it. + */ + configured: boolean; /** Equal-weighted floor across delta-eligible pillars. */ noiseFloor: number; scoreVersion: typeof SCORE_VERSION;