mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge Oura resilience tile into release/v1.19.2
Surface the captured Oura resilience band (limited…exceptional) as a calm tile on the recovery surface, with a quiet above/in-line/below-average cue and a learning state while the series is sparse. Server-authoritative read of the existing summaries slice; neutral card chrome.
This commit is contained in:
commit
559db472fc
9 files changed
+301
-3
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -234,3 +234,60 @@ describe("<RecoverySection> data-gating", () => {
|
||||
expect(html).not.toContain('data-slot="recovery-empty"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ResilienceTile> 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(<RecoverySection />);
|
||||
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(<RecoverySection />);
|
||||
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(<RecoverySection />);
|
||||
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(<RecoverySection />);
|
||||
expect(html).not.toContain('data-slot="recovery-empty"');
|
||||
expect(html).toContain('data-slot="resilience-tile"');
|
||||
// "adequate" band label is surfaced.
|
||||
expect(html).toContain("Adequate");
|
||||
});
|
||||
});
|
||||
@@ -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. */}
|
||||
<RestModeBanner annotation={restMode} />
|
||||
{/* v1.19.2 — Oura resilience band, self-gating (renders nothing without
|
||||
a reading). A calm ordinal readout, not a chart block. */}
|
||||
<ResilienceTile />
|
||||
{presentBlocks.map((block) => (
|
||||
<RecoveryMetricBlock
|
||||
key={block.type}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
|
||||
/**
|
||||
* v1.19.2 — calm readout for Oura's daily resilience level.
|
||||
*
|
||||
* v1.19.0 ingests `daily_resilience.level` end-to-end as the `RESILIENCE`
|
||||
* MeasurementType, ordinal-encoded into the numeric value (limited=1 …
|
||||
* exceptional=5; see `RESILIENCE_LEVELS` in `src/lib/oura/client`). The data
|
||||
* landed but no surface rendered it; this tile is that surface.
|
||||
*
|
||||
* Resilience is a categorical band, not a continuous score, so the generic
|
||||
* chart block would paint a misleading line over five discrete steps. Instead
|
||||
* this tile reads the stored latest ordinal, names its band, and gives a calm
|
||||
* one-line description plus a quiet "vs the recent average" cue. It is
|
||||
* server-authoritative throughout — it reads the shared
|
||||
* `["analytics", "summaries"]` slice (`latest` + `mean`, both computed
|
||||
* server-side) and never re-derives the value.
|
||||
*
|
||||
* Calm posture (the maintainer's standing card rule): one neutral card, no
|
||||
* green-when-good / red-when-low tint, no alarming chrome. The band label and
|
||||
* the trend cue carry the read in muted text only.
|
||||
*
|
||||
* Self-gating: renders nothing when the metric has no readings, so a
|
||||
* non-Oura account stays byte-identical. When the series is present but still
|
||||
* sparse it shows the shared `<LearningGate>` 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 (
|
||||
<Card data-slot="resilience-tile" data-metric="RESILIENCE">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ShieldCheck
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{t("measurements.typeResilience")}</span>
|
||||
</CardTitle>
|
||||
{bandKey != null ? (
|
||||
<CardAction
|
||||
data-slot="resilience-band"
|
||||
className="text-foreground self-baseline text-lg font-semibold"
|
||||
>
|
||||
{t(bandKey)}
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("insights.resilience.explainer")}
|
||||
</p>
|
||||
{count < MIN_TREND_READINGS ? (
|
||||
<LearningGate
|
||||
compact
|
||||
bodySlot="resilience-learning"
|
||||
message={t("insights.resilience.learning")}
|
||||
/>
|
||||
) : trendKey != null ? (
|
||||
<p
|
||||
data-slot="resilience-trend"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(TREND_KEYS[trendKey])}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user