Files
HealthLog/src/lib/analytics/pulse-targets.ts
T
Marc-André Bombeck 6f2f43bb49 Say what the AI surfaces actually compute
Three places where the copy promised more than the handler delivers.

The hero's personal-baseline line rendered unconditionally while both its
siblings in the same meta row gate on their own data. It claims the subtitle
above it was drawn from the reader's own last 90 days, but with no briefing the
subtitle is fixed fallback copy computed from nothing — so an account with no
briefing at all was told its generic sentence rested on a personal baseline.
Gate it on the briefing, like the rest of the row.

The pulse sub-page promised a personalised Karvonen target band. Karvonen is
not computed anywhere: the band comes from CDC/NCHS population percentiles by
age and sex, and falls back to a fixed 60-100 for a reader with no age on file.
The copy now says that in all six locales, and the fallback's own source label
says it is a general adult range rather than a personal one, because that arm
is the same band for everyone. Karvonen itself stays unimplemented; whether to
build it is a product decision, not a copy fix.

The Coach's `allTime` window returns 365 days, identical to `lastYear`, and it
is the default. "year so far" was wrong too — a rolling 365 days is not
year-to-date. Both labels now say what the reads do. The two options remain
functionally identical and one of them should probably go, but removing a
default is a product decision.
2026-08-22 16:14:54 +02:00

149 lines
3.9 KiB
TypeScript

type PulseGender = "MALE" | "FEMALE" | null | undefined;
interface PulsePercentileRow {
minAge: number;
maxAge: number;
p10: number;
p25: number;
p75: number;
p90: number;
}
const MALE_PERCENTILES: PulsePercentileRow[] = [
{ minAge: 20, maxAge: 39, p10: 55, p25: 61, p75: 76, p90: 84 },
{ minAge: 40, maxAge: 59, p10: 55, p25: 61, p75: 77, p90: 85 },
{ minAge: 60, maxAge: 79, p10: 54, p25: 60, p75: 75, p90: 84 },
{ minAge: 80, maxAge: 200, p10: 54, p25: 61, p75: 78, p90: 86 },
];
const FEMALE_PERCENTILES: PulsePercentileRow[] = [
{ minAge: 20, maxAge: 39, p10: 60, p25: 66, p75: 82, p90: 89 },
{ minAge: 40, maxAge: 59, p10: 59, p25: 64, p75: 79, p90: 86 },
{ minAge: 60, maxAge: 79, p10: 59, p25: 64, p75: 78, p90: 86 },
{ minAge: 80, maxAge: 200, p10: 59, p25: 64, p75: 77, p90: 85 },
];
export interface PersonalizedPulseTarget {
greenMin: number;
greenMax: number;
orangeMin: number;
orangeMax: number;
source: string;
}
export interface PulseTargetClassification {
category: string;
color: string;
severity: "info" | "normal" | "warning" | "danger";
}
function resolveRow(
age: number,
table: PulsePercentileRow[],
): PulsePercentileRow {
return (
table.find((row) => age >= row.minAge && age <= row.maxAge) ??
table[table.length - 1]
);
}
export function getAgeFromDateOfBirth(
dateOfBirth: Date | string | null | undefined,
): number | null {
if (!dateOfBirth) return null;
const dob =
typeof dateOfBirth === "string" ? new Date(dateOfBirth) : dateOfBirth;
if (Number.isNaN(dob.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
}
export function getPersonalizedPulseTarget(
age: number | null,
gender: PulseGender,
): PersonalizedPulseTarget {
// No age to personalise against (missing profile, or under 20 where the
// percentile table does not apply), so this arm returns a fixed band that is
// the same for everyone. The source label says so in as many words: the
// surrounding UI describes the pulse band as age- and sex-based, and a reader
// who falls into this arm needs to know theirs is not.
if (age == null || age < 20) {
return {
greenMin: 60,
greenMax: 100,
orangeMin: 55,
orangeMax: 105,
source:
"AHA general adult range (resting pulse 60-100 bpm) — not personalised",
};
}
const male = resolveRow(age, MALE_PERCENTILES);
const female = resolveRow(age, FEMALE_PERCENTILES);
const selected =
gender === "MALE"
? male
: gender === "FEMALE"
? female
: {
p10: (male.p10 + female.p10) / 2,
p25: (male.p25 + female.p25) / 2,
p75: (male.p75 + female.p75) / 2,
p90: (male.p90 + female.p90) / 2,
};
return {
greenMin: Math.round(selected.p25),
greenMax: Math.round(selected.p75),
orangeMin: Math.round(selected.p10),
orangeMax: Math.round(selected.p90),
source: "CDC/NCHS",
};
}
export function classifyPulseByTarget(
bpm: number,
target: PersonalizedPulseTarget,
): PulseTargetClassification {
if (bpm < target.orangeMin) {
return {
category: "Significantly low",
color: "var(--info)",
severity: "info",
};
}
if (bpm < target.greenMin) {
return {
category: "Slightly low",
color: "var(--warning)",
severity: "warning",
};
}
if (bpm <= target.greenMax) {
return {
category: "On target",
color: "var(--success)",
severity: "normal",
};
}
if (bpm <= target.orangeMax) {
return {
category: "Slightly elevated",
color: "var(--warning)",
severity: "warning",
};
}
return {
category: "Significantly elevated",
color: "var(--destructive)",
severity: "danger",
};
}