Third pass: the strings libraries build and views render raw

A sweep of lib/ and store/ for user-facing English, after the views were done.
The pattern here is the one #248 found: a module returns an English sentence
and the view renders it without asking for a translation.

Scheduled send was entirely untranslated. The four presets -- Later today,
Tomorrow morning, Tomorrow afternoon, Monday morning -- were rendered raw, and
scheduleError() returned three English sentences straight to the picker.
describeSpan() built "30 days" by appending an "s" unless the count was one,
which is English grammar written into the code: it produces the right German
only by the two languages happening to agree, and Russian needs three forms.
It is plural() now. scheduleError translates in place rather than returning a
key, because it composes a sentence around that span.

Read receipts: refusalText() returns five explanations, all rendered raw in
MessageView, and the offered case was a template literal -- "Requested, to
x@y. Never sent automatically." -- with the address concatenated in. It takes
a placeholder now, so the sentence can be reordered.

Compose: the sending toast and its Undo, the Open draft action, the two
attachment failures, and three thrown errors that surface to the reader as
toasts.

Two swipe labels, Add star and Remove star, are rendered through
t(desc.label) and have never been in any catalogue -- the coverage scan that
found the other 176 only looked at t("literal") sites, so labels reaching t()
through a variable were invisible to it. Every such table is now enumerated
and all 61 values checked: these two were the only ones missing.

17 strings and 3 plural forms are new and land with each language.

Verified: typecheck clean, 1000 tests pass.
This commit is contained in:
2026-09-03 13:52:24 -07:00
parent 3ffee1224f
commit be088ed78d
4 changed files with 25 additions and 18 deletions
+15 -8
View File
@@ -13,6 +13,7 @@
*/
import { addDays, startOfDay } from "./dates";
import { formatFullDateTime } from "./datetime";
import { plural, t } from "@/lib/i18n";
export const SUBMISSION_CAP = "urn:ietf:params:jmap:submission";
@@ -96,21 +97,27 @@ export function schedulePresets(now: Date, maxMs: number): SchedulePreset[] {
* surfaces as a failed send rather than anything the user can act on.
*/
export function scheduleError(at: Date, now: Date, maxMs: number): string | null {
const t = at.getTime();
if (Number.isNaN(t)) return "Pick a date and time.";
if (t < now.getTime() + MIN_LEAD_MS) return "Pick a time at least a minute from now.";
if (maxMs > 0 && t > now.getTime() + maxMs) {
return `This server will not hold a message longer than ${describeSpan(maxMs)}.`;
const ms = at.getTime();
if (Number.isNaN(ms)) return t("Pick a date and time.");
if (ms < now.getTime() + MIN_LEAD_MS) return t("Pick a time at least a minute from now.");
if (maxMs > 0 && ms > now.getTime() + maxMs) {
return t("This server will not hold a message longer than {span}.", { span: describeSpan(maxMs) });
}
return null;
}
/** "30 days", "7 days", "12 hours" -- for explaining the server's own limit. */
/**
* "30 days", "7 days", "12 hours" -- for explaining the server's own limit.
*
* plural() rather than `day${n === 1 ? "" : "s"}`: that suffix trick is English
* grammar written into the code, and it produces "2 Tage" only by accident of
* the two languages agreeing. Russian needs three forms and Japanese one.
*/
export function describeSpan(ms: number): string {
const days = Math.floor(ms / 86_400_000);
if (days >= 1) return `${days} day${days === 1 ? "" : "s"}`;
if (days >= 1) return plural(days, { one: "{n} day", other: "{n} days" });
const hours = Math.max(1, Math.floor(ms / 3_600_000));
return `${hours} hour${hours === 1 ? "" : "s"}`;
return plural(hours, { one: "{n} hour", other: "{n} hours" });
}
/** How a scheduled time reads in menus, banners and toasts. */