From ba0f94561738536e11549937690cfa13aabd8a70 Mon Sep 17 00:00:00 2001 From: suskozaver Date: Wed, 16 Sep 2026 18:19:56 +0200 Subject: [PATCH] fix: two places the app still spoke English in every language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were found by reading the app rather than by grepping it, which is the point of the note added to 06-traps.md: the file-level check for a component that never imports useLang cannot see either of these, because both sit in files that use it everywhere else. The add button on the water shelf was built as `Add ${count} bottles` in JSX, so a Slovenian install offered "Add 4 bottles". It is now a plural family, which it has to be: one steklenicka, two steklenicki, three steklenicke, five steklenick, and a number glued to a noun is an English habit that no Slavic language forgives. The neighbouring stock_add_bottle moves from "steklenico" to "stekleničko" so the two buttons in one panel use one word for one thing. The rescue notice built its sentence from RECORD_LABEL in calc/rescue.ts, an English noun and its plural per collection. That is the rule this project wrote down after describePhase: a module under src/lib/calc may return an id, a number or a date, and if it is about to return a sentence, the sentence belongs to whoever renders it. So describeLoss and RECORD_LABEL are gone, lostCount returns the number, and the screen holds a Record from collection to plural family. Typed as a total record, so adding a collection to RECORD_KEYS fails to compile until it has a name, rather than printing a count with no noun on the one screen that only appears after a data loss. Five of the eleven collections already had a family. Six are new, in four languages. The Polish forms want a native reader, as the rest of the Polish does. --- document/06-traps.md | 8 +++ src/app/stock/page.tsx | 2 +- src/components/RescueNotice.tsx | 40 +++++++++++++-- src/lib/calc/rescue.test.ts | 14 ++++-- src/lib/calc/rescue.ts | 32 +++++------- src/lib/i18n/translations.ts | 89 ++++++++++++++++++++++++++++++++- 6 files changed, 154 insertions(+), 31 deletions(-) diff --git a/document/06-traps.md b/document/06-traps.md index 3d65c36..2bb8e31 100755 --- a/document/06-traps.md +++ b/document/06-traps.md @@ -598,6 +598,14 @@ done Run that before claiming a translation pass is finished. +It is necessary and not sufficient, and two later finds show where it stops. A +button reading `Add {count > 1 ? \`${count} bottles\` : "bottle"}` and a rescue +notice built from labels in `calc/rescue.ts` both sat in files that import +`useLang` and use it everywhere else, so a check about the file cannot see +either. What found them was somebody using the app in their own language and +reading a word that was not it. Budget for that: the last English in a screen is +found by a reader, not by a grep. + ## A shelf with something on it that reports nothing The Now card said **0 doses** for KPV while the Stock page, one tap away, said diff --git a/src/app/stock/page.tsx b/src/app/stock/page.tsx index 9a1d444..1e57ea2 100755 --- a/src/app/stock/page.tsx +++ b/src/app/stock/page.tsx @@ -1469,7 +1469,7 @@ function DiluentShelf() { setAdding(false); }} > - Add {count > 1 ? `${count} bottles` : "bottle"} + {t("stock_add_bottles", { n: Math.max(1, count) })} diff --git a/src/components/RescueNotice.tsx b/src/components/RescueNotice.tsx index 9e25761..9835078 100644 --- a/src/components/RescueNotice.tsx +++ b/src/components/RescueNotice.tsx @@ -4,9 +4,37 @@ import { useCallback, useEffect, useState } from "react"; import { LifeBuoy, Undo2, X } from "lucide-react"; import { Button, Callout, Card, SectionLabel } from "./ui"; import { clearRescue, readRescue, useStore } from "@/lib/store"; -import { describeLoss, recoverable, type Rescue } from "@/lib/calc/rescue"; +import { lostCount, recoverable, type Loss, type RecordKey, type Rescue } from "@/lib/calc/rescue"; import { formatDateTime } from "@/lib/format"; -import { useLang } from "@/lib/i18n"; +import { useLang, type PluralBase } from "@/lib/i18n"; + +/** + * What each collection is called, in the reader's language. + * + * `Record` rather than a lookup with a fallback, so adding a + * collection to `RECORD_KEYS` fails to compile until it has a name here. The + * alternative is a screen that says "3" and leaves off the noun, on the one + * occasion when being exact matters most. + * + * Five of these are families the app already had; the rest were added with + * this. Each is a plural family rather than a noun with a number glued in + * front, because Slovenian counts one steklenička, two steklenički, three + * stekleničke and five stekleničk, and an app that says "5 steklenička" is an + * app that was written in English. + */ +const LOSS_KEY: Record = { + profiles: "count_profiles", + protocols: "count_protocols", + logs: "count_doses", + vials: "count_vials", + measurements: "count_measurements", + labs: "count_results", + checkIns: "count_ratings", + customPeptides: "count_compounds", + orders: "count_orders", + diluents: "count_bottles", + compoundNotes: "count_notes", +}; /** * Says that records disappeared, and offers them back. @@ -30,6 +58,8 @@ export function RescueNotice() { const [rescue, setRescue] = useState(null); const [done, setDone] = useState(null); + const describe = (l: Loss) => t(LOSS_KEY[l.key], { n: lostCount(l) }); + const look = useCallback(() => { readRescue().then(setRescue).catch(() => setRescue(null)); }, []); @@ -51,7 +81,9 @@ export function RescueNotice() { setDone( missing.length ? t("rescue_done", { - what: missing.map((l) => describeLoss({ ...l, from: l.to, to: l.from })).join(", "), + // The pair is swapped because these are rows going back in rather + // than rows that went, and the count is the difference either way. + what: missing.map((l) => describe({ ...l, from: l.to, to: l.from })).join(", "), }) : t("rescue_done_nothing")); setRescue(null); @@ -73,7 +105,7 @@ export function RescueNotice() { {t("rescue_lost", { - what: rescue.losses.map((l) => describeLoss(l)).join(", "), + what: rescue.losses.map(describe).join(", "), when: formatDateTime(rescue.at), })} diff --git a/src/lib/calc/rescue.test.ts b/src/lib/calc/rescue.test.ts index 65e621f..ca55764 100644 --- a/src/lib/calc/rescue.test.ts +++ b/src/lib/calc/rescue.test.ts @@ -3,9 +3,9 @@ import { RECORD_KEYS, alarmingLosses, countRecords, - describeLoss, isAlarming, losses, + lostCount, recoverable, restoreLost, type Rescue, @@ -88,10 +88,14 @@ describe("the incident this was written for", () => { expect(found).toEqual([{ key: "diluents", from: 3, to: 0 }]); }); - it("is described in words a person can act on", () => { - expect(describeLoss({ key: "diluents", from: 3, to: 0 })).toBe("3 bottles of water"); - expect(describeLoss({ key: "diluents", from: 1, to: 0 })).toBe("1 bottle of water"); - expect(describeLoss({ key: "logs", from: 103, to: 40 })).toBe("63 logged doses"); + /* + * The count only. The noun and its plural form live with the screen that + * draws them, because this module cannot know which language is on. + */ + it("counts what went rather than naming it", () => { + expect(lostCount({ key: "diluents", from: 3, to: 0 })).toBe(3); + expect(lostCount({ key: "diluents", from: 1, to: 0 })).toBe(1); + expect(lostCount({ key: "logs", from: 103, to: 40 })).toBe(63); }); it("puts the bottles back without undoing the twelve doses since", () => { diff --git a/src/lib/calc/rescue.ts b/src/lib/calc/rescue.ts index 101c904..9ffa714 100644 --- a/src/lib/calc/rescue.ts +++ b/src/lib/calc/rescue.ts @@ -169,24 +169,16 @@ export function recoverable(current: AppData, rescue: Rescue): Loss[] { })); } -/** What a collection is called when the app has to say it out loud. */ -export const RECORD_LABEL: Record = { - profiles: { one: "profile", many: "profiles" }, - protocols: { one: "protocol", many: "protocols" }, - logs: { one: "logged dose", many: "logged doses" }, - vials: { one: "vial", many: "vials" }, - measurements: { one: "measurement", many: "measurements" }, - labs: { one: "lab result", many: "lab results" }, - checkIns: { one: "daily rating", many: "daily ratings" }, - customPeptides: { one: "compound you added", many: "compounds you added" }, - orders: { one: "order", many: "orders" }, - diluents: { one: "bottle of water", many: "bottles of water" }, - compoundNotes: { one: "compound note", many: "compound notes" }, -}; - -/** "3 bottles of water", for a sentence rather than for a table. */ -export function describeLoss(loss: Loss): string { - const gone = loss.from - loss.to; - const label = RECORD_LABEL[loss.key]; - return `${gone} ${gone === 1 ? label.one : label.many}`; +/** + * How many rows a loss took. + * + * There used to be a `RECORD_LABEL` here and a `describeLoss` that returned + * "3 bottles of water". Both are gone, and for the reason written into + * 06-traps.md: this module is pure logic, and a finished English sentence + * coming out of it is a sentence that cannot be translated, on the one screen + * that only ever appears at a bad moment. The count is the fact; the noun and + * its plural form belong to whoever draws the row. + */ +export function lostCount(loss: Loss): number { + return loss.from - loss.to; } diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts index 2a5d535..f5ac83e 100644 --- a/src/lib/i18n/translations.ts +++ b/src/lib/i18n/translations.ts @@ -336,6 +336,9 @@ export const TRANSLATIONS = { stock_water_section: "Water and diluents", stock_water_section_desc: "Optional. Track bottles here and reconstituting will draw from one.", stock_add_bottle: "Add a bottle", + // The save button on the add form, which knows how many are being added. + stock_add_bottles_one: "Add a bottle", + stock_add_bottles_other: "Add {n} bottles", stock_what: "What", stock_bottle_size: "Bottle size", stock_used_elsewhere: "Used elsewhere", @@ -842,6 +845,20 @@ export const TRANSLATIONS = { pep_blend_dose: "Blend dose", count_compounds_one: "{n} compound", count_compounds_other: "{n} compounds", + // Counted things the rescue notice names. Five of the collections it can + // report already have a family above; these are the rest. + count_profiles_one: "{n} profile", + count_profiles_other: "{n} profiles", + count_measurements_one: "{n} measurement", + count_measurements_other: "{n} measurements", + count_ratings_one: "{n} daily rating", + count_ratings_other: "{n} daily ratings", + count_orders_one: "{n} order", + count_orders_other: "{n} orders", + count_bottles_one: "{n} bottle of water", + count_bottles_other: "{n} bottles of water", + count_notes_one: "{n} compound note", + count_notes_other: "{n} compound notes", report_title: "Report for a clinician", report_subtitle: "Everything below prints on paper or to a PDF. It never leaves the device either way.", report_last_30: "Last 30 days", @@ -1626,6 +1643,8 @@ export const TRANSLATIONS = { stock_water_section: "Wasser und Verdünnungsmittel", stock_water_section_desc: "Optional. Verfolge hier Flaschen und die Rekonstitution zieht aus einer davon.", stock_add_bottle: "Flasche hinzufügen", + stock_add_bottles_one: "Flasche hinzufügen", + stock_add_bottles_other: "{n} Flaschen hinzufügen", stock_what: "Was", stock_bottle_size: "Flaschengröße", stock_used_elsewhere: "Anderswo verbraucht", @@ -2132,6 +2151,18 @@ export const TRANSLATIONS = { pep_blend_dose: "Dosis der Mischung", count_compounds_one: "{n} Substanz", count_compounds_other: "{n} Substanzen", + count_profiles_one: "{n} Profil", + count_profiles_other: "{n} Profile", + count_measurements_one: "{n} Messung", + count_measurements_other: "{n} Messungen", + count_ratings_one: "{n} Tagesbewertung", + count_ratings_other: "{n} Tagesbewertungen", + count_orders_one: "{n} Bestellung", + count_orders_other: "{n} Bestellungen", + count_bottles_one: "{n} Flasche Wasser", + count_bottles_other: "{n} Flaschen Wasser", + count_notes_one: "{n} Notiz zu einer Substanz", + count_notes_other: "{n} Notizen zu Substanzen", report_title: "Bericht für eine Ärztin oder einen Arzt", report_subtitle: "Alles unten druckt auf Papier oder in ein PDF. So oder so verlässt es das Gerät nie.", report_last_30: "Letzte 30 Tage", @@ -2938,7 +2969,11 @@ export const TRANSLATIONS = { stock_topup_note: "Izračunano iz tega, kar je še v vialki, in ne z nalepke, tako da delno porabljena vialka pride prav. Masa se ne spremeni in tudi datum uporabnosti ne, ta teče od prvega vboda in ne od tega.", stock_water_section: "Voda in topila", stock_water_section_desc: "Neobvezno. Tu spremljaj steklenice in rekonstitucija bo črpala iz ene.", - stock_add_bottle: "Dodaj steklenico", + stock_add_bottle: "Dodaj stekleničko", + stock_add_bottles_one: "Dodaj stekleničko", + stock_add_bottles_two: "Dodaj {n} steklenički", + stock_add_bottles_few: "Dodaj {n} stekleničke", + stock_add_bottles_other: "Dodaj {n} stekleničk", stock_what: "Kaj", stock_bottle_size: "Velikost steklenice", stock_used_elsewhere: "Porabljeno drugje", @@ -3475,6 +3510,30 @@ export const TRANSLATIONS = { count_compounds_two: "{n} snovi", count_compounds_few: "{n} snovi", count_compounds_other: "{n} snovi", + count_profiles_one: "{n} profil", + count_profiles_two: "{n} profila", + count_profiles_few: "{n} profili", + count_profiles_other: "{n} profilov", + count_measurements_one: "{n} meritev", + count_measurements_two: "{n} meritvi", + count_measurements_few: "{n} meritve", + count_measurements_other: "{n} meritev", + count_ratings_one: "{n} dnevna ocena", + count_ratings_two: "{n} dnevni oceni", + count_ratings_few: "{n} dnevne ocene", + count_ratings_other: "{n} dnevnih ocen", + count_orders_one: "{n} naročilo", + count_orders_two: "{n} naročili", + count_orders_few: "{n} naročila", + count_orders_other: "{n} naročil", + count_bottles_one: "{n} steklenička vode", + count_bottles_two: "{n} steklenički vode", + count_bottles_few: "{n} stekleničke vode", + count_bottles_other: "{n} stekleničk vode", + count_notes_one: "{n} zapisek o spojini", + count_notes_two: "{n} zapiska o spojini", + count_notes_few: "{n} zapiski o spojini", + count_notes_other: "{n} zapiskov o spojini", report_title: "Poročilo za zdravnika", report_subtitle: "Vse spodaj se natisne na papir ali v PDF. V nobenem primeru ne zapusti naprave.", report_last_30: "Zadnjih 30 dni", @@ -4304,6 +4363,10 @@ export const TRANSLATIONS = { stock_water_section: "Woda i rozpuszczalniki", stock_water_section_desc: "Opcjonalnie. Śledź tu butelki, a rozpuszczanie będzie z nich pobierać.", stock_add_bottle: "Dodaj butelkę", + stock_add_bottles_one: "Dodaj butelkę", + stock_add_bottles_few: "Dodaj {n} butelki", + stock_add_bottles_many: "Dodaj {n} butelek", + stock_add_bottles_other: "Dodaj {n} butelki", stock_what: "Co", stock_bottle_size: "Wielkość butelki", stock_used_elsewhere: "Zużyte gdzie indziej", @@ -4840,6 +4903,30 @@ export const TRANSLATIONS = { count_compounds_few: "{n} substancje", count_compounds_many: "{n} substancji", count_compounds_other: "{n} substancji", + count_profiles_one: "{n} profil", + count_profiles_few: "{n} profile", + count_profiles_many: "{n} profili", + count_profiles_other: "{n} profila", + count_measurements_one: "{n} pomiar", + count_measurements_few: "{n} pomiary", + count_measurements_many: "{n} pomiarów", + count_measurements_other: "{n} pomiaru", + count_ratings_one: "{n} ocena dnia", + count_ratings_few: "{n} oceny dnia", + count_ratings_many: "{n} ocen dnia", + count_ratings_other: "{n} oceny dnia", + count_orders_one: "{n} zamówienie", + count_orders_few: "{n} zamówienia", + count_orders_many: "{n} zamówień", + count_orders_other: "{n} zamówienia", + count_bottles_one: "{n} butelka wody", + count_bottles_few: "{n} butelki wody", + count_bottles_many: "{n} butelek wody", + count_bottles_other: "{n} butelki wody", + count_notes_one: "{n} notatka o związku", + count_notes_few: "{n} notatki o związku", + count_notes_many: "{n} notatek o związku", + count_notes_other: "{n} notatki o związku", report_title: "Raport dla lekarza", report_subtitle: "Wszystko poniżej drukuje się na papierze albo do PDF. Tak czy tak nie opuszcza urządzenia.", report_last_30: "Ostatnie 30 dni",