diff --git a/messages/de.json b/messages/de.json index c15520593..b8b30f2b0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Körpertemperatur-Abweichung", "subtitle": "Nächtliche Abweichung von deiner Basislinie" + }, + "resilience": { + "explainer": "Wie gut dein Körper die tägliche Belastung mit der Erholung ausbalanciert — aus deinen nächtlichen Signalen der letzten Tage abgelesen.", + "learning": "Deine Resilienz wird noch erlernt — ein paar Tage mehr festigen das Niveau.", + "band": { + "limited": "Eingeschränkt", + "adequate": "Ausreichend", + "solid": "Solide", + "strong": "Stark", + "exceptional": "Außergewöhnlich" + }, + "trend": { + "above": "Über deinem jüngsten Durchschnitt.", + "steady": "Im Einklang mit deinem jüngsten Durchschnitt.", + "below": "Unter deinem jüngsten Durchschnitt." + } } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 4ad161b92..06ccb722f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Body temperature deviation", "subtitle": "Overnight delta from your baseline" + }, + "resilience": { + "explainer": "How well your body is balancing daily strain with recovery, read from your overnight signals across recent days.", + "learning": "Still learning your resilience — a few more days will settle the band.", + "band": { + "limited": "Limited", + "adequate": "Adequate", + "solid": "Solid", + "strong": "Strong", + "exceptional": "Exceptional" + }, + "trend": { + "above": "Above your recent average.", + "steady": "In line with your recent average.", + "below": "Below your recent average." + } } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 66d9d2421..1fcc09ad7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Desviación de la temperatura corporal", "subtitle": "Variación nocturna respecto a tu línea base" + }, + "resilience": { + "explainer": "Cómo equilibra tu cuerpo la carga diaria con la recuperación, leído a partir de tus señales nocturnas de los últimos días.", + "learning": "Aún estamos aprendiendo tu resiliencia — unos días más asentarán el nivel.", + "band": { + "limited": "Limitada", + "adequate": "Adecuada", + "solid": "Sólida", + "strong": "Fuerte", + "exceptional": "Excepcional" + }, + "trend": { + "above": "Por encima de tu media reciente.", + "steady": "En línea con tu media reciente.", + "below": "Por debajo de tu media reciente." + } } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 608aef1a6..fdd7744d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Écart de température corporelle", "subtitle": "Écart nocturne par rapport à votre référence" + }, + "resilience": { + "explainer": "Comment votre corps équilibre la charge quotidienne et la récupération, d'après vos signaux nocturnes des derniers jours.", + "learning": "Votre résilience est encore en cours d'apprentissage — quelques jours de plus stabiliseront le niveau.", + "band": { + "limited": "Limitée", + "adequate": "Adéquate", + "solid": "Solide", + "strong": "Forte", + "exceptional": "Exceptionnelle" + }, + "trend": { + "above": "Au-dessus de votre moyenne récente.", + "steady": "Conforme à votre moyenne récente.", + "below": "En dessous de votre moyenne récente." + } } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 226eaebb0..310561160 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Deviazione della temperatura corporea", "subtitle": "Scostamento notturno dalla tua linea di base" + }, + "resilience": { + "explainer": "Quanto bene il tuo corpo bilancia il carico quotidiano con il recupero, letto dai tuoi segnali notturni degli ultimi giorni.", + "learning": "Stiamo ancora imparando la tua resilienza — qualche giorno in più stabilizzerà il livello.", + "band": { + "limited": "Limitata", + "adequate": "Adeguata", + "solid": "Solida", + "strong": "Forte", + "exceptional": "Eccezionale" + }, + "trend": { + "above": "Sopra la tua media recente.", + "steady": "In linea con la tua media recente.", + "below": "Sotto la tua media recente." + } } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 4853b2972..de8e33e11 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3539,6 +3539,22 @@ "bodyTempDeviation": { "title": "Odchylenie temperatury ciała", "subtitle": "Nocne odchylenie od Twojej linii bazowej" + }, + "resilience": { + "explainer": "Jak dobrze Twój organizm równoważy codzienne obciążenie z regeneracją — odczytane z nocnych sygnałów z ostatnich dni.", + "learning": "Wciąż poznajemy Twoją odporność — kilka kolejnych dni ustali poziom.", + "band": { + "limited": "Ograniczona", + "adequate": "Wystarczająca", + "solid": "Solidna", + "strong": "Silna", + "exceptional": "Wyjątkowa" + }, + "trend": { + "above": "Powyżej Twojej ostatniej średniej.", + "steady": "Zgodnie z Twoją ostatnią średnią.", + "below": "Poniżej Twojej ostatniej średniej." + } } }, "thresholds": { diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 0018b2cab..4aa8994ed 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -234,3 +234,60 @@ describe(" data-gating", () => { expect(html).not.toContain('data-slot="recovery-empty"'); }); }); + +describe(" on the recovery surface", () => { + it("stays hidden when there is no resilience reading", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }), + RESILIENCE: summary({ count: 0 }), + }), + ); + const html = render(); + expect(html).not.toContain('data-slot="resilience-tile"'); + }); + + it("names the latest band and explains it once enough days exist", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + // latest ordinal 4 → "strong"; mean 3 → above the recent average. + RESILIENCE: summary({ count: 9, latest: 4, mean: 3 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="resilience-tile"'); + expect(html).toContain('data-slot="resilience-band"'); + expect(html).toContain("Strong"); + expect(html).toContain('data-slot="resilience-trend"'); + expect(html).toContain("Above your recent average."); + // Calm by construction — no learning gate once past the threshold. + expect(html).not.toContain('data-slot="resilience-learning"'); + }); + + it("shows the calm learning note while the series is still sparse", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + RESILIENCE: summary({ count: 2, latest: 3, mean: 3 }), + }), + ); + const html = render(); + expect(html).toContain('data-slot="resilience-tile"'); + expect(html).toContain('data-slot="resilience-learning"'); + // No trend cue until the band has settled. + expect(html).not.toContain('data-slot="resilience-trend"'); + }); + + it("keeps the recovery surface non-empty when resilience is the only signal", () => { + analyticsMock.mockReturnValue( + analyticsWith({ + SLEEP_DURATION: summary({ count: 10 }), + RESILIENCE: summary({ count: 9, latest: 2, mean: 2 }), + }), + ); + const html = render(); + expect(html).not.toContain('data-slot="recovery-empty"'); + expect(html).toContain('data-slot="resilience-tile"'); + // "adequate" band label is surfaced. + expect(html).toContain("Adequate"); + }); +}); diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 6fdf36cf6..908d96795 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -20,6 +20,7 @@ import type { RestModeAnnotation } from "@/lib/analytics/health-score"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block"; +import { ResilienceTile } from "@/components/insights/recovery/resilience-tile"; interface BlockSpec { type: string; @@ -168,11 +169,17 @@ export function RecoverySection() { }, ]; + // v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced + // as a calm dedicated tile rather than a chart block, so it counts toward the + // data gate independently of `groups` (a chart-block list). + const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0; + const hasAny = summaries != null && - groups.some((group) => - group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), - ); + (hasResilience || + groups.some((group) => + group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), + )); if (isLoading || summaries == null) { return ( @@ -208,6 +215,9 @@ export function RecoverySection() { {/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface. Self-gating: renders nothing unless an episode is active. */} + {/* v1.19.2 — Oura resilience band, self-gating (renders nothing without + a reading). A calm ordinal readout, not a chart block. */} + {presentBlocks.map((block) => ( ` rather than a one-off trend. + */ + +/** Min readings before the "vs recent average" cue is shown. */ +const MIN_TREND_READINGS = 3; + +/** + * The ordinal → static i18n key, in lock-step with `RESILIENCE_LEVELS` + * (limited=1 … exceptional=5). Index 0 is unused so the ordinal indexes + * directly. The keys are written out in full (not `band.${ordinal}`) so the + * i18n call-site coverage guard verifies every one resolves. The band + * vocabulary is Oura's own — not invented here. + */ +const BAND_KEYS = [ + null, + "insights.resilience.band.limited", + "insights.resilience.band.adequate", + "insights.resilience.band.solid", + "insights.resilience.band.strong", + "insights.resilience.band.exceptional", +] as const; + +/** Static trend keys, written out so the i18n coverage guard resolves them. */ +const TREND_KEYS = { + above: "insights.resilience.trend.above", + steady: "insights.resilience.trend.steady", + below: "insights.resilience.trend.below", +} as const; + +export function ResilienceTile() { + const { t } = useTranslations(); + const { data } = useInsightsAnalytics("SLEEP_DURATION"); + const summary = data?.summaries?.["RESILIENCE"]; + + const count = summary?.count ?? 0; + if (count === 0) return null; + + const latest = summary?.latest ?? null; + const mean = summary?.mean ?? null; + // The ordinal lands as a number; clamp into the known 1–5 band range so a + // future Oura level the ingest does not yet recognise can never index out + // of the label table (ingest already drops unknown levels, this is belt). + const ordinal = + latest != null ? Math.min(5, Math.max(1, Math.round(latest))) : null; + const bandKey = ordinal != null ? BAND_KEYS[ordinal] : null; + + // Quiet trend: today's level against the rounded trailing-window mean. Calm + // wording (above / steady / below the recent average), never a colour. Both + // operands are server-computed; nothing is recomputed from raw rows here. + let trendKey: "above" | "steady" | "below" | null = null; + if (count >= MIN_TREND_READINGS && ordinal != null && mean != null) { + const meanOrdinal = Math.round(mean); + if (ordinal > meanOrdinal) trendKey = "above"; + else if (ordinal < meanOrdinal) trendKey = "below"; + else trendKey = "steady"; + } + + return ( + + + + + {bandKey != null ? ( + + {t(bandKey)} + + ) : null} + + +

+ {t("insights.resilience.explainer")} +

+ {count < MIN_TREND_READINGS ? ( + + ) : trendKey != null ? ( +

+ {t(TREND_KEYS[trendKey])} +

+ ) : null} +
+
+ ); +}