Files
HealthLog/src/lib/analytics/score/derived.ts
T
Marc-André Bombeck 76e68f0247 Let the score's recipe decide what counts, not the module switches
Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.

The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.

Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.

A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
2026-07-31 15:47:12 +02:00

79 lines
2.3 KiB
TypeScript

import type { Derived } from "@/lib/insights/derived/types";
import { prisma } from "@/lib/db";
import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveModuleMap } from "@/lib/modules/gate";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { computeUserHealthScore } from "./reader";
import type { CompositeValue } from "./types";
const DAY_MS = 86_400_000;
/** Load the same full Health Score composite served by the analytics surfaces. */
export async function computeHealthScoreDerived(
userId: string,
): Promise<Derived<CompositeValue>> {
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
timezone: true,
thresholdsJson: true,
healthScoreConfigJson: true,
},
});
const now = new Date();
const timezone = user.timezone ?? DEFAULT_TIMEZONE;
const bpTargets = getBpTargets(user.dateOfBirth);
const bpAt = (at: Date) =>
bpTargets
? computeBpInTargetFastPath({
userId,
targets: bpTargets,
now: at,
userTz: timezone,
})
: Promise.resolve(null);
const [
modules,
sourcePriorityJson,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
] = await Promise.all([
resolveModuleMap(userId),
loadUserSourcePriority(userId),
bpAt(now),
bpAt(new Date(now.getTime() - 7 * DAY_MS)),
bpAt(new Date(now.getTime() - 14 * DAY_MS)),
]);
const report = await computeUserHealthScore({
userId,
now,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm,
timezone,
sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
},
modules: {
glucose: modules.glucose !== false,
labs: modules.labs !== false,
sleep: modules.sleep !== false,
mentalHealth: modules.mentalHealth !== false,
},
healthScoreConfigJson: user.healthScoreConfigJson,
bpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
});
return report.composite;
}