feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:39:11 +02:00
1 parent b91328d67e
commit 79f7eb199b
35 files changed
+2454 -50

No files matched your search

+23
View File
@@ -4224,6 +4224,21 @@
"learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.",
"noPattern": "In den bisher erfassten {entries} Tagen ist noch nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wenn du auch den Kontext eines Tages festhältst, wird mehr davon vergleichbar.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Muster in deiner Stimmung",
"basedOn": "Grundlage: {subject} und {comparison} Tage. Ein Muster, keine Ursache.",
"previousDayHigher": "Nach einem Tag unter deinem Üblichen ist der nächste Tag meist besser ({subject} gegenüber {comparison} nach einem Tag darüber).",
"previousDayLower": "Ein Tag unter deinem Üblichen wirkt meist in den nächsten Tag hinein ({subject} gegenüber {comparison} nach einem Tag darüber).",
"weekendHigher": "Am Wochenende ist deine Stimmung meist besser ({subject} gegenüber {comparison} unter der Woche).",
"weekendLower": "Am Wochenende ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} unter der Woche).",
"bestWeekday": "{weekday} ist meist dein bester Tag ({subject} gegenüber {comparison} an den übrigen Tagen).",
"notesHigher": "An Tagen mit Notiz ist deine Stimmung meist besser ({subject} gegenüber {comparison} ohne).",
"notesLower": "An Tagen mit Notiz ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} ohne).",
"delayedTagHigher": "Am Tag nach „{tag}“ ist deine Stimmung meist besser ({subject} gegenüber {comparison} sonst).",
"delayedTagLower": "Am Tag nach „{tag}“ ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} sonst).",
"multipleEntriesHigher": "An Tagen mit mehreren Einträgen ist deine Stimmung meist besser ({subject} gegenüber {comparison} an Tagen mit einem).",
"multipleEntriesLower": "An Tagen mit mehreren Einträgen ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} an Tagen mit einem)."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "Zuletzt aktualisiert: {date}",
"project": "Projekt",
"credits": "Danksagungen"
},
"series": {
"reference": {
"targetUpper": "Dein Ziel, Obergrenze",
"targetLower": "Dein Ziel, Untergrenze",
"clinicalUpper": "Klinische Obergrenze",
"clinicalLower": "Klinische Untergrenze"
}
}
}
+23
View File
@@ -4224,6 +4224,21 @@
"learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.",
"noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. When you also capture a day's context, more of it becomes comparable.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Patterns in your mood",
"basedOn": "Based on {subject} and {comparison} days. A pattern, not a cause.",
"previousDayHigher": "After a below-usual day, the next day tends to be better ({subject} against {comparison} after an above-usual day).",
"previousDayLower": "A below-usual day tends to carry over into the next one ({subject} against {comparison} after an above-usual day).",
"weekendHigher": "Your mood tends to be higher at the weekend ({subject} against {comparison} on weekdays).",
"weekendLower": "Your mood tends to be lower at the weekend ({subject} against {comparison} on weekdays).",
"bestWeekday": "{weekday} tends to be your best day ({subject} against {comparison} on the other days).",
"notesHigher": "On days with a note your mood tends to be higher ({subject} against {comparison} without one).",
"notesLower": "On days with a note your mood tends to be lower ({subject} against {comparison} without one).",
"delayedTagHigher": "The day after “{tag}”, your mood tends to be higher ({subject} against {comparison} otherwise).",
"delayedTagLower": "The day after “{tag}”, your mood tends to be lower ({subject} against {comparison} otherwise).",
"multipleEntriesHigher": "On days you log more than once, your mood tends to be higher ({subject} against {comparison} on single-entry days).",
"multipleEntriesLower": "On days you log more than once, your mood tends to be lower ({subject} against {comparison} on single-entry days)."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "Last updated: {date}",
"project": "Project",
"credits": "Credits"
},
"series": {
"reference": {
"targetUpper": "Your target, upper limit",
"targetLower": "Your target, lower limit",
"clinicalUpper": "Clinical upper limit",
"clinicalLower": "Clinical lower limit"
}
}
}
+23
View File
@@ -4224,6 +4224,21 @@
"learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.",
"noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Cuando anotas también el contexto del día, se puede comparar una parte mayor.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Patrones en tu estado de ánimo",
"basedOn": "Basado en {subject} y {comparison} días. Un patrón, no una causa.",
"previousDayHigher": "Después de un día por debajo de lo habitual, el siguiente suele ser mejor ({subject} frente a {comparison} tras un día por encima).",
"previousDayLower": "Un día por debajo de lo habitual suele arrastrarse al siguiente ({subject} frente a {comparison} tras un día por encima).",
"weekendHigher": "Tu estado de ánimo suele ser mejor el fin de semana ({subject} frente a {comparison} entre semana).",
"weekendLower": "Tu estado de ánimo suele ser peor el fin de semana ({subject} frente a {comparison} entre semana).",
"bestWeekday": "{weekday} suele ser tu mejor día ({subject} frente a {comparison} el resto de días).",
"notesHigher": "Los días con una nota tu estado de ánimo suele ser mejor ({subject} frente a {comparison} sin nota).",
"notesLower": "Los días con una nota tu estado de ánimo suele ser peor ({subject} frente a {comparison} sin nota).",
"delayedTagHigher": "El día después de «{tag}», tu estado de ánimo suele ser mejor ({subject} frente a {comparison} en otros casos).",
"delayedTagLower": "El día después de «{tag}», tu estado de ánimo suele ser peor ({subject} frente a {comparison} en otros casos).",
"multipleEntriesHigher": "Los días que registras más de una vez, tu estado de ánimo suele ser mejor ({subject} frente a {comparison} con un solo registro).",
"multipleEntriesLower": "Los días que registras más de una vez, tu estado de ánimo suele ser peor ({subject} frente a {comparison} con un solo registro)."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "Última actualización: {date}",
"project": "Proyecto",
"credits": "Créditos"
},
"series": {
"reference": {
"targetUpper": "Tu objetivo, límite superior",
"targetLower": "Tu objetivo, límite inferior",
"clinicalUpper": "Límite clínico superior",
"clinicalLower": "Límite clínico inferior"
}
}
}
+23
View File
@@ -4224,6 +4224,21 @@
"learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.",
"noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Quand tu notes aussi le contexte d'une journée, tu en rends davantage comparable.",
"featureValue": "{field} : {value}"
},
"patterns": {
"title": "Tendances de votre humeur",
"basedOn": "Sur la base de {subject} et {comparison} jours. Une tendance, pas une cause.",
"previousDayHigher": "Après une journée en dessous de l’habitude, le lendemain est souvent meilleur ({subject} contre {comparison} après une journée au-dessus).",
"previousDayLower": "Une journée en dessous de l’habitude a tendance à se prolonger le lendemain ({subject} contre {comparison} après une journée au-dessus).",
"weekendHigher": "Votre humeur a tendance à être meilleure le week-end ({subject} contre {comparison} en semaine).",
"weekendLower": "Votre humeur a tendance à être plus basse le week-end ({subject} contre {comparison} en semaine).",
"bestWeekday": "{weekday} est souvent votre meilleur jour ({subject} contre {comparison} les autres jours).",
"notesHigher": "Les jours avec une note, votre humeur a tendance à être meilleure ({subject} contre {comparison} sans note).",
"notesLower": "Les jours avec une note, votre humeur a tendance à être plus basse ({subject} contre {comparison} sans note).",
"delayedTagHigher": "Le lendemain de « {tag} », votre humeur a tendance à être meilleure ({subject} contre {comparison} sinon).",
"delayedTagLower": "Le lendemain de « {tag} », votre humeur a tendance à être plus basse ({subject} contre {comparison} sinon).",
"multipleEntriesHigher": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être meilleure ({subject} contre {comparison} avec une seule saisie).",
"multipleEntriesLower": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être plus basse ({subject} contre {comparison} avec une seule saisie)."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "Dernière mise à jour : {date}",
"project": "Projet",
"credits": "Crédits"
},
"series": {
"reference": {
"targetUpper": "Votre objectif, limite haute",
"targetLower": "Votre objectif, limite basse",
"clinicalUpper": "Limite clinique haute",
"clinicalLower": "Limite clinique basse"
}
}
}
+23
View File
@@ -4224,6 +4224,21 @@
"learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.",
"noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Quando registri anche il contesto della giornata, ne rendi confrontabile una parte maggiore.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Schemi nel tuo umore",
"basedOn": "Basato su {subject} e {comparison} giorni. Uno schema, non una causa.",
"previousDayHigher": "Dopo un giorno sotto la tua media, il giorno seguente tende a essere migliore ({subject} contro {comparison} dopo un giorno sopra).",
"previousDayLower": "Un giorno sotto la tua media tende a trascinarsi in quello seguente ({subject} contro {comparison} dopo un giorno sopra).",
"weekendHigher": "Il tuo umore tende a essere migliore nel fine settimana ({subject} contro {comparison} nei giorni feriali).",
"weekendLower": "Il tuo umore tende a essere peggiore nel fine settimana ({subject} contro {comparison} nei giorni feriali).",
"bestWeekday": "{weekday} tende a essere il tuo giorno migliore ({subject} contro {comparison} negli altri giorni).",
"notesHigher": "Nei giorni con una nota il tuo umore tende a essere migliore ({subject} contro {comparison} senza).",
"notesLower": "Nei giorni con una nota il tuo umore tende a essere peggiore ({subject} contro {comparison} senza).",
"delayedTagHigher": "Il giorno dopo «{tag}», il tuo umore tende a essere migliore ({subject} contro {comparison} negli altri casi).",
"delayedTagLower": "Il giorno dopo «{tag}», il tuo umore tende a essere peggiore ({subject} contro {comparison} negli altri casi).",
"multipleEntriesHigher": "Nei giorni in cui registri più volte, il tuo umore tende a essere migliore ({subject} contro {comparison} con una sola voce).",
"multipleEntriesLower": "Nei giorni in cui registri più volte, il tuo umore tende a essere peggiore ({subject} contro {comparison} con una sola voce)."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "Ultimo aggiornamento: {date}",
"project": "Progetto",
"credits": "Riconoscimenti"
},
"series": {
"reference": {
"targetUpper": "Il tuo obiettivo, limite superiore",
"targetLower": "Il tuo obiettivo, limite inferiore",
"clinicalUpper": "Limite clinico superiore",
"clinicalLower": "Limite clinico inferiore"
}
}
}
+23
View File
@@ -4224,6 +4224,21 @@
"learning": "학습 중이에요: {threshold}일 중 {entries}일 기록했어요. 충분히 모이면 비교가 나타나요.",
"noPattern": "지금까지 기록한 {entries}일 중에는 하루를 견줄 만큼 일정한 패턴이 없어요. 하루의 맥락까지 함께 남기면 비교할 수 있는 게 늘어나요.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "기분 패턴",
"basedOn": "{subject}일과 {comparison}일 기준. 원인이 아닌 패턴이에요.",
"previousDayHigher": "평소보다 낮은 날 다음 날은 대체로 더 나아요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).",
"previousDayLower": "평소보다 낮은 날은 다음 날까지 이어지는 경향이 있어요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).",
"weekendHigher": "주말에는 기분이 대체로 더 좋아요 (평일 {comparison} 대비 {subject}).",
"weekendLower": "주말에는 기분이 대체로 더 낮아요 (평일 {comparison} 대비 {subject}).",
"bestWeekday": "{weekday}이 대체로 가장 좋은 날이에요 (다른 날 {comparison} 대비 {subject}).",
"notesHigher": "메모를 남긴 날에는 기분이 대체로 더 좋아요 (메모 없는 날 {comparison} 대비 {subject}).",
"notesLower": "메모를 남긴 날에는 기분이 대체로 더 낮아요 (메모 없는 날 {comparison} 대비 {subject}).",
"delayedTagHigher": "'{tag}' 다음 날에는 기분이 대체로 더 좋아요 (그 외 {comparison} 대비 {subject}).",
"delayedTagLower": "'{tag}' 다음 날에는 기분이 대체로 더 낮아요 (그 외 {comparison} 대비 {subject}).",
"multipleEntriesHigher": "하루에 여러 번 기록한 날에는 기분이 대체로 더 좋아요 (한 번 기록한 날 {comparison} 대비 {subject}).",
"multipleEntriesLower": "하루에 여러 번 기록한 날에는 기분이 대체로 더 낮아요 (한 번 기록한 날 {comparison} 대비 {subject})."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "마지막 업데이트: {date}",
"project": "프로젝트",
"credits": "크레딧"
},
"series": {
"reference": {
"targetUpper": "내 목표 상한",
"targetLower": "내 목표 하한",
"clinicalUpper": "임상 기준 상한",
"clinicalLower": "임상 기준 하한"
}
}
}
+23
View File
@@ -4224,6 +4224,21 @@
"learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.",
"noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z nimi dzień. Gdy zapisujesz też kontekst dnia, więcej da się porównać.",
"featureValue": "{field}: {value}"
},
"patterns": {
"title": "Wzorce w twoim nastroju",
"basedOn": "Na podstawie {subject} i {comparison} dni. Wzorzec, nie przyczyna.",
"previousDayHigher": "Po dniu poniżej twojej normy następny dzień jest zwykle lepszy ({subject} wobec {comparison} po dniu powyżej normy).",
"previousDayLower": "Dzień poniżej twojej normy zwykle przenosi się na następny ({subject} wobec {comparison} po dniu powyżej normy).",
"weekendHigher": "W weekendy twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w dni robocze).",
"weekendLower": "W weekendy twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w dni robocze).",
"bestWeekday": "{weekday} to zwykle twój najlepszy dzień ({subject} wobec {comparison} w pozostałe dni).",
"notesHigher": "W dni z notatką twój nastrój jest zwykle lepszy ({subject} wobec {comparison} bez notatki).",
"notesLower": "W dni z notatką twój nastrój jest zwykle gorszy ({subject} wobec {comparison} bez notatki).",
"delayedTagHigher": "Dzień po „{tag}” twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w pozostałe dni).",
"delayedTagLower": "Dzień po „{tag}” twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w pozostałe dni).",
"multipleEntriesHigher": "W dni z kilkoma wpisami twój nastrój jest zwykle lepszy ({subject} wobec {comparison} przy jednym wpisie).",
"multipleEntriesLower": "W dni z kilkoma wpisami twój nastrój jest zwykle gorszy ({subject} wobec {comparison} przy jednym wpisie)."
}
},
"derived": {
@@ -12840,5 +12855,13 @@
"lastUpdated": "Ostatnia aktualizacja: {date}",
"project": "Projekt",
"credits": "Podziękowania"
},
"series": {
"reference": {
"targetUpper": "Twój cel, górna granica",
"targetLower": "Twój cel, dolna granica",
"clinicalUpper": "Kliniczna górna granica",
"clinicalLower": "Kliniczna dolna granica"
}
}
}
@@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => {
expect(bp?.allTimeCount).toBe(28);
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
expect(bp?.sparkline).toEqual([130, 132]);
// v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the
// sparkline's mean sits above it, so the rise is a setback.
const judged = bp as unknown as {
direction: unknown;
trendSentiment: string;
};
expect(judged.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "profile_default",
});
expect(judged.trendSentiment).toBe("negative");
});
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
+89 -1
View File
@@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendSentiment,
type TrendVerdict,
type TrendVerdictContext,
} from "@/lib/targets/trend-verdict";
import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -185,6 +192,69 @@ interface MetricCard {
* at all.
*/
lastSeenAt: string | null;
/**
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
* the person, against their own target or a profile default
* (`src/lib/targets/trend-verdict.ts`).
*/
direction?: TrendVerdict | null;
/** v1.43 — `direction` applied to this card's `trend`. */
trendSentiment?: TrendSentiment;
}
/**
* The verdict key behind each card: the measurement type it reads, or the
* derived key for the two cards that are not one.
*/
const VERDICT_KEY: Record<MetricKind, string> = {
weight: "WEIGHT",
bloodPressure: "BLOOD_PRESSURE_SYS",
pulse: "PULSE",
bodyFat: "BODY_FAT",
glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
mood: "MOOD",
bmi: "BMI",
};
/**
* Lay the verdict over each card. The reference placed against the band is
* the mean of the card's own seven-day sparkline, else its latest value, the
* seven-day average the web tiles place. Sleep cards are in hours and the
* verdict reads minutes.
*/
function withVerdicts(
cards: MetricCard[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
): MetricCard[] {
const referenceOf = (card: MetricCard | undefined): number | null => {
if (!card) return null;
const spark = card.sparkline.filter((v) => Number.isFinite(v));
return spark.length > 0
? spark.reduce((sum, v) => sum + v, 0) / spark.length
: card.latestValue;
};
// BMI is judged by the weight it is derived from (the target is a weight).
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
return cards.map((card) => {
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
...ctx,
referenceValue:
reference !== null && card.kind === "sleep"
? reference * 60
: reference,
});
return {
...card,
direction,
trendSentiment: trendSentimentOfTrend(card.trend, direction),
};
});
}
/**
@@ -945,6 +1015,17 @@ async function buildDashboardSummary(
const t = getServerTranslator(ctx.locale).t;
// v1.43 — the record's profile and own targets, for the card verdicts.
const verdictProfile = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
annotate({ meta: timings });
return {
@@ -962,7 +1043,14 @@ async function buildDashboardSummary(
takenToday,
},
highlightInsight: null,
metrics,
metrics: withVerdicts(metrics, {
profile: {
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
gender: verdictProfile?.gender ?? null,
heightCm: verdictProfile?.heightCm ?? null,
},
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
}),
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
// the Sleep page's `/api/sleep/rhythm` read returns.
sleepRhythm,
@@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO BMI",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--info)",
},
source: "ESH 2023",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ESH 2023",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "CDC/NCHS",
direction: {
direction: "up-good",
targetPosition: "below",
basis: "profile_default",
},
trendSentiment: "neutral",
daysInRange7d: 0,
daysLogged7d: 4,
daysInRange30d: 0,
@@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "AASM/SRS",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "ACE",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Saint-Maurice JAMA 2020",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
range: { min: 90, max: 100 },
classification: null,
source: "7-day",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
details: {
medications: [
{
@@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ADA 2024 / DDG",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
daysInRange7d: 3,
daysLogged7d: 3,
daysInRange30d: 3,
@@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => {
);
});
describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => {
function bpDays(values: Array<[string, number]>) {
vi.mocked(prisma.measurement.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { type: string } };
if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]);
return Promise.resolve(
values.map(([day, value], i) => ({
id: `s${i}`,
value,
measuredAt: new Date(`${day}T08:00:00.000Z`),
})),
);
}) as never);
}
it("judges the window's change against the person's own target and draws its lines", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 128],
["2026-05-02", 130],
["2026-05-03", 134],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: {
direction: unknown;
change: unknown;
referenceLines: Array<Record<string, unknown>>;
};
};
expect(body.data.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "personal_target",
});
expect(body.data.change).toEqual({
from: 128,
to: 134,
delta: 6,
sentiment: "negative",
});
expect(
body.data.referenceLines.map((l) => [
l.appliesTo,
l.kind,
l.value,
l.source,
]),
).toEqual([
["value", "lower", 115, "personal"],
["value", "upper", 125, "personal"],
["secondary", "upper", 90, "clinical"],
]);
expect(body.data.referenceLines[0].label).toBe("Your target, lower limit");
});
it("calls the same rise progress for someone below their target", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 100],
["2026-05-03", 106],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: { change: { sentiment: string } };
};
expect(body.data.change.sentiment).toBe("positive");
});
it("has no change under two days and the clinical glucose lines in mmol/L", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
locale: "de",
thresholdsJson: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: {
change: unknown;
direction: { direction: string };
referenceLines: Array<{ value: number; label: string }>;
};
};
expect(body.data.change).toBeNull();
expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]);
expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze");
});
});
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+68 -1
View File
@@ -7,6 +7,12 @@
*
* The `kind=bloodPressure` case pairs systolic + diastolic by
* `measuredAt` so iOS can render the dual-line chart with one fetch.
*
* v1.43 (ios #116 items 3 and 4) — the answer also carries what a client
* would otherwise decide itself: `direction` (which way a change of this
* metric counts for this person), `change` (first and last day value of the
* window, judged by that direction) and `referenceLines` (the person's own
* target lines, else the clinical ones, in the unit of the points).
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
import {
computeSeriesChange,
seriesDayValues,
seriesReferenceValue,
} from "@/lib/measurements/series-change";
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
@@ -199,7 +214,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
const { user, actor } = await requireRecordAuth("read", "measurements");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
const values = dataPoints.map((p) => p.value);
// v1.43 — the resolved judgements. The profile and targets are the
// RECORD's (the chart is theirs); the labels are in the language of the
// account reading it.
const [record, reader, seriesTz] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
glucoseUnit: true,
locale: true,
},
}),
actor.id === user.id
? null
: prisma.user.findUnique({
where: { id: actor.id },
select: { locale: true },
}),
resolveUserTimezone(user.id),
]);
const rangeProfile = {
dateOfBirth: record?.dateOfBirth ?? null,
gender: record?.gender ?? null,
heightCm: record?.heightCm ?? null,
};
const verdictType = KIND_TO_TYPE[kind];
const dayValues = seriesDayValues(verdictType, points, seriesTz);
const reference = seriesReferenceValue(dayValues);
const direction = resolveTrendVerdict(verdictType, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
// The verdict reads stored units: sleep is served in hours here and
// stored in minutes. Glucose has no band of its own on this chart.
referenceValue:
reference !== null && kind === "sleep" ? reference * 60 : reference,
});
const translator = getServerTranslator(
await resolveJobLocale((reader ?? record)?.locale ?? null),
);
const referenceLines = resolveSeriesReferenceLines(kind, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
t: (key) => translator.t(key),
});
annotate({
action: { name: "measurements.series" },
meta: { kind, days, count: points.length },
@@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
kind,
unit,
points,
direction,
change: computeSeriesChange(dayValues, direction),
referenceLines,
stats:
statsOverride ??
(summary
@@ -41,6 +41,22 @@ const AGGREGATE = {
tags: [],
stability: year,
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
patternsByWindow: {
30: [],
90: [],
180: [],
365: [
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
qValue: 0.001,
},
],
},
};
const call = (query = "") =>
@@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => {
expect(body.data.stabilityWindowDays).toBe(90);
});
it("serves the patterns of the same window, without the cache's internals", async () => {
const year = await (await call()).json();
expect(year.data.patterns).toEqual([
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
},
]);
expect(year.data).not.toHaveProperty("patternsByWindow");
const month = await (await call("?days=30")).json();
expect(month.data.patterns).toEqual([]);
});
it("refuses a window it does not offer", async () => {
const res = await call("?days=45");
expect(res.status).toBe(422);
+10 -2
View File
@@ -12,6 +12,8 @@ import {
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
export const dynamic = "force-dynamic";
@@ -33,7 +35,8 @@ export const dynamic = "force-dynamic";
*/
/**
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
* the steadiness of the period it shows. Without it the score covers the
* the steadiness of the period it shows. Since v1.43 it cuts `patterns` the
* same way. Without it the score covers the
* whole year, as it always has; `stabilityWindowDays` names the window the
* served score covers either way.
*/
@@ -75,5 +78,10 @@ export const GET = apiHandler(async (request: Request) => {
},
});
return apiSuccess(moodInsightsWire(result, stabilityWindowDays));
// v1.43 — `patterns` follows the same `?days` window, and a catalogue
// tag in a pattern is named in the reader's language.
const translator = getServerTranslator(await resolveJobLocale(user.locale));
return apiSuccess(
moodInsightsWire(result, stabilityWindowDays, (key) => translator.t(key)),
);
});
+10 -1
View File
@@ -4,6 +4,8 @@ import {
QueryClient,
} from "@tanstack/react-query";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import {
@@ -91,10 +93,17 @@ export default async function InsightsMoodPage() {
),
]);
const queryClient = new QueryClient();
const translator = getServerTranslator(
await resolveJobLocale(user.locale),
);
if (aggregates) {
queryClient.setQueryData(
queryKeys.moodInsights(),
JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))),
JSON.parse(
JSON.stringify(
moodInsightsWire(aggregates, 365, (key) => translator.t(key)),
),
),
);
}
if (series) {
+29 -16
View File
@@ -169,6 +169,7 @@ import {
resolveWeightTrend,
weightTrendReferenceKg,
} from "@/lib/targets/weight-trend";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
import { toProfileSex } from "@/lib/profile/sex";
import { apiGet } from "@/lib/api/api-fetch";
@@ -785,6 +786,15 @@ export default function DashboardPageClient({
clientBandProfile.weightTargetOverride,
weightTrendReferenceKg(w),
);
// v1.43 — every other tile's direction comes resolved from the server
// (`tiles.directions`, against the record's own targets and profile). The
// snapshot-disabled path has no resolved answer and renders neutral rather
// than guessing a polarity.
const tileDirections = snapshotEnabled
? snapshotQuery.data?.tiles.directions
: undefined;
const directionOf = (key: string): TrendDirectionSentiment =>
tileDirections?.[key]?.direction ?? "neutral";
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
// The range hint tooltip prints its band bounds in the display unit, so scale
@@ -1093,7 +1103,7 @@ export default function DashboardPageClient({
slope30={sys?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sys)}
icon={Heart}
directionSentiment="up-bad"
directionSentiment={directionOf("BLOOD_PRESSURE_SYS")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sys)}
staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")}
@@ -1135,7 +1145,7 @@ export default function DashboardPageClient({
slope30={dia?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(dia)}
icon={Heart}
directionSentiment="up-bad"
directionSentiment={directionOf("BLOOD_PRESSURE_DIA")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(dia)}
staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")}
@@ -1177,6 +1187,9 @@ export default function DashboardPageClient({
slope30={pulseTileSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(pulseTileSummary)}
icon={TrendingUp}
directionSentiment={directionOf(
hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
)}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(pulseTileSummary)}
staleDays={tileStaleDays(
@@ -1201,7 +1214,7 @@ export default function DashboardPageClient({
slope30={bf?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(bf)}
icon={Percent}
directionSentiment="up-bad"
directionSentiment={directionOf("BODY_FAT")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(bf)}
staleDays={tileStaleDays("BODY_FAT")}
@@ -1224,7 +1237,7 @@ export default function DashboardPageClient({
slope30={moodSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(moodSummary)}
icon={Smile}
directionSentiment="up-good"
directionSentiment={directionOf("MOOD")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(moodSummary)}
/>
@@ -1246,7 +1259,7 @@ export default function DashboardPageClient({
slope30={sleepSummaryHours?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sleepSummaryHours)}
icon={Moon}
directionSentiment="up-good"
directionSentiment={directionOf("SLEEP_DURATION")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sleepSummaryHours)}
staleDays={tileStaleDays("SLEEP_DURATION")}
@@ -1282,7 +1295,7 @@ export default function DashboardPageClient({
slope30={stepsSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(stepsSummary)}
icon={Footprints}
directionSentiment="up-good"
directionSentiment={directionOf("ACTIVITY_STEPS")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(stepsSummary)}
staleDays={tileStaleDays("ACTIVITY_STEPS")}
@@ -1312,7 +1325,7 @@ export default function DashboardPageClient({
slope30={vo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(vo2Summary)}
icon={Gauge}
directionSentiment="up-good"
directionSentiment={directionOf("VO2_MAX")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(vo2Summary)}
staleDays={tileStaleDays("VO2_MAX")}
@@ -1354,7 +1367,7 @@ export default function DashboardPageClient({
slope30={hrvSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(hrvSummary)}
icon={HeartPulse}
directionSentiment="up-good"
directionSentiment={directionOf(hrvType)}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(hrvSummary)}
// The type that actually backs the tile — reading freshness
@@ -1380,7 +1393,7 @@ export default function DashboardPageClient({
slope30={spo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(spo2Summary)}
icon={Droplets}
directionSentiment="up-good"
directionSentiment={directionOf("OXYGEN_SATURATION")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(spo2Summary)}
staleDays={tileStaleDays("OXYGEN_SATURATION")}
@@ -1403,7 +1416,7 @@ export default function DashboardPageClient({
slope30={respRateSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(respRateSummary)}
icon={Wind}
directionSentiment="neutral"
directionSentiment={directionOf("RESPIRATORY_RATE")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(respRateSummary)}
staleDays={tileStaleDays("RESPIRATORY_RATE")}
@@ -1429,7 +1442,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(wristTempSummary),
)}
icon={Thermometer}
directionSentiment="neutral"
directionSentiment={directionOf("WRIST_TEMPERATURE")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"WRIST_TEMPERATURE",
@@ -1461,7 +1474,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(muscleMassSummary),
)}
icon={Dumbbell}
directionSentiment="up-good"
directionSentiment={directionOf("MUSCLE_MASS")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"MUSCLE_MASS",
@@ -1490,7 +1503,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(bodyWaterSummary),
)}
icon={Droplet}
directionSentiment="neutral"
directionSentiment={directionOf("TOTAL_BODY_WATER")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"TOTAL_BODY_WATER",
@@ -1519,7 +1532,7 @@ export default function DashboardPageClient({
summaryToTrend7Delta(boneMassSummary),
)}
icon={Bone}
directionSentiment="neutral"
directionSentiment={directionOf("BONE_MASS")}
compareBaseline={compareBaseline}
compareDelta={tdd(
"BONE_MASS",
@@ -1548,7 +1561,7 @@ export default function DashboardPageClient({
slope30={waterIntakeSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
icon={GlassWater}
directionSentiment="neutral"
directionSentiment={directionOf("NUTRIENT_WATER")}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(waterIntakeSummary)}
staleDays={tileStaleDays("NUTRIENT_WATER")}
@@ -1639,7 +1652,7 @@ export default function DashboardPageClient({
slope30={null}
trend7Delta={bpSufficient ? bpTrendDelta : null}
icon={Target}
directionSentiment="up-good"
directionSentiment={directionOf("BLOOD_PRESSURE_IN_TARGET")}
compareBaseline={compareBaseline}
compareDelta={bpSufficient ? bpCompareDelta : null}
/>
@@ -28,7 +28,6 @@ import {
} from "./use-dashboard-derived";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
@@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
// and feed it to both the wellness strip and this grid. Imported above.
/**
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
* them: which way it should move depends on the person's own target (#1006,
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
* it stays neutral rather than assuming a fall is good.
*/
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
]);
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
function vitalSentiment(type: string): TrendDirectionSentiment {
if (UP_BAD_VITALS.has(type)) return "up-bad";
if (UP_GOOD_VITALS.has(type)) return "up-good";
return "neutral";
}
interface DashboardProps {
/**
* The shared overview derived batch (owned by the page so the wellness
@@ -306,7 +285,10 @@ function BaselineTile({
icon={Icon}
series={displaySeries}
framing={framing}
directionSentiment={vitalSentiment(type)}
// v1.43 — resolved on the server against the person's own target
// or profile default; an older server sends none and the tile
// stays neutral rather than assuming a polarity.
directionSentiment={v.direction?.direction ?? "neutral"}
provenance={
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
// "typical range = median ± MAD" caption repeated across every vital
@@ -30,6 +30,7 @@ import {
Gauge,
Grid3x3,
Link2,
Repeat,
Tag,
Tags,
TrendingUp,
@@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile";
import { MoodTagInfluence } from "./mood-tag-influence";
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
import { MoodPatterns } from "./mood-patterns";
export function MoodInsightsBreakdowns({
data,
@@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const patterns = data.patterns ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
return (
@@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.43 — the patterns the server found and gated; the card stays
away when none passed. */}
{patterns.length > 0 && (
<SectionCard title={t("insights.mood.patterns.title")} icon={Repeat}>
<MoodPatterns patterns={patterns} />
</SectionCard>
)}
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
@@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days";
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
import type { MoodPatternRow } from "./mood-patterns";
export interface MoodInsightsResponse {
summary: {
@@ -65,6 +66,11 @@ export interface MoodInsightsResponse {
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
/**
* v1.43 — the gated patterns the server found over the requested window.
* Optional to tolerate a payload from before it existed.
*/
patterns?: MoodPatternRow[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
@@ -0,0 +1,111 @@
"use client";
/**
* v1.43 (ios #116 item 6) — the mood patterns the server found.
*
* Nothing here decides whether a pattern exists: every row is one the server
* published after its gate (eight days a side, Welch, false discovery
* control), computed on the mood day. This component only phrases each one.
* Rows render in the order delivered; a kind this build does not know is
* skipped rather than guessed at.
*/
import { useFormatters, useTranslations } from "@/lib/i18n/context";
export interface MoodPatternRow {
id: string;
kind: string;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
tagLabel?: string;
};
sampleSize: { subject: number; comparison: number };
}
const WEEKDAY_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
"charts.weekdaysFull.wed",
"charts.weekdaysFull.thu",
"charts.weekdaysFull.fri",
"charts.weekdaysFull.sat",
"charts.weekdaysFull.sun",
] as const;
/** The sentence key per kind and direction; `none` cannot pass the gate. */
const SENTENCE_KEYS: Record<string, { higher: string; lower: string | null }> =
{
previous_day: {
higher: "insights.mood.patterns.previousDayHigher",
lower: "insights.mood.patterns.previousDayLower",
},
weekend: {
higher: "insights.mood.patterns.weekendHigher",
lower: "insights.mood.patterns.weekendLower",
},
best_weekday: {
higher: "insights.mood.patterns.bestWeekday",
lower: null,
},
notes: {
higher: "insights.mood.patterns.notesHigher",
lower: "insights.mood.patterns.notesLower",
},
delayed_tag: {
higher: "insights.mood.patterns.delayedTagHigher",
lower: "insights.mood.patterns.delayedTagLower",
},
multiple_entries: {
higher: "insights.mood.patterns.multipleEntriesHigher",
lower: "insights.mood.patterns.multipleEntriesLower",
},
};
export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
const rows = patterns.flatMap((pattern) => {
const keys = SENTENCE_KEYS[pattern.kind];
if (!keys || pattern.direction === "none") return [];
const key = pattern.direction === "higher" ? keys.higher : keys.lower;
if (!key) return [];
const weekdayKey =
pattern.values.weekday !== undefined
? WEEKDAY_KEYS[pattern.values.weekday]
: undefined;
return [
{
id: pattern.id,
sentence: t(key, {
subject: fmt.number(pattern.values.subject, 1),
comparison: fmt.number(pattern.values.comparison, 1),
weekday: weekdayKey ? t(weekdayKey) : "",
tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "",
}),
basedOn: t("insights.mood.patterns.basedOn", {
subject: pattern.sampleSize.subject,
comparison: pattern.sampleSize.comparison,
}),
},
];
});
if (rows.length === 0) return null;
return (
<ul className="space-y-3" data-slot="mood-patterns">
{rows.map((row) => (
<li key={row.id} data-pattern-id={row.id} className="space-y-1">
<p className="text-sm">{row.sentence}</p>
<p className="text-muted-foreground text-xs">{row.basedOn}</p>
</li>
))}
</ul>
);
}
+6 -1
View File
@@ -332,7 +332,12 @@ export function resolveWeightTargetOverride(
return { min, max };
}
function resolveThresholdOverride(
/**
* Narrow one metric's own target out of a raw `User.thresholdsJson`, the same
* way `resolveWeightTargetOverride` does for weight. `null` for anything that
* is not a well-formed, ordered pair of finite numbers.
*/
export function resolveThresholdOverride(
thresholdsJson: unknown,
metric: ThresholdMetric,
): { min: number; max: number } | null {
+21
View File
@@ -64,6 +64,10 @@ import {
weightTrendReferenceKg,
type WeightTrendJudgement,
} from "@/lib/targets/weight-trend";
import {
resolveSummaryVerdicts,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import {
probeRollupCoverage,
typesWithReadingsInFoldWindow,
@@ -452,6 +456,15 @@ export interface DashboardSnapshot {
* builder always sets it.
*/
weightTrend?: WeightTrendJudgement;
/**
* v1.43 (ios #116 item 3) — per measurement type (and `MOOD`,
* `BLOOD_PRESSURE_IN_TARGET`), which way a change counts for this
* person, against their own target or a profile default where one
* applies (`src/lib/targets/trend-verdict.ts`). Every tile colours its
* arrow and deltas from this; `weightTrend` stays for older clients.
* Optional on the type so older cached snapshots stay valid.
*/
directions?: Record<string, TrendVerdict>;
};
/** Thick phase — null on a rollup-coverage miss. */
extras: DashboardSnapshotExtras | null;
@@ -1496,6 +1509,14 @@ export async function buildDashboardSnapshot(
weightTargetOverride,
weightTrendReferenceKg(slim.summaries.WEIGHT),
),
directions: resolveSummaryVerdicts(slim.summaries, {
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
},
thresholdsJson: user.thresholdsJson,
}),
},
extras: extrasResult?.extras ?? null,
medsToday,
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import type { MoodAggregateEntry } from "@/lib/insights/mood-aggregates";
import { shiftDateKey } from "@/lib/tz/format";
import {
computeMoodPatterns,
moodPatternsWire,
PATTERN_MIN_DAYS_PER_SIDE,
weekdayOfKey,
} from "../mood-pattern-cards";
const TODAY = "2026-10-11"; // a Sunday
/** A deterministic pseudo-random stream (mulberry32). */
function rng(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function entry(
date: string,
score: number,
extra: Partial<MoodAggregateEntry> = {},
): MoodAggregateEntry {
return {
date,
score,
tags: null,
moodLoggedAt: new Date(`${date}T18:00:00.000Z`),
...extra,
};
}
function days(n: number, score: (date: string, i: number) => number) {
return Array.from({ length: n }, (_, i) => {
const date = shiftDateKey(TODAY, -(n - 1 - i));
return entry(date, score(date, i));
});
}
function run(entries: MoodAggregateEntry[], noteDays = new Set<string>()) {
return computeMoodPatterns({
entries,
noteDays,
todayKey: TODAY,
windowDays: 365,
});
}
describe("computeMoodPatterns", () => {
it("finds a clear weekend effect and names it", () => {
const r = rng(7);
const entries = days(120, (date) =>
Math.min(
5,
Math.max(1, (weekdayOfKey(date) >= 5 ? 4.2 : 3) + (r() - 0.5)),
),
);
const patterns = run(entries);
const weekend = patterns.find((p) => p.kind === "weekend");
expect(weekend).toBeDefined();
expect(weekend).toMatchObject({
id: "weekend",
direction: "higher",
strength: "strong",
});
expect(weekend!.sampleSize.subject).toBeGreaterThanOrEqual(
PATTERN_MIN_DAYS_PER_SIDE,
);
expect(weekend!.values.subject).toBeGreaterThan(weekend!.values.comparison);
// Saturday or Sunday is the best weekday, and only one is published.
const best = patterns.filter((p) => p.kind === "best_weekday");
expect(best.length).toBeLessThanOrEqual(1);
if (best[0]) expect(best[0].values.weekday).toBeGreaterThanOrEqual(5);
});
it("finds nothing in noise", () => {
for (const seed of [1, 2, 3, 4, 5]) {
const r = rng(seed);
const entries = days(180, () => 1 + Math.round(r() * 4));
expect(run(entries), `seed ${seed}`).toEqual([]);
}
});
it("holds every comparison to eight days a side", () => {
// Twelve days, all with a strong weekend split: too few weekend days.
const r = rng(16);
const entries = days(
12,
(date) => (weekdayOfKey(date) >= 5 ? 4.6 : 1.4) + (r() - 0.5) * 0.4,
);
expect(run(entries).find((p) => p.kind === "weekend")).toBeUndefined();
});
it("compares days with a note against days without", () => {
const r = rng(11);
const entries = days(
90,
(_, i) => (i % 3 === 0 ? 2 : 4) + (r() - 0.5) * 0.4,
);
const noteDays = new Set(
entries.filter((_, i) => i % 3 === 0).map((e) => e.date),
);
const notes = run(entries, noteDays).find((p) => p.kind === "notes");
expect(notes).toMatchObject({ id: "notes", direction: "lower" });
});
it("compares days with several entries against single-entry days", () => {
const r = rng(12);
const base = days(
90,
(_, i) => (i % 2 === 0 ? 4.5 : 3) + (r() - 0.5) * 0.4,
);
const extra = base
.filter((_, i) => i % 2 === 0)
.map((e) => entry(e.date, e.score));
const multi = run([...base, ...extra]).find(
(p) => p.kind === "multiple_entries",
);
expect(multi).toMatchObject({ direction: "higher" });
});
it("reads a tag's association with the NEXT day", () => {
const r = rng(13);
const tag = {
key: "alcohol",
categoryKey: "health",
labelKey: "mood.tag.alcohol",
icon: null,
};
const tagged = new Set<number>();
const entries = days(120, (_, i) => {
// The day after a tagged day is low.
return tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
});
entries.forEach((e, i) => {
if (i % 4 === 0) {
tagged.add(i);
e.structuredTags = [tag];
}
});
// Recompute the scores now that the tag days are known.
for (let i = 0; i < entries.length; i += 1) {
entries[i].score = tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
}
const delayed = run(entries).find((p) => p.kind === "delayed_tag");
expect(delayed).toMatchObject({
id: "delayed_tag:alcohol",
direction: "lower",
values: { tagKey: "alcohol" },
});
});
it("reads the day after a below-median day", () => {
const r = rng(14);
const entries: MoodAggregateEntry[] = [];
let prevLow = false;
for (let i = 0; i < 150; i += 1) {
const date = shiftDateKey(TODAY, -(149 - i));
// Persistent mood: a low day is followed by another low-ish day.
const low: boolean = r() < (prevLow ? 0.85 : 0.15);
entries.push(entry(date, low ? 2 + r() * 0.5 : 4 + r() * 0.5));
prevLow = low;
}
const previous = run(entries).find((p) => p.kind === "previous_day");
expect(previous).toMatchObject({ id: "previous_day", direction: "lower" });
});
it("only counts days inside the window", () => {
const r = rng(15);
const entries = days(
200,
(date) => (weekdayOfKey(date) >= 5 ? 4.5 : 2.5) + (r() - 0.5) * 0.4,
);
const recent = computeMoodPatterns({
entries,
noteDays: new Set(),
todayKey: TODAY,
windowDays: 30,
}).find((p) => p.kind === "weekend");
expect(recent).toBeDefined();
expect(recent!.sampleSize.subject).toBeGreaterThanOrEqual(8);
expect(recent!.sampleSize.subject + recent!.sampleSize.comparison).toBe(30);
});
});
describe("moodPatternsWire", () => {
it("names a catalogue tag in the reader's language and a custom one verbatim, and drops q", () => {
const wire = moodPatternsWire(
[
{
id: "delayed_tag:alcohol",
kind: "delayed_tag",
strength: "moderate",
direction: "lower",
values: {
subject: 2.1,
comparison: 4,
delta: -1.9,
tagKey: "alcohol",
},
sampleSize: { subject: 20, comparison: 80 },
tag: { labelKey: "mood.tag.alcohol", label: null },
qValue: 0.01,
},
{
id: "delayed_tag:custom:x",
kind: "delayed_tag",
strength: "weak",
direction: "higher",
values: { subject: 4, comparison: 3, delta: 1, tagKey: "custom:x" },
sampleSize: { subject: 9, comparison: 50 },
tag: { labelKey: "custom:x", label: "Choir" },
qValue: 0.08,
},
],
(key) => `t:${key}`,
);
expect(wire[0].values.tagLabel).toBe("t:mood.tag.alcohol");
expect(wire[1].values.tagLabel).toBe("Choir");
expect(wire[0]).not.toHaveProperty("qValue");
expect(wire[0]).not.toHaveProperty("tag");
expect(moodPatternsWire(undefined, (k) => k)).toEqual([]);
});
});
+43 -3
View File
@@ -28,6 +28,10 @@
* statistics helpers below are exported so the unit test can assert the
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
*/
import {
resolveTrendVerdict,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
@@ -91,6 +95,14 @@ export interface VitalsBaselineValue {
* band is already computed from (no extra read).
*/
series: number[];
/**
* v1.43 (ios #116 item 3) — which way a change of this vital counts for
* the person, with the band's centre placed against their own target or a
* profile default (`src/lib/targets/trend-verdict.ts`). The tile colours
* its delta from this instead of a fixed polarity. Absent when the caller
* passed no profile facts to judge with.
*/
direction?: TrendVerdict;
}
/** Caller-supplied profile (read once per request, never re-fetched here). */
@@ -109,6 +121,13 @@ export interface BaselineProfile {
* metric (weight ÷ height²); `null` when the profile has no height.
*/
heightCm?: number | null;
/**
* v1.43 — the date of birth and the person's own targets, which the trend
* verdict on `VITALS_BASELINE` is judged with. Optional: a caller that does
* not read them gets no verdict rather than a wrong one.
*/
dateOfBirth?: Date | null;
thresholdsJson?: unknown;
}
/**
@@ -127,13 +146,20 @@ export async function loadBaselineProfile(
): Promise<BaselineProfile> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { dateOfBirth: true, gender: true, heightCm: true },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
const sex = toProfileSex(user?.gender);
return {
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
sex,
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
thresholdsJson: user?.thresholdsJson ?? null,
};
}
@@ -311,7 +337,7 @@ export async function readDayMeanSeries(
*/
export async function computeVitalsBaseline(
userId: string,
_profile: BaselineProfile,
profile: BaselineProfile,
opts: VitalsBaselineOpts,
): Promise<Derived<VitalsBaselineValue>> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
@@ -403,8 +429,22 @@ export async function computeVitalsBaseline(
// means the band is built from, capped to the recent window.
const series = points.slice(-SPARKLINE_MAX_POINTS).map((p) => p.mean);
// Judged only when the caller read the facts it is judged with; the
// nightly engines that never show a delta pass a bare profile.
const direction =
profile.dateOfBirth !== undefined
? resolveTrendVerdict(type, {
profile: {
dateOfBirth: profile.dateOfBirth,
gender: profile.sex,
heightCm: profile.heightCm ?? null,
},
thresholdsJson: profile.thresholdsJson ?? null,
referenceValue: band.center,
})
: undefined;
return buildOk<VitalsBaselineValue>({
value: { type, ...band, series },
value: { type, ...band, series, ...(direction ? { direction } : {}) },
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
+49 -2
View File
@@ -18,6 +18,11 @@
* importing from here unchanged.
*/
import {
computeMoodPatterns,
moodPatternsWire,
type MoodPatternFinding,
} from "@/lib/insights/mood-pattern-cards";
import { prisma } from "@/lib/db";
import {
significantPearsonCorrelation,
@@ -512,6 +517,13 @@ export interface MoodAggregates {
* `stability` and does not publish this map.
*/
stabilityByWindow: Record<MoodStabilityWindow, MoodStability | null>;
/**
* v1.43 (ios #116 item 6) — the gated mood patterns per selectable window
* (`mood-pattern-cards.ts`). Cached with the aggregate like the stability
* map; the route serves the chosen window as `patterns`. Optional so an
* aggregate cached before it existed still reads (as none found).
*/
patternsByWindow?: Record<MoodStabilityWindow, MoodPatternFinding[]>;
tags: TagSummaryRow[];
/**
* v1.38 — how the day's pleasantness sat on days carrying each context
@@ -579,6 +591,11 @@ export function computeMoodAggregates(args: {
* calendar day. Defaults to Berlin for legacy / test callers.
*/
tz?: string;
/**
* v1.43 — the mood days that carry a note, for the `notes` pattern. Read
* as a separate projection so the note text itself is never loaded here.
*/
noteDays?: ReadonlySet<string>;
}): MoodAggregates {
const { entries, measurements, now } = args;
const userPriorityJson = args.userPriorityJson ?? null;
@@ -633,6 +650,18 @@ export function computeMoodAggregates(args: {
),
]),
) as Record<MoodStabilityWindow, MoodStability | null>;
const todayKey = dayOffsetToDayKey(now, 0, tz);
const patternsByWindow = Object.fromEntries(
MOOD_STABILITY_WINDOWS.map((days) => [
days,
computeMoodPatterns({
entries,
noteDays: args.noteDays ?? new Set<string>(),
todayKey,
windowDays: days,
}),
]),
) as Record<MoodStabilityWindow, MoodPatternFinding[]>;
const tags = computeTagSummary(entries, now);
const structuredTags = computeStructuredTagSummary(entries, now);
const tagInfluence = computeTagInfluence(entries, now);
@@ -715,6 +744,7 @@ export function computeMoodAggregates(args: {
timeOfDay,
stability,
stabilityByWindow,
patternsByWindow,
tags,
structuredTags,
tagInfluence,
@@ -921,9 +951,20 @@ export async function fetchMoodAggregates(
});
}
const [userPriorityJson, tz] = await Promise.all([
// v1.43 — which mood days carry a note, without reading a single note.
const [userPriorityJson, tz, noteRows] = await Promise.all([
loadUserSourcePriority(userId),
resolveUserTimezone(userId),
prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: windowCutoff },
OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }],
},
select: { date: true },
distinct: ["date"],
}),
]);
const aggregate = computeMoodAggregates({
@@ -932,6 +973,7 @@ export async function fetchMoodAggregates(
now,
userPriorityJson,
tz,
noteDays: new Set(noteRows.map((row) => row.date)),
});
const tagEvidence = aggregate.tagMetricCrosstab.map((row) => ({
@@ -995,8 +1037,10 @@ export async function fetchMoodAggregates(
export function moodInsightsWire(
result: MoodAggregates,
stabilityWindowDays: MoodStabilityWindow,
/** Names a pattern's tag in the reader's language (catalogue tags). */
t: (key: string) => string,
) {
const { stabilityByWindow, ...wire } = result;
const { stabilityByWindow, patternsByWindow, ...wire } = result;
return {
...wire,
stability:
@@ -1004,5 +1048,8 @@ export function moodInsightsWire(
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
// v1.43 — the patterns of the same window; an aggregate cached before
// they existed reads as none found until its next rebuild.
patterns: moodPatternsWire(patternsByWindow?.[stabilityWindowDays], t),
};
}
+352
View File
@@ -0,0 +1,352 @@
/**
* Mood patterns the server finds and publishes (v1.43, ios #116 item 6).
*
* The native client used to compute six pattern cards itself (the day after
* a low day, weekends, the best weekday, days with a note, delayed tag
* effects, several entries a day) on the device calendar and without any
* statistical gate, and removed them. They are computed here instead, on the
* account's mood day (`MoodEntry.date`, anchored to the entry's own zone),
* and only the ones that pass the gate are published:
*
* - at least `PATTERN_MIN_DAYS_PER_SIDE` days on each side of a comparison;
* - a Welch two-sample t-test on the two groups of daily means (the same
* test the tag influence board uses);
* - Benjamini-Hochberg over every comparison tested in the window, kept at
* q ≤ `PATTERN_FDR_Q` (the false discovery rate the mood crosstabs use).
*
* An empty list means nothing passed, not that something failed. Every
* comparison is a daily-mean comparison, so a day with five entries weighs
* as much as a day with one. Observational only: the copy a client renders
* says "tends to", never "because".
*
* Pure; the caller hands over the entries and which days carry a note.
*/
import { welchTTest } from "@/lib/insights/correlations";
import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
import type {
MoodAggregateEntry,
StructuredTagRef,
} from "@/lib/insights/mood-aggregates";
import { round } from "@/lib/insights/status-shared";
import { shiftDateKey } from "@/lib/tz/format";
export const PATTERN_MIN_DAYS_PER_SIDE = 8;
export const PATTERN_FDR_Q = 0.1;
export type MoodPatternKind =
| "previous_day"
| "weekend"
| "best_weekday"
| "notes"
| "delayed_tag"
| "multiple_entries";
/** A pattern as the aggregate caches it: the tag's label is resolved at the wire. */
export interface MoodPatternFinding {
id: string;
kind: MoodPatternKind;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
};
sampleSize: { subject: number; comparison: number };
/** For `delayed_tag`: how to name the tag (custom label first). */
tag?: { labelKey: string; label: string | null };
/** BH-adjusted q, kept for ordering and tests; not on the wire. */
qValue: number;
}
/** The wire shape (`MoodPattern` in OpenAPI). */
export interface MoodPatternWire {
id: string;
kind: MoodPatternKind;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
tagLabel?: string;
};
sampleSize: { subject: number; comparison: number };
}
interface MoodDay {
date: string;
mean: number;
entries: number;
hasNote: boolean;
tags: Map<string, StructuredTagRef>;
}
interface Candidate {
id: string;
kind: MoodPatternKind;
subject: number[];
comparison: number[];
weekday?: number;
tag?: StructuredTagRef;
}
/** 0 = Monday … 6 = Sunday, read off the day key itself. */
export function weekdayOfKey(dateKey: string): number {
const d = new Date(`${dateKey}T12:00:00Z`);
return (d.getUTCDay() + 6) % 7;
}
function collapseDays(
entries: readonly MoodAggregateEntry[],
noteDays: ReadonlySet<string>,
): Map<string, MoodDay> {
const days = new Map<
string,
{ sum: number; count: number; tags: Map<string, StructuredTagRef> }
>();
for (const entry of entries) {
const day = days.get(entry.date) ?? {
sum: 0,
count: 0,
tags: new Map<string, StructuredTagRef>(),
};
day.sum += entry.score;
day.count += 1;
for (const ref of entry.structuredTags ?? []) day.tags.set(ref.key, ref);
days.set(entry.date, day);
}
const out = new Map<string, MoodDay>();
for (const [date, day] of days) {
out.set(date, {
date,
mean: day.sum / day.count,
entries: day.count,
hasNote: noteDays.has(date),
tags: day.tags,
});
}
return out;
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
function mean(values: readonly number[]): number {
return values.reduce((s, v) => s + v, 0) / values.length;
}
/** Cohen's d on the pooled SD, banded at the conventional 0.5 / 0.8. */
function strengthOf(
subject: readonly number[],
comparison: readonly number[],
): MoodPatternFinding["strength"] {
const ms = mean(subject);
const mc = mean(comparison);
const vs =
subject.reduce((s, v) => s + (v - ms) ** 2, 0) / (subject.length - 1);
const vc =
comparison.reduce((s, v) => s + (v - mc) ** 2, 0) / (comparison.length - 1);
const pooled =
((subject.length - 1) * vs + (comparison.length - 1) * vc) /
(subject.length + comparison.length - 2);
const d = pooled > 0 ? Math.abs(ms - mc) / Math.sqrt(pooled) : 0;
if (d >= 0.8) return "strong";
if (d >= 0.5) return "moderate";
return "weak";
}
function candidates(days: Map<string, MoodDay>): Candidate[] {
const list = [...days.values()].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
);
const out: Candidate[] = [];
// The day after a day below the person's own median, against the day after
// one above it. Days on the median itself belong to neither side.
const med = list.length > 0 ? median(list.map((d) => d.mean)) : 0;
const afterLow: number[] = [];
const afterHigh: number[] = [];
for (const day of list) {
const prev = days.get(shiftDateKey(day.date, -1));
if (!prev) continue;
if (prev.mean < med) afterLow.push(day.mean);
else if (prev.mean > med) afterHigh.push(day.mean);
}
out.push({
id: "previous_day",
kind: "previous_day",
subject: afterLow,
comparison: afterHigh,
});
// Saturday and Sunday against Monday to Friday.
out.push({
id: "weekend",
kind: "weekend",
subject: list.filter((d) => weekdayOfKey(d.date) >= 5).map((d) => d.mean),
comparison: list.filter((d) => weekdayOfKey(d.date) < 5).map((d) => d.mean),
});
// Each weekday against the rest. All seven are tested so the selection of
// the best one is paid for in the false discovery control; only the best
// can be published.
for (let weekday = 0; weekday < 7; weekday += 1) {
out.push({
id: `weekday:${weekday}`,
kind: "best_weekday",
weekday,
subject: list
.filter((d) => weekdayOfKey(d.date) === weekday)
.map((d) => d.mean),
comparison: list
.filter((d) => weekdayOfKey(d.date) !== weekday)
.map((d) => d.mean),
});
}
out.push({
id: "notes",
kind: "notes",
subject: list.filter((d) => d.hasNote).map((d) => d.mean),
comparison: list.filter((d) => !d.hasNote).map((d) => d.mean),
});
out.push({
id: "multiple_entries",
kind: "multiple_entries",
subject: list.filter((d) => d.entries >= 2).map((d) => d.mean),
comparison: list.filter((d) => d.entries === 1).map((d) => d.mean),
});
// A tag's association with the NEXT day's mood: the day after a tagged day
// against the day after an untagged one. Only consecutive logged days count.
const tagRefs = new Map<string, StructuredTagRef>();
for (const day of list) {
for (const [key, ref] of day.tags)
if (!tagRefs.has(key)) tagRefs.set(key, ref);
}
for (const [key, ref] of tagRefs) {
const afterTag: number[] = [];
const afterOther: number[] = [];
for (const day of list) {
const prev = days.get(shiftDateKey(day.date, -1));
if (!prev) continue;
if (prev.tags.has(key)) afterTag.push(day.mean);
else afterOther.push(day.mean);
}
out.push({
id: `delayed_tag:${key}`,
kind: "delayed_tag",
tag: ref,
subject: afterTag,
comparison: afterOther,
});
}
return out;
}
/**
* The gated patterns over the entries whose mood day falls inside the
* trailing `windowDays` ending at `todayKey`.
*/
export function computeMoodPatterns(args: {
entries: readonly MoodAggregateEntry[];
noteDays: ReadonlySet<string>;
todayKey: string;
windowDays: number;
}): MoodPatternFinding[] {
const fromKey = shiftDateKey(args.todayKey, -(args.windowDays - 1));
const inWindow = args.entries.filter(
(e) => e.date >= fromKey && e.date <= args.todayKey,
);
const days = collapseDays(inWindow, args.noteDays);
const tested: Array<{ c: Candidate; p: number }> = [];
for (const c of candidates(days)) {
if (
c.subject.length < PATTERN_MIN_DAYS_PER_SIDE ||
c.comparison.length < PATTERN_MIN_DAYS_PER_SIDE
) {
continue;
}
const welch = welchTTest(c.subject, c.comparison);
// No spread on either side: nothing a test can stand behind.
if (welch.status !== "ok") continue;
tested.push({ c, p: welch.pValue });
}
if (tested.length === 0) return [];
const q = benjaminiHochberg(tested.map((t) => t.p));
const passed: MoodPatternFinding[] = [];
tested.forEach(({ c }, i) => {
if (q[i] > PATTERN_FDR_Q) return;
const subject = mean(c.subject);
const comparison = mean(c.comparison);
const delta = subject - comparison;
passed.push({
id: c.kind === "best_weekday" ? "best_weekday" : c.id,
kind: c.kind,
strength: strengthOf(c.subject, c.comparison),
direction: delta > 0 ? "higher" : delta < 0 ? "lower" : "none",
values: {
subject: round(subject, 2),
comparison: round(comparison, 2),
delta: round(delta, 2),
...(c.weekday !== undefined ? { weekday: c.weekday } : {}),
...(c.tag ? { tagKey: c.tag.key } : {}),
},
sampleSize: {
subject: c.subject.length,
comparison: c.comparison.length,
},
...(c.tag
? { tag: { labelKey: c.tag.labelKey, label: c.tag.label ?? null } }
: {}),
qValue: q[i],
});
});
// One best weekday at most: the highest one that passed and is above the
// rest. A weekday that is only significantly LOWER is not a best weekday.
const weekdays = passed
.filter((p) => p.kind === "best_weekday" && p.direction === "higher")
.sort((a, b) => b.values.subject - a.values.subject);
const kept = passed.filter((p) => p.kind !== "best_weekday");
if (weekdays.length > 0) kept.push(weekdays[0]);
const STRENGTH_RANK = { strong: 0, moderate: 1, weak: 2 } as const;
return kept.sort(
(a, b) =>
STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength] ||
Math.abs(b.values.delta) - Math.abs(a.values.delta) ||
a.id.localeCompare(b.id),
);
}
/** The wire form: the tag named in the reader's language, no internal fields. */
export function moodPatternsWire(
findings: readonly MoodPatternFinding[] | undefined,
t: (key: string) => string,
): MoodPatternWire[] {
return (findings ?? []).map((f) => ({
id: f.id,
kind: f.kind,
strength: f.strength,
direction: f.direction,
values: {
...f.values,
...(f.tag ? { tagLabel: f.tag.label ?? t(f.tag.labelKey) } : {}),
},
sampleSize: f.sampleSize,
}));
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
import {
computeSeriesChange,
seriesDayValues,
seriesReferenceValue,
} from "../series-change";
const TZ = "Europe/Berlin";
const upBad = resolveTrendVerdict("RESTING_HEART_RATE", {
profile: { dateOfBirth: null, gender: null, heightCm: null },
thresholdsJson: null,
referenceValue: null,
});
describe("seriesDayValues", () => {
it("sums a summed type per local day and averages the rest", () => {
const points = [
{ at: "2026-09-01T06:00:00.000Z", value: 1000 },
{ at: "2026-09-01T20:00:00.000Z", value: 2000 },
// 23:30 UTC is already the next day in Berlin.
{ at: "2026-09-01T23:30:00.000Z", value: 500 },
];
expect(seriesDayValues("ACTIVITY_STEPS", points, TZ)).toEqual([
{ day: "2026-09-01", value: 3000 },
{ day: "2026-09-02", value: 500 },
]);
expect(seriesDayValues("WEIGHT", points, TZ)[0]).toEqual({
day: "2026-09-01",
value: 1500,
});
});
});
describe("computeSeriesChange", () => {
it("is null under two days", () => {
expect(
computeSeriesChange([{ day: "2026-09-01", value: 60 }], upBad),
).toBeNull();
});
it("compares the last day with the first and judges the change", () => {
expect(
computeSeriesChange(
[
{ day: "2026-09-01", value: 60 },
{ day: "2026-09-02", value: 70 },
{ day: "2026-09-03", value: 64.123 },
],
upBad,
),
).toEqual({ from: 60, to: 64.12, delta: 4.12, sentiment: "negative" });
});
});
describe("seriesReferenceValue", () => {
it("averages the last seven days", () => {
const days = Array.from({ length: 10 }, (_, i) => ({
day: `2026-09-${String(i + 1).padStart(2, "0")}`,
value: i + 1,
}));
expect(seriesReferenceValue(days)).toBe(7);
expect(seriesReferenceValue([])).toBeNull();
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales as LOCALES } from "@/lib/i18n/config";
import { resolveSeriesReferenceLines } from "../series-reference-lines";
const en = getServerTranslator("en");
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
const MAN_41 = {
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
};
function lines(
kind: Parameters<typeof resolveSeriesReferenceLines>[0],
opts: {
profile?: typeof NO_PROFILE | typeof MAN_41;
thresholdsJson?: unknown;
glucoseUnit?: "mg/dL" | "mmol/L";
} = {},
) {
return resolveSeriesReferenceLines(kind, {
profile: opts.profile ?? NO_PROFILE,
thresholdsJson: opts.thresholdsJson ?? null,
glucoseUnit: opts.glucoseUnit ?? "mg/dL",
t: (key) => en.t(key),
});
}
describe("resolveSeriesReferenceLines", () => {
it("draws glucose 70 / 180 mg/dL, converted for an mmol/L account", () => {
expect(lines("glucose").map((l) => [l.kind, l.value])).toEqual([
["lower", 70],
["upper", 180],
]);
expect(
lines("glucose", { glucoseUnit: "mmol/L" }).map((l) => [l.kind, l.value]),
).toEqual([
["lower", 3.9],
["upper", 10],
]);
});
it("draws the ESH 140 / 90 lines without a profile or a target", () => {
expect(
lines("bloodPressure").map((l) => [
l.appliesTo,
l.kind,
l.value,
l.source,
]),
).toEqual([
["value", "upper", 140, "clinical"],
["secondary", "upper", 90, "clinical"],
]);
});
it("replaces the clinical edge with the person's band", () => {
const own = lines("bloodPressure", {
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
});
expect(own.map((l) => [l.appliesTo, l.kind, l.value, l.source])).toEqual([
["value", "lower", 115, "personal"],
["value", "upper", 125, "personal"],
["secondary", "upper", 90, "clinical"],
]);
// An age turns the diastolic side into the profile band too.
const aged = lines("bloodPressure", { profile: MAN_41 });
expect(aged.every((l) => l.source === "personal")).toBe(true);
expect(aged.map((l) => l.value)).toEqual([120, 129, 70, 79]);
});
it("draws the SpO2 floor, or the person's band instead", () => {
expect(lines("oxygenSaturation")).toMatchObject([
{ value: 92, kind: "lower", source: "clinical" },
]);
expect(
lines("oxygenSaturation", {
thresholdsJson: { OXYGEN_SATURATION: { min: 90, max: 96 } },
}).map((l) => l.value),
).toEqual([90, 96]);
});
it("draws the resting band on the resting chart only", () => {
expect(lines("restingHeartRate", { profile: MAN_41 })).toHaveLength(2);
expect(lines("pulse", { profile: MAN_41 })).toEqual([]);
});
it("is empty when there is nothing to draw", () => {
expect(lines("heartRateVariability")).toEqual([]);
expect(lines("steps")).toEqual([]);
expect(lines("weight", { profile: MAN_41 })).toEqual([]);
});
it("names every label key in every bundle, and labels in the account language", () => {
const keys = new Set([
...lines("bloodPressure").map((l) => l.labelKey),
...lines("bloodPressure", { profile: MAN_41 }).map((l) => l.labelKey),
...lines("glucose").map((l) => l.labelKey),
]);
expect(keys.size).toBe(4);
for (const locale of LOCALES) {
const t = getServerTranslator(locale);
for (const key of keys) expect(t.t(key)).not.toBe(key);
}
const de = getServerTranslator("de");
const german = resolveSeriesReferenceLines("glucose", {
profile: NO_PROFILE,
thresholdsJson: null,
glucoseUnit: "mg/dL",
t: (key) => de.t(key),
});
expect(german[0].label).toBe("Klinische Untergrenze");
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* The first-to-last change of a chart window, by day (v1.43, ios #116
* item 3).
*
* A chart's change is read off day values, not off single points: the first
* reading of a window can be an outlier minute of a dense series, and a day
* of steps is its total, not its last sample. Each local day of the window is
* reduced with the type's own day statistic (the total for a summed type, the
* mean of the hours' means for pulse, the mean otherwise), and the change is
* the last day against the first. Under two days there is no change to show.
*
* Pure: the series route hands over the points it already serves, in the
* unit it serves them in.
*/
import { dayValue } from "@/lib/measurements/day-mean";
import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum";
import {
trendSentimentOf,
type TrendSentiment,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import { userDayKey } from "@/lib/tz/format";
export interface SeriesChange {
from: number;
to: number;
delta: number;
sentiment: TrendSentiment;
}
export interface SeriesDayValue {
day: string;
value: number;
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** One value per local day, oldest first. */
export function seriesDayValues(
type: string,
points: ReadonlyArray<{ at: string; value: number }>,
timezone: string,
): SeriesDayValue[] {
const byDay = new Map<string, Array<{ value: number; measuredAt: Date }>>();
for (const point of points) {
const measuredAt = new Date(point.at);
if (!Number.isFinite(point.value) || Number.isNaN(measuredAt.getTime())) {
continue;
}
const day = userDayKey(measuredAt, timezone);
const list = byDay.get(day);
if (list) list.push({ value: point.value, measuredAt });
else byDay.set(day, [{ value: point.value, measuredAt }]);
}
const summed = isCumulativeDaySumType(type);
const out: SeriesDayValue[] = [];
for (const [day, rows] of byDay) {
const value = summed
? rows.reduce((sum, r) => sum + r.value, 0)
: dayValue(type, rows, timezone);
if (value !== null) out.push({ day, value });
}
return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
export function computeSeriesChange(
days: readonly SeriesDayValue[],
verdict: TrendVerdict | null,
): SeriesChange | null {
if (days.length < 2) return null;
const from = round2(days[0].value);
const to = round2(days[days.length - 1].value);
const delta = round2(to - from);
return { from, to, delta, sentiment: trendSentimentOf(delta, verdict) };
}
/**
* The value a window's verdict places against the band: the mean of its last
* seven day values (the dashboard places the seven-day average the same way),
* so one day near a band edge does not flip the colour.
*/
export function seriesReferenceValue(
days: readonly SeriesDayValue[],
): number | null {
if (days.length === 0) return null;
const recent = days.slice(-7);
return recent.reduce((sum, d) => sum + d.value, 0) / recent.length;
}
@@ -0,0 +1,178 @@
/**
* Horizontal reference lines for a measurement chart (v1.43, ios #116 item 4).
*
* The native client drew fixed threshold lines (blood pressure 140/60, pulse
* 100/60, glucose 70/180 mg/dL, fever 37.5, SpO2 95) that knew neither the
* series' unit nor the person's targets, and removed them. The server knows
* both, so it resolves the lines here and `GET /api/measurements/series`
* serves them in the unit of its own points.
*
* Two sources, one rule: a personal line (the person's own target from
* `/targets`, else the profile default the target cards use: the age-based
* blood pressure band, the age- and sex-based resting pulse band, the
* sex-based body fat band) replaces the clinical line for the same edge.
* The clinical lines are the ones a guideline names:
*
* - blood pressure 140 / 90 mmHg, the ESH 2023 hypertension threshold;
* - glucose 70 and 180 mg/dL, the CGM time-in-range band (ATTD consensus,
* Battelino et al. 2019), converted for an mmol/L account;
* - SpO2 lower edge 92 %, the red-flag floor in `clinical-floors.ts`.
*
* Without either, the list is empty: nothing to draw is an answer, not an
* error.
*/
import type {
ThresholdMetric,
UserProfileForRange,
} from "@/lib/analytics/effective-range";
import { TIME_IN_RANGE_MGDL } from "@/lib/analytics/glucose-metrics";
import { SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import { REFERENCE_RANGES } from "@/lib/reference-ranges";
import { resolvePersonalBand } from "@/lib/targets/trend-verdict";
export type SeriesKind =
| "weight"
| "bloodPressure"
| "pulse"
| "bodyFat"
| "glucose"
| "sleep"
| "steps"
| "totalBodyWater"
| "boneMass"
| "oxygenSaturation"
| "restingHeartRate"
| "heartRateVariability"
| "vo2Max";
export interface SeriesReferenceLine {
value: number;
kind: "lower" | "upper";
source: "personal" | "clinical";
/** `secondary` is the diastolic line of a blood pressure chart. */
appliesTo: "value" | "secondary";
labelKey: string;
label: string;
}
export interface SeriesReferenceContext {
/** The profile of the record on screen. */
profile: UserProfileForRange;
thresholdsJson: unknown;
glucoseUnit: GlucoseUnit;
/** Server translator in the account's language. */
t: (key: string) => string;
}
/** The ESH 2023 hypertension threshold, systolic from the shipped bands. */
const ESH_HYPERTENSION_SYS =
REFERENCE_RANGES.BLOOD_PRESSURE.bands.find((b) => b.label === "Hypertension")
?.low ?? 140;
/** Its diastolic counterpart (ESH 2023 grade 1 starts at 140 / 90). */
const ESH_HYPERTENSION_DIA = 90;
/** Which `/targets` threshold a kind's personal lines come from. */
const PERSONAL_METRIC: Partial<Record<SeriesKind, ThresholdMetric>> = {
weight: "WEIGHT",
bodyFat: "BODY_FAT",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
// A resting band on the resting chart only: raw heart rate includes every
// workout, where the resting band says nothing.
restingHeartRate: "PULSE",
};
function personalBand(
metric: ThresholdMetric,
ctx: SeriesReferenceContext,
): { min: number; max: number } | null {
return (
resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson)?.band ?? null
);
}
function line(
ctx: SeriesReferenceContext,
value: number,
kind: "lower" | "upper",
source: "personal" | "clinical",
appliesTo: "value" | "secondary",
): SeriesReferenceLine {
const labelKey =
source === "personal"
? kind === "upper"
? "series.reference.targetUpper"
: "series.reference.targetLower"
: kind === "upper"
? "series.reference.clinicalUpper"
: "series.reference.clinicalLower";
return { value, kind, source, appliesTo, labelKey, label: ctx.t(labelKey) };
}
/** Both edges of a personal band. */
function bandLines(
ctx: SeriesReferenceContext,
band: { min: number; max: number },
appliesTo: "value" | "secondary",
): SeriesReferenceLine[] {
return [
line(ctx, band.min, "lower", "personal", appliesTo),
line(ctx, band.max, "upper", "personal", appliesTo),
];
}
export function resolveSeriesReferenceLines(
kind: SeriesKind,
ctx: SeriesReferenceContext,
): SeriesReferenceLine[] {
switch (kind) {
case "bloodPressure": {
const sys = personalBand("BLOOD_PRESSURE_SYS", ctx);
const dia = personalBand("BLOOD_PRESSURE_DIA", ctx);
return [
...(sys
? bandLines(ctx, sys, "value")
: [line(ctx, ESH_HYPERTENSION_SYS, "upper", "clinical", "value")]),
...(dia
? bandLines(ctx, dia, "secondary")
: [
line(ctx, ESH_HYPERTENSION_DIA, "upper", "clinical", "secondary"),
]),
];
}
case "glucose":
// The series mixes every meal context, so no single context's target
// is the person's line here; the time-in-range band is.
return [
line(
ctx,
convertGlucose(TIME_IN_RANGE_MGDL.low, ctx.glucoseUnit),
"lower",
"clinical",
"value",
),
line(
ctx,
convertGlucose(TIME_IN_RANGE_MGDL.high, ctx.glucoseUnit),
"upper",
"clinical",
"value",
),
];
case "oxygenSaturation": {
const own = personalBand("OXYGEN_SATURATION", ctx);
if (own) return bandLines(ctx, own, "value");
return [line(ctx, SPO2_RED_FLAG_PCT, "lower", "clinical", "value")];
}
default: {
const metric = PERSONAL_METRIC[kind];
if (!metric) return [];
const band = personalBand(metric, ctx);
return band ? bandLines(ctx, band, "value") : [];
}
}
}
+1 -1
View File
@@ -412,7 +412,7 @@ export const derivedMetricResponse = z
.record(z.string(), z.unknown())
.nullable()
.describe(
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
),
coverage: derivedCoverage,
confidence: derivedConfidence
@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import {
resolvePersonalBand,
resolveSummaryVerdicts,
resolveTrendVerdict,
trendSentimentOf,
trendSentimentOfTrend,
} from "../trend-verdict";
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
const MAN_41 = {
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
};
const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
function verdict(
key: string,
referenceValue: number | null,
thresholdsJson: unknown = null,
profile: typeof NO_PROFILE | typeof MAN_41 = NO_PROFILE,
) {
return resolveTrendVerdict(key, { profile, thresholdsJson, referenceValue });
}
describe("resolveTrendVerdict against the person's own target", () => {
it("calls a rising blood pressure above the target a setback", () => {
const v = verdict("BLOOD_PRESSURE_SYS", 132, OWN_BP);
expect(v).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "personal_target",
});
expect(trendSentimentOf(4, v)).toBe("negative");
});
it("calls the same rise below the target progress", () => {
const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP);
expect(v).toEqual({
direction: "up-good",
targetPosition: "below",
basis: "personal_target",
});
expect(trendSentimentOf(4, v)).toBe("positive");
});
it("holds inside the target, with the edges counted as inside", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP)).toMatchObject({
direction: "hold",
targetPosition: "inside",
});
const v = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
expect(trendSentimentOf(0, v)).toBe("positive");
expect(trendSentimentOf(3, v)).toBe("neutral");
});
it("judges the BLOOD_PRESSURE target tile by the systolic band", () => {
expect(verdict("BLOOD_PRESSURE", 132, OWN_BP).direction).toBe("up-bad");
});
it("reads a sleep target in hours against a reference in minutes", () => {
const own = { SLEEP_DURATION: { min: 7, max: 8 } };
expect(verdict("SLEEP_DURATION", 6 * 60, own)).toMatchObject({
direction: "up-good",
targetPosition: "below",
});
expect(verdict("SLEEP_DURATION", 7.5 * 60, own).direction).toBe("hold");
});
it("ignores a malformed target and falls back", () => {
expect(
verdict("BLOOD_PRESSURE_SYS", 132, {
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
}),
).toEqual({
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
});
});
});
describe("resolveTrendVerdict against a profile default", () => {
it("uses the age-based blood pressure band", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, null, MAN_41)).toEqual({
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
});
});
it("uses the age- and sex-based resting pulse band for both pulse keys", () => {
for (const key of ["PULSE", "RESTING_HEART_RATE"]) {
expect(verdict(key, 95, null, MAN_41)).toMatchObject({
direction: "up-bad",
targetPosition: "above",
basis: "profile_default",
});
}
});
it("does not call a band nobody is personalised for a profile default", () => {
// No age: the pulse band is the same for everyone.
expect(verdict("RESTING_HEART_RATE", 95)).toEqual({
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
});
// No sex: no body fat band.
expect(verdict("BODY_FAT", 30).basis).toBe("metric_default");
});
it("prefers the person's own target over the profile default", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe(
"personal_target",
);
});
});
describe("resolveTrendVerdict metric defaults", () => {
it.each([
["ACTIVITY_STEPS", "up-good"],
["HEART_RATE_VARIABILITY", "up-good"],
["HRV_RMSSD", "up-good"],
["VO2_MAX", "up-good"],
["MOOD", "up-good"],
["BODY_FAT", "up-bad"],
["RESTING_HEART_RATE", "up-bad"],
["RESPIRATORY_RATE", "neutral"],
["TOTAL_BODY_WATER", "neutral"],
["BLOOD_GLUCOSE_FASTING", "neutral"],
])("%s defaults to %s", (key, direction) => {
expect(verdict(key, null)).toEqual({
direction,
targetPosition: null,
basis: "metric_default",
});
});
it("keeps weight's own rule: only a stored target counts", () => {
expect(verdict("WEIGHT", 70, null, MAN_41)).toEqual({
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
});
expect(verdict("WEIGHT", 60, { WEIGHT: { min: 65, max: 72 } })).toEqual({
direction: "up-good",
targetPosition: "below",
basis: "personal_target",
});
// BMI follows the weight it is derived from.
expect(verdict("BMI", 60, { WEIGHT: { min: 65, max: 72 } }).direction).toBe(
"up-good",
);
});
});
describe("trend sentiment", () => {
it("applies the noise floor and treats no verdict as neutral", () => {
const upBad = verdict("RESTING_HEART_RATE", null);
expect(trendSentimentOf(0.01, upBad)).toBe("neutral");
expect(trendSentimentOf(-2, upBad)).toBe("positive");
expect(trendSentimentOf(2, null)).toBe("neutral");
});
it("reads a trend word the way it reads a signed change", () => {
const upGood = verdict("ACTIVITY_STEPS", null);
expect(trendSentimentOfTrend("up", upGood)).toBe("positive");
expect(trendSentimentOfTrend("down", upGood)).toBe("negative");
expect(trendSentimentOfTrend("flat", upGood)).toBe("neutral");
expect(trendSentimentOfTrend("unknown", upGood)).toBe("neutral");
expect(trendSentimentOfTrend(null, upGood)).toBe("neutral");
const hold = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
expect(trendSentimentOfTrend("stable", hold)).toBe("positive");
});
});
describe("resolvePersonalBand", () => {
it("is null when neither an own target nor a personalised default exists", () => {
expect(resolvePersonalBand("ACTIVITY_STEPS", NO_PROFILE, null)).toBeNull();
expect(resolvePersonalBand("BLOOD_PRESSURE_DIA", MAN_41, null)).toEqual({
band: { min: 70, max: 79 },
basis: "profile_default",
});
});
});
describe("resolveSummaryVerdicts", () => {
it("places each tile's seven-day average and adds the summaryless keys", () => {
const out = resolveSummaryVerdicts(
{
BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 },
ACTIVITY_STEPS: { avg7: null, latest: 9000 },
},
{ profile: NO_PROFILE, thresholdsJson: OWN_BP },
);
expect(out.BLOOD_PRESSURE_SYS.direction).toBe("up-bad");
expect(out.ACTIVITY_STEPS.direction).toBe("up-good");
expect(out.MOOD.direction).toBe("up-good");
expect(out.BLOOD_PRESSURE_IN_TARGET.direction).toBe("up-good");
});
});
+49
View File
@@ -39,6 +39,11 @@ import { buildSleepTarget } from "./sleep-builder";
import { buildTargetPageSummary } from "./summary-builder";
import { buildVitalTargets } from "./vitals-builder";
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendVerdictContext,
} from "./trend-verdict";
import type { User } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
@@ -362,6 +367,18 @@ export async function buildTargetsResponse(user: AuthedUser) {
}),
);
// v1.43 (ios #116 item 3) — every item carries which way its change
// counts, judged with the same rule the dashboard tiles use.
const verdictTargets = withTargetVerdicts(targets, {
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
},
thresholdsJson: dbUser?.thresholdsJson ?? null,
});
targets.splice(0, targets.length, ...verdictTargets);
const pageSummary = buildTargetPageSummary(targets);
annotate({
action: { name: "insights.targets" },
@@ -393,3 +410,35 @@ export async function buildTargetsResponse(user: AuthedUser) {
},
};
}
/**
* Lay the verdict over each target item. The reference is the item's latest
* value, else its thirty-day average, in the unit the item is served in:
* sleep is served in hours and judged in minutes, and BMI is judged by the
* weight it is derived from.
*/
export function withTargetVerdicts(
targets: readonly TargetItem[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
): TargetItem[] {
const weight = targets.find((item) => item.type === "WEIGHT");
const weightReference = weight ? (weight.current ?? weight.average30) : null;
return targets.map((item) => {
const own = item.current ?? item.average30;
const reference =
item.type === "BMI"
? weightReference
: own !== null && item.type === "SLEEP_DURATION"
? own * 60
: own;
const direction = resolveTrendVerdict(item.type, {
...ctx,
referenceValue: reference,
});
return {
...item,
direction,
trendSentiment: trendSentimentOfTrend(item.trend, direction),
};
});
}
+312
View File
@@ -0,0 +1,312 @@
/**
* Which way a change of a metric counts as progress, for this person (v1.43,
* ios #116 item 3). The generalisation of `resolveWeightTrend`.
*
* Every surface that shows a change (a dashboard arrow, a seven-day delta, a
* target tile, a chart's first-to-last change) used to carry its own fixed
* polarity: a rising blood pressure turned orange whatever the person's
* target was, and the native client kept a third copy of the table. The
* verdict is resolved here, once, and published on every route that serves a
* change; clients colour from it and never decide it themselves.
*
* The rule:
*
* - a band applies (the person's own target from `/targets`, else a
* profile-derived default: the age-based blood pressure target, the
* age- and sex-based resting pulse band, the sex-based body fat band):
* below the band → a rise is progress (`up-good`)
* above the band → a fall is progress (`up-bad`)
* inside the band → holding is progress (`hold`)
* - no band, or no value to place: the metric's own default direction.
*
* Weight keeps its own rule (`weight-trend.ts`): only a target the person set
* counts, never the height-derived band, because a band nobody chose says
* nothing about which way they want to move.
*
* Pure and client-safe: no DB read, no clock beyond the age the profile
* helpers compute.
*/
import {
getEffectiveRange,
resolveThresholdOverride,
resolveWeightTargetOverride,
type ThresholdMetric,
type UserProfileForRange,
} from "@/lib/analytics/effective-range";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
getTrendSentiment,
type TrendDirectionSentiment,
type TrendSentimentDirection,
} from "@/lib/insights/trend-sentiment";
import { binaryReferenceSex } from "@/lib/profile/sex";
import { resolveWeightTrend } from "./weight-trend";
export type TrendTargetPosition = "below" | "inside" | "above";
export type TrendVerdictBasis =
"personal_target" | "profile_default" | "metric_default";
export interface TrendVerdict {
direction: TrendDirectionSentiment;
targetPosition: TrendTargetPosition | null;
basis: TrendVerdictBasis;
}
export type TrendSentiment = TrendSentimentDirection;
/**
* What a verdict can be asked for: a measurement type, or one of the derived
* keys a target tile or dashboard card carries (`BLOOD_PRESSURE` is the
* systolic side of the pair, `BMI` follows weight, `MOOD` / `MOOD_SCORE` the
* mood mean, `BLOOD_PRESSURE_IN_TARGET` and `MEDICATION_COMPLIANCE` a share).
*/
export type TrendVerdictKey = string;
/**
* The metric's own direction when no band applies. A rise is progress for the
* capacity and activity measures, a setback for the load measures, and
* carries no judgement for everything else (body water, bone mass, breathing
* rate, skin temperature, intake, …).
*/
const METRIC_DEFAULT_DIRECTION: Readonly<
Record<string, Exclude<TrendDirectionSentiment, "hold">>
> = {
ACTIVITY_STEPS: "up-good",
HEART_RATE_VARIABILITY: "up-good",
HRV_RMSSD: "up-good",
VO2_MAX: "up-good",
OXYGEN_SATURATION: "up-good",
MUSCLE_MASS: "up-good",
SLEEP_DURATION: "up-good",
SIX_MINUTE_WALK_DISTANCE: "up-good",
GRIP_STRENGTH: "up-good",
MOOD: "up-good",
MOOD_SCORE: "up-good",
BLOOD_PRESSURE_IN_TARGET: "up-good",
MEDICATION_COMPLIANCE: "up-good",
RESTING_HEART_RATE: "up-bad",
PULSE: "up-bad",
BODY_FAT: "up-bad",
BLOOD_PRESSURE: "up-bad",
BLOOD_PRESSURE_SYS: "up-bad",
BLOOD_PRESSURE_DIA: "up-bad",
BODY_TEMPERATURE: "up-bad",
BLOOD_GLUCOSE: "up-bad",
};
/** The `/targets` threshold a key is judged against, when it has one. */
const THRESHOLD_FOR_KEY: Readonly<Record<string, ThresholdMetric>> = {
BLOOD_PRESSURE: "BLOOD_PRESSURE_SYS",
BLOOD_PRESSURE_SYS: "BLOOD_PRESSURE_SYS",
BLOOD_PRESSURE_DIA: "BLOOD_PRESSURE_DIA",
// The resting pulse card and the dashboard pulse tile share one target.
PULSE: "PULSE",
RESTING_HEART_RATE: "PULSE",
BODY_FAT: "BODY_FAT",
SLEEP_DURATION: "SLEEP_DURATION",
ACTIVITY_STEPS: "ACTIVITY_STEPS",
TOTAL_BODY_WATER: "TOTAL_BODY_WATER",
BONE_MASS: "BONE_MASS",
OXYGEN_SATURATION: "OXYGEN_SATURATION",
};
/**
* Stored values the threshold is not written in. Sleep is stored in minutes
* and targeted in hours; every other threshold shares the stored unit.
*/
const TO_THRESHOLD_UNIT: Readonly<Record<string, (value: number) => number>> = {
SLEEP_DURATION: (minutes) => minutes / 60,
};
export interface TrendVerdictContext {
/** The profile of the RECORD on screen, not of the viewer. */
profile: UserProfileForRange;
/** `User.thresholdsJson` of the same record, unparsed. */
thresholdsJson: unknown;
/**
* The value placed against the band, in the stored (canonical) unit of the
* key: usually the seven-day average, else the latest reading. Null when
* there is nothing to place, which leaves the metric's default.
*/
referenceValue: number | null;
}
/**
* Whether the profile carries what the default band of this metric is
* personalised from. Without it the band is the same for everyone, which is a
* metric default and not a statement about this person.
*/
function hasProfileDefault(
metric: ThresholdMetric,
profile: UserProfileForRange,
): boolean {
switch (metric) {
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return profile.dateOfBirth != null;
case "PULSE": {
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
return age != null && age >= 20;
}
case "BODY_FAT":
return binaryReferenceSex(profile.gender) !== null;
default:
return false;
}
}
/**
* The band a person's values are judged against: their own target, else the
* profile default when the profile carries what it is personalised from.
* Null when neither applies. Shared with the chart reference lines, so the
* line drawn and the verdict coloured come from the same band.
*/
export function resolvePersonalBand(
metric: ThresholdMetric,
profile: UserProfileForRange,
thresholdsJson: unknown,
): {
band: { min: number; max: number };
basis: Exclude<TrendVerdictBasis, "metric_default">;
} | null {
const own = resolveThresholdOverride(thresholdsJson, metric);
if (own) return { band: own, basis: "personal_target" };
if (!hasProfileDefault(metric, profile)) return null;
const range = getEffectiveRange(metric, profile, null).range;
return range
? {
band: { min: range.greenMin, max: range.greenMax },
basis: "profile_default",
}
: null;
}
function metricDefault(key: string): TrendVerdict {
return {
direction: METRIC_DEFAULT_DIRECTION[key] ?? "neutral",
targetPosition: null,
basis: "metric_default",
};
}
/** Place a value against a band; the edges count as inside. */
export function placeAgainstBand(
band: { min: number; max: number },
value: number,
): TrendTargetPosition {
if (value < band.min) return "below";
if (value > band.max) return "above";
return "inside";
}
const DIRECTION_BY_POSITION: Record<
TrendTargetPosition,
TrendDirectionSentiment
> = {
below: "up-good",
above: "up-bad",
inside: "hold",
};
export function resolveTrendVerdict(
key: TrendVerdictKey,
ctx: TrendVerdictContext,
): TrendVerdict {
const reference =
ctx.referenceValue !== null && Number.isFinite(ctx.referenceValue)
? ctx.referenceValue
: null;
// Weight, and BMI which moves exactly when weight does: only the person's
// own target counts (see the module comment). For `BMI` the reference is
// the WEIGHT it is derived from, in kg, since the target is a weight.
if (key === "WEIGHT" || key === "BMI") {
const target = resolveWeightTargetOverride(ctx.thresholdsJson);
const judgement = resolveWeightTrend(target, reference);
return {
direction: judgement.direction,
targetPosition: judgement.targetPosition,
basis:
judgement.targetPosition !== null
? "personal_target"
: "metric_default",
};
}
const metric = THRESHOLD_FOR_KEY[key];
if (!metric || reference === null) return metricDefault(key);
const personal = resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson);
if (!personal) return metricDefault(key);
const toUnit = TO_THRESHOLD_UNIT[key];
const position = placeAgainstBand(
personal.band,
toUnit ? toUnit(reference) : reference,
);
return {
direction: DIRECTION_BY_POSITION[position],
targetPosition: position,
basis: personal.basis,
};
}
/**
* A delivered change judged by a verdict, with the noise floor the arrows
* have always used (`getTrendSentiment`). No verdict reads neutral.
*/
export function trendSentimentOf(
change: number | null | undefined,
verdict: TrendVerdict | null,
): TrendSentiment {
return getTrendSentiment(change, verdict?.direction ?? "neutral");
}
/**
* The same judgement for a change that is only known by its direction (the
* `trend` of a dashboard card or a target tile). A flat or unknown trend is
* no change: inside a band that is the one case where it is news.
*/
export function trendSentimentOfTrend(
trend: "up" | "down" | "flat" | "stable" | "unknown" | null | undefined,
verdict: TrendVerdict | null,
): TrendSentiment {
if (trend === "up") return trendSentimentOf(1, verdict);
if (trend === "down") return trendSentimentOf(-1, verdict);
if (trend === "flat" || trend === "stable")
return trendSentimentOf(0, verdict);
return "neutral";
}
/** Keys that carry a verdict without a measurement summary of their own. */
export const SUMMARYLESS_VERDICT_KEYS = [
"MOOD",
"BLOOD_PRESSURE_IN_TARGET",
] as const;
/**
* One verdict per summarised type, each placed with the value its tile shows
* as the reference (the seven-day average, else the latest reading), plus the
* summaryless keys at their metric default. The dashboard snapshot publishes
* this as `tiles.directions`.
*/
export function resolveSummaryVerdicts(
summaries: Readonly<
Record<string, { avg7: number | null; latest: number | null } | undefined>
>,
ctx: Omit<TrendVerdictContext, "referenceValue">,
): Record<string, TrendVerdict> {
const out: Record<string, TrendVerdict> = {};
for (const [type, summary] of Object.entries(summaries)) {
if (!summary) continue;
out[type] = resolveTrendVerdict(type, {
...ctx,
referenceValue: summary.avg7 ?? summary.latest ?? null,
});
}
for (const key of SUMMARYLESS_VERDICT_KEYS) {
out[key] ??= resolveTrendVerdict(key, { ...ctx, referenceValue: null });
}
return out;
}
+8
View File
@@ -10,6 +10,7 @@ import type {
} from "@/lib/analytics/compliance";
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import type { TrendSentiment, TrendVerdict } from "./trend-verdict";
export type TargetTrend = "up" | "down" | "stable" | null;
export type DayBand = "in" | "near" | "out";
@@ -35,6 +36,13 @@ export interface TargetItem extends TargetConsistency {
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.43 (ios #116 item 3) — which way a change of this target counts for
* the person (`src/lib/targets/trend-verdict.ts`), and that verdict
* applied to `trend`. Set on every item by `buildTargetsResponse`.
*/
direction?: TrendVerdict | null;
trendSentiment?: TrendSentiment | null;
details?: {
medications?: Array<{
name: string;