fix(insights): convert weight and temperature in the mood/cycle crosstabs and the plateau note

Route the weight and temperature displays in the mood-factor and cycle-phase
crosstabs and the GLP-1 plateau note through the unit transform: absolute phase
averages take the affine conversion, deltas stay factor-only, and the unit
symbol follows the preference. Thread a unit token into the plateau evidence
copy across the six locales so the sentence reads in the user's unit.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 00:16:41 +02:00
1 parent e1ca523e21
commit 13b88e47f5
9 files changed
+64 -20

No files matched your search

+1 -1
View File
@@ -1885,7 +1885,7 @@
"levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.",
"plateau": {
"title": "Gewichts-Plateau auf aktueller Dosis",
"evidence": "Gewichtsveränderung von {delta} kg in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
"evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
},
"retarget": {
"label": "Nicht der richtige Messwert?",
+1 -1
View File
@@ -1875,7 +1875,7 @@
"levelShift": "A shift in the level was detected around {date}.",
"plateau": {
"title": "Weight plateau on the current dose",
"evidence": "Weight moved by {delta} kg over the last {window} days ({readings} readings) — {days} days on {dose}."
"evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings) — {days} days on {dose}."
},
"retarget": {
"label": "Not the right measurement?",
+1 -1
View File
@@ -1887,7 +1887,7 @@
"levelShift": "Se detectó un cambio de nivel en torno a {date}.",
"plateau": {
"title": "Meseta de peso con la dosis actual",
"evidence": "Variación de peso de {delta} kg en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
"evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
},
"retarget": {
"label": "¿No es la medición correcta?",
+1 -1
View File
@@ -1889,7 +1889,7 @@
"levelShift": "Un changement de niveau a été détecté autour du {date}.",
"plateau": {
"title": "Plateau de poids à la dose actuelle",
"evidence": "Variation de poids de {delta} kg sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
"evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
},
"retarget": {
"label": "Ce n'est pas la bonne mesure ?",
+1 -1
View File
@@ -1879,7 +1879,7 @@
"levelShift": "È stato rilevato un cambio di livello intorno al {date}.",
"plateau": {
"title": "Plateau del peso alla dose attuale",
"evidence": "Variazione di peso di {delta} kg negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
"evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
},
"retarget": {
"label": "Non è la misurazione giusta?",
+1 -1
View File
@@ -1873,7 +1873,7 @@
"levelShift": "Wykryto zmianę poziomu w okolicach {date}.",
"plateau": {
"title": "Plateau masy ciała przy obecnej dawce",
"evidence": "Zmiana masy ciała o {delta} kg w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
"evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
},
"retarget": {
"label": "To nie ten pomiar?",
+33 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { cn } from "@/lib/utils";
/**
@@ -77,6 +78,18 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
}
/**
* v1.32.26 — the two displays whose values follow the metric/imperial
* preference, mapped to the measurement type that carries the transform. Weight
* is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase
* average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the
* offset must never leak into a difference).
*/
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
kg: "WEIGHT",
celsius: "BODY_TEMPERATURE",
};
const HEADLINE_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
@@ -93,6 +106,7 @@ export function CyclePhaseHeadline({
headline: CyclePhaseCrosstabRow | null;
}) {
const { t } = useTranslations();
const unitDisplay = useUnitDisplay();
if (!headline) {
return (
<p
@@ -108,8 +122,9 @@ export function CyclePhaseHeadline({
);
// Defensive: an unmapped display must never pass undefined to `t` (whose
// resolver does `key.split(".")`). An empty unit is the honest degrade.
const mt = DISPLAY_TO_TYPE[headline.display];
const unitKey = UNIT_KEY[headline.display];
const unit = unitKey ? t(unitKey) : "";
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const up = headline.delta >= 0;
const dir = t(
up
@@ -118,7 +133,13 @@ export function CyclePhaseHeadline({
);
const params = {
metric: metricLabel,
delta: fmt(Math.abs(headline.delta), headline.display),
// A phase delta → factor-only conversion (no affine offset).
delta: fmt(
Math.abs(
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
),
headline.display,
),
unit,
dir,
};
@@ -141,6 +162,7 @@ export function CyclePhaseCrosstab({
rows: CyclePhaseCrosstabRow[];
}) {
const { t } = useTranslations();
const unitDisplay = useUnitDisplay();
if (rows.length === 0) return null;
return (
@@ -155,15 +177,20 @@ export function CyclePhaseCrosstab({
);
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const mt = DISPLAY_TO_TYPE[row.display];
const unitKey = UNIT_KEY[row.display];
const unit = unitKey ? t(unitKey) : "";
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const absConv = (v: number) =>
mt ? unitDisplay.toDisplay(mt, v) : v;
const deltaConv = (v: number) =>
mt ? unitDisplay.toDisplayDelta(mt, v) : v;
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
// observational, so colouring it would imply a verdict the data
// doesn't support. The sign prefix + `data-direction` carry it.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`;
return (
<li
key={row.metricKey}
@@ -197,8 +224,8 @@ export function CyclePhaseCrosstab({
<p className="text-muted-foreground text-xs">
{t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(row.lutealAvg, row.display),
follicularAvg: fmt(row.follicularAvg, row.display),
lutealAvg: fmt(absConv(row.lutealAvg), row.display),
follicularAvg: fmt(absConv(row.follicularAvg), row.display),
unit,
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
+10 -4
View File
@@ -27,6 +27,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import type { Glp1PlateauContext } from "@/lib/insights/glp1-plateau";
interface Glp1PlateauResponse {
@@ -36,6 +37,7 @@ interface Glp1PlateauResponse {
export function Glp1PlateauNote() {
const { t } = useTranslations();
const unitDisplay = useUnitDisplay();
const { data } = useQuery({
queryKey: queryKeys.insightsGlp1Plateau(),
queryFn: () => apiGet<Glp1PlateauResponse>("/api/insights/glp1-plateau"),
@@ -45,10 +47,13 @@ export function Glp1PlateauNote() {
if (!data?.plateau) return null;
const plateau = data.plateau;
const delta =
plateau.weightDeltaKg > 0
? `+${plateau.weightDeltaKg}`
: `${plateau.weightDeltaKg}`;
// The weight change is a DELTA → factor-only conversion (no affine offset).
const deltaValue =
Math.round(
unitDisplay.toDisplayDelta("WEIGHT", plateau.weightDeltaKg) * 10,
) / 10;
const delta = deltaValue > 0 ? `+${deltaValue}` : `${deltaValue}`;
const unit = unitDisplay.unitFor("WEIGHT");
const dose = `${plateau.drug} ${plateau.doseValue} ${plateau.doseUnit}`;
return (
@@ -64,6 +69,7 @@ export function Glp1PlateauNote() {
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.plateau.evidence", {
delta,
unit,
window: data.windowDays,
readings: plateau.readingsCount,
days: plateau.daysOnDose,
@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
@@ -85,6 +86,7 @@ export function MoodFactorMetricCrosstab({
rows: MoodFactorMetricCrosstabRow[];
}) {
const { t } = useTranslations();
const unitDisplay = useUnitDisplay();
if (rows.length === 0) return null;
return (
@@ -99,7 +101,16 @@ export function MoodFactorMetricCrosstab({
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const unit = t(UNIT_KEY[row.display]);
// v1.32.26 — the weight ("kg") row follows the metric/imperial
// preference: the unit symbol + the values (averages + delta, all in
// kg, offset-free) convert together. Every other display is unitless
// to the preference and keeps its i18n label + raw value.
const isWeightRow = row.display === "kg";
const unit = isWeightRow
? unitDisplay.unitFor("WEIGHT")
: t(UNIT_KEY[row.display]);
const conv = (v: number) =>
isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v;
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
// low-factor (worse) days. The number stays NEUTRAL — a higher value
// is good for steps but bad for resting HR, and this board is
@@ -107,7 +118,7 @@ export function MoodFactorMetricCrosstab({
// green/red would imply a health verdict the data doesn't support.
// The sign prefix + `data-direction` carry the direction.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
const deltaText = `${up ? "+" : ""}${fmt(conv(row.delta), row.display)} ${unit}`;
return (
<li
key={`${row.metricKey}:${row.factor}`}
@@ -153,8 +164,8 @@ export function MoodFactorMetricCrosstab({
{
factor: factorLabel,
metric: metricLabel,
lowAvg: fmt(row.lowAvg, row.display),
highAvg: fmt(row.highAvg, row.display),
lowAvg: fmt(conv(row.lowAvg), row.display),
highAvg: fmt(conv(row.highAvg), row.display),
unit,
lowDays: row.lowDays,
},