From 79f7eb199b44550a218223e58107343ab8b67562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:39:11 +0200 Subject: [PATCH] feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server --- messages/de.json | 23 ++ messages/en.json | 23 ++ messages/es.json | 23 ++ messages/fr.json | 23 ++ messages/it.json | 23 ++ messages/ko.json | 23 ++ messages/pl.json | 23 ++ .../dashboard/summary/__tests__/route.test.ts | 12 + src/app/api/dashboard/summary/route.ts | 90 ++++- .../insights/targets/__tests__/route.test.ts | 72 ++++ .../series/__tests__/route.test.ts | 111 ++++++ src/app/api/measurements/series/route.ts | 69 +++- .../api/mood/insights/__tests__/route.test.ts | 33 ++ src/app/api/mood/insights/route.ts | 12 +- src/app/insights/mood/page.tsx | 11 +- src/app/page-client.tsx | 45 ++- .../insights/derived/vitals-dashboard.tsx | 26 +- .../mood/mood-insights-breakdowns.tsx | 11 + .../insights/mood/mood-insights-shared.tsx | 6 + .../insights/mood/mood-patterns.tsx | 111 ++++++ src/lib/analytics/effective-range.ts | 7 +- src/lib/dashboard/snapshot.ts | 21 ++ .../__tests__/mood-pattern-cards.test.ts | 232 ++++++++++++ src/lib/insights/derived/baseline.ts | 46 ++- src/lib/insights/mood-aggregates.ts | 51 ++- src/lib/insights/mood-pattern-cards.ts | 352 ++++++++++++++++++ .../__tests__/series-change.test.ts | 67 ++++ .../__tests__/series-reference-lines.test.ts | 117 ++++++ src/lib/measurements/series-change.ts | 88 +++++ .../measurements/series-reference-lines.ts | 178 +++++++++ src/lib/openapi/routes/insights/schemas.ts | 2 +- .../targets/__tests__/trend-verdict.test.ts | 204 ++++++++++ src/lib/targets/build-response.ts | 49 +++ src/lib/targets/trend-verdict.ts | 312 ++++++++++++++++ src/lib/targets/types.ts | 8 + 35 files changed, 2454 insertions(+), 50 deletions(-) create mode 100644 src/components/insights/mood/mood-patterns.tsx create mode 100644 src/lib/insights/__tests__/mood-pattern-cards.test.ts create mode 100644 src/lib/insights/mood-pattern-cards.ts create mode 100644 src/lib/measurements/__tests__/series-change.test.ts create mode 100644 src/lib/measurements/__tests__/series-reference-lines.test.ts create mode 100644 src/lib/measurements/series-change.ts create mode 100644 src/lib/measurements/series-reference-lines.ts create mode 100644 src/lib/targets/__tests__/trend-verdict.test.ts create mode 100644 src/lib/targets/trend-verdict.ts diff --git a/messages/de.json b/messages/de.json index 7136e471a..f30261f62 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" + } } } diff --git a/messages/en.json b/messages/en.json index 978148004..146ad753d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" + } } } diff --git a/messages/es.json b/messages/es.json index 29ea0677d..8f07b72b5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" + } } } diff --git a/messages/fr.json b/messages/fr.json index 7beb6c029..1eca293b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" + } } } diff --git a/messages/it.json b/messages/it.json index cd9ba3c3b..9fc11b0b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" + } } } diff --git a/messages/ko.json b/messages/ko.json index 223c4ff1a..09171a7a7 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "임상 기준 하한" + } } } diff --git a/messages/pl.json b/messages/pl.json index ae3cf4116..cb5a9a4b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" + } } } diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index e61286e5b..df46851ab 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 26d76552f..5a0a78d98 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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 = { + 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, +): 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, diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 3fe45c806..ed3041d2a 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -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, diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 2d82924ff..1d8be88a7 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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>; + }; + }; + 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); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index a60dd7992..72901ddb9 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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 diff --git a/src/app/api/mood/insights/__tests__/route.test.ts b/src/app/api/mood/insights/__tests__/route.test.ts index 55836ce48..a00ef9dbf 100644 --- a/src/app/api/mood/insights/__tests__/route.test.ts +++ b/src/app/api/mood/insights/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index d856af2fd..c44d885c1 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -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)), + ); }); diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index b4b544dde..3438c7d53 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -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) { diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 023839f2d..1e32be413 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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} /> diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 3a1926eac..1201bb12f 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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 diff --git a/src/components/insights/mood/mood-insights-breakdowns.tsx b/src/components/insights/mood/mood-insights-breakdowns.tsx index 4c304be52..29bf0eb16 100644 --- a/src/components/insights/mood/mood-insights-breakdowns.tsx +++ b/src/components/insights/mood/mood-insights-breakdowns.tsx @@ -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. */} + {/* v1.43 — the patterns the server found and gated; the card stays + away when none passed. */} + {patterns.length > 0 && ( + + + + )} + {/* 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. */} diff --git a/src/components/insights/mood/mood-insights-shared.tsx b/src/components/insights/mood/mood-insights-shared.tsx index fe4842984..40e42f763 100644 --- a/src/components/insights/mood/mood-insights-shared.tsx +++ b/src/components/insights/mood/mood-insights-shared.tsx @@ -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; diff --git a/src/components/insights/mood/mood-patterns.tsx b/src/components/insights/mood/mood-patterns.tsx new file mode 100644 index 000000000..69700dd90 --- /dev/null +++ b/src/components/insights/mood/mood-patterns.tsx @@ -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 = + { + 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 ( +
    + {rows.map((row) => ( +
  • +

    {row.sentence}

    +

    {row.basedOn}

    +
  • + ))} +
+ ); +} diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 16fdd2587..38f02113b 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -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 { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 53579cd84..4d404906e 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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; }; /** 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, diff --git a/src/lib/insights/__tests__/mood-pattern-cards.test.ts b/src/lib/insights/__tests__/mood-pattern-cards.test.ts new file mode 100644 index 000000000..e6fecc746 --- /dev/null +++ b/src/lib/insights/__tests__/mood-pattern-cards.test.ts @@ -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 { + 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()) { + 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(); + 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([]); + }); +}); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 4aa3ea559..f17913712 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -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 { 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> { 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({ - value: { type, ...band, series }, + value: { type, ...band, series, ...(direction ? { direction } : {}) }, coverage: cov, confidence, provenance: { inputs: [String(type)], source, windowDays, computedAt }, diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index ad4549276..d8f4e1fa7 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -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; + /** + * 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; 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; }): MoodAggregates { const { entries, measurements, now } = args; const userPriorityJson = args.userPriorityJson ?? null; @@ -633,6 +650,18 @@ export function computeMoodAggregates(args: { ), ]), ) as Record; + const todayKey = dayOffsetToDayKey(now, 0, tz); + const patternsByWindow = Object.fromEntries( + MOOD_STABILITY_WINDOWS.map((days) => [ + days, + computeMoodPatterns({ + entries, + noteDays: args.noteDays ?? new Set(), + todayKey, + windowDays: days, + }), + ]), + ) as Record; 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), }; } diff --git a/src/lib/insights/mood-pattern-cards.ts b/src/lib/insights/mood-pattern-cards.ts new file mode 100644 index 000000000..7355d9afd --- /dev/null +++ b/src/lib/insights/mood-pattern-cards.ts @@ -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; +} + +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, +): Map { + const days = new Map< + string, + { sum: number; count: number; tags: Map } + >(); + for (const entry of entries) { + const day = days.get(entry.date) ?? { + sum: 0, + count: 0, + tags: new Map(), + }; + 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(); + 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): 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(); + 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; + 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, + })); +} diff --git a/src/lib/measurements/__tests__/series-change.test.ts b/src/lib/measurements/__tests__/series-change.test.ts new file mode 100644 index 000000000..9941dfbd7 --- /dev/null +++ b/src/lib/measurements/__tests__/series-change.test.ts @@ -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(); + }); +}); diff --git a/src/lib/measurements/__tests__/series-reference-lines.test.ts b/src/lib/measurements/__tests__/series-reference-lines.test.ts new file mode 100644 index 000000000..83b55a5b8 --- /dev/null +++ b/src/lib/measurements/__tests__/series-reference-lines.test.ts @@ -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[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"); + }); +}); diff --git a/src/lib/measurements/series-change.ts b/src/lib/measurements/series-change.ts new file mode 100644 index 000000000..b5bc64054 --- /dev/null +++ b/src/lib/measurements/series-change.ts @@ -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>(); + 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; +} diff --git a/src/lib/measurements/series-reference-lines.ts b/src/lib/measurements/series-reference-lines.ts new file mode 100644 index 000000000..0915c3b2d --- /dev/null +++ b/src/lib/measurements/series-reference-lines.ts @@ -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> = { + 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") : []; + } + } +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index cf1812beb..325af4af1 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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 diff --git a/src/lib/targets/__tests__/trend-verdict.test.ts b/src/lib/targets/__tests__/trend-verdict.test.ts new file mode 100644 index 000000000..2051265c4 --- /dev/null +++ b/src/lib/targets/__tests__/trend-verdict.test.ts @@ -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"); + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 5e7d3f3b2..4ce2e7d19 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -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, +): 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), + }; + }); +} diff --git a/src/lib/targets/trend-verdict.ts b/src/lib/targets/trend-verdict.ts new file mode 100644 index 000000000..22ca94065 --- /dev/null +++ b/src/lib/targets/trend-verdict.ts @@ -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> +> = { + 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> = { + 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 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; +} | 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 + >, + ctx: Omit, +): Record { + const out: Record = {}; + 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; +} diff --git a/src/lib/targets/types.ts b/src/lib/targets/types.ts index a92e3a1db..5ae330480 100644 --- a/src/lib/targets/types.ts +++ b/src/lib/targets/types.ts @@ -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;