diff --git a/messages/de.json b/messages/de.json index 670f4787e..3f965de1d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index 6d73a0395..ef2007aae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index 10c00c0aa..c6c9f21a2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index 754070013..870ed5dfd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index b4eeea2a1..c8eae2287 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index ddd224985..0ea309978 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 8e0f4d693..8303c18d3 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -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> = { + kg: "WEIGHT", + celsius: "BODY_TEMPERATURE", +}; + const HEADLINE_KEY: Record = { 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 (

= 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 (

  • {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, diff --git a/src/components/insights/glp1-plateau-note.tsx b/src/components/insights/glp1-plateau-note.tsx index dd8d9f01e..28bc1ee29 100644 --- a/src/components/insights/glp1-plateau-note.tsx +++ b/src/components/insights/glp1-plateau-note.tsx @@ -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("/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() {

    {t("medications.efficacy.plateau.evidence", { delta, + unit, window: data.windowDays, readings: plateau.readingsCount, days: plateau.daysOnDose, diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx index c4c1460e7..8393d0bbf 100644 --- a/src/components/insights/mood/mood-factor-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -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 (