diff --git a/messages/de.json b/messages/de.json index f790fc6f9..d2f3a034a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2625,7 +2625,9 @@ "description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV." }, "STRAIN_SCORE": { - "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag. Die Skala ist an deine eigene jüngste Trainingstagslast angelehnt — relativ zu deiner typischen Anstrengung —, sodass ein für dich wirklich harter Tag hoch ausfällt. Solange du noch Verlauf aufbaust, wird stattdessen ein allgemeiner Referenzwert verwendet.", + "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.", + "anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.", + "anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.", "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." }, diff --git a/messages/en.json b/messages/en.json index f9c78cba6..f403b2a34 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2625,7 +2625,9 @@ "description": "A nightly stress proxy from the shape of your intra-day HRV." }, "STRAIN_SCORE": { - "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day. The scale is anchored to your own recent training-day load — relative to your typical effort — so a genuinely hard day for you reads high. While you are still building history it uses a general reference instead.", + "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.", + "anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.", + "anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.", "caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.", "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort." }, diff --git a/messages/es.json b/messages/es.json index 4d7b1d04f..86177a82b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2625,7 +2625,9 @@ "description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día." }, "STRAIN_SCORE": { - "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente. La escala se ancla a tu propia carga reciente de días de entrenamiento —relativa a tu esfuerzo habitual—, de modo que un día realmente duro para ti aparece alto. Mientras aún estás acumulando historial usa una referencia general en su lugar.", + "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.", + "anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.", + "anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.", "caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.", "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual." }, diff --git a/messages/fr.json b/messages/fr.json index ba70c8a37..891517d37 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2625,7 +2625,9 @@ "description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée." }, "STRAIN_SCORE": { - "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante. L’échelle est calée sur votre propre charge récente des jours d’entraînement — relative à votre effort habituel —, de sorte qu’une journée vraiment difficile pour vous ressort élevée. Tant que vous constituez encore votre historique, elle utilise plutôt une référence générale.", + "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.", + "anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.", + "anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.", "caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.", "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel." }, diff --git a/messages/it.json b/messages/it.json index 883ac29a3..507a4a8fc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2625,7 +2625,9 @@ "description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata." }, "STRAIN_SCORE": { - "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa. La scala è ancorata al tuo carico recente nei giorni di allenamento — relativo al tuo sforzo abituale —, così che una giornata davvero dura per te risulti alta. Finché stai ancora costruendo lo storico usa invece un riferimento generale.", + "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.", + "anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.", + "anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.", "caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.", "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale." }, diff --git a/messages/pl.json b/messages/pl.json index a1da6cf7a..a9c068f1f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2625,7 +2625,9 @@ "description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia." }, "STRAIN_SCORE": { - "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień. Skala jest zakotwiczona w Twoim własnym niedawnym obciążeniu z dni treningowych — względem Twojego typowego wysiłku — więc naprawdę ciężki dzień dla Ciebie wypada wysoko. Dopóki dopiero budujesz historię, używa zamiast tego ogólnego punktu odniesienia.", + "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.", + "anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.", + "anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.", "caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.", "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku." }, diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index f253b9f0c..dd7bd263f 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -73,6 +73,29 @@ export function CompositeScoreAnatomy({ const meta = METRIC_PROVENANCE[metric]; const standard = meta.standard; const title = titleFor(metric, t); + + // STRAIN frames itself by the ACTUAL anchor that produced this score: once + // the user has enough history it is judged against their own typical effort + // (`personal`); during cold start it falls back to a general reference + // (`population`). The base method copy stays anchor-neutral; this line makes + // the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no + // cache row yet) renders nothing. + let anchorLine: string | null = null; + if ( + metric === "STRAIN_SCORE" && + data?.status === "ok" && + data.value + ) { + const anchor = (data.value as WellnessScoreValue).anchor; + if (anchor === "personal") { + anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal"); + } else if (anchor === "population") { + anchorLine = t( + "insights.derived.composite.STRAIN_SCORE.anchorPopulation", + ); + } + } + // Method copy carries an optional honesty caveat above it (STRESS proxy, // descriptive-not-clinical, …) so the caveat reaches the user, not just // the engine header. @@ -84,6 +107,7 @@ export function CompositeScoreAnatomy({ ) : null} {t(meta.methodKey)} + {anchorLine ? {anchorLine} : null} ); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index ffc105b4e..0928c979a 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ - prisma: { measurement: { findMany: vi.fn() } }, + prisma: { + measurement: { findMany: vi.fn() }, + strainTrimpCache: { findUnique: vi.fn() }, + }, })); import { prisma } from "@/lib/db"; @@ -14,9 +17,13 @@ import { const PROFILE = { ageYears: 40, sex: "MALE" as const }; const NOW = new Date("2026-06-02T08:00:00Z"); const findMany = prisma.measurement.findMany as ReturnType; +const cacheFindUnique = prisma.strainTrimpCache + .findUnique as ReturnType; beforeEach(() => { findMany.mockReset(); + cacheFindUnique.mockReset(); + cacheFindUnique.mockResolvedValue(null); }); describe("bandWellnessScore", () => { @@ -79,4 +86,51 @@ describe("computeWellnessScore", () => { expect((r.value as WellnessScoreValue).trendDelta).toBeNull(); } }); + + it("STRAIN carries the active anchor from the day's cache row", async () => { + findMany.mockResolvedValue([ + { value: 55, measuredAt: new Date("2026-06-01T12:00:00Z") }, + ]); + cacheFindUnique.mockResolvedValue({ anchor: "personal" }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + // The cache is keyed by the scored day (the latest score's day key). + expect(cacheFindUnique).toHaveBeenCalledWith({ + where: { userId_day: { userId: "u1", day: "2026-06-01" } }, + select: { anchor: true }, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).anchor).toBe("personal"); + } + }); + + it("STRAIN anchor is null when no cache row exists", async () => { + findMany.mockResolvedValue([ + { value: 55, measuredAt: new Date("2026-06-01T12:00:00Z") }, + ]); + cacheFindUnique.mockResolvedValue(null); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).anchor).toBeNull(); + } + }); + + it("RECOVERY does not read the strain cache and carries a null anchor", async () => { + findMany.mockResolvedValue([ + { value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") }, + ]); + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(cacheFindUnique).not.toHaveBeenCalled(); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).anchor).toBeNull(); + } + }); }); diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 3dca29a72..f52f739f8 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -16,6 +16,7 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage"; import type { BaselineProfile } from "./baseline"; +import type { StrainAnchor } from "@/lib/insights/strain-score"; import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; /** A 0–100 wellness score band. Higher is better for recovery; for stress a @@ -38,6 +39,16 @@ export interface WellnessScoreValue { * query. */ series: number[]; + /** + * STRAIN only — which anchor produced THIS score: `personal` once the user + * has enough training history to be judged against their own typical effort, + * `population` during cold start. Read from the latest `strain_trimp_cache` + * row for the scored day so the UI can show the framing line that actually + * applies, not a generic both-regimes blurb. `null` for RECOVERY / STRESS + * (no anchor concept) or when no cache row exists yet (additive — iOS + * non-breaking). + */ + anchor?: StrainAnchor | null; } /** The three persisted score types this engine serves. */ @@ -131,6 +142,24 @@ export async function computeWellnessScore( ) : null; + // STRAIN carries the active anchor for the scored day so the UI shows the + // framing line that actually produced THIS score (personal-relative vs the + // cold-start population reference). The `strain_trimp_cache` row is keyed by + // the same day stamp the score row carries (`scoreDayKey` → noon-UTC + // `measuredAt`), so the latest score's day key is the cache key. Source of + // truth is the cache row, not a re-derivation here. + let anchor: StrainAnchor | null = null; + if (type === "STRAIN_SCORE") { + const day = latest.measuredAt.toISOString().slice(0, 10); + const cache = await prisma.strainTrimpCache.findUnique({ + where: { userId_day: { userId, day } }, + select: { anchor: true }, + }); + if (cache?.anchor === "personal" || cache?.anchor === "population") { + anchor = cache.anchor; + } + } + return buildOk({ value: { score, @@ -143,6 +172,7 @@ export async function computeWellnessScore( .slice(0, SPARKLINE_MAX_POINTS) .map((r) => r.value) .reverse(), + anchor, }, coverage: { requiredInputs: 1,