fix(i18n): stop one key template from whitelisting a whole namespace

The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 16:23:58 +02:00
1 parent f9a5e6a11f
commit d8a3c08e12
17 files changed
+680 -1155

No files matched your search

@@ -37,16 +37,29 @@ import {
const DAY_MS = 24 * 60 * 60 * 1000;
const SUMMARY_LIMIT = 3;
/**
* v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site
* that interpolates the tail onto the namespace root anchors the
* reverse-coverage guard at that bare namespace, which marks every key under
* it reachable and turns the guard into a no-op for the whole namespace. The
* guard now refuses that shape outright, so the key is built whole here.
*/
function relativeDueKey(
nextDueAt: string | null,
now: number,
): { key: string; days: number } {
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 };
const deltaDays = Math.round((new Date(nextDueAt).getTime() - now) / DAY_MS);
if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
return { key: "nextDue.inDays", days: deltaDays };
if (deltaDays < 0)
return {
key: "measurementReminders.overdueByDays",
days: Math.abs(deltaDays),
};
if (deltaDays === 0)
return { key: "measurementReminders.nextDue.today", days: 0 };
if (deltaDays === 1)
return { key: "measurementReminders.nextDue.tomorrow", days: 1 };
return { key: "measurementReminders.nextDue.inDays", days: deltaDays };
}
function resolveLabel(
@@ -141,7 +154,7 @@ export function VorsorgeDashboardCard() {
{resolveLabel(reminder, t)}
</p>
<Badge variant="secondary" className="text-xs">
{t(`measurementReminders.${due.key}`, {
{t(due.key, {
days: due.days,
})}
</Badge>
@@ -143,13 +143,21 @@ const CADENCE_CUSTOM = "custom";
const CADENCE_RRULE = "rrule";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site
* that interpolates the tail onto the namespace root anchors the
* reverse-coverage guard at that bare namespace, which marks every key under
* it reachable and turns the guard into a no-op for the whole namespace. The
* guard now refuses that shape outright, so the key is built whole here.
*/
function relativeDueKey(
nextDueAt: string | null,
now: number,
): { key: string; days: number } {
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 };
const due = new Date(nextDueAt);
if (Number.isNaN(due.getTime())) return { key: "nextDue.none", days: 0 };
if (Number.isNaN(due.getTime()))
return { key: "measurementReminders.nextDue.none", days: 0 };
// v1.18.9 (recon) — compare CALENDAR-day deltas in the user's local zone,
// not a rolling-24h delta. A reminder due at 09:00 viewed the same evening
// at 20:00 must still read "heute", and one whose due calendar-day is before
@@ -159,10 +167,16 @@ function relativeDueKey(
const dueDay = startOfLocalDayInTz(due, undefined).getTime();
const nowDay = startOfLocalDayInTz(new Date(now), undefined).getTime();
const deltaDays = Math.round((dueDay - nowDay) / DAY_MS);
if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
return { key: "nextDue.inDays", days: deltaDays };
if (deltaDays < 0)
return {
key: "measurementReminders.overdueByDays",
days: Math.abs(deltaDays),
};
if (deltaDays === 0)
return { key: "measurementReminders.nextDue.today", days: 0 };
if (deltaDays === 1)
return { key: "measurementReminders.nextDue.tomorrow", days: 1 };
return { key: "measurementReminders.nextDue.inDays", days: deltaDays };
}
/**
@@ -831,7 +845,9 @@ function VorsorgeCard({
// v1.27.6 — a screening reminder's action is "start the check-in" (it
// routes to the check-in page), never the numeric value-entry wording.
const isScreening = isScreeningReminderType(reminder.measurementType);
const isDue = due.key === "nextDue.today" || due.key === "overdueByDays";
const isDue =
due.key === "measurementReminders.nextDue.today" ||
due.key === "measurementReminders.overdueByDays";
const progress = intervalProgress(reminder, now);
// Category-style header badge: the measurement label, or "self-planned"
@@ -984,7 +1000,7 @@ function VorsorgeCard({
metric text (no shaded pill). */}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground">
{t(`measurementReminders.${due.key}`, { days: due.days })}
{t(due.key, { days: due.days })}
</span>
<span>{categoryLabel}</span>
{cadenceText ? <span>{cadenceText}</span> : null}
@@ -1037,8 +1053,8 @@ function VorsorgeCard({
// a card-background tint): due today → green, overdue → orange, otherwise the
// normal muted-resolved date. The card surface stays neutral per the house
// rule; only this inline label carries the status hue.
const dueIsToday = due.key === "nextDue.today";
const dueIsOverdue = due.key === "overdueByDays";
const dueIsToday = due.key === "measurementReminders.nextDue.today";
const dueIsOverdue = due.key === "measurementReminders.overdueByDays";
const nextLastSlot = (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -1055,7 +1071,7 @@ function VorsorgeCard({
: "text-foreground",
)}
>
{t(`measurementReminders.${due.key}`, { days: due.days })}
{t(due.key, { days: due.days })}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -208,9 +208,7 @@ export function CheckInWizard({
>
{onFunctionalStep
? t(`mentalHealth.functional.${v}`)
: t(
`mentalHealth.${optionLabelKey(instrument, questionIndex, v)}`,
)}
: t(optionLabelKey(instrument, questionIndex, v))}
</Button>
);
})}
@@ -93,6 +93,23 @@ const OAUTH_OUTCOME_KEYS: Record<
strava: queryKeys.strava,
};
/**
* v1.32.36 — the error-copy subtree per provider, spelled out rather than
* interpolated from the provider id. Building the key from the bare `settings`
* root left the reverse-i18n guard with no anchor deeper than the namespace,
* which marked every key under `settings.*` reachable and let genuinely dead
* copy sit in the bundle unnoticed. Written out, each base is a full node path
* the guard resolves on its own.
*/
const OAUTH_ERROR_KEY_BASE: Record<OAuthOutcomeProvider, string> = {
polar: "settings.polarOauthError",
oura: "settings.ouraOauthError",
whoop: "settings.whoopOauthError",
fitbit: "settings.fitbitOauthError",
googleHealth: "settings.googleHealthOauthError",
strava: "settings.stravaOauthError",
};
/**
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
* message; anything else falls back to the provider's `generic` copy. Union of
@@ -143,9 +160,10 @@ export function oauthReasonKey(
provider: OAuthOutcomeProvider,
reason: string,
): string {
const base = OAUTH_ERROR_KEY_BASE[provider];
return OAUTH_OUTCOME_REASONS.has(reason)
? `settings.${provider}OauthError.${reason}`
: `settings.${provider}OauthError.generic`;
? `${base}.${reason}`
: `${base}.generic`;
}
/**