mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server
This commit is contained in:
1 parent
b91328d67e
commit
79f7eb199b
35 files changed
+2454
-50
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "임상 기준 하한"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4224,6 +4224,21 @@
|
||||
"learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.",
|
||||
"noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z nimi dzień. Gdy zapisujesz też kontekst dnia, więcej da się porównać.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Wzorce w twoim nastroju",
|
||||
"basedOn": "Na podstawie {subject} i {comparison} dni. Wzorzec, nie przyczyna.",
|
||||
"previousDayHigher": "Po dniu poniżej twojej normy następny dzień jest zwykle lepszy ({subject} wobec {comparison} po dniu powyżej normy).",
|
||||
"previousDayLower": "Dzień poniżej twojej normy zwykle przenosi się na następny ({subject} wobec {comparison} po dniu powyżej normy).",
|
||||
"weekendHigher": "W weekendy twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w dni robocze).",
|
||||
"weekendLower": "W weekendy twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w dni robocze).",
|
||||
"bestWeekday": "{weekday} to zwykle twój najlepszy dzień ({subject} wobec {comparison} w pozostałe dni).",
|
||||
"notesHigher": "W dni z notatką twój nastrój jest zwykle lepszy ({subject} wobec {comparison} bez notatki).",
|
||||
"notesLower": "W dni z notatką twój nastrój jest zwykle gorszy ({subject} wobec {comparison} bez notatki).",
|
||||
"delayedTagHigher": "Dzień po „{tag}” twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w pozostałe dni).",
|
||||
"delayedTagLower": "Dzień po „{tag}” twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w pozostałe dni).",
|
||||
"multipleEntriesHigher": "W dni z kilkoma wpisami twój nastrój jest zwykle lepszy ({subject} wobec {comparison} przy jednym wpisie).",
|
||||
"multipleEntriesLower": "W dni z kilkoma wpisami twój nastrój jest zwykle gorszy ({subject} wobec {comparison} przy jednym wpisie)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -12840,5 +12855,13 @@
|
||||
"lastUpdated": "Ostatnia aktualizacja: {date}",
|
||||
"project": "Projekt",
|
||||
"credits": "Podziękowania"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Twój cel, górna granica",
|
||||
"targetLower": "Twój cel, dolna granica",
|
||||
"clinicalUpper": "Kliniczna górna granica",
|
||||
"clinicalLower": "Kliniczna dolna granica"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(bp?.allTimeCount).toBe(28);
|
||||
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
|
||||
expect(bp?.sparkline).toEqual([130, 132]);
|
||||
// v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the
|
||||
// sparkline's mean sits above it, so the rise is a setback.
|
||||
const judged = bp as unknown as {
|
||||
direction: unknown;
|
||||
trendSentiment: string;
|
||||
};
|
||||
expect(judged.direction).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "profile_default",
|
||||
});
|
||||
expect(judged.trendSentiment).toBe("negative");
|
||||
});
|
||||
|
||||
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
|
||||
|
||||
@@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOfTrend,
|
||||
type TrendSentiment,
|
||||
type TrendVerdict,
|
||||
type TrendVerdictContext,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -185,6 +192,69 @@ interface MetricCard {
|
||||
* at all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
|
||||
* the person, against their own target or a profile default
|
||||
* (`src/lib/targets/trend-verdict.ts`).
|
||||
*/
|
||||
direction?: TrendVerdict | null;
|
||||
/** v1.43 — `direction` applied to this card's `trend`. */
|
||||
trendSentiment?: TrendSentiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict key behind each card: the measurement type it reads, or the
|
||||
* derived key for the two cards that are not one.
|
||||
*/
|
||||
const VERDICT_KEY: Record<MetricKind, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
mood: "MOOD",
|
||||
bmi: "BMI",
|
||||
};
|
||||
|
||||
/**
|
||||
* Lay the verdict over each card. The reference placed against the band is
|
||||
* the mean of the card's own seven-day sparkline, else its latest value, the
|
||||
* seven-day average the web tiles place. Sleep cards are in hours and the
|
||||
* verdict reads minutes.
|
||||
*/
|
||||
function withVerdicts(
|
||||
cards: MetricCard[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): MetricCard[] {
|
||||
const referenceOf = (card: MetricCard | undefined): number | null => {
|
||||
if (!card) return null;
|
||||
const spark = card.sparkline.filter((v) => Number.isFinite(v));
|
||||
return spark.length > 0
|
||||
? spark.reduce((sum, v) => sum + v, 0) / spark.length
|
||||
: card.latestValue;
|
||||
};
|
||||
// BMI is judged by the weight it is derived from (the target is a weight).
|
||||
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
|
||||
return cards.map((card) => {
|
||||
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
|
||||
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
|
||||
...ctx,
|
||||
referenceValue:
|
||||
reference !== null && card.kind === "sleep"
|
||||
? reference * 60
|
||||
: reference,
|
||||
});
|
||||
return {
|
||||
...card,
|
||||
direction,
|
||||
trendSentiment: trendSentimentOfTrend(card.trend, direction),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -945,6 +1015,17 @@ async function buildDashboardSummary(
|
||||
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
// v1.43 — the record's profile and own targets, for the card verdicts.
|
||||
const verdictProfile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: timings });
|
||||
|
||||
return {
|
||||
@@ -962,7 +1043,14 @@ async function buildDashboardSummary(
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
metrics: withVerdicts(metrics, {
|
||||
profile: {
|
||||
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
|
||||
gender: verdictProfile?.gender ?? null,
|
||||
heightCm: verdictProfile?.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
|
||||
}),
|
||||
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
|
||||
// the Sleep page's `/api/sleep/rhythm` read returns.
|
||||
sleepRhythm,
|
||||
|
||||
@@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "WHO BMI",
|
||||
direction: {
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--info)",
|
||||
},
|
||||
source: "ESH 2023",
|
||||
direction: {
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
basis: "profile_default",
|
||||
},
|
||||
trendSentiment: "positive",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "ESH 2023",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--warning)",
|
||||
},
|
||||
source: "CDC/NCHS",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
basis: "profile_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
daysInRange7d: 0,
|
||||
daysLogged7d: 4,
|
||||
daysInRange30d: 0,
|
||||
@@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "AASM/SRS",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "WHO",
|
||||
direction: {
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--warning)",
|
||||
},
|
||||
source: "ACE",
|
||||
direction: {
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
basis: "profile_default",
|
||||
},
|
||||
trendSentiment: "positive",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
range: { min: 90, max: 100 },
|
||||
classification: null,
|
||||
source: "7-day",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
details: {
|
||||
medications: [
|
||||
{
|
||||
@@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "Mood entries",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fiveDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "Mood entries",
|
||||
direction: {
|
||||
direction: "neutral",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fiveDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "ADA 2024 / DDG",
|
||||
direction: {
|
||||
direction: "neutral",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
daysInRange7d: 3,
|
||||
daysLogged7d: 3,
|
||||
daysInRange30d: 3,
|
||||
|
||||
@@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => {
|
||||
function bpDays(values: Array<[string, number]>) {
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { type: string } };
|
||||
if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]);
|
||||
return Promise.resolve(
|
||||
values.map(([day, value], i) => ({
|
||||
id: `s${i}`,
|
||||
value,
|
||||
measuredAt: new Date(`${day}T08:00:00.000Z`),
|
||||
})),
|
||||
);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
it("judges the window's change against the person's own target and draws its lines", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
} as never);
|
||||
bpDays([
|
||||
["2026-05-01", 128],
|
||||
["2026-05-02", 130],
|
||||
["2026-05-03", 134],
|
||||
]);
|
||||
const res = await GET(req("kind=bloodPressure&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
direction: unknown;
|
||||
change: unknown;
|
||||
referenceLines: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
expect(body.data.direction).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "personal_target",
|
||||
});
|
||||
expect(body.data.change).toEqual({
|
||||
from: 128,
|
||||
to: 134,
|
||||
delta: 6,
|
||||
sentiment: "negative",
|
||||
});
|
||||
expect(
|
||||
body.data.referenceLines.map((l) => [
|
||||
l.appliesTo,
|
||||
l.kind,
|
||||
l.value,
|
||||
l.source,
|
||||
]),
|
||||
).toEqual([
|
||||
["value", "lower", 115, "personal"],
|
||||
["value", "upper", 125, "personal"],
|
||||
["secondary", "upper", 90, "clinical"],
|
||||
]);
|
||||
expect(body.data.referenceLines[0].label).toBe("Your target, lower limit");
|
||||
});
|
||||
|
||||
it("calls the same rise progress for someone below their target", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
} as never);
|
||||
bpDays([
|
||||
["2026-05-01", 100],
|
||||
["2026-05-03", 106],
|
||||
]);
|
||||
const res = await GET(req("kind=bloodPressure&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: { change: { sentiment: string } };
|
||||
};
|
||||
expect(body.data.change.sentiment).toBe("positive");
|
||||
});
|
||||
|
||||
it("has no change under two days and the clinical glucose lines in mmol/L", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
locale: "de",
|
||||
thresholdsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
change: unknown;
|
||||
direction: { direction: string };
|
||||
referenceLines: Array<{ value: number; label: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.change).toBeNull();
|
||||
expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]);
|
||||
expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
|
||||
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
*
|
||||
* The `kind=bloodPressure` case pairs systolic + diastolic by
|
||||
* `measuredAt` so iOS can render the dual-line chart with one fetch.
|
||||
*
|
||||
* v1.43 (ios #116 items 3 and 4) — the answer also carries what a client
|
||||
* would otherwise decide itself: `direction` (which way a change of this
|
||||
* metric counts for this person), `change` (first and last day value of the
|
||||
* window, judged by that direction) and `referenceLines` (the person's own
|
||||
* target lines, else the clinical ones, in the unit of the points).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
computeSeriesChange,
|
||||
seriesDayValues,
|
||||
seriesReferenceValue,
|
||||
} from "@/lib/measurements/series-change";
|
||||
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
|
||||
/**
|
||||
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
|
||||
@@ -199,7 +214,7 @@ function stdDev(values: number[]): number {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { user, actor } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = querySchema.safeParse(rawQuery);
|
||||
@@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
|
||||
const values = dataPoints.map((p) => p.value);
|
||||
|
||||
// v1.43 — the resolved judgements. The profile and targets are the
|
||||
// RECORD's (the chart is theirs); the labels are in the language of the
|
||||
// account reading it.
|
||||
const [record, reader, seriesTz] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
glucoseUnit: true,
|
||||
locale: true,
|
||||
},
|
||||
}),
|
||||
actor.id === user.id
|
||||
? null
|
||||
: prisma.user.findUnique({
|
||||
where: { id: actor.id },
|
||||
select: { locale: true },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
const rangeProfile = {
|
||||
dateOfBirth: record?.dateOfBirth ?? null,
|
||||
gender: record?.gender ?? null,
|
||||
heightCm: record?.heightCm ?? null,
|
||||
};
|
||||
const verdictType = KIND_TO_TYPE[kind];
|
||||
const dayValues = seriesDayValues(verdictType, points, seriesTz);
|
||||
const reference = seriesReferenceValue(dayValues);
|
||||
const direction = resolveTrendVerdict(verdictType, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
// The verdict reads stored units: sleep is served in hours here and
|
||||
// stored in minutes. Glucose has no band of its own on this chart.
|
||||
referenceValue:
|
||||
reference !== null && kind === "sleep" ? reference * 60 : reference,
|
||||
});
|
||||
const translator = getServerTranslator(
|
||||
await resolveJobLocale((reader ?? record)?.locale ?? null),
|
||||
);
|
||||
const referenceLines = resolveSeriesReferenceLines(kind, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
|
||||
t: (key) => translator.t(key),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurements.series" },
|
||||
meta: { kind, days, count: points.length },
|
||||
@@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
kind,
|
||||
unit,
|
||||
points,
|
||||
direction,
|
||||
change: computeSeriesChange(dayValues, direction),
|
||||
referenceLines,
|
||||
stats:
|
||||
statsOverride ??
|
||||
(summary
|
||||
|
||||
@@ -41,6 +41,22 @@ const AGGREGATE = {
|
||||
tags: [],
|
||||
stability: year,
|
||||
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
|
||||
patternsByWindow: {
|
||||
30: [],
|
||||
90: [],
|
||||
180: [],
|
||||
365: [
|
||||
{
|
||||
id: "weekend",
|
||||
kind: "weekend",
|
||||
strength: "strong",
|
||||
direction: "higher",
|
||||
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
|
||||
sampleSize: { subject: 100, comparison: 250 },
|
||||
qValue: 0.001,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const call = (query = "") =>
|
||||
@@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => {
|
||||
expect(body.data.stabilityWindowDays).toBe(90);
|
||||
});
|
||||
|
||||
it("serves the patterns of the same window, without the cache's internals", async () => {
|
||||
const year = await (await call()).json();
|
||||
expect(year.data.patterns).toEqual([
|
||||
{
|
||||
id: "weekend",
|
||||
kind: "weekend",
|
||||
strength: "strong",
|
||||
direction: "higher",
|
||||
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
|
||||
sampleSize: { subject: 100, comparison: 250 },
|
||||
},
|
||||
]);
|
||||
expect(year.data).not.toHaveProperty("patternsByWindow");
|
||||
const month = await (await call("?days=30")).json();
|
||||
expect(month.data.patterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a window it does not offer", async () => {
|
||||
const res = await call("?days=45");
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import {
|
||||
@@ -91,10 +93,17 @@ export default async function InsightsMoodPage() {
|
||||
),
|
||||
]);
|
||||
const queryClient = new QueryClient();
|
||||
const translator = getServerTranslator(
|
||||
await resolveJobLocale(user.locale),
|
||||
);
|
||||
if (aggregates) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.moodInsights(),
|
||||
JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))),
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
moodInsightsWire(aggregates, 365, (key) => translator.t(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (series) {
|
||||
|
||||
+29
-16
@@ -169,6 +169,7 @@ import {
|
||||
resolveWeightTrend,
|
||||
weightTrendReferenceKg,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -785,6 +786,15 @@ export default function DashboardPageClient({
|
||||
clientBandProfile.weightTargetOverride,
|
||||
weightTrendReferenceKg(w),
|
||||
);
|
||||
// v1.43 — every other tile's direction comes resolved from the server
|
||||
// (`tiles.directions`, against the record's own targets and profile). The
|
||||
// snapshot-disabled path has no resolved answer and renders neutral rather
|
||||
// than guessing a polarity.
|
||||
const tileDirections = snapshotEnabled
|
||||
? snapshotQuery.data?.tiles.directions
|
||||
: undefined;
|
||||
const directionOf = (key: string): TrendDirectionSentiment =>
|
||||
tileDirections?.[key]?.direction ?? "neutral";
|
||||
const bpTargets = bands.bpTargets;
|
||||
const weightRange = bands.weightRange;
|
||||
// The range hint tooltip prints its band bounds in the display unit, so scale
|
||||
@@ -1093,7 +1103,7 @@ export default function DashboardPageClient({
|
||||
slope30={sys?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(sys)}
|
||||
icon={Heart}
|
||||
directionSentiment="up-bad"
|
||||
directionSentiment={directionOf("BLOOD_PRESSURE_SYS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(sys)}
|
||||
staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")}
|
||||
@@ -1135,7 +1145,7 @@ export default function DashboardPageClient({
|
||||
slope30={dia?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(dia)}
|
||||
icon={Heart}
|
||||
directionSentiment="up-bad"
|
||||
directionSentiment={directionOf("BLOOD_PRESSURE_DIA")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(dia)}
|
||||
staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")}
|
||||
@@ -1177,6 +1187,9 @@ export default function DashboardPageClient({
|
||||
slope30={pulseTileSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(pulseTileSummary)}
|
||||
icon={TrendingUp}
|
||||
directionSentiment={directionOf(
|
||||
hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
|
||||
)}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(pulseTileSummary)}
|
||||
staleDays={tileStaleDays(
|
||||
@@ -1201,7 +1214,7 @@ export default function DashboardPageClient({
|
||||
slope30={bf?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(bf)}
|
||||
icon={Percent}
|
||||
directionSentiment="up-bad"
|
||||
directionSentiment={directionOf("BODY_FAT")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(bf)}
|
||||
staleDays={tileStaleDays("BODY_FAT")}
|
||||
@@ -1224,7 +1237,7 @@ export default function DashboardPageClient({
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(moodSummary)}
|
||||
icon={Smile}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("MOOD")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(moodSummary)}
|
||||
/>
|
||||
@@ -1246,7 +1259,7 @@ export default function DashboardPageClient({
|
||||
slope30={sleepSummaryHours?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(sleepSummaryHours)}
|
||||
icon={Moon}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("SLEEP_DURATION")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(sleepSummaryHours)}
|
||||
staleDays={tileStaleDays("SLEEP_DURATION")}
|
||||
@@ -1282,7 +1295,7 @@ export default function DashboardPageClient({
|
||||
slope30={stepsSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(stepsSummary)}
|
||||
icon={Footprints}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("ACTIVITY_STEPS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(stepsSummary)}
|
||||
staleDays={tileStaleDays("ACTIVITY_STEPS")}
|
||||
@@ -1312,7 +1325,7 @@ export default function DashboardPageClient({
|
||||
slope30={vo2Summary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(vo2Summary)}
|
||||
icon={Gauge}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("VO2_MAX")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(vo2Summary)}
|
||||
staleDays={tileStaleDays("VO2_MAX")}
|
||||
@@ -1354,7 +1367,7 @@ export default function DashboardPageClient({
|
||||
slope30={hrvSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(hrvSummary)}
|
||||
icon={HeartPulse}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf(hrvType)}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(hrvSummary)}
|
||||
// The type that actually backs the tile — reading freshness
|
||||
@@ -1380,7 +1393,7 @@ export default function DashboardPageClient({
|
||||
slope30={spo2Summary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(spo2Summary)}
|
||||
icon={Droplets}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("OXYGEN_SATURATION")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(spo2Summary)}
|
||||
staleDays={tileStaleDays("OXYGEN_SATURATION")}
|
||||
@@ -1403,7 +1416,7 @@ export default function DashboardPageClient({
|
||||
slope30={respRateSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(respRateSummary)}
|
||||
icon={Wind}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("RESPIRATORY_RATE")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(respRateSummary)}
|
||||
staleDays={tileStaleDays("RESPIRATORY_RATE")}
|
||||
@@ -1429,7 +1442,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(wristTempSummary),
|
||||
)}
|
||||
icon={Thermometer}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("WRIST_TEMPERATURE")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"WRIST_TEMPERATURE",
|
||||
@@ -1461,7 +1474,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(muscleMassSummary),
|
||||
)}
|
||||
icon={Dumbbell}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("MUSCLE_MASS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"MUSCLE_MASS",
|
||||
@@ -1490,7 +1503,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(bodyWaterSummary),
|
||||
)}
|
||||
icon={Droplet}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("TOTAL_BODY_WATER")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"TOTAL_BODY_WATER",
|
||||
@@ -1519,7 +1532,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(boneMassSummary),
|
||||
)}
|
||||
icon={Bone}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("BONE_MASS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"BONE_MASS",
|
||||
@@ -1548,7 +1561,7 @@ export default function DashboardPageClient({
|
||||
slope30={waterIntakeSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
|
||||
icon={GlassWater}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("NUTRIENT_WATER")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(waterIntakeSummary)}
|
||||
staleDays={tileStaleDays("NUTRIENT_WATER")}
|
||||
@@ -1639,7 +1652,7 @@ export default function DashboardPageClient({
|
||||
slope30={null}
|
||||
trend7Delta={bpSufficient ? bpTrendDelta : null}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("BLOOD_PRESSURE_IN_TARGET")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={bpSufficient ? bpCompareDelta : null}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "./use-dashboard-derived";
|
||||
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
|
||||
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
|
||||
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
|
||||
@@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
|
||||
// and feed it to both the wellness strip and this grid. Imported above.
|
||||
|
||||
/**
|
||||
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
|
||||
* them: which way it should move depends on the person's own target (#1006,
|
||||
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
|
||||
* it stays neutral rather than assuming a fall is good.
|
||||
*/
|
||||
const UP_BAD_VITALS = new Set([
|
||||
"RESTING_HEART_RATE",
|
||||
"BODY_TEMPERATURE",
|
||||
"BLOOD_GLUCOSE",
|
||||
]);
|
||||
/** Up-is-good for the vitals where a rise is favourable. */
|
||||
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
|
||||
|
||||
function vitalSentiment(type: string): TrendDirectionSentiment {
|
||||
if (UP_BAD_VITALS.has(type)) return "up-bad";
|
||||
if (UP_GOOD_VITALS.has(type)) return "up-good";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
/**
|
||||
* The shared overview derived batch (owned by the page so the wellness
|
||||
@@ -306,7 +285,10 @@ function BaselineTile({
|
||||
icon={Icon}
|
||||
series={displaySeries}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
// v1.43 — resolved on the server against the person's own target
|
||||
// or profile default; an older server sends none and the tile
|
||||
// stays neutral rather than assuming a polarity.
|
||||
directionSentiment={v.direction?.direction ?? "neutral"}
|
||||
provenance={
|
||||
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
|
||||
// "typical range = median ± MAD" caption repeated across every vital
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Link2,
|
||||
Repeat,
|
||||
Tag,
|
||||
Tags,
|
||||
TrendingUp,
|
||||
@@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile";
|
||||
import { MoodTagInfluence } from "./mood-tag-influence";
|
||||
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
|
||||
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
|
||||
import { MoodPatterns } from "./mood-patterns";
|
||||
|
||||
export function MoodInsightsBreakdowns({
|
||||
data,
|
||||
@@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({
|
||||
const factorCrosstabRows = data.factorCrosstab ?? [];
|
||||
const hasFactorCrosstab = factorCrosstabRows.length > 0;
|
||||
const dimensionSummaries = data.dimensions ?? [];
|
||||
const patterns = data.patterns ?? [];
|
||||
const hasDimensions = dimensionSummaries.some((d) => d.present);
|
||||
|
||||
return (
|
||||
@@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({
|
||||
renders nothing when both halves are empty. */}
|
||||
<MoodWhatStandsOut narratives={narratives} />
|
||||
|
||||
{/* v1.43 — the patterns the server found and gated; the card stays
|
||||
away when none passed. */}
|
||||
{patterns.length > 0 && (
|
||||
<SectionCard title={t("insights.mood.patterns.title")} icon={Repeat}>
|
||||
<MoodPatterns patterns={patterns} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* v1.37 — the five level-A dimensions. Renders only once at least one
|
||||
of them carries a value; an account that has never opened the detail
|
||||
section sees the page it saw before. */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days";
|
||||
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
|
||||
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
|
||||
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
|
||||
import type { MoodPatternRow } from "./mood-patterns";
|
||||
|
||||
export interface MoodInsightsResponse {
|
||||
summary: {
|
||||
@@ -65,6 +66,11 @@ export interface MoodInsightsResponse {
|
||||
// rollout; the live endpoint always populates it.
|
||||
factorCrosstab?: MoodFactorMetricCrosstabRow[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
/**
|
||||
* v1.43 — the gated patterns the server found over the requested window.
|
||||
* Optional to tolerate a payload from before it existed.
|
||||
*/
|
||||
patterns?: MoodPatternRow[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
steps: MoodMetricCorrelationData;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (ios #116 item 6) — the mood patterns the server found.
|
||||
*
|
||||
* Nothing here decides whether a pattern exists: every row is one the server
|
||||
* published after its gate (eight days a side, Welch, false discovery
|
||||
* control), computed on the mood day. This component only phrases each one.
|
||||
* Rows render in the order delivered; a kind this build does not know is
|
||||
* skipped rather than guessed at.
|
||||
*/
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MoodPatternRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
direction: "higher" | "lower" | "none";
|
||||
values: {
|
||||
subject: number;
|
||||
comparison: number;
|
||||
delta: number;
|
||||
weekday?: number;
|
||||
tagKey?: string;
|
||||
tagLabel?: string;
|
||||
};
|
||||
sampleSize: { subject: number; comparison: number };
|
||||
}
|
||||
|
||||
const WEEKDAY_KEYS = [
|
||||
"charts.weekdaysFull.mon",
|
||||
"charts.weekdaysFull.tue",
|
||||
"charts.weekdaysFull.wed",
|
||||
"charts.weekdaysFull.thu",
|
||||
"charts.weekdaysFull.fri",
|
||||
"charts.weekdaysFull.sat",
|
||||
"charts.weekdaysFull.sun",
|
||||
] as const;
|
||||
|
||||
/** The sentence key per kind and direction; `none` cannot pass the gate. */
|
||||
const SENTENCE_KEYS: Record<string, { higher: string; lower: string | null }> =
|
||||
{
|
||||
previous_day: {
|
||||
higher: "insights.mood.patterns.previousDayHigher",
|
||||
lower: "insights.mood.patterns.previousDayLower",
|
||||
},
|
||||
weekend: {
|
||||
higher: "insights.mood.patterns.weekendHigher",
|
||||
lower: "insights.mood.patterns.weekendLower",
|
||||
},
|
||||
best_weekday: {
|
||||
higher: "insights.mood.patterns.bestWeekday",
|
||||
lower: null,
|
||||
},
|
||||
notes: {
|
||||
higher: "insights.mood.patterns.notesHigher",
|
||||
lower: "insights.mood.patterns.notesLower",
|
||||
},
|
||||
delayed_tag: {
|
||||
higher: "insights.mood.patterns.delayedTagHigher",
|
||||
lower: "insights.mood.patterns.delayedTagLower",
|
||||
},
|
||||
multiple_entries: {
|
||||
higher: "insights.mood.patterns.multipleEntriesHigher",
|
||||
lower: "insights.mood.patterns.multipleEntriesLower",
|
||||
},
|
||||
};
|
||||
|
||||
export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const rows = patterns.flatMap((pattern) => {
|
||||
const keys = SENTENCE_KEYS[pattern.kind];
|
||||
if (!keys || pattern.direction === "none") return [];
|
||||
const key = pattern.direction === "higher" ? keys.higher : keys.lower;
|
||||
if (!key) return [];
|
||||
const weekdayKey =
|
||||
pattern.values.weekday !== undefined
|
||||
? WEEKDAY_KEYS[pattern.values.weekday]
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id: pattern.id,
|
||||
sentence: t(key, {
|
||||
subject: fmt.number(pattern.values.subject, 1),
|
||||
comparison: fmt.number(pattern.values.comparison, 1),
|
||||
weekday: weekdayKey ? t(weekdayKey) : "",
|
||||
tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "",
|
||||
}),
|
||||
basedOn: t("insights.mood.patterns.basedOn", {
|
||||
subject: pattern.sampleSize.subject,
|
||||
comparison: pattern.sampleSize.comparison,
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="space-y-3" data-slot="mood-patterns">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} data-pattern-id={row.id} className="space-y-1">
|
||||
<p className="text-sm">{row.sentence}</p>
|
||||
<p className="text-muted-foreground text-xs">{row.basedOn}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
weightTrendReferenceKg,
|
||||
type WeightTrendJudgement,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import {
|
||||
resolveSummaryVerdicts,
|
||||
type TrendVerdict,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
typesWithReadingsInFoldWindow,
|
||||
@@ -452,6 +456,15 @@ export interface DashboardSnapshot {
|
||||
* builder always sets it.
|
||||
*/
|
||||
weightTrend?: WeightTrendJudgement;
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — per measurement type (and `MOOD`,
|
||||
* `BLOOD_PRESSURE_IN_TARGET`), which way a change counts for this
|
||||
* person, against their own target or a profile default where one
|
||||
* applies (`src/lib/targets/trend-verdict.ts`). Every tile colours its
|
||||
* arrow and deltas from this; `weightTrend` stays for older clients.
|
||||
* Optional on the type so older cached snapshots stay valid.
|
||||
*/
|
||||
directions?: Record<string, TrendVerdict>;
|
||||
};
|
||||
/** Thick phase — null on a rollup-coverage miss. */
|
||||
extras: DashboardSnapshotExtras | null;
|
||||
@@ -1496,6 +1509,14 @@ export async function buildDashboardSnapshot(
|
||||
weightTargetOverride,
|
||||
weightTrendReferenceKg(slim.summaries.WEIGHT),
|
||||
),
|
||||
directions: resolveSummaryVerdicts(slim.summaries, {
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
}),
|
||||
},
|
||||
extras: extrasResult?.extras ?? null,
|
||||
medsToday,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoodAggregateEntry } from "@/lib/insights/mood-aggregates";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import {
|
||||
computeMoodPatterns,
|
||||
moodPatternsWire,
|
||||
PATTERN_MIN_DAYS_PER_SIDE,
|
||||
weekdayOfKey,
|
||||
} from "../mood-pattern-cards";
|
||||
|
||||
const TODAY = "2026-10-11"; // a Sunday
|
||||
|
||||
/** A deterministic pseudo-random stream (mulberry32). */
|
||||
function rng(seed: number): () => number {
|
||||
let a = seed;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function entry(
|
||||
date: string,
|
||||
score: number,
|
||||
extra: Partial<MoodAggregateEntry> = {},
|
||||
): MoodAggregateEntry {
|
||||
return {
|
||||
date,
|
||||
score,
|
||||
tags: null,
|
||||
moodLoggedAt: new Date(`${date}T18:00:00.000Z`),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function days(n: number, score: (date: string, i: number) => number) {
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const date = shiftDateKey(TODAY, -(n - 1 - i));
|
||||
return entry(date, score(date, i));
|
||||
});
|
||||
}
|
||||
|
||||
function run(entries: MoodAggregateEntry[], noteDays = new Set<string>()) {
|
||||
return computeMoodPatterns({
|
||||
entries,
|
||||
noteDays,
|
||||
todayKey: TODAY,
|
||||
windowDays: 365,
|
||||
});
|
||||
}
|
||||
|
||||
describe("computeMoodPatterns", () => {
|
||||
it("finds a clear weekend effect and names it", () => {
|
||||
const r = rng(7);
|
||||
const entries = days(120, (date) =>
|
||||
Math.min(
|
||||
5,
|
||||
Math.max(1, (weekdayOfKey(date) >= 5 ? 4.2 : 3) + (r() - 0.5)),
|
||||
),
|
||||
);
|
||||
const patterns = run(entries);
|
||||
const weekend = patterns.find((p) => p.kind === "weekend");
|
||||
expect(weekend).toBeDefined();
|
||||
expect(weekend).toMatchObject({
|
||||
id: "weekend",
|
||||
direction: "higher",
|
||||
strength: "strong",
|
||||
});
|
||||
expect(weekend!.sampleSize.subject).toBeGreaterThanOrEqual(
|
||||
PATTERN_MIN_DAYS_PER_SIDE,
|
||||
);
|
||||
expect(weekend!.values.subject).toBeGreaterThan(weekend!.values.comparison);
|
||||
// Saturday or Sunday is the best weekday, and only one is published.
|
||||
const best = patterns.filter((p) => p.kind === "best_weekday");
|
||||
expect(best.length).toBeLessThanOrEqual(1);
|
||||
if (best[0]) expect(best[0].values.weekday).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("finds nothing in noise", () => {
|
||||
for (const seed of [1, 2, 3, 4, 5]) {
|
||||
const r = rng(seed);
|
||||
const entries = days(180, () => 1 + Math.round(r() * 4));
|
||||
expect(run(entries), `seed ${seed}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds every comparison to eight days a side", () => {
|
||||
// Twelve days, all with a strong weekend split: too few weekend days.
|
||||
const r = rng(16);
|
||||
const entries = days(
|
||||
12,
|
||||
(date) => (weekdayOfKey(date) >= 5 ? 4.6 : 1.4) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
expect(run(entries).find((p) => p.kind === "weekend")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("compares days with a note against days without", () => {
|
||||
const r = rng(11);
|
||||
const entries = days(
|
||||
90,
|
||||
(_, i) => (i % 3 === 0 ? 2 : 4) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
const noteDays = new Set(
|
||||
entries.filter((_, i) => i % 3 === 0).map((e) => e.date),
|
||||
);
|
||||
const notes = run(entries, noteDays).find((p) => p.kind === "notes");
|
||||
expect(notes).toMatchObject({ id: "notes", direction: "lower" });
|
||||
});
|
||||
|
||||
it("compares days with several entries against single-entry days", () => {
|
||||
const r = rng(12);
|
||||
const base = days(
|
||||
90,
|
||||
(_, i) => (i % 2 === 0 ? 4.5 : 3) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
const extra = base
|
||||
.filter((_, i) => i % 2 === 0)
|
||||
.map((e) => entry(e.date, e.score));
|
||||
const multi = run([...base, ...extra]).find(
|
||||
(p) => p.kind === "multiple_entries",
|
||||
);
|
||||
expect(multi).toMatchObject({ direction: "higher" });
|
||||
});
|
||||
|
||||
it("reads a tag's association with the NEXT day", () => {
|
||||
const r = rng(13);
|
||||
const tag = {
|
||||
key: "alcohol",
|
||||
categoryKey: "health",
|
||||
labelKey: "mood.tag.alcohol",
|
||||
icon: null,
|
||||
};
|
||||
const tagged = new Set<number>();
|
||||
const entries = days(120, (_, i) => {
|
||||
// The day after a tagged day is low.
|
||||
return tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
|
||||
});
|
||||
entries.forEach((e, i) => {
|
||||
if (i % 4 === 0) {
|
||||
tagged.add(i);
|
||||
e.structuredTags = [tag];
|
||||
}
|
||||
});
|
||||
// Recompute the scores now that the tag days are known.
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
entries[i].score = tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
|
||||
}
|
||||
const delayed = run(entries).find((p) => p.kind === "delayed_tag");
|
||||
expect(delayed).toMatchObject({
|
||||
id: "delayed_tag:alcohol",
|
||||
direction: "lower",
|
||||
values: { tagKey: "alcohol" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the day after a below-median day", () => {
|
||||
const r = rng(14);
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
let prevLow = false;
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
const date = shiftDateKey(TODAY, -(149 - i));
|
||||
// Persistent mood: a low day is followed by another low-ish day.
|
||||
const low: boolean = r() < (prevLow ? 0.85 : 0.15);
|
||||
entries.push(entry(date, low ? 2 + r() * 0.5 : 4 + r() * 0.5));
|
||||
prevLow = low;
|
||||
}
|
||||
const previous = run(entries).find((p) => p.kind === "previous_day");
|
||||
expect(previous).toMatchObject({ id: "previous_day", direction: "lower" });
|
||||
});
|
||||
|
||||
it("only counts days inside the window", () => {
|
||||
const r = rng(15);
|
||||
const entries = days(
|
||||
200,
|
||||
(date) => (weekdayOfKey(date) >= 5 ? 4.5 : 2.5) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
const recent = computeMoodPatterns({
|
||||
entries,
|
||||
noteDays: new Set(),
|
||||
todayKey: TODAY,
|
||||
windowDays: 30,
|
||||
}).find((p) => p.kind === "weekend");
|
||||
expect(recent).toBeDefined();
|
||||
expect(recent!.sampleSize.subject).toBeGreaterThanOrEqual(8);
|
||||
expect(recent!.sampleSize.subject + recent!.sampleSize.comparison).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moodPatternsWire", () => {
|
||||
it("names a catalogue tag in the reader's language and a custom one verbatim, and drops q", () => {
|
||||
const wire = moodPatternsWire(
|
||||
[
|
||||
{
|
||||
id: "delayed_tag:alcohol",
|
||||
kind: "delayed_tag",
|
||||
strength: "moderate",
|
||||
direction: "lower",
|
||||
values: {
|
||||
subject: 2.1,
|
||||
comparison: 4,
|
||||
delta: -1.9,
|
||||
tagKey: "alcohol",
|
||||
},
|
||||
sampleSize: { subject: 20, comparison: 80 },
|
||||
tag: { labelKey: "mood.tag.alcohol", label: null },
|
||||
qValue: 0.01,
|
||||
},
|
||||
{
|
||||
id: "delayed_tag:custom:x",
|
||||
kind: "delayed_tag",
|
||||
strength: "weak",
|
||||
direction: "higher",
|
||||
values: { subject: 4, comparison: 3, delta: 1, tagKey: "custom:x" },
|
||||
sampleSize: { subject: 9, comparison: 50 },
|
||||
tag: { labelKey: "custom:x", label: "Choir" },
|
||||
qValue: 0.08,
|
||||
},
|
||||
],
|
||||
(key) => `t:${key}`,
|
||||
);
|
||||
expect(wire[0].values.tagLabel).toBe("t:mood.tag.alcohol");
|
||||
expect(wire[1].values.tagLabel).toBe("Choir");
|
||||
expect(wire[0]).not.toHaveProperty("qValue");
|
||||
expect(wire[0]).not.toHaveProperty("tag");
|
||||
expect(moodPatternsWire(undefined, (k) => k)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@
|
||||
* statistics helpers below are exported so the unit test can assert the
|
||||
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
|
||||
*/
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
type TrendVerdict,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
@@ -91,6 +95,14 @@ export interface VitalsBaselineValue {
|
||||
* band is already computed from (no extra read).
|
||||
*/
|
||||
series: number[];
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — which way a change of this vital counts for
|
||||
* the person, with the band's centre placed against their own target or a
|
||||
* profile default (`src/lib/targets/trend-verdict.ts`). The tile colours
|
||||
* its delta from this instead of a fixed polarity. Absent when the caller
|
||||
* passed no profile facts to judge with.
|
||||
*/
|
||||
direction?: TrendVerdict;
|
||||
}
|
||||
|
||||
/** Caller-supplied profile (read once per request, never re-fetched here). */
|
||||
@@ -109,6 +121,13 @@ export interface BaselineProfile {
|
||||
* metric (weight ÷ height²); `null` when the profile has no height.
|
||||
*/
|
||||
heightCm?: number | null;
|
||||
/**
|
||||
* v1.43 — the date of birth and the person's own targets, which the trend
|
||||
* verdict on `VITALS_BASELINE` is judged with. Optional: a caller that does
|
||||
* not read them gets no verdict rather than a wrong one.
|
||||
*/
|
||||
dateOfBirth?: Date | null;
|
||||
thresholdsJson?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,13 +146,20 @@ export async function loadBaselineProfile(
|
||||
): Promise<BaselineProfile> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dateOfBirth: true, gender: true, heightCm: true },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
const sex = toProfileSex(user?.gender);
|
||||
return {
|
||||
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
|
||||
sex,
|
||||
heightCm: user?.heightCm ?? null,
|
||||
dateOfBirth: user?.dateOfBirth ?? null,
|
||||
thresholdsJson: user?.thresholdsJson ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,7 +337,7 @@ export async function readDayMeanSeries(
|
||||
*/
|
||||
export async function computeVitalsBaseline(
|
||||
userId: string,
|
||||
_profile: BaselineProfile,
|
||||
profile: BaselineProfile,
|
||||
opts: VitalsBaselineOpts,
|
||||
): Promise<Derived<VitalsBaselineValue>> {
|
||||
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
||||
@@ -403,8 +429,22 @@ export async function computeVitalsBaseline(
|
||||
// means the band is built from, capped to the recent window.
|
||||
const series = points.slice(-SPARKLINE_MAX_POINTS).map((p) => p.mean);
|
||||
|
||||
// Judged only when the caller read the facts it is judged with; the
|
||||
// nightly engines that never show a delta pass a bare profile.
|
||||
const direction =
|
||||
profile.dateOfBirth !== undefined
|
||||
? resolveTrendVerdict(type, {
|
||||
profile: {
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
gender: profile.sex,
|
||||
heightCm: profile.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: profile.thresholdsJson ?? null,
|
||||
referenceValue: band.center,
|
||||
})
|
||||
: undefined;
|
||||
return buildOk<VitalsBaselineValue>({
|
||||
value: { type, ...band, series },
|
||||
value: { type, ...band, series, ...(direction ? { direction } : {}) },
|
||||
coverage: cov,
|
||||
confidence,
|
||||
provenance: { inputs: [String(type)], source, windowDays, computedAt },
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
* importing from here unchanged.
|
||||
*/
|
||||
|
||||
import {
|
||||
computeMoodPatterns,
|
||||
moodPatternsWire,
|
||||
type MoodPatternFinding,
|
||||
} from "@/lib/insights/mood-pattern-cards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
significantPearsonCorrelation,
|
||||
@@ -512,6 +517,13 @@ export interface MoodAggregates {
|
||||
* `stability` and does not publish this map.
|
||||
*/
|
||||
stabilityByWindow: Record<MoodStabilityWindow, MoodStability | null>;
|
||||
/**
|
||||
* v1.43 (ios #116 item 6) — the gated mood patterns per selectable window
|
||||
* (`mood-pattern-cards.ts`). Cached with the aggregate like the stability
|
||||
* map; the route serves the chosen window as `patterns`. Optional so an
|
||||
* aggregate cached before it existed still reads (as none found).
|
||||
*/
|
||||
patternsByWindow?: Record<MoodStabilityWindow, MoodPatternFinding[]>;
|
||||
tags: TagSummaryRow[];
|
||||
/**
|
||||
* v1.38 — how the day's pleasantness sat on days carrying each context
|
||||
@@ -579,6 +591,11 @@ export function computeMoodAggregates(args: {
|
||||
* calendar day. Defaults to Berlin for legacy / test callers.
|
||||
*/
|
||||
tz?: string;
|
||||
/**
|
||||
* v1.43 — the mood days that carry a note, for the `notes` pattern. Read
|
||||
* as a separate projection so the note text itself is never loaded here.
|
||||
*/
|
||||
noteDays?: ReadonlySet<string>;
|
||||
}): MoodAggregates {
|
||||
const { entries, measurements, now } = args;
|
||||
const userPriorityJson = args.userPriorityJson ?? null;
|
||||
@@ -633,6 +650,18 @@ export function computeMoodAggregates(args: {
|
||||
),
|
||||
]),
|
||||
) as Record<MoodStabilityWindow, MoodStability | null>;
|
||||
const todayKey = dayOffsetToDayKey(now, 0, tz);
|
||||
const patternsByWindow = Object.fromEntries(
|
||||
MOOD_STABILITY_WINDOWS.map((days) => [
|
||||
days,
|
||||
computeMoodPatterns({
|
||||
entries,
|
||||
noteDays: args.noteDays ?? new Set<string>(),
|
||||
todayKey,
|
||||
windowDays: days,
|
||||
}),
|
||||
]),
|
||||
) as Record<MoodStabilityWindow, MoodPatternFinding[]>;
|
||||
const tags = computeTagSummary(entries, now);
|
||||
const structuredTags = computeStructuredTagSummary(entries, now);
|
||||
const tagInfluence = computeTagInfluence(entries, now);
|
||||
@@ -715,6 +744,7 @@ export function computeMoodAggregates(args: {
|
||||
timeOfDay,
|
||||
stability,
|
||||
stabilityByWindow,
|
||||
patternsByWindow,
|
||||
tags,
|
||||
structuredTags,
|
||||
tagInfluence,
|
||||
@@ -921,9 +951,20 @@ export async function fetchMoodAggregates(
|
||||
});
|
||||
}
|
||||
|
||||
const [userPriorityJson, tz] = await Promise.all([
|
||||
// v1.43 — which mood days carry a note, without reading a single note.
|
||||
const [userPriorityJson, tz, noteRows] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
resolveUserTimezone(userId),
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: windowCutoff },
|
||||
OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }],
|
||||
},
|
||||
select: { date: true },
|
||||
distinct: ["date"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const aggregate = computeMoodAggregates({
|
||||
@@ -932,6 +973,7 @@ export async function fetchMoodAggregates(
|
||||
now,
|
||||
userPriorityJson,
|
||||
tz,
|
||||
noteDays: new Set(noteRows.map((row) => row.date)),
|
||||
});
|
||||
|
||||
const tagEvidence = aggregate.tagMetricCrosstab.map((row) => ({
|
||||
@@ -995,8 +1037,10 @@ export async function fetchMoodAggregates(
|
||||
export function moodInsightsWire(
|
||||
result: MoodAggregates,
|
||||
stabilityWindowDays: MoodStabilityWindow,
|
||||
/** Names a pattern's tag in the reader's language (catalogue tags). */
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
const { stabilityByWindow, ...wire } = result;
|
||||
const { stabilityByWindow, patternsByWindow, ...wire } = result;
|
||||
return {
|
||||
...wire,
|
||||
stability:
|
||||
@@ -1004,5 +1048,8 @@ export function moodInsightsWire(
|
||||
? result.stability
|
||||
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
|
||||
stabilityWindowDays,
|
||||
// v1.43 — the patterns of the same window; an aggregate cached before
|
||||
// they existed reads as none found until its next rebuild.
|
||||
patterns: moodPatternsWire(patternsByWindow?.[stabilityWindowDays], t),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Mood patterns the server finds and publishes (v1.43, ios #116 item 6).
|
||||
*
|
||||
* The native client used to compute six pattern cards itself (the day after
|
||||
* a low day, weekends, the best weekday, days with a note, delayed tag
|
||||
* effects, several entries a day) on the device calendar and without any
|
||||
* statistical gate, and removed them. They are computed here instead, on the
|
||||
* account's mood day (`MoodEntry.date`, anchored to the entry's own zone),
|
||||
* and only the ones that pass the gate are published:
|
||||
*
|
||||
* - at least `PATTERN_MIN_DAYS_PER_SIDE` days on each side of a comparison;
|
||||
* - a Welch two-sample t-test on the two groups of daily means (the same
|
||||
* test the tag influence board uses);
|
||||
* - Benjamini-Hochberg over every comparison tested in the window, kept at
|
||||
* q ≤ `PATTERN_FDR_Q` (the false discovery rate the mood crosstabs use).
|
||||
*
|
||||
* An empty list means nothing passed, not that something failed. Every
|
||||
* comparison is a daily-mean comparison, so a day with five entries weighs
|
||||
* as much as a day with one. Observational only: the copy a client renders
|
||||
* says "tends to", never "because".
|
||||
*
|
||||
* Pure; the caller hands over the entries and which days carry a note.
|
||||
*/
|
||||
import { welchTTest } from "@/lib/insights/correlations";
|
||||
import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
|
||||
import type {
|
||||
MoodAggregateEntry,
|
||||
StructuredTagRef,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
export const PATTERN_MIN_DAYS_PER_SIDE = 8;
|
||||
export const PATTERN_FDR_Q = 0.1;
|
||||
|
||||
export type MoodPatternKind =
|
||||
| "previous_day"
|
||||
| "weekend"
|
||||
| "best_weekday"
|
||||
| "notes"
|
||||
| "delayed_tag"
|
||||
| "multiple_entries";
|
||||
|
||||
/** A pattern as the aggregate caches it: the tag's label is resolved at the wire. */
|
||||
export interface MoodPatternFinding {
|
||||
id: string;
|
||||
kind: MoodPatternKind;
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
direction: "higher" | "lower" | "none";
|
||||
values: {
|
||||
subject: number;
|
||||
comparison: number;
|
||||
delta: number;
|
||||
weekday?: number;
|
||||
tagKey?: string;
|
||||
};
|
||||
sampleSize: { subject: number; comparison: number };
|
||||
/** For `delayed_tag`: how to name the tag (custom label first). */
|
||||
tag?: { labelKey: string; label: string | null };
|
||||
/** BH-adjusted q, kept for ordering and tests; not on the wire. */
|
||||
qValue: number;
|
||||
}
|
||||
|
||||
/** The wire shape (`MoodPattern` in OpenAPI). */
|
||||
export interface MoodPatternWire {
|
||||
id: string;
|
||||
kind: MoodPatternKind;
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
direction: "higher" | "lower" | "none";
|
||||
values: {
|
||||
subject: number;
|
||||
comparison: number;
|
||||
delta: number;
|
||||
weekday?: number;
|
||||
tagKey?: string;
|
||||
tagLabel?: string;
|
||||
};
|
||||
sampleSize: { subject: number; comparison: number };
|
||||
}
|
||||
|
||||
interface MoodDay {
|
||||
date: string;
|
||||
mean: number;
|
||||
entries: number;
|
||||
hasNote: boolean;
|
||||
tags: Map<string, StructuredTagRef>;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: string;
|
||||
kind: MoodPatternKind;
|
||||
subject: number[];
|
||||
comparison: number[];
|
||||
weekday?: number;
|
||||
tag?: StructuredTagRef;
|
||||
}
|
||||
|
||||
/** 0 = Monday … 6 = Sunday, read off the day key itself. */
|
||||
export function weekdayOfKey(dateKey: string): number {
|
||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||
return (d.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
function collapseDays(
|
||||
entries: readonly MoodAggregateEntry[],
|
||||
noteDays: ReadonlySet<string>,
|
||||
): Map<string, MoodDay> {
|
||||
const days = new Map<
|
||||
string,
|
||||
{ sum: number; count: number; tags: Map<string, StructuredTagRef> }
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
const day = days.get(entry.date) ?? {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
tags: new Map<string, StructuredTagRef>(),
|
||||
};
|
||||
day.sum += entry.score;
|
||||
day.count += 1;
|
||||
for (const ref of entry.structuredTags ?? []) day.tags.set(ref.key, ref);
|
||||
days.set(entry.date, day);
|
||||
}
|
||||
const out = new Map<string, MoodDay>();
|
||||
for (const [date, day] of days) {
|
||||
out.set(date, {
|
||||
date,
|
||||
mean: day.sum / day.count,
|
||||
entries: day.count,
|
||||
hasNote: noteDays.has(date),
|
||||
tags: day.tags,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function mean(values: readonly number[]): number {
|
||||
return values.reduce((s, v) => s + v, 0) / values.length;
|
||||
}
|
||||
|
||||
/** Cohen's d on the pooled SD, banded at the conventional 0.5 / 0.8. */
|
||||
function strengthOf(
|
||||
subject: readonly number[],
|
||||
comparison: readonly number[],
|
||||
): MoodPatternFinding["strength"] {
|
||||
const ms = mean(subject);
|
||||
const mc = mean(comparison);
|
||||
const vs =
|
||||
subject.reduce((s, v) => s + (v - ms) ** 2, 0) / (subject.length - 1);
|
||||
const vc =
|
||||
comparison.reduce((s, v) => s + (v - mc) ** 2, 0) / (comparison.length - 1);
|
||||
const pooled =
|
||||
((subject.length - 1) * vs + (comparison.length - 1) * vc) /
|
||||
(subject.length + comparison.length - 2);
|
||||
const d = pooled > 0 ? Math.abs(ms - mc) / Math.sqrt(pooled) : 0;
|
||||
if (d >= 0.8) return "strong";
|
||||
if (d >= 0.5) return "moderate";
|
||||
return "weak";
|
||||
}
|
||||
|
||||
function candidates(days: Map<string, MoodDay>): Candidate[] {
|
||||
const list = [...days.values()].sort((a, b) =>
|
||||
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
|
||||
);
|
||||
const out: Candidate[] = [];
|
||||
|
||||
// The day after a day below the person's own median, against the day after
|
||||
// one above it. Days on the median itself belong to neither side.
|
||||
const med = list.length > 0 ? median(list.map((d) => d.mean)) : 0;
|
||||
const afterLow: number[] = [];
|
||||
const afterHigh: number[] = [];
|
||||
for (const day of list) {
|
||||
const prev = days.get(shiftDateKey(day.date, -1));
|
||||
if (!prev) continue;
|
||||
if (prev.mean < med) afterLow.push(day.mean);
|
||||
else if (prev.mean > med) afterHigh.push(day.mean);
|
||||
}
|
||||
out.push({
|
||||
id: "previous_day",
|
||||
kind: "previous_day",
|
||||
subject: afterLow,
|
||||
comparison: afterHigh,
|
||||
});
|
||||
|
||||
// Saturday and Sunday against Monday to Friday.
|
||||
out.push({
|
||||
id: "weekend",
|
||||
kind: "weekend",
|
||||
subject: list.filter((d) => weekdayOfKey(d.date) >= 5).map((d) => d.mean),
|
||||
comparison: list.filter((d) => weekdayOfKey(d.date) < 5).map((d) => d.mean),
|
||||
});
|
||||
|
||||
// Each weekday against the rest. All seven are tested so the selection of
|
||||
// the best one is paid for in the false discovery control; only the best
|
||||
// can be published.
|
||||
for (let weekday = 0; weekday < 7; weekday += 1) {
|
||||
out.push({
|
||||
id: `weekday:${weekday}`,
|
||||
kind: "best_weekday",
|
||||
weekday,
|
||||
subject: list
|
||||
.filter((d) => weekdayOfKey(d.date) === weekday)
|
||||
.map((d) => d.mean),
|
||||
comparison: list
|
||||
.filter((d) => weekdayOfKey(d.date) !== weekday)
|
||||
.map((d) => d.mean),
|
||||
});
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: "notes",
|
||||
kind: "notes",
|
||||
subject: list.filter((d) => d.hasNote).map((d) => d.mean),
|
||||
comparison: list.filter((d) => !d.hasNote).map((d) => d.mean),
|
||||
});
|
||||
|
||||
out.push({
|
||||
id: "multiple_entries",
|
||||
kind: "multiple_entries",
|
||||
subject: list.filter((d) => d.entries >= 2).map((d) => d.mean),
|
||||
comparison: list.filter((d) => d.entries === 1).map((d) => d.mean),
|
||||
});
|
||||
|
||||
// A tag's association with the NEXT day's mood: the day after a tagged day
|
||||
// against the day after an untagged one. Only consecutive logged days count.
|
||||
const tagRefs = new Map<string, StructuredTagRef>();
|
||||
for (const day of list) {
|
||||
for (const [key, ref] of day.tags)
|
||||
if (!tagRefs.has(key)) tagRefs.set(key, ref);
|
||||
}
|
||||
for (const [key, ref] of tagRefs) {
|
||||
const afterTag: number[] = [];
|
||||
const afterOther: number[] = [];
|
||||
for (const day of list) {
|
||||
const prev = days.get(shiftDateKey(day.date, -1));
|
||||
if (!prev) continue;
|
||||
if (prev.tags.has(key)) afterTag.push(day.mean);
|
||||
else afterOther.push(day.mean);
|
||||
}
|
||||
out.push({
|
||||
id: `delayed_tag:${key}`,
|
||||
kind: "delayed_tag",
|
||||
tag: ref,
|
||||
subject: afterTag,
|
||||
comparison: afterOther,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gated patterns over the entries whose mood day falls inside the
|
||||
* trailing `windowDays` ending at `todayKey`.
|
||||
*/
|
||||
export function computeMoodPatterns(args: {
|
||||
entries: readonly MoodAggregateEntry[];
|
||||
noteDays: ReadonlySet<string>;
|
||||
todayKey: string;
|
||||
windowDays: number;
|
||||
}): MoodPatternFinding[] {
|
||||
const fromKey = shiftDateKey(args.todayKey, -(args.windowDays - 1));
|
||||
const inWindow = args.entries.filter(
|
||||
(e) => e.date >= fromKey && e.date <= args.todayKey,
|
||||
);
|
||||
const days = collapseDays(inWindow, args.noteDays);
|
||||
|
||||
const tested: Array<{ c: Candidate; p: number }> = [];
|
||||
for (const c of candidates(days)) {
|
||||
if (
|
||||
c.subject.length < PATTERN_MIN_DAYS_PER_SIDE ||
|
||||
c.comparison.length < PATTERN_MIN_DAYS_PER_SIDE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const welch = welchTTest(c.subject, c.comparison);
|
||||
// No spread on either side: nothing a test can stand behind.
|
||||
if (welch.status !== "ok") continue;
|
||||
tested.push({ c, p: welch.pValue });
|
||||
}
|
||||
if (tested.length === 0) return [];
|
||||
|
||||
const q = benjaminiHochberg(tested.map((t) => t.p));
|
||||
const passed: MoodPatternFinding[] = [];
|
||||
tested.forEach(({ c }, i) => {
|
||||
if (q[i] > PATTERN_FDR_Q) return;
|
||||
const subject = mean(c.subject);
|
||||
const comparison = mean(c.comparison);
|
||||
const delta = subject - comparison;
|
||||
passed.push({
|
||||
id: c.kind === "best_weekday" ? "best_weekday" : c.id,
|
||||
kind: c.kind,
|
||||
strength: strengthOf(c.subject, c.comparison),
|
||||
direction: delta > 0 ? "higher" : delta < 0 ? "lower" : "none",
|
||||
values: {
|
||||
subject: round(subject, 2),
|
||||
comparison: round(comparison, 2),
|
||||
delta: round(delta, 2),
|
||||
...(c.weekday !== undefined ? { weekday: c.weekday } : {}),
|
||||
...(c.tag ? { tagKey: c.tag.key } : {}),
|
||||
},
|
||||
sampleSize: {
|
||||
subject: c.subject.length,
|
||||
comparison: c.comparison.length,
|
||||
},
|
||||
...(c.tag
|
||||
? { tag: { labelKey: c.tag.labelKey, label: c.tag.label ?? null } }
|
||||
: {}),
|
||||
qValue: q[i],
|
||||
});
|
||||
});
|
||||
|
||||
// One best weekday at most: the highest one that passed and is above the
|
||||
// rest. A weekday that is only significantly LOWER is not a best weekday.
|
||||
const weekdays = passed
|
||||
.filter((p) => p.kind === "best_weekday" && p.direction === "higher")
|
||||
.sort((a, b) => b.values.subject - a.values.subject);
|
||||
const kept = passed.filter((p) => p.kind !== "best_weekday");
|
||||
if (weekdays.length > 0) kept.push(weekdays[0]);
|
||||
|
||||
const STRENGTH_RANK = { strong: 0, moderate: 1, weak: 2 } as const;
|
||||
return kept.sort(
|
||||
(a, b) =>
|
||||
STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength] ||
|
||||
Math.abs(b.values.delta) - Math.abs(a.values.delta) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** The wire form: the tag named in the reader's language, no internal fields. */
|
||||
export function moodPatternsWire(
|
||||
findings: readonly MoodPatternFinding[] | undefined,
|
||||
t: (key: string) => string,
|
||||
): MoodPatternWire[] {
|
||||
return (findings ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
kind: f.kind,
|
||||
strength: f.strength,
|
||||
direction: f.direction,
|
||||
values: {
|
||||
...f.values,
|
||||
...(f.tag ? { tagLabel: f.tag.label ?? t(f.tag.labelKey) } : {}),
|
||||
},
|
||||
sampleSize: f.sampleSize,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
|
||||
|
||||
import {
|
||||
computeSeriesChange,
|
||||
seriesDayValues,
|
||||
seriesReferenceValue,
|
||||
} from "../series-change";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const upBad = resolveTrendVerdict("RESTING_HEART_RATE", {
|
||||
profile: { dateOfBirth: null, gender: null, heightCm: null },
|
||||
thresholdsJson: null,
|
||||
referenceValue: null,
|
||||
});
|
||||
|
||||
describe("seriesDayValues", () => {
|
||||
it("sums a summed type per local day and averages the rest", () => {
|
||||
const points = [
|
||||
{ at: "2026-09-01T06:00:00.000Z", value: 1000 },
|
||||
{ at: "2026-09-01T20:00:00.000Z", value: 2000 },
|
||||
// 23:30 UTC is already the next day in Berlin.
|
||||
{ at: "2026-09-01T23:30:00.000Z", value: 500 },
|
||||
];
|
||||
expect(seriesDayValues("ACTIVITY_STEPS", points, TZ)).toEqual([
|
||||
{ day: "2026-09-01", value: 3000 },
|
||||
{ day: "2026-09-02", value: 500 },
|
||||
]);
|
||||
expect(seriesDayValues("WEIGHT", points, TZ)[0]).toEqual({
|
||||
day: "2026-09-01",
|
||||
value: 1500,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSeriesChange", () => {
|
||||
it("is null under two days", () => {
|
||||
expect(
|
||||
computeSeriesChange([{ day: "2026-09-01", value: 60 }], upBad),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("compares the last day with the first and judges the change", () => {
|
||||
expect(
|
||||
computeSeriesChange(
|
||||
[
|
||||
{ day: "2026-09-01", value: 60 },
|
||||
{ day: "2026-09-02", value: 70 },
|
||||
{ day: "2026-09-03", value: 64.123 },
|
||||
],
|
||||
upBad,
|
||||
),
|
||||
).toEqual({ from: 60, to: 64.12, delta: 4.12, sentiment: "negative" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("seriesReferenceValue", () => {
|
||||
it("averages the last seven days", () => {
|
||||
const days = Array.from({ length: 10 }, (_, i) => ({
|
||||
day: `2026-09-${String(i + 1).padStart(2, "0")}`,
|
||||
value: i + 1,
|
||||
}));
|
||||
expect(seriesReferenceValue(days)).toBe(7);
|
||||
expect(seriesReferenceValue([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { locales as LOCALES } from "@/lib/i18n/config";
|
||||
|
||||
import { resolveSeriesReferenceLines } from "../series-reference-lines";
|
||||
|
||||
const en = getServerTranslator("en");
|
||||
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
|
||||
const MAN_41 = {
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
};
|
||||
|
||||
function lines(
|
||||
kind: Parameters<typeof resolveSeriesReferenceLines>[0],
|
||||
opts: {
|
||||
profile?: typeof NO_PROFILE | typeof MAN_41;
|
||||
thresholdsJson?: unknown;
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
} = {},
|
||||
) {
|
||||
return resolveSeriesReferenceLines(kind, {
|
||||
profile: opts.profile ?? NO_PROFILE,
|
||||
thresholdsJson: opts.thresholdsJson ?? null,
|
||||
glucoseUnit: opts.glucoseUnit ?? "mg/dL",
|
||||
t: (key) => en.t(key),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveSeriesReferenceLines", () => {
|
||||
it("draws glucose 70 / 180 mg/dL, converted for an mmol/L account", () => {
|
||||
expect(lines("glucose").map((l) => [l.kind, l.value])).toEqual([
|
||||
["lower", 70],
|
||||
["upper", 180],
|
||||
]);
|
||||
expect(
|
||||
lines("glucose", { glucoseUnit: "mmol/L" }).map((l) => [l.kind, l.value]),
|
||||
).toEqual([
|
||||
["lower", 3.9],
|
||||
["upper", 10],
|
||||
]);
|
||||
});
|
||||
|
||||
it("draws the ESH 140 / 90 lines without a profile or a target", () => {
|
||||
expect(
|
||||
lines("bloodPressure").map((l) => [
|
||||
l.appliesTo,
|
||||
l.kind,
|
||||
l.value,
|
||||
l.source,
|
||||
]),
|
||||
).toEqual([
|
||||
["value", "upper", 140, "clinical"],
|
||||
["secondary", "upper", 90, "clinical"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the clinical edge with the person's band", () => {
|
||||
const own = lines("bloodPressure", {
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
});
|
||||
expect(own.map((l) => [l.appliesTo, l.kind, l.value, l.source])).toEqual([
|
||||
["value", "lower", 115, "personal"],
|
||||
["value", "upper", 125, "personal"],
|
||||
["secondary", "upper", 90, "clinical"],
|
||||
]);
|
||||
// An age turns the diastolic side into the profile band too.
|
||||
const aged = lines("bloodPressure", { profile: MAN_41 });
|
||||
expect(aged.every((l) => l.source === "personal")).toBe(true);
|
||||
expect(aged.map((l) => l.value)).toEqual([120, 129, 70, 79]);
|
||||
});
|
||||
|
||||
it("draws the SpO2 floor, or the person's band instead", () => {
|
||||
expect(lines("oxygenSaturation")).toMatchObject([
|
||||
{ value: 92, kind: "lower", source: "clinical" },
|
||||
]);
|
||||
expect(
|
||||
lines("oxygenSaturation", {
|
||||
thresholdsJson: { OXYGEN_SATURATION: { min: 90, max: 96 } },
|
||||
}).map((l) => l.value),
|
||||
).toEqual([90, 96]);
|
||||
});
|
||||
|
||||
it("draws the resting band on the resting chart only", () => {
|
||||
expect(lines("restingHeartRate", { profile: MAN_41 })).toHaveLength(2);
|
||||
expect(lines("pulse", { profile: MAN_41 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty when there is nothing to draw", () => {
|
||||
expect(lines("heartRateVariability")).toEqual([]);
|
||||
expect(lines("steps")).toEqual([]);
|
||||
expect(lines("weight", { profile: MAN_41 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("names every label key in every bundle, and labels in the account language", () => {
|
||||
const keys = new Set([
|
||||
...lines("bloodPressure").map((l) => l.labelKey),
|
||||
...lines("bloodPressure", { profile: MAN_41 }).map((l) => l.labelKey),
|
||||
...lines("glucose").map((l) => l.labelKey),
|
||||
]);
|
||||
expect(keys.size).toBe(4);
|
||||
for (const locale of LOCALES) {
|
||||
const t = getServerTranslator(locale);
|
||||
for (const key of keys) expect(t.t(key)).not.toBe(key);
|
||||
}
|
||||
const de = getServerTranslator("de");
|
||||
const german = resolveSeriesReferenceLines("glucose", {
|
||||
profile: NO_PROFILE,
|
||||
thresholdsJson: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
t: (key) => de.t(key),
|
||||
});
|
||||
expect(german[0].label).toBe("Klinische Untergrenze");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The first-to-last change of a chart window, by day (v1.43, ios #116
|
||||
* item 3).
|
||||
*
|
||||
* A chart's change is read off day values, not off single points: the first
|
||||
* reading of a window can be an outlier minute of a dense series, and a day
|
||||
* of steps is its total, not its last sample. Each local day of the window is
|
||||
* reduced with the type's own day statistic (the total for a summed type, the
|
||||
* mean of the hours' means for pulse, the mean otherwise), and the change is
|
||||
* the last day against the first. Under two days there is no change to show.
|
||||
*
|
||||
* Pure: the series route hands over the points it already serves, in the
|
||||
* unit it serves them in.
|
||||
*/
|
||||
import { dayValue } from "@/lib/measurements/day-mean";
|
||||
import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import {
|
||||
trendSentimentOf,
|
||||
type TrendSentiment,
|
||||
type TrendVerdict,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export interface SeriesChange {
|
||||
from: number;
|
||||
to: number;
|
||||
delta: number;
|
||||
sentiment: TrendSentiment;
|
||||
}
|
||||
|
||||
export interface SeriesDayValue {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** One value per local day, oldest first. */
|
||||
export function seriesDayValues(
|
||||
type: string,
|
||||
points: ReadonlyArray<{ at: string; value: number }>,
|
||||
timezone: string,
|
||||
): SeriesDayValue[] {
|
||||
const byDay = new Map<string, Array<{ value: number; measuredAt: Date }>>();
|
||||
for (const point of points) {
|
||||
const measuredAt = new Date(point.at);
|
||||
if (!Number.isFinite(point.value) || Number.isNaN(measuredAt.getTime())) {
|
||||
continue;
|
||||
}
|
||||
const day = userDayKey(measuredAt, timezone);
|
||||
const list = byDay.get(day);
|
||||
if (list) list.push({ value: point.value, measuredAt });
|
||||
else byDay.set(day, [{ value: point.value, measuredAt }]);
|
||||
}
|
||||
const summed = isCumulativeDaySumType(type);
|
||||
const out: SeriesDayValue[] = [];
|
||||
for (const [day, rows] of byDay) {
|
||||
const value = summed
|
||||
? rows.reduce((sum, r) => sum + r.value, 0)
|
||||
: dayValue(type, rows, timezone);
|
||||
if (value !== null) out.push({ day, value });
|
||||
}
|
||||
return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
}
|
||||
|
||||
export function computeSeriesChange(
|
||||
days: readonly SeriesDayValue[],
|
||||
verdict: TrendVerdict | null,
|
||||
): SeriesChange | null {
|
||||
if (days.length < 2) return null;
|
||||
const from = round2(days[0].value);
|
||||
const to = round2(days[days.length - 1].value);
|
||||
const delta = round2(to - from);
|
||||
return { from, to, delta, sentiment: trendSentimentOf(delta, verdict) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a window's verdict places against the band: the mean of its last
|
||||
* seven day values (the dashboard places the seven-day average the same way),
|
||||
* so one day near a band edge does not flip the colour.
|
||||
*/
|
||||
export function seriesReferenceValue(
|
||||
days: readonly SeriesDayValue[],
|
||||
): number | null {
|
||||
if (days.length === 0) return null;
|
||||
const recent = days.slice(-7);
|
||||
return recent.reduce((sum, d) => sum + d.value, 0) / recent.length;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Horizontal reference lines for a measurement chart (v1.43, ios #116 item 4).
|
||||
*
|
||||
* The native client drew fixed threshold lines (blood pressure 140/60, pulse
|
||||
* 100/60, glucose 70/180 mg/dL, fever 37.5, SpO2 95) that knew neither the
|
||||
* series' unit nor the person's targets, and removed them. The server knows
|
||||
* both, so it resolves the lines here and `GET /api/measurements/series`
|
||||
* serves them in the unit of its own points.
|
||||
*
|
||||
* Two sources, one rule: a personal line (the person's own target from
|
||||
* `/targets`, else the profile default the target cards use: the age-based
|
||||
* blood pressure band, the age- and sex-based resting pulse band, the
|
||||
* sex-based body fat band) replaces the clinical line for the same edge.
|
||||
* The clinical lines are the ones a guideline names:
|
||||
*
|
||||
* - blood pressure 140 / 90 mmHg, the ESH 2023 hypertension threshold;
|
||||
* - glucose 70 and 180 mg/dL, the CGM time-in-range band (ATTD consensus,
|
||||
* Battelino et al. 2019), converted for an mmol/L account;
|
||||
* - SpO2 lower edge 92 %, the red-flag floor in `clinical-floors.ts`.
|
||||
*
|
||||
* Without either, the list is empty: nothing to draw is an answer, not an
|
||||
* error.
|
||||
*/
|
||||
import type {
|
||||
ThresholdMetric,
|
||||
UserProfileForRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { TIME_IN_RANGE_MGDL } from "@/lib/analytics/glucose-metrics";
|
||||
import { SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { REFERENCE_RANGES } from "@/lib/reference-ranges";
|
||||
import { resolvePersonalBand } from "@/lib/targets/trend-verdict";
|
||||
|
||||
export type SeriesKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation"
|
||||
| "restingHeartRate"
|
||||
| "heartRateVariability"
|
||||
| "vo2Max";
|
||||
|
||||
export interface SeriesReferenceLine {
|
||||
value: number;
|
||||
kind: "lower" | "upper";
|
||||
source: "personal" | "clinical";
|
||||
/** `secondary` is the diastolic line of a blood pressure chart. */
|
||||
appliesTo: "value" | "secondary";
|
||||
labelKey: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SeriesReferenceContext {
|
||||
/** The profile of the record on screen. */
|
||||
profile: UserProfileForRange;
|
||||
thresholdsJson: unknown;
|
||||
glucoseUnit: GlucoseUnit;
|
||||
/** Server translator in the account's language. */
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/** The ESH 2023 hypertension threshold, systolic from the shipped bands. */
|
||||
const ESH_HYPERTENSION_SYS =
|
||||
REFERENCE_RANGES.BLOOD_PRESSURE.bands.find((b) => b.label === "Hypertension")
|
||||
?.low ?? 140;
|
||||
/** Its diastolic counterpart (ESH 2023 grade 1 starts at 140 / 90). */
|
||||
const ESH_HYPERTENSION_DIA = 90;
|
||||
|
||||
/** Which `/targets` threshold a kind's personal lines come from. */
|
||||
const PERSONAL_METRIC: Partial<Record<SeriesKind, ThresholdMetric>> = {
|
||||
weight: "WEIGHT",
|
||||
bodyFat: "BODY_FAT",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
// A resting band on the resting chart only: raw heart rate includes every
|
||||
// workout, where the resting band says nothing.
|
||||
restingHeartRate: "PULSE",
|
||||
};
|
||||
|
||||
function personalBand(
|
||||
metric: ThresholdMetric,
|
||||
ctx: SeriesReferenceContext,
|
||||
): { min: number; max: number } | null {
|
||||
return (
|
||||
resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson)?.band ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function line(
|
||||
ctx: SeriesReferenceContext,
|
||||
value: number,
|
||||
kind: "lower" | "upper",
|
||||
source: "personal" | "clinical",
|
||||
appliesTo: "value" | "secondary",
|
||||
): SeriesReferenceLine {
|
||||
const labelKey =
|
||||
source === "personal"
|
||||
? kind === "upper"
|
||||
? "series.reference.targetUpper"
|
||||
: "series.reference.targetLower"
|
||||
: kind === "upper"
|
||||
? "series.reference.clinicalUpper"
|
||||
: "series.reference.clinicalLower";
|
||||
return { value, kind, source, appliesTo, labelKey, label: ctx.t(labelKey) };
|
||||
}
|
||||
|
||||
/** Both edges of a personal band. */
|
||||
function bandLines(
|
||||
ctx: SeriesReferenceContext,
|
||||
band: { min: number; max: number },
|
||||
appliesTo: "value" | "secondary",
|
||||
): SeriesReferenceLine[] {
|
||||
return [
|
||||
line(ctx, band.min, "lower", "personal", appliesTo),
|
||||
line(ctx, band.max, "upper", "personal", appliesTo),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveSeriesReferenceLines(
|
||||
kind: SeriesKind,
|
||||
ctx: SeriesReferenceContext,
|
||||
): SeriesReferenceLine[] {
|
||||
switch (kind) {
|
||||
case "bloodPressure": {
|
||||
const sys = personalBand("BLOOD_PRESSURE_SYS", ctx);
|
||||
const dia = personalBand("BLOOD_PRESSURE_DIA", ctx);
|
||||
return [
|
||||
...(sys
|
||||
? bandLines(ctx, sys, "value")
|
||||
: [line(ctx, ESH_HYPERTENSION_SYS, "upper", "clinical", "value")]),
|
||||
...(dia
|
||||
? bandLines(ctx, dia, "secondary")
|
||||
: [
|
||||
line(ctx, ESH_HYPERTENSION_DIA, "upper", "clinical", "secondary"),
|
||||
]),
|
||||
];
|
||||
}
|
||||
case "glucose":
|
||||
// The series mixes every meal context, so no single context's target
|
||||
// is the person's line here; the time-in-range band is.
|
||||
return [
|
||||
line(
|
||||
ctx,
|
||||
convertGlucose(TIME_IN_RANGE_MGDL.low, ctx.glucoseUnit),
|
||||
"lower",
|
||||
"clinical",
|
||||
"value",
|
||||
),
|
||||
line(
|
||||
ctx,
|
||||
convertGlucose(TIME_IN_RANGE_MGDL.high, ctx.glucoseUnit),
|
||||
"upper",
|
||||
"clinical",
|
||||
"value",
|
||||
),
|
||||
];
|
||||
case "oxygenSaturation": {
|
||||
const own = personalBand("OXYGEN_SATURATION", ctx);
|
||||
if (own) return bandLines(ctx, own, "value");
|
||||
return [line(ctx, SPO2_RED_FLAG_PCT, "lower", "clinical", "value")];
|
||||
}
|
||||
default: {
|
||||
const metric = PERSONAL_METRIC[kind];
|
||||
if (!metric) return [];
|
||||
const band = personalBand(metric, ctx);
|
||||
return band ? bandLines(ctx, band, "value") : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +412,7 @@ export const derivedMetricResponse = z
|
||||
.record(z.string(), z.unknown())
|
||||
.nullable()
|
||||
.describe(
|
||||
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
|
||||
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
|
||||
),
|
||||
coverage: derivedCoverage,
|
||||
confidence: derivedConfidence
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolvePersonalBand,
|
||||
resolveSummaryVerdicts,
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOf,
|
||||
trendSentimentOfTrend,
|
||||
} from "../trend-verdict";
|
||||
|
||||
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
|
||||
const MAN_41 = {
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
};
|
||||
const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
|
||||
|
||||
function verdict(
|
||||
key: string,
|
||||
referenceValue: number | null,
|
||||
thresholdsJson: unknown = null,
|
||||
profile: typeof NO_PROFILE | typeof MAN_41 = NO_PROFILE,
|
||||
) {
|
||||
return resolveTrendVerdict(key, { profile, thresholdsJson, referenceValue });
|
||||
}
|
||||
|
||||
describe("resolveTrendVerdict against the person's own target", () => {
|
||||
it("calls a rising blood pressure above the target a setback", () => {
|
||||
const v = verdict("BLOOD_PRESSURE_SYS", 132, OWN_BP);
|
||||
expect(v).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "personal_target",
|
||||
});
|
||||
expect(trendSentimentOf(4, v)).toBe("negative");
|
||||
});
|
||||
|
||||
it("calls the same rise below the target progress", () => {
|
||||
const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP);
|
||||
expect(v).toEqual({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
basis: "personal_target",
|
||||
});
|
||||
expect(trendSentimentOf(4, v)).toBe("positive");
|
||||
});
|
||||
|
||||
it("holds inside the target, with the edges counted as inside", () => {
|
||||
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP)).toMatchObject({
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
});
|
||||
const v = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
|
||||
expect(trendSentimentOf(0, v)).toBe("positive");
|
||||
expect(trendSentimentOf(3, v)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("judges the BLOOD_PRESSURE target tile by the systolic band", () => {
|
||||
expect(verdict("BLOOD_PRESSURE", 132, OWN_BP).direction).toBe("up-bad");
|
||||
});
|
||||
|
||||
it("reads a sleep target in hours against a reference in minutes", () => {
|
||||
const own = { SLEEP_DURATION: { min: 7, max: 8 } };
|
||||
expect(verdict("SLEEP_DURATION", 6 * 60, own)).toMatchObject({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
});
|
||||
expect(verdict("SLEEP_DURATION", 7.5 * 60, own).direction).toBe("hold");
|
||||
});
|
||||
|
||||
it("ignores a malformed target and falls back", () => {
|
||||
expect(
|
||||
verdict("BLOOD_PRESSURE_SYS", 132, {
|
||||
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTrendVerdict against a profile default", () => {
|
||||
it("uses the age-based blood pressure band", () => {
|
||||
expect(verdict("BLOOD_PRESSURE_SYS", 125, null, MAN_41)).toEqual({
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
basis: "profile_default",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the age- and sex-based resting pulse band for both pulse keys", () => {
|
||||
for (const key of ["PULSE", "RESTING_HEART_RATE"]) {
|
||||
expect(verdict(key, 95, null, MAN_41)).toMatchObject({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "profile_default",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not call a band nobody is personalised for a profile default", () => {
|
||||
// No age: the pulse band is the same for everyone.
|
||||
expect(verdict("RESTING_HEART_RATE", 95)).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
// No sex: no body fat band.
|
||||
expect(verdict("BODY_FAT", 30).basis).toBe("metric_default");
|
||||
});
|
||||
|
||||
it("prefers the person's own target over the profile default", () => {
|
||||
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe(
|
||||
"personal_target",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTrendVerdict metric defaults", () => {
|
||||
it.each([
|
||||
["ACTIVITY_STEPS", "up-good"],
|
||||
["HEART_RATE_VARIABILITY", "up-good"],
|
||||
["HRV_RMSSD", "up-good"],
|
||||
["VO2_MAX", "up-good"],
|
||||
["MOOD", "up-good"],
|
||||
["BODY_FAT", "up-bad"],
|
||||
["RESTING_HEART_RATE", "up-bad"],
|
||||
["RESPIRATORY_RATE", "neutral"],
|
||||
["TOTAL_BODY_WATER", "neutral"],
|
||||
["BLOOD_GLUCOSE_FASTING", "neutral"],
|
||||
])("%s defaults to %s", (key, direction) => {
|
||||
expect(verdict(key, null)).toEqual({
|
||||
direction,
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps weight's own rule: only a stored target counts", () => {
|
||||
expect(verdict("WEIGHT", 70, null, MAN_41)).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
expect(verdict("WEIGHT", 60, { WEIGHT: { min: 65, max: 72 } })).toEqual({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
basis: "personal_target",
|
||||
});
|
||||
// BMI follows the weight it is derived from.
|
||||
expect(verdict("BMI", 60, { WEIGHT: { min: 65, max: 72 } }).direction).toBe(
|
||||
"up-good",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trend sentiment", () => {
|
||||
it("applies the noise floor and treats no verdict as neutral", () => {
|
||||
const upBad = verdict("RESTING_HEART_RATE", null);
|
||||
expect(trendSentimentOf(0.01, upBad)).toBe("neutral");
|
||||
expect(trendSentimentOf(-2, upBad)).toBe("positive");
|
||||
expect(trendSentimentOf(2, null)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("reads a trend word the way it reads a signed change", () => {
|
||||
const upGood = verdict("ACTIVITY_STEPS", null);
|
||||
expect(trendSentimentOfTrend("up", upGood)).toBe("positive");
|
||||
expect(trendSentimentOfTrend("down", upGood)).toBe("negative");
|
||||
expect(trendSentimentOfTrend("flat", upGood)).toBe("neutral");
|
||||
expect(trendSentimentOfTrend("unknown", upGood)).toBe("neutral");
|
||||
expect(trendSentimentOfTrend(null, upGood)).toBe("neutral");
|
||||
const hold = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
|
||||
expect(trendSentimentOfTrend("stable", hold)).toBe("positive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePersonalBand", () => {
|
||||
it("is null when neither an own target nor a personalised default exists", () => {
|
||||
expect(resolvePersonalBand("ACTIVITY_STEPS", NO_PROFILE, null)).toBeNull();
|
||||
expect(resolvePersonalBand("BLOOD_PRESSURE_DIA", MAN_41, null)).toEqual({
|
||||
band: { min: 70, max: 79 },
|
||||
basis: "profile_default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSummaryVerdicts", () => {
|
||||
it("places each tile's seven-day average and adds the summaryless keys", () => {
|
||||
const out = resolveSummaryVerdicts(
|
||||
{
|
||||
BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 },
|
||||
ACTIVITY_STEPS: { avg7: null, latest: 9000 },
|
||||
},
|
||||
{ profile: NO_PROFILE, thresholdsJson: OWN_BP },
|
||||
);
|
||||
expect(out.BLOOD_PRESSURE_SYS.direction).toBe("up-bad");
|
||||
expect(out.ACTIVITY_STEPS.direction).toBe("up-good");
|
||||
expect(out.MOOD.direction).toBe("up-good");
|
||||
expect(out.BLOOD_PRESSURE_IN_TARGET.direction).toBe("up-good");
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,11 @@ import { buildSleepTarget } from "./sleep-builder";
|
||||
import { buildTargetPageSummary } from "./summary-builder";
|
||||
import { buildVitalTargets } from "./vitals-builder";
|
||||
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOfTrend,
|
||||
type TrendVerdictContext,
|
||||
} from "./trend-verdict";
|
||||
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
@@ -362,6 +367,18 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.43 (ios #116 item 3) — every item carries which way its change
|
||||
// counts, judged with the same rule the dashboard tiles use.
|
||||
const verdictTargets = withTargetVerdicts(targets, {
|
||||
profile: {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
},
|
||||
thresholdsJson: dbUser?.thresholdsJson ?? null,
|
||||
});
|
||||
targets.splice(0, targets.length, ...verdictTargets);
|
||||
|
||||
const pageSummary = buildTargetPageSummary(targets);
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
@@ -393,3 +410,35 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the verdict over each target item. The reference is the item's latest
|
||||
* value, else its thirty-day average, in the unit the item is served in:
|
||||
* sleep is served in hours and judged in minutes, and BMI is judged by the
|
||||
* weight it is derived from.
|
||||
*/
|
||||
export function withTargetVerdicts(
|
||||
targets: readonly TargetItem[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): TargetItem[] {
|
||||
const weight = targets.find((item) => item.type === "WEIGHT");
|
||||
const weightReference = weight ? (weight.current ?? weight.average30) : null;
|
||||
return targets.map((item) => {
|
||||
const own = item.current ?? item.average30;
|
||||
const reference =
|
||||
item.type === "BMI"
|
||||
? weightReference
|
||||
: own !== null && item.type === "SLEEP_DURATION"
|
||||
? own * 60
|
||||
: own;
|
||||
const direction = resolveTrendVerdict(item.type, {
|
||||
...ctx,
|
||||
referenceValue: reference,
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
direction,
|
||||
trendSentiment: trendSentimentOfTrend(item.trend, direction),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Which way a change of a metric counts as progress, for this person (v1.43,
|
||||
* ios #116 item 3). The generalisation of `resolveWeightTrend`.
|
||||
*
|
||||
* Every surface that shows a change (a dashboard arrow, a seven-day delta, a
|
||||
* target tile, a chart's first-to-last change) used to carry its own fixed
|
||||
* polarity: a rising blood pressure turned orange whatever the person's
|
||||
* target was, and the native client kept a third copy of the table. The
|
||||
* verdict is resolved here, once, and published on every route that serves a
|
||||
* change; clients colour from it and never decide it themselves.
|
||||
*
|
||||
* The rule:
|
||||
*
|
||||
* - a band applies (the person's own target from `/targets`, else a
|
||||
* profile-derived default: the age-based blood pressure target, the
|
||||
* age- and sex-based resting pulse band, the sex-based body fat band):
|
||||
* below the band → a rise is progress (`up-good`)
|
||||
* above the band → a fall is progress (`up-bad`)
|
||||
* inside the band → holding is progress (`hold`)
|
||||
* - no band, or no value to place: the metric's own default direction.
|
||||
*
|
||||
* Weight keeps its own rule (`weight-trend.ts`): only a target the person set
|
||||
* counts, never the height-derived band, because a band nobody chose says
|
||||
* nothing about which way they want to move.
|
||||
*
|
||||
* Pure and client-safe: no DB read, no clock beyond the age the profile
|
||||
* helpers compute.
|
||||
*/
|
||||
import {
|
||||
getEffectiveRange,
|
||||
resolveThresholdOverride,
|
||||
resolveWeightTargetOverride,
|
||||
type ThresholdMetric,
|
||||
type UserProfileForRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import {
|
||||
getTrendSentiment,
|
||||
type TrendDirectionSentiment,
|
||||
type TrendSentimentDirection,
|
||||
} from "@/lib/insights/trend-sentiment";
|
||||
import { binaryReferenceSex } from "@/lib/profile/sex";
|
||||
|
||||
import { resolveWeightTrend } from "./weight-trend";
|
||||
|
||||
export type TrendTargetPosition = "below" | "inside" | "above";
|
||||
export type TrendVerdictBasis =
|
||||
"personal_target" | "profile_default" | "metric_default";
|
||||
|
||||
export interface TrendVerdict {
|
||||
direction: TrendDirectionSentiment;
|
||||
targetPosition: TrendTargetPosition | null;
|
||||
basis: TrendVerdictBasis;
|
||||
}
|
||||
|
||||
export type TrendSentiment = TrendSentimentDirection;
|
||||
|
||||
/**
|
||||
* What a verdict can be asked for: a measurement type, or one of the derived
|
||||
* keys a target tile or dashboard card carries (`BLOOD_PRESSURE` is the
|
||||
* systolic side of the pair, `BMI` follows weight, `MOOD` / `MOOD_SCORE` the
|
||||
* mood mean, `BLOOD_PRESSURE_IN_TARGET` and `MEDICATION_COMPLIANCE` a share).
|
||||
*/
|
||||
export type TrendVerdictKey = string;
|
||||
|
||||
/**
|
||||
* The metric's own direction when no band applies. A rise is progress for the
|
||||
* capacity and activity measures, a setback for the load measures, and
|
||||
* carries no judgement for everything else (body water, bone mass, breathing
|
||||
* rate, skin temperature, intake, …).
|
||||
*/
|
||||
const METRIC_DEFAULT_DIRECTION: Readonly<
|
||||
Record<string, Exclude<TrendDirectionSentiment, "hold">>
|
||||
> = {
|
||||
ACTIVITY_STEPS: "up-good",
|
||||
HEART_RATE_VARIABILITY: "up-good",
|
||||
HRV_RMSSD: "up-good",
|
||||
VO2_MAX: "up-good",
|
||||
OXYGEN_SATURATION: "up-good",
|
||||
MUSCLE_MASS: "up-good",
|
||||
SLEEP_DURATION: "up-good",
|
||||
SIX_MINUTE_WALK_DISTANCE: "up-good",
|
||||
GRIP_STRENGTH: "up-good",
|
||||
MOOD: "up-good",
|
||||
MOOD_SCORE: "up-good",
|
||||
BLOOD_PRESSURE_IN_TARGET: "up-good",
|
||||
MEDICATION_COMPLIANCE: "up-good",
|
||||
RESTING_HEART_RATE: "up-bad",
|
||||
PULSE: "up-bad",
|
||||
BODY_FAT: "up-bad",
|
||||
BLOOD_PRESSURE: "up-bad",
|
||||
BLOOD_PRESSURE_SYS: "up-bad",
|
||||
BLOOD_PRESSURE_DIA: "up-bad",
|
||||
BODY_TEMPERATURE: "up-bad",
|
||||
BLOOD_GLUCOSE: "up-bad",
|
||||
};
|
||||
|
||||
/** The `/targets` threshold a key is judged against, when it has one. */
|
||||
const THRESHOLD_FOR_KEY: Readonly<Record<string, ThresholdMetric>> = {
|
||||
BLOOD_PRESSURE: "BLOOD_PRESSURE_SYS",
|
||||
BLOOD_PRESSURE_SYS: "BLOOD_PRESSURE_SYS",
|
||||
BLOOD_PRESSURE_DIA: "BLOOD_PRESSURE_DIA",
|
||||
// The resting pulse card and the dashboard pulse tile share one target.
|
||||
PULSE: "PULSE",
|
||||
RESTING_HEART_RATE: "PULSE",
|
||||
BODY_FAT: "BODY_FAT",
|
||||
SLEEP_DURATION: "SLEEP_DURATION",
|
||||
ACTIVITY_STEPS: "ACTIVITY_STEPS",
|
||||
TOTAL_BODY_WATER: "TOTAL_BODY_WATER",
|
||||
BONE_MASS: "BONE_MASS",
|
||||
OXYGEN_SATURATION: "OXYGEN_SATURATION",
|
||||
};
|
||||
|
||||
/**
|
||||
* Stored values the threshold is not written in. Sleep is stored in minutes
|
||||
* and targeted in hours; every other threshold shares the stored unit.
|
||||
*/
|
||||
const TO_THRESHOLD_UNIT: Readonly<Record<string, (value: number) => number>> = {
|
||||
SLEEP_DURATION: (minutes) => minutes / 60,
|
||||
};
|
||||
|
||||
export interface TrendVerdictContext {
|
||||
/** The profile of the RECORD on screen, not of the viewer. */
|
||||
profile: UserProfileForRange;
|
||||
/** `User.thresholdsJson` of the same record, unparsed. */
|
||||
thresholdsJson: unknown;
|
||||
/**
|
||||
* The value placed against the band, in the stored (canonical) unit of the
|
||||
* key: usually the seven-day average, else the latest reading. Null when
|
||||
* there is nothing to place, which leaves the metric's default.
|
||||
*/
|
||||
referenceValue: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the profile carries what the default band of this metric is
|
||||
* personalised from. Without it the band is the same for everyone, which is a
|
||||
* metric default and not a statement about this person.
|
||||
*/
|
||||
function hasProfileDefault(
|
||||
metric: ThresholdMetric,
|
||||
profile: UserProfileForRange,
|
||||
): boolean {
|
||||
switch (metric) {
|
||||
case "BLOOD_PRESSURE_SYS":
|
||||
case "BLOOD_PRESSURE_DIA":
|
||||
return profile.dateOfBirth != null;
|
||||
case "PULSE": {
|
||||
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
|
||||
return age != null && age >= 20;
|
||||
}
|
||||
case "BODY_FAT":
|
||||
return binaryReferenceSex(profile.gender) !== null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The band a person's values are judged against: their own target, else the
|
||||
* profile default when the profile carries what it is personalised from.
|
||||
* Null when neither applies. Shared with the chart reference lines, so the
|
||||
* line drawn and the verdict coloured come from the same band.
|
||||
*/
|
||||
export function resolvePersonalBand(
|
||||
metric: ThresholdMetric,
|
||||
profile: UserProfileForRange,
|
||||
thresholdsJson: unknown,
|
||||
): {
|
||||
band: { min: number; max: number };
|
||||
basis: Exclude<TrendVerdictBasis, "metric_default">;
|
||||
} | null {
|
||||
const own = resolveThresholdOverride(thresholdsJson, metric);
|
||||
if (own) return { band: own, basis: "personal_target" };
|
||||
if (!hasProfileDefault(metric, profile)) return null;
|
||||
const range = getEffectiveRange(metric, profile, null).range;
|
||||
return range
|
||||
? {
|
||||
band: { min: range.greenMin, max: range.greenMax },
|
||||
basis: "profile_default",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function metricDefault(key: string): TrendVerdict {
|
||||
return {
|
||||
direction: METRIC_DEFAULT_DIRECTION[key] ?? "neutral",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
};
|
||||
}
|
||||
|
||||
/** Place a value against a band; the edges count as inside. */
|
||||
export function placeAgainstBand(
|
||||
band: { min: number; max: number },
|
||||
value: number,
|
||||
): TrendTargetPosition {
|
||||
if (value < band.min) return "below";
|
||||
if (value > band.max) return "above";
|
||||
return "inside";
|
||||
}
|
||||
|
||||
const DIRECTION_BY_POSITION: Record<
|
||||
TrendTargetPosition,
|
||||
TrendDirectionSentiment
|
||||
> = {
|
||||
below: "up-good",
|
||||
above: "up-bad",
|
||||
inside: "hold",
|
||||
};
|
||||
|
||||
export function resolveTrendVerdict(
|
||||
key: TrendVerdictKey,
|
||||
ctx: TrendVerdictContext,
|
||||
): TrendVerdict {
|
||||
const reference =
|
||||
ctx.referenceValue !== null && Number.isFinite(ctx.referenceValue)
|
||||
? ctx.referenceValue
|
||||
: null;
|
||||
|
||||
// Weight, and BMI which moves exactly when weight does: only the person's
|
||||
// own target counts (see the module comment). For `BMI` the reference is
|
||||
// the WEIGHT it is derived from, in kg, since the target is a weight.
|
||||
if (key === "WEIGHT" || key === "BMI") {
|
||||
const target = resolveWeightTargetOverride(ctx.thresholdsJson);
|
||||
const judgement = resolveWeightTrend(target, reference);
|
||||
return {
|
||||
direction: judgement.direction,
|
||||
targetPosition: judgement.targetPosition,
|
||||
basis:
|
||||
judgement.targetPosition !== null
|
||||
? "personal_target"
|
||||
: "metric_default",
|
||||
};
|
||||
}
|
||||
|
||||
const metric = THRESHOLD_FOR_KEY[key];
|
||||
if (!metric || reference === null) return metricDefault(key);
|
||||
|
||||
const personal = resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson);
|
||||
if (!personal) return metricDefault(key);
|
||||
|
||||
const toUnit = TO_THRESHOLD_UNIT[key];
|
||||
const position = placeAgainstBand(
|
||||
personal.band,
|
||||
toUnit ? toUnit(reference) : reference,
|
||||
);
|
||||
return {
|
||||
direction: DIRECTION_BY_POSITION[position],
|
||||
targetPosition: position,
|
||||
basis: personal.basis,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivered change judged by a verdict, with the noise floor the arrows
|
||||
* have always used (`getTrendSentiment`). No verdict reads neutral.
|
||||
*/
|
||||
export function trendSentimentOf(
|
||||
change: number | null | undefined,
|
||||
verdict: TrendVerdict | null,
|
||||
): TrendSentiment {
|
||||
return getTrendSentiment(change, verdict?.direction ?? "neutral");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same judgement for a change that is only known by its direction (the
|
||||
* `trend` of a dashboard card or a target tile). A flat or unknown trend is
|
||||
* no change: inside a band that is the one case where it is news.
|
||||
*/
|
||||
export function trendSentimentOfTrend(
|
||||
trend: "up" | "down" | "flat" | "stable" | "unknown" | null | undefined,
|
||||
verdict: TrendVerdict | null,
|
||||
): TrendSentiment {
|
||||
if (trend === "up") return trendSentimentOf(1, verdict);
|
||||
if (trend === "down") return trendSentimentOf(-1, verdict);
|
||||
if (trend === "flat" || trend === "stable")
|
||||
return trendSentimentOf(0, verdict);
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/** Keys that carry a verdict without a measurement summary of their own. */
|
||||
export const SUMMARYLESS_VERDICT_KEYS = [
|
||||
"MOOD",
|
||||
"BLOOD_PRESSURE_IN_TARGET",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One verdict per summarised type, each placed with the value its tile shows
|
||||
* as the reference (the seven-day average, else the latest reading), plus the
|
||||
* summaryless keys at their metric default. The dashboard snapshot publishes
|
||||
* this as `tiles.directions`.
|
||||
*/
|
||||
export function resolveSummaryVerdicts(
|
||||
summaries: Readonly<
|
||||
Record<string, { avg7: number | null; latest: number | null } | undefined>
|
||||
>,
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): Record<string, TrendVerdict> {
|
||||
const out: Record<string, TrendVerdict> = {};
|
||||
for (const [type, summary] of Object.entries(summaries)) {
|
||||
if (!summary) continue;
|
||||
out[type] = resolveTrendVerdict(type, {
|
||||
...ctx,
|
||||
referenceValue: summary.avg7 ?? summary.latest ?? null,
|
||||
});
|
||||
}
|
||||
for (const key of SUMMARYLESS_VERDICT_KEYS) {
|
||||
out[key] ??= resolveTrendVerdict(key, { ...ctx, referenceValue: null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user