Give every notification event type words to render

The matrix derives each row's label from the event name, so a new event
type gets a row whether or not anybody wrote the strings for it. That is
how SECURITY_ALERT has been rendering its own raw key since v1.23: the
forward i18n coverage test only sees literal call sites, and this key is
assembled from a runtime value.

Move the derivation out of the page so a test can compute the keys the
page computes, assert both resolve in all six bundles, and name the
missing one. Write the sign-in row's strings, and the strings for the
delegated-dose row and its push body.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 09:51:53 +02:00
1 parent 427e25d1ae
commit 696b4d7d1c
9 files changed
+202 -13

No files matched your search

+9
View File
@@ -7089,11 +7089,20 @@
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
"eventDailyBriefing": "Täglicher Überblick",
"eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.",
"eventSecurityAlert": "Neue Anmeldung",
"eventSecurityAlertDesc": "Wird gesendet, wenn dein Konto auf einem unbekannten Gerät oder von einem unbekannten Ort verwendet wird.",
"eventSharedRecordIntake": "Jemand mit Zugriff markiert eine Dosis",
"eventSharedRecordIntakeDesc": "Wird nur gesendet, wenn eine andere Person es tut, nie bei deinen eigenen Einträgen.",
"security": {
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
"unknownLocation": "unbekannter Ort"
},
"sharedRecordIntake": {
"title": "{name} hat eine Dosis markiert",
"bodyTaken": "{medication}: von {name} als eingenommen markiert.",
"bodySkipped": "{medication}: von {name} als ausgelassen markiert."
},
"loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.",
"someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.",
"reminderSettingsLink": "Erinnerungseinstellungen",
+9
View File
@@ -7089,11 +7089,20 @@
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
"eventDailyBriefing": "Daily briefing",
"eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.",
"eventSecurityAlert": "New sign-in",
"eventSecurityAlertDesc": "Sent when your account is used on a device or from a place we have not seen before.",
"eventSharedRecordIntake": "Someone you shared your record with marks a dose",
"eventSharedRecordIntakeDesc": "Only sent when another person does it, never for your own entries.",
"security": {
"newDeviceTitle": "New sign-in to your account",
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
"unknownLocation": "unknown location"
},
"sharedRecordIntake": {
"title": "{name} marked a dose",
"bodyTaken": "{medication}: marked as taken by {name}.",
"bodySkipped": "{medication}: marked as skipped by {name}."
},
"loginRequired": "Please sign in to manage notifications.",
"someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.",
"reminderSettingsLink": "Reminder settings",
+9
View File
@@ -7089,11 +7089,20 @@
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
"eventDailyBriefing": "Resumen diario",
"eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.",
"eventSecurityAlert": "Nuevo inicio de sesión",
"eventSecurityAlertDesc": "Se envía cuando se usa tu cuenta en un dispositivo o desde un lugar desconocidos.",
"eventSharedRecordIntake": "Alguien con acceso marca una dosis",
"eventSharedRecordIntakeDesc": "Solo se envía cuando lo hace otra persona, nunca con tus propias entradas.",
"security": {
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
"unknownLocation": "ubicación desconocida"
},
"sharedRecordIntake": {
"title": "{name} ha marcado una dosis",
"bodyTaken": "{medication}: marcada como tomada por {name}.",
"bodySkipped": "{medication}: marcada como omitida por {name}."
},
"loginRequired": "Inicia sesión para gestionar las notificaciones.",
"someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.",
"reminderSettingsLink": "Ajustes de recordatorios",
+9
View File
@@ -7089,11 +7089,20 @@
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
"eventDailyBriefing": "Résumé quotidien",
"eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.",
"eventSecurityAlert": "Nouvelle connexion",
"eventSecurityAlertDesc": "Envoyé quand votre compte est utilisé sur un appareil ou depuis un lieu inconnus.",
"eventSharedRecordIntake": "Une personne avec accès marque une dose",
"eventSharedRecordIntakeDesc": "Envoyé uniquement quand une autre personne le fait, jamais pour vos propres saisies.",
"security": {
"newDeviceTitle": "Nouvelle connexion à votre compte",
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
"unknownLocation": "lieu inconnu"
},
"sharedRecordIntake": {
"title": "{name} a marqué une dose",
"bodyTaken": "{medication} : marquée comme prise par {name}.",
"bodySkipped": "{medication} : marquée comme ignorée par {name}."
},
"loginRequired": "Connecte-toi pour gérer les notifications.",
"someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.",
"reminderSettingsLink": "Réglages des rappels",
+9
View File
@@ -7089,11 +7089,20 @@
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
"eventDailyBriefing": "Riepilogo quotidiano",
"eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.",
"eventSecurityAlert": "Nuovo accesso",
"eventSecurityAlertDesc": "Inviato quando il tuo account viene usato su un dispositivo o da un luogo sconosciuti.",
"eventSharedRecordIntake": "Una persona con accesso segna una dose",
"eventSharedRecordIntakeDesc": "Inviato solo quando lo fa un'altra persona, mai per le tue voci.",
"security": {
"newDeviceTitle": "Nuovo accesso al tuo account",
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
"unknownLocation": "posizione sconosciuta"
},
"sharedRecordIntake": {
"title": "{name} ha segnato una dose",
"bodyTaken": "{medication}: segnata come assunta da {name}.",
"bodySkipped": "{medication}: segnata come saltata da {name}."
},
"loginRequired": "Accedi per gestire le notifiche.",
"someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.",
"reminderSettingsLink": "Impostazioni promemoria",
+9
View File
@@ -7089,11 +7089,20 @@
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
"eventDailyBriefing": "Codzienne podsumowanie",
"eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.",
"eventSecurityAlert": "Nowe logowanie",
"eventSecurityAlertDesc": "Wysyłane, gdy Twoje konto zostanie użyte na nieznanym urządzeniu lub z nieznanego miejsca.",
"eventSharedRecordIntake": "Ktoś z dostępem oznacza dawkę",
"eventSharedRecordIntakeDesc": "Wysyłane tylko wtedy, gdy robi to inna osoba, nigdy przy Twoich własnych wpisach.",
"security": {
"newDeviceTitle": "Nowe logowanie do Twojego konta",
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
"unknownLocation": "nieznana lokalizacja"
},
"sharedRecordIntake": {
"title": "{name} oznaczył(a) dawkę",
"bodyTaken": "{medication}: oznaczona jako przyjęta przez {name}.",
"bodySkipped": "{medication}: oznaczona jako pominięta przez {name}."
},
"loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.",
"someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.",
"reminderSettingsLink": "Ustawienia przypomnień",
+6 -13
View File
@@ -15,6 +15,10 @@ import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
import {
eventDescriptionTranslationKey,
eventTranslationKey,
} from "@/lib/notifications/event-labels";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
@@ -38,17 +42,6 @@ interface PreferencesData {
eventTypes: string[];
}
/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */
function eventTranslationKey(eventType: string): string {
return (
"notifications.event" +
eventType
.toLowerCase()
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
);
}
export default function NotificationsPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
@@ -265,7 +258,7 @@ export default function NotificationsPage() {
<tbody>
{eventTypes.map((eventType, idx) => {
const nameKey = eventTranslationKey(eventType);
const descKey = nameKey + "Desc";
const descKey = eventDescriptionTranslationKey(eventType);
return (
<tr
key={eventType}
@@ -311,7 +304,7 @@ export default function NotificationsPage() {
<div className="space-y-4 md:hidden">
{eventTypes.map((eventType) => {
const nameKey = eventTranslationKey(eventType);
const descKey = nameKey + "Desc";
const descKey = eventDescriptionTranslationKey(eventType);
return (
<div
key={eventType}
@@ -0,0 +1,114 @@
/**
* Every event type the /notifications matrix renders has words to render.
*
* The matrix builds its rows from `EVENT_TYPES` and derives each row's label
* and description key from the event name, so a new event type silently gets a
* row whether or not anybody wrote the two strings for it. That is not a
* theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key
* `notifications.eventSecurityAlert` in the matrix until v1.36.x, because
* nothing anywhere asserted the pairing — the forward i18n coverage test only
* sees literal `t("…")` call sites, and this key is assembled from a runtime
* value.
*
* So this test walks the event list the page walks, computes the keys the page
* computes, and asserts both resolve in every locale bundle.
*
* Mutation check: remove `notifications.eventSharedRecordIntakeDesc` from
* `messages/pl.json` and this file reports
* `pl is missing notifications.eventSharedRecordIntakeDesc`.
*/
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { EVENT_TYPES } from "@/lib/notifications/types";
import {
eventDescriptionTranslationKey,
eventTranslationKey,
} from "@/lib/notifications/event-labels";
const MESSAGES_DIR = join(__dirname, "../../../../messages");
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
function resolve(root: Record<string, unknown>, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
root,
);
}
const LOCALES = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
describe("notification event labels", () => {
it("derives the key the matrix renders under", () => {
expect(eventTranslationKey("MEDICATION_REMINDER")).toBe(
"notifications.eventMedicationReminder",
);
expect(eventDescriptionTranslationKey("SHARED_RECORD_INTAKE")).toBe(
"notifications.eventSharedRecordIntakeDesc",
);
});
it("has a label and a description for every event type, in every locale", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
expect(EVENT_TYPES.length).toBeGreaterThan(10);
const missing: string[] = [];
for (const locale of LOCALES) {
const root = bundle(locale);
for (const eventType of EVENT_TYPES) {
for (const key of [
eventTranslationKey(eventType),
eventDescriptionTranslationKey(eventType),
]) {
const value = resolve(root, key);
if (typeof value !== "string" || value.trim().length === 0) {
missing.push(`${locale} is missing ${key}`);
}
}
}
}
expect(missing).toEqual([]);
});
});
describe("the delegated-dose push copy", () => {
it("interpolates the same names in every locale", () => {
const keys = [
"notifications.sharedRecordIntake.title",
"notifications.sharedRecordIntake.bodyTaken",
"notifications.sharedRecordIntake.bodySkipped",
];
const placeholders: Record<string, string[]> = {
"notifications.sharedRecordIntake.title": ["{name}"],
"notifications.sharedRecordIntake.bodyTaken": ["{name}", "{medication}"],
"notifications.sharedRecordIntake.bodySkipped": [
"{name}",
"{medication}",
],
};
for (const locale of LOCALES) {
const root = bundle(locale);
for (const key of keys) {
const value = resolve(root, key);
expect(typeof value, `${locale} ${key}`).toBe("string");
for (const token of placeholders[key]) {
expect(value as string, `${locale} ${key}`).toContain(token);
}
}
}
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* The i18n key an event type is rendered under.
*
* The /notifications matrix builds its rows from the event list the server
* publishes, so the label for a new event type is never written at a call site
* — it is derived here. Extracted from the page so the derivation can be
* pinned by a test without rendering anything, and so the guard that asserts
* every event type actually HAS its two strings (label + description, six
* locales) computes the key exactly the way the page does rather than a way
* that agrees with it today.
*
* `MEDICATION_REMINDER` → `notifications.eventMedicationReminder`; the
* description key is that plus `Desc`.
*/
export function eventTranslationKey(eventType: string): string {
return (
"notifications.event" +
eventType
.toLowerCase()
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
);
}
/** The description key that sits under the label in the matrix row. */
export function eventDescriptionTranslationKey(eventType: string): string {
return `${eventTranslationKey(eventType)}Desc`;
}