diff --git a/messages/de.json b/messages/de.json index 68ee1bc1b..ae960aaca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index eaa97402e..59d320516 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 6b6cc1188..9105f8ebf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 6c6f91a58..72bd859ea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index d6e1847c4..5ad71e1af 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 7ca42645b..d84ad5e7d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ń", diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index f5c64067d..db199f853 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -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() { {eventTypes.map((eventType, idx) => { const nameKey = eventTranslationKey(eventType); - const descKey = nameKey + "Desc"; + const descKey = eventDescriptionTranslationKey(eventType); return ( {eventTypes.map((eventType) => { const nameKey = eventTranslationKey(eventType); - const descKey = nameKey + "Desc"; + const descKey = eventDescriptionTranslationKey(eventType); return (
{ + return JSON.parse( + readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"), + ) as Record; +} + +function resolve(root: Record, key: string): unknown { + return key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[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 = { + "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); + } + } + } + }); +}); diff --git a/src/lib/notifications/event-labels.ts b/src/lib/notifications/event-labels.ts new file mode 100644 index 000000000..ae7e725a8 --- /dev/null +++ b/src/lib/notifications/event-labels.ts @@ -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`; +}