mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
e1ca523e21
commit
13b88e47f5
9 files changed
+64
-20
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user