From 5d7d00949cba1706cd84b2f598c5a5255d37aa84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 18:02:22 +0200 Subject: [PATCH] Resolve the configured flag on the score composite One boolean on the composite: true when the account's resolved composition differs from the composition its defaults would resolve to today, both narrowed by the same modules. Decided once in the score reader, from the same config and module map the composition came from, so no client interprets a config blob to answer it. This is not hasSelection. An account that opened the surface and kept every pillar has written a selection and is not configured; an account whose modules alone narrow the set has authored nothing and is not configured either. --- .../health-score-card-geometry.test.tsx | 1 + .../__tests__/health-score-card.test.tsx | 2 + .../insights/__tests__/hero-strip.test.tsx | 1 + .../analytics/score/__tests__/breadth.test.ts | 1 + .../score/__tests__/composite.test.ts | 1 + .../__tests__/config-delta-guard.test.ts | 2 + .../analytics/score/__tests__/record.test.ts | 1 + src/lib/analytics/score/composite.ts | 9 +++ src/lib/analytics/score/config.ts | 57 +++++++++++++++++++ src/lib/analytics/score/index.ts | 7 +++ src/lib/analytics/score/reader.ts | 18 +++++- src/lib/analytics/score/types.ts | 8 +++ 12 files changed, 106 insertions(+), 2 deletions(-) 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;