mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
0a524c542b
commit
5d7d00949c
12 files changed
+106
-2
No files matched your search
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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("<HealthScoreCard> pillars", () => {
|
||||
band: "red",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
|
||||
@@ -114,6 +114,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
band: "green",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -83,6 +83,7 @@ function evaluation(
|
||||
pillars: entries,
|
||||
availablePillars: available,
|
||||
asOf,
|
||||
configured: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ function composite(score: number, asOf: Date): Derived<CompositeValue> {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ function report(
|
||||
pillars,
|
||||
availablePillars: available,
|
||||
asOf: NOW,
|
||||
configured: false,
|
||||
});
|
||||
return {
|
||||
composite,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<ResolvedHealthScoreConfig, "pillars">;
|
||||
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
|
||||
|
||||
@@ -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<WeightGoalValue>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user