Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions document/06-traps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/stock/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1469,7 +1469,7 @@ function DiluentShelf() {
setAdding(false);
}}
>
Add {count > 1 ? `${count} bottles` : "bottle"}
{t("stock_add_bottles", { n: Math.max(1, count) })}
</Button>
</div>
</Card>
Expand Down
40 changes: 36 additions & 4 deletions src/components/RescueNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecordKey, ...>` 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<RecordKey, PluralBase> = {
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.
Expand All @@ -30,6 +58,8 @@ export function RescueNotice() {
const [rescue, setRescue] = useState<Rescue | null>(null);
const [done, setDone] = useState<string | null>(null);

const describe = (l: Loss) => t(LOSS_KEY[l.key], { n: lostCount(l) });

const look = useCallback(() => {
readRescue().then(setRescue).catch(() => setRescue(null));
}, []);
Expand All @@ -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);
Expand All @@ -73,7 +105,7 @@ export function RescueNotice() {

<Callout tone="warn">
{t("rescue_lost", {
what: rescue.losses.map((l) => describeLoss(l)).join(", "),
what: rescue.losses.map(describe).join(", "),
when: formatDateTime(rescue.at),
})}
</Callout>
Expand Down
14 changes: 9 additions & 5 deletions src/lib/calc/rescue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {
RECORD_KEYS,
alarmingLosses,
countRecords,
describeLoss,
isAlarming,
losses,
lostCount,
recoverable,
restoreLost,
type Rescue,
Expand Down Expand Up @@ -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", () => {
Expand Down
32 changes: 12 additions & 20 deletions src/lib/calc/rescue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecordKey, { one: string; many: string }> = {
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;
}
89 changes: 88 additions & 1 deletion src/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading