From 51e2d68a88bad92016021a46776a13f5239c73c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:30:56 +0200 Subject: [PATCH] i18n(measurements): say how a duration entry was read Refs #1101 --- messages/de.json | 8 ++- messages/en.json | 8 ++- messages/es.json | 8 ++- messages/fr.json | 8 ++- messages/it.json | 8 ++- messages/ko.json | 8 ++- messages/pl.json | 8 ++- src/__tests__/i18n-english-leak-guard.test.ts | 2 + .../__tests__/duration-read-as.test.tsx | 57 +++++++++++++++++++ 9 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/components/measurements/__tests__/duration-read-as.test.tsx diff --git a/messages/de.json b/messages/de.json index f6fb39d6b..d319c56a1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -798,7 +798,13 @@ "unitRatio": "Verhältnis", "unitRating": "Stufe", "typeWho5Score": "WHO-5-Wert", - "typeSciScore": "SCI-Wert" + "typeSciScore": "SCI-Wert", + "durationReadAs": "Gelesen als {duration}", + "durationHoursMinutes": "{hours} Std. {minutes} Min.", + "durationHoursOnly": "{hours} Std.", + "durationMinutesOnly": "{minutes} Min.", + "durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.", + "durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein." }, "metricCatalog": { "title": "Was HealthLog erfassen kann", diff --git a/messages/en.json b/messages/en.json index a8907d336..d077dfde3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -798,7 +798,13 @@ "unitRatio": "ratio", "unitRating": "rating", "typeWho5Score": "WHO-5 score", - "typeSciScore": "SCI score" + "typeSciScore": "SCI score", + "durationReadAs": "Read as {duration}", + "durationHoursMinutes": "{hours} h {minutes} min", + "durationHoursOnly": "{hours} h", + "durationMinutesOnly": "{minutes} min", + "durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.", + "durationTooLong": "One entry can't be longer than 24 hours." }, "metricCatalog": { "title": "What HealthLog can track", diff --git a/messages/es.json b/messages/es.json index ff74d35ae..b1038b4e0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -798,7 +798,13 @@ "unitRatio": "ratio", "unitRating": "nivel", "typeWho5Score": "Puntuación WHO-5", - "typeSciScore": "Puntuación SCI" + "typeSciScore": "Puntuación SCI", + "durationReadAs": "Se lee como {duration}", + "durationHoursMinutes": "{hours} h {minutes} min", + "durationHoursOnly": "{hours} h", + "durationMinutesOnly": "{minutes} min", + "durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.", + "durationTooLong": "Una entrada no puede durar más de 24 horas." }, "metricCatalog": { "title": "Lo que HealthLog puede registrar", diff --git a/messages/fr.json b/messages/fr.json index a52b3f3f4..9a8f0222e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -798,7 +798,13 @@ "unitRatio": "ratio", "unitRating": "niveau", "typeWho5Score": "Score WHO-5", - "typeSciScore": "Score SCI" + "typeSciScore": "Score SCI", + "durationReadAs": "Compris comme {duration}", + "durationHoursMinutes": "{hours} h {minutes} min", + "durationHoursOnly": "{hours} h", + "durationMinutesOnly": "{minutes} min", + "durationInvalid": "Saisis une durée comme 7,5, 7:30 ou 7h 30m.", + "durationTooLong": "Une saisie ne peut pas dépasser 24 heures." }, "metricCatalog": { "title": "Ce que HealthLog peut suivre", diff --git a/messages/it.json b/messages/it.json index db9a2d087..13192eaee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -798,7 +798,13 @@ "unitRatio": "rapporto", "unitRating": "livello", "typeWho5Score": "Punteggio WHO-5", - "typeSciScore": "Punteggio SCI" + "typeSciScore": "Punteggio SCI", + "durationReadAs": "Letto come {duration}", + "durationHoursMinutes": "{hours} h {minutes} min", + "durationHoursOnly": "{hours} h", + "durationMinutesOnly": "{minutes} min", + "durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.", + "durationTooLong": "Una voce non può superare le 24 ore." }, "metricCatalog": { "title": "Cosa può monitorare HealthLog", diff --git a/messages/ko.json b/messages/ko.json index 032cda26a..271f03069 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -798,7 +798,13 @@ "unitRatio": "비율", "unitRating": "단계", "typeWho5Score": "WHO-5 점수", - "typeSciScore": "SCI 점수" + "typeSciScore": "SCI 점수", + "durationReadAs": "{duration}(으)로 읽었어요", + "durationHoursMinutes": "{hours}시간 {minutes}분", + "durationHoursOnly": "{hours}시간", + "durationMinutesOnly": "{minutes}분", + "durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.", + "durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요." }, "metricCatalog": { "title": "HealthLog가 기록할 수 있는 것", diff --git a/messages/pl.json b/messages/pl.json index cc2ca36e2..388671013 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -798,7 +798,13 @@ "unitRatio": "stosunek", "unitRating": "poziom", "typeWho5Score": "Wynik WHO-5", - "typeSciScore": "Wynik SCI" + "typeSciScore": "Wynik SCI", + "durationReadAs": "Odczytano jako {duration}", + "durationHoursMinutes": "{hours} godz. {minutes} min", + "durationHoursOnly": "{hours} godz.", + "durationMinutesOnly": "{minutes} min", + "durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.", + "durationTooLong": "Jeden wpis nie może przekraczać 24 godzin." }, "metricCatalog": { "title": "Co HealthLog potrafi śledzić", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 897cd03bb..b5e5ad23a 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -99,6 +99,8 @@ const LEGIT_IDENTICAL = new Set([ "labs.catalog.hs-crp", "measurements.typeHrvRmssd", "measurements.typeVo2Max", + // "{hours} h {minutes} min": the SI symbols for hour and minute. + "measurements.durationHoursMinutes", "settings.sections.sources.metrics.vo2Max", // Brand / product / proper nouns. "settings.googleHealth", diff --git a/src/components/measurements/__tests__/duration-read-as.test.tsx b/src/components/measurements/__tests__/duration-read-as.test.tsx new file mode 100644 index 000000000..eb7eb2554 --- /dev/null +++ b/src/components/measurements/__tests__/duration-read-as.test.tsx @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { parseDurationEntry } from "@/lib/measurements/parse-duration"; +import { DurationReadAs } from "../duration-read-as"; + +/** + * The hint under a duration field says how the entry was read, so "7.5", + * "7:30" and "7h30" can each be checked before saving. + */ +function render(minutes: number, locale: "en" | "de" | "ko" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +function readAs(raw: string): string { + const read = parseDurationEntry(raw, "h"); + if (!read.ok) throw new Error(`refused ${raw}`); + return render(read.minutes); +} + +describe("", () => { + it.each(["7.5", "7,5", "7:30", "7h30", "7 h 30 min"])( + "shows %j as seven and a half hours", + (raw) => { + expect(readAs(raw)).toContain("Read as 7 h 30 min"); + }, + ); + + it("leaves out a zero part", () => { + expect(render(480)).toContain("Read as 8 h<"); + expect(render(45)).toContain("Read as 45 min"); + }); + + it("speaks the reader's language", () => { + expect(render(450, "de")).toContain("7 Std. 30 Min."); + expect(render(450, "ko")).toContain("7시간 30분"); + }); + + it("is wired under the measurement form's value field", () => { + const form = readFileSync( + join(process.cwd(), "src/components/measurements/measurement-form.tsx"), + "utf8", + ); + expect(form).toMatch( + /hint=\{\s*durationRead\?\.ok === true \? \(\s*/, + ); + // A refused entry is an inline field error, never a toast. + expect(form).toMatch(/setValueError\(\s*t\(/); + }); +});