Keep chart ticks readable and insights strings in the reader's language

A word-length unit on the y-axis ticks ('Schritte', 'Atemzüge/min',
'mL/(kg·min)') was wider than the axis gutter and lost its leading
digits; such units stay off the ticks, where the stat strip above
already names them. The sleep stage toggle reads '7T / 14T / 30T' like
every other range control, the 'Mood entries' and '7-day' target sources
are named in the reader's language, and the BMI and medication empty
states ask the coach in that language too.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:10:41 +02:00
1 parent 3f00d2effd
commit 0f51a828c4
16 files changed
+159 -8

No files matched your search

+11
View File
@@ -2407,6 +2407,7 @@
"days7Label": "7T",
"days7Title": "Letzte 7 Tage",
"days30Label": "30T",
"daysNLabel": "{days}T",
"days30Title": "Letzte 30 Tage",
"days90Label": "90T",
"days90Title": "Letzte 90 Tage",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog funktioniert vollständig ohne KI. Verbinde unten einen Anbieter, wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest.",
"action": "KI einrichten",
"dismiss": "Nicht jetzt"
},
"bmiHeight": {
"coachPrefill": "Ich habe meine Körpergröße noch nicht eingetragen — warum ist der BMI wichtig, und was sollte ich wissen, bevor ich sie eintrage?"
},
"medicationsEmpty": {
"coachPrefill": "Ich habe noch keine Medikamente angelegt — was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "Aktuell: {value} {unit}",
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
"sourceLabel": "Quelle: {source}",
"sourceNames": {
"moodEntries": "Stimmungseinträge",
"sevenDays": "7 Tage"
},
"inTarget": "Im Zielbereich",
"aboveTarget": "{delta} {unit} über Zielbereich",
"belowTarget": "{delta} {unit} unter Zielbereich",
+11
View File
@@ -2407,6 +2407,7 @@
"days7Label": "7d",
"days7Title": "Last 7 days",
"days30Label": "30d",
"daysNLabel": "{days}d",
"days30Title": "Last 30 days",
"days90Label": "90d",
"days90Title": "Last 90 days",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog works fully without AI. Connect a provider below if you want a daily briefing, short notes on your readings and the Coach.",
"action": "Set up AI",
"dismiss": "Not now"
},
"bmiHeight": {
"coachPrefill": "I haven’t set my height yet — why does BMI matter, and what should I know before I configure it?"
},
"medicationsEmpty": {
"coachPrefill": "I haven’t added any medications yet — what should I know before I start tracking medication compliance here?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "Current: {value} {unit}",
"targetRangeValue": "Target: {min}–{max} {unit}",
"sourceLabel": "Source: {source}",
"sourceNames": {
"moodEntries": "Mood entries",
"sevenDays": "7 days"
},
"inTarget": "Within target range",
"aboveTarget": "{delta} {unit} above target",
"belowTarget": "{delta} {unit} below target",
+11
View File
@@ -2407,6 +2407,7 @@
"days7Label": "7 d",
"days7Title": "Últimos 7 días",
"days30Label": "30 d",
"daysNLabel": "{days} d",
"days30Title": "Últimos 30 días",
"days90Label": "90 d",
"days90Title": "Últimos 90 días",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog funciona por completo sin IA. Conecta un proveedor abajo si quieres un resumen diario, notas breves sobre tus mediciones y el Coach.",
"action": "Configurar IA",
"dismiss": "Ahora no"
},
"bmiHeight": {
"coachPrefill": "Todavía no he indicado mi altura: ¿por qué importa el IMC y qué debería saber antes de configurarla?"
},
"medicationsEmpty": {
"coachPrefill": "Todavía no he añadido medicamentos: ¿qué debería saber antes de empezar a seguir aquí la adherencia?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "Actual: {value} {unit}",
"targetRangeValue": "Rango objetivo: {min}–{max} {unit}",
"sourceLabel": "Fuente: {source}",
"sourceNames": {
"moodEntries": "Entradas de ánimo",
"sevenDays": "7 días"
},
"inTarget": "En rango objetivo",
"aboveTarget": "{delta} {unit} por encima del rango",
"belowTarget": "{delta} {unit} por debajo del rango",
+11
View File
@@ -2407,6 +2407,7 @@
"days7Label": "7 j",
"days7Title": "7 derniers jours",
"days30Label": "30 j",
"daysNLabel": "{days} j",
"days30Title": "30 derniers jours",
"days90Label": "90 j",
"days90Title": "90 derniers jours",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog fonctionne entièrement sans IA. Connectez un fournisseur ci-dessous si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach.",
"action": "Configurer l'IA",
"dismiss": "Pas maintenant"
},
"bmiHeight": {
"coachPrefill": "Je n’ai pas encore indiqué ma taille — pourquoi l’IMC compte-t-il, et que dois-je savoir avant de la renseigner ?"
},
"medicationsEmpty": {
"coachPrefill": "Je n’ai encore ajouté aucun médicament — que dois-je savoir avant de suivre ici l’observance ?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "Actuel : {value} {unit}",
"targetRangeValue": "Plage cible : {min}–{max} {unit}",
"sourceLabel": "Source : {source}",
"sourceNames": {
"moodEntries": "Entrées d’humeur",
"sevenDays": "7 jours"
},
"inTarget": "Dans la plage cible",
"aboveTarget": "{delta} {unit} au-dessus de la plage",
"belowTarget": "{delta} {unit} en dessous de la plage",
+11
View File
@@ -2407,6 +2407,7 @@
"days7Label": "7 g",
"days7Title": "Ultimi 7 giorni",
"days30Label": "30 g",
"daysNLabel": "{days} g",
"days30Title": "Ultimi 30 giorni",
"days90Label": "90 g",
"days90Title": "Ultimi 90 giorni",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog funziona completamente senza IA. Collega un fornitore qui sotto se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach.",
"action": "Configura IA",
"dismiss": "Non ora"
},
"bmiHeight": {
"coachPrefill": "Non ho ancora inserito la mia altezza: perché conta il BMI e cosa dovrei sapere prima di impostarla?"
},
"medicationsEmpty": {
"coachPrefill": "Non ho ancora aggiunto farmaci: cosa dovrei sapere prima di iniziare a monitorare qui l’aderenza?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "Attuale: {value} {unit}",
"targetRangeValue": "Range obiettivo: {min}–{max} {unit}",
"sourceLabel": "Fonte: {source}",
"sourceNames": {
"moodEntries": "Voci dell’umore",
"sevenDays": "7 giorni"
},
"inTarget": "Nel range obiettivo",
"aboveTarget": "{delta} {unit} sopra il range",
"belowTarget": "{delta} {unit} sotto il range",
+11
View File
@@ -2498,6 +2498,7 @@
"days7Label": "7일",
"days7Title": "최근 7일",
"days30Label": "30일",
"daysNLabel": "{days}일",
"days30Title": "최근 30일",
"days90Label": "90일",
"days90Title": "최근 90일",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog는 AI 없이도 모든 기능이 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 아래에서 제공자를 연결하세요.",
"action": "AI 설정",
"dismiss": "나중에"
},
"bmiHeight": {
"coachPrefill": "아직 키를 입력하지 않았어요. BMI가 왜 중요하고, 입력하기 전에 무엇을 알아야 하나요?"
},
"medicationsEmpty": {
"coachPrefill": "아직 약을 추가하지 않았어요. 여기서 복약 순응도를 기록하기 전에 무엇을 알아야 하나요?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "현재: {value} {unit}",
"targetRangeValue": "목표: {min}–{max} {unit}",
"sourceLabel": "출처: {source}",
"sourceNames": {
"moodEntries": "기분 기록",
"sevenDays": "7일"
},
"inTarget": "목표 범위 안",
"aboveTarget": "목표보다 {delta} {unit} 높음",
"belowTarget": "목표보다 {delta} {unit} 낮음",
+11
View File
@@ -2407,6 +2407,7 @@
"days7Label": "7 dni",
"days7Title": "Ostatnie 7 dni",
"days30Label": "30 dni",
"daysNLabel": "{days} dni",
"days30Title": "Ostatnie 30 dni",
"days90Label": "90 dni",
"days90Title": "Ostatnie 90 dni",
@@ -4745,6 +4746,12 @@
"settingsBody": "HealthLog działa w pełni bez AI. Podłącz dostawcę poniżej, jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha.",
"action": "Skonfiguruj AI",
"dismiss": "Nie teraz"
},
"bmiHeight": {
"coachPrefill": "Nie podałem jeszcze wzrostu — dlaczego BMI ma znaczenie i co powinienem wiedzieć, zanim go ustawię?"
},
"medicationsEmpty": {
"coachPrefill": "Nie dodałem jeszcze żadnych leków — co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?"
}
},
"thresholds": {
@@ -8497,6 +8504,10 @@
"currentValue": "Aktualnie: {value} {unit}",
"targetRangeValue": "Zakres docelowy: {min}–{max} {unit}",
"sourceLabel": "Źródło: {source}",
"sourceNames": {
"moodEntries": "Wpisy nastroju",
"sevenDays": "7 dni"
},
"inTarget": "W zakresie docelowym",
"aboveTarget": "{delta} {unit} powyżej zakresu",
"belowTarget": "{delta} {unit} poniżej zakresu",
+1 -1
View File
@@ -95,7 +95,7 @@ export default function InsightsBmiPage() {
</Button>
}
/>
<CoachLaunchButton prefill="I haven't set my height yet — why does BMI matter, and what should I know before I configure it?" />
<CoachLaunchButton prefill={t("insights.bmiHeight.coachPrefill")} />
</SubPageShell>
);
}
+3 -1
View File
@@ -208,7 +208,9 @@ export default function InsightsMedikamentePage() {
</Button>
}
/>
<CoachLaunchButton prefill="I haven't added any medications yet — what should I know before I start tracking medication compliance here?" />
<CoachLaunchButton
prefill={t("insights.medicationsEmpty.coachPrefill")}
/>
</SubPageShell>
);
}
+2 -2
View File
@@ -76,7 +76,7 @@ import {
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
import { chartPointDayKey } from "@/components/day/chart-day";
import {
ChartDayFooter,
@@ -2078,7 +2078,7 @@ export function HealthChart({
}
unit={
showYAxisUnit
? axisUnitSuffix(yAxisUnit ?? unit)
? axisTickUnitSuffix(yAxisUnit ?? unit)
: undefined
}
/>
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { sourceName } from "../metric-target-summary";
describe("sourceName", () => {
const t = (key: string) => `t:${key}`;
it("names the plain-description sources in the reader's language", () => {
expect(sourceName("Mood entries", t)).toBe(
"t:targets.sourceNames.moodEntries",
);
expect(sourceName("7-day", t)).toBe("t:targets.sourceNames.sevenDays");
});
it("leaves a citation as it is", () => {
expect(sourceName("ESH 2023", t)).toBe("ESH 2023");
});
});
@@ -88,6 +88,9 @@ describe("<SleepStageStackedBar>", () => {
const html = render(<SleepStageStackedBar breakdown={breakdown} />, "de");
expect(html).toContain("Phasen-Verteilung");
expect(html).toContain("Letzte 7 Nächte");
// The window toggle speaks the app's range vocabulary, not "7d".
expect(html).toContain(">7T<");
expect(html).not.toContain(">7d<");
});
it("does not crash when the breakdown carries unknown stage keys", () => {
@@ -421,12 +421,18 @@ function TargetReferencePanel({
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<span>
{t("targets.sourceLabel", {
source: sourceName(target.source, t),
})}
</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
{t("targets.sourceLabel", {
source: sourceName(target.source, t),
})}
</span>
)}
</div>
@@ -434,3 +440,19 @@ function TargetReferencePanel({
</Card>
);
}
/**
* Two target sources are plain descriptions rather than a citation ("Mood
* entries", "7-day"); the server sends them in English, so they are named in
* the reader's language here. Every other source is a citation (ESH 2023,
* WHO, AASM/SRS) and reads as it is.
*/
const SOURCE_NAME_KEYS: Record<string, string> = {
"Mood entries": "targets.sourceNames.moodEntries",
"7-day": "targets.sourceNames.sevenDays",
};
export function sourceName(source: string, t: (key: string) => string): string {
const key = SOURCE_NAME_KEYS[source];
return key ? t(key) : source;
}
@@ -396,7 +396,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
aria-pressed={windowDays === w}
data-slot={`sleep-stage-window-${w}`}
>
{w}d
{/* The page's other range controls read "7T / 30T"; this
one printed an English "7d" beside them. */}
{t("charts.daysNLabel", { days: w })}
</Button>
))}
</div>
+13 -1
View File
@@ -7,7 +7,7 @@ import {
stripComments,
walkSourceFiles,
} from "@/__tests__/helpers/source-files";
import { axisUnitSuffix } from "../axis-unit";
import { axisTickUnitSuffix, axisUnitSuffix } from "../axis-unit";
const COMPONENTS = join(process.cwd(), "src", "components");
@@ -54,3 +54,15 @@ describe("axis unit suffix", () => {
expect(offenders).toEqual([]);
});
});
describe("axisTickUnitSuffix", () => {
it("keeps a short unit on the ticks", () => {
expect(axisTickUnitSuffix("bpm")).toBe(axisUnitSuffix("bpm"));
expect(axisTickUnitSuffix("mg/dL")).toBe(axisUnitSuffix("mg/dL"));
});
it("keeps a word-length unit off the ticks, where it was clipped", () => {
expect(axisTickUnitSuffix("Schritte")).toBeUndefined();
expect(axisTickUnitSuffix("Atemzüge/min")).toBeUndefined();
expect(axisTickUnitSuffix("mL/(kg·min)")).toBeUndefined();
});
});
+17
View File
@@ -13,3 +13,20 @@ export function axisUnitSuffix(
): string | undefined {
return unit ? ` ${unit}` : undefined;
}
/**
* The longest unit a tick label carries. A word-length unit ("Schritte",
* "Atemzüge/min", "mL/(kg·min)") made every tick wider than the fixed y-axis
* gutter, and the label was clipped at its left edge (".744 Schritte"). Such
* a unit stays off the ticks; the stat strip and the tile above the chart
* already name it.
*/
export const MAX_TICK_UNIT_LENGTH = 5;
/** The tick suffix for `unit`, or none when the unit is too long to fit. */
export function axisTickUnitSuffix(
unit: string | null | undefined,
): string | undefined {
if (!unit || unit.length > MAX_TICK_UNIT_LENGTH) return undefined;
return axisUnitSuffix(unit);
}