mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(notifications): add the content detail preference and the morning score
This commit is contained in:
1 parent
f19dfc4572
commit
40e66bf027
21 files changed
+547
-33
No files matched your search
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "Im Krankheitstagebuch erfasst: {count} Symptome",
|
||||
"bodyNotLogged": "Für diesen Tag ist im Krankheitstagebuch nichts erfasst",
|
||||
"openIllness": "Öffnen"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "Benachrichtigung"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
|
||||
"remindersHeading": "Erinnerungen",
|
||||
"matrixLink": "Benachrichtigungen je Kanal",
|
||||
"channelsLink": "Zustellkanäle"
|
||||
"channelsLink": "Zustellkanäle",
|
||||
"displayHeading": "Auf dem Handy"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "Du kannst die Gesundheitsakte der Person, die du betreut hast, jetzt ansehen, aber nicht ändern.",
|
||||
"changedManage": "Du kannst die Gesundheitsakte der Person, die du betreut hast, verwalten."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "In HealthLog öffnen"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "Gut",
|
||||
"moodOkay": "Okay",
|
||||
"moodBad": "Schlecht"
|
||||
},
|
||||
"content": {
|
||||
"title": "Gesundheitswerte in Benachrichtigungen",
|
||||
"description": "Ob eine Benachrichtigung Werte wie deinen Morgenscore zeigen darf.",
|
||||
"detail": "Aus: Eine Benachrichtigung sagt nur, worum es geht, der Sperrbildschirm zeigt keine Gesundheitswerte. An: Sie nennt auch die Werte, etwa den Morgenscore im Überblick. Ein Tipp auf die Benachrichtigung öffnet in beiden Fällen die passende Stelle in der App.",
|
||||
"toggleAria": "Gesundheitswerte in Benachrichtigungen zeigen",
|
||||
"statusDetailed": "Mit Werten",
|
||||
"statusGeneric": "Ohne Werte",
|
||||
"detailedToast": "Benachrichtigungen zeigen jetzt Gesundheitswerte.",
|
||||
"genericToast": "Benachrichtigungen zeigen keine Gesundheitswerte mehr.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Dein Morgenüberblick",
|
||||
"bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch."
|
||||
"bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch.",
|
||||
"titleScore": "Morgenscore {score}",
|
||||
"band": {
|
||||
"green": "Heute sieht es stabil aus.",
|
||||
"yellow": "Heute lohnt sich ein Blick auf ein paar Dinge.",
|
||||
"red": "Heute brauchen einige Bereiche Aufmerksamkeit."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "Logged in the illness journal: {count} symptoms",
|
||||
"bodyNotLogged": "Nothing is logged in the illness journal for this day",
|
||||
"openIllness": "Open"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "Notification"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "Choose which reminders you receive and where they're delivered.",
|
||||
"remindersHeading": "Reminders",
|
||||
"matrixLink": "Per-channel notifications",
|
||||
"channelsLink": "Delivery channels"
|
||||
"channelsLink": "Delivery channels",
|
||||
"displayHeading": "On your phone"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "You can now view the health record of the person you looked after, but not change it.",
|
||||
"changedManage": "You can manage the health record of the person you looked after."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "Open in HealthLog"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "Good",
|
||||
"moodOkay": "Okay",
|
||||
"moodBad": "Bad"
|
||||
},
|
||||
"content": {
|
||||
"title": "Health figures in notifications",
|
||||
"description": "Whether a notification may show values such as your morning score.",
|
||||
"detail": "Off, a notification only says what it is about, so a lock screen shows no health figures. On, it adds the figures, for example the morning score in the briefing. Tapping a notification opens the right place in the app either way.",
|
||||
"toggleAria": "Show health figures in notifications",
|
||||
"statusDetailed": "Figures shown",
|
||||
"statusGeneric": "Without figures",
|
||||
"detailedToast": "Notifications now include health figures.",
|
||||
"genericToast": "Notifications no longer include health figures.",
|
||||
"saveError": "Could not save. Please try again later."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Your morning briefing",
|
||||
"bodyProvisional": "{line}\nLast night's sleep isn't in yet."
|
||||
"bodyProvisional": "{line}\nLast night's sleep isn't in yet.",
|
||||
"titleScore": "Morning score {score}",
|
||||
"band": {
|
||||
"green": "Looking steady today.",
|
||||
"yellow": "A few things are worth a look today.",
|
||||
"red": "Some areas need attention today."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "Registrado en el diario de enfermedad: {count} síntomas",
|
||||
"bodyNotLogged": "No hay nada registrado en el diario de enfermedad para este día",
|
||||
"openIllness": "Abrir"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "Notificación"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
|
||||
"remindersHeading": "Recordatorios",
|
||||
"matrixLink": "Notificaciones por canal",
|
||||
"channelsLink": "Canales de entrega"
|
||||
"channelsLink": "Canales de entrega",
|
||||
"displayHeading": "En tu teléfono"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Diseño",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "Ahora puedes ver el historial de salud de la persona a la que cuidabas, pero no modificarlo.",
|
||||
"changedManage": "Puedes gestionar el historial de salud de la persona a la que cuidabas."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "Abrir en HealthLog"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "Bien",
|
||||
"moodOkay": "Normal",
|
||||
"moodBad": "Mal"
|
||||
},
|
||||
"content": {
|
||||
"title": "Valores de salud en las notificaciones",
|
||||
"description": "Si una notificación puede mostrar valores como tu puntuación de la mañana.",
|
||||
"detail": "Desactivado, una notificación solo dice de qué trata, así que la pantalla de bloqueo no muestra valores de salud. Activado, añade los valores, por ejemplo la puntuación de la mañana en el resumen. Al tocar una notificación se abre el lugar adecuado de la app en ambos casos.",
|
||||
"toggleAria": "Mostrar valores de salud en las notificaciones",
|
||||
"statusDetailed": "Con valores",
|
||||
"statusGeneric": "Sin valores",
|
||||
"detailedToast": "Las notificaciones ahora incluyen valores de salud.",
|
||||
"genericToast": "Las notificaciones ya no incluyen valores de salud.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Tu resumen matinal",
|
||||
"bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible."
|
||||
"bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible.",
|
||||
"titleScore": "Puntuación de la mañana {score}",
|
||||
"band": {
|
||||
"green": "Hoy todo parece estable.",
|
||||
"yellow": "Hoy vale la pena revisar algunas cosas.",
|
||||
"red": "Hoy algunas áreas necesitan atención."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "Consigné dans le journal de maladie : {count} symptômes",
|
||||
"bodyNotLogged": "Rien n'est consigné dans le journal de maladie pour cette journée",
|
||||
"openIllness": "Ouvrir"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "Notification"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
|
||||
"remindersHeading": "Rappels",
|
||||
"matrixLink": "Notifications par canal",
|
||||
"channelsLink": "Canaux de diffusion"
|
||||
"channelsLink": "Canaux de diffusion",
|
||||
"displayHeading": "Sur ton téléphone"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Disposition",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "Vous pouvez désormais consulter le dossier de santé de la personne dont vous vous occupiez, mais pas le modifier.",
|
||||
"changedManage": "Vous pouvez gérer le dossier de santé de la personne dont vous vous occupiez."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "Ouvrir dans HealthLog"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "Bien",
|
||||
"moodOkay": "Moyen",
|
||||
"moodBad": "Mal"
|
||||
},
|
||||
"content": {
|
||||
"title": "Valeurs de santé dans les notifications",
|
||||
"description": "Si une notification peut afficher des valeurs comme ton score du matin.",
|
||||
"detail": "Désactivé, une notification dit seulement de quoi il s'agit, l'écran verrouillé n'affiche donc aucune valeur de santé. Activé, elle ajoute les valeurs, par exemple le score du matin dans le récapitulatif. Toucher une notification ouvre le bon endroit de l'app dans les deux cas.",
|
||||
"toggleAria": "Afficher les valeurs de santé dans les notifications",
|
||||
"statusDetailed": "Avec valeurs",
|
||||
"statusGeneric": "Sans valeurs",
|
||||
"detailedToast": "Les notifications incluent désormais des valeurs de santé.",
|
||||
"genericToast": "Les notifications n'incluent plus de valeurs de santé.",
|
||||
"saveError": "Enregistrement impossible. Réessaie plus tard."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Votre résumé du matin",
|
||||
"bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible."
|
||||
"bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible.",
|
||||
"titleScore": "Score du matin {score}",
|
||||
"band": {
|
||||
"green": "Tout semble stable aujourd'hui.",
|
||||
"yellow": "Quelques points méritent un coup d'œil aujourd'hui.",
|
||||
"red": "Certains domaines demandent de l'attention aujourd'hui."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "Registrato nel diario di malattia: {count} sintomi",
|
||||
"bodyNotLogged": "Per questa giornata non c'è nulla nel diario di malattia",
|
||||
"openIllness": "Apri"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "Notifica"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
|
||||
"remindersHeading": "Promemoria",
|
||||
"matrixLink": "Notifiche per canale",
|
||||
"channelsLink": "Canali di recapito"
|
||||
"channelsLink": "Canali di recapito",
|
||||
"displayHeading": "Sul telefono"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "Ora puoi consultare la cartella sanitaria della persona di cui ti occupavi, ma non modificarla.",
|
||||
"changedManage": "Puoi gestire la cartella sanitaria della persona di cui ti occupavi."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "Apri in HealthLog"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "Bene",
|
||||
"moodOkay": "Così così",
|
||||
"moodBad": "Male"
|
||||
},
|
||||
"content": {
|
||||
"title": "Valori di salute nelle notifiche",
|
||||
"description": "Se una notifica può mostrare valori come il tuo punteggio del mattino.",
|
||||
"detail": "Disattivato, una notifica dice solo di cosa si tratta, quindi la schermata di blocco non mostra valori di salute. Attivato, aggiunge i valori, per esempio il punteggio del mattino nel riepilogo. Toccando una notifica si apre il punto giusto dell'app in entrambi i casi.",
|
||||
"toggleAria": "Mostra i valori di salute nelle notifiche",
|
||||
"statusDetailed": "Con valori",
|
||||
"statusGeneric": "Senza valori",
|
||||
"detailedToast": "Le notifiche ora includono valori di salute.",
|
||||
"genericToast": "Le notifiche non includono più valori di salute.",
|
||||
"saveError": "Impossibile salvare. Riprova più tardi."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Il tuo riepilogo mattutino",
|
||||
"bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile."
|
||||
"bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile.",
|
||||
"titleScore": "Punteggio del mattino {score}",
|
||||
"band": {
|
||||
"green": "Oggi tutto sembra stabile.",
|
||||
"yellow": "Oggi vale la pena dare un'occhiata ad alcune cose.",
|
||||
"red": "Oggi alcune aree richiedono attenzione."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "아픈 날 일지에 증상 {count}개를 기록했어요",
|
||||
"bodyNotLogged": "이 날은 아픈 날 일지에 기록된 게 없어요",
|
||||
"openIllness": "열기"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "알림"
|
||||
},
|
||||
"medications": {
|
||||
"title": "복약",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "받을 알림과 전달될 곳을 골라요.",
|
||||
"remindersHeading": "알림",
|
||||
"matrixLink": "채널별 알림",
|
||||
"channelsLink": "전송 채널"
|
||||
"channelsLink": "전송 채널",
|
||||
"displayHeading": "휴대폰에서"
|
||||
},
|
||||
"layout": {
|
||||
"title": "화면 구성",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "이제 돌보던 분의 건강 기록을 볼 수 있지만 변경할 수는 없어요.",
|
||||
"changedManage": "돌보던 분의 건강 기록을 관리할 수 있어요."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "HealthLog에서 열기"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "좋음",
|
||||
"moodOkay": "보통",
|
||||
"moodBad": "나쁨"
|
||||
},
|
||||
"content": {
|
||||
"title": "알림의 건강 수치",
|
||||
"description": "알림에 아침 점수 같은 수치를 표시할지 정합니다.",
|
||||
"detail": "끄면 알림은 무엇에 관한 것인지만 알려 주므로 잠금 화면에 건강 수치가 나타나지 않아요. 켜면 수치도 함께 보여 줘요. 예를 들어 브리핑에 아침 점수가 표시됩니다. 어느 쪽이든 알림을 누르면 앱의 알맞은 곳이 열려요.",
|
||||
"toggleAria": "알림에 건강 수치 표시",
|
||||
"statusDetailed": "수치 표시",
|
||||
"statusGeneric": "수치 없이",
|
||||
"detailedToast": "이제 알림에 건강 수치가 포함됩니다.",
|
||||
"genericToast": "이제 알림에 건강 수치가 포함되지 않습니다.",
|
||||
"saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "아침 브리핑",
|
||||
"bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요."
|
||||
"bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요.",
|
||||
"titleScore": "아침 점수 {score}",
|
||||
"band": {
|
||||
"green": "오늘은 안정적으로 보여요.",
|
||||
"yellow": "오늘은 몇 가지를 살펴볼 만해요.",
|
||||
"red": "오늘은 몇몇 영역에 주의가 필요해요."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
+30
-3
@@ -1229,7 +1229,8 @@
|
||||
"bodyLogged": "Zapisano w dzienniku choroby: {count} objawów",
|
||||
"bodyNotLogged": "Na ten dzień nic nie zapisano w dzienniku choroby",
|
||||
"openIllness": "Otwórz"
|
||||
}
|
||||
},
|
||||
"sourceNotification": "Powiadomienie"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
@@ -5325,7 +5326,8 @@
|
||||
"subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
|
||||
"remindersHeading": "Przypomnienia",
|
||||
"matrixLink": "Powiadomienia wg kanału",
|
||||
"channelsLink": "Kanały dostarczania"
|
||||
"channelsLink": "Kanały dostarczania",
|
||||
"displayHeading": "Na telefonie"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Układ",
|
||||
@@ -8459,6 +8461,25 @@
|
||||
"changedRead": "Możesz teraz przeglądać dokumentację zdrowotną osoby pod Twoją opieką, ale nie możesz jej zmieniać.",
|
||||
"changedManage": "Możesz zarządzać dokumentacją zdrowotną osoby pod Twoją opieką."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"open": "Otwórz w HealthLog"
|
||||
},
|
||||
"quickActions": {
|
||||
"moodGood": "Dobrze",
|
||||
"moodOkay": "W porządku",
|
||||
"moodBad": "Źle"
|
||||
},
|
||||
"content": {
|
||||
"title": "Wartości zdrowotne w powiadomieniach",
|
||||
"description": "Czy powiadomienie może pokazywać wartości, takie jak poranny wynik.",
|
||||
"detail": "Wyłączone: powiadomienie mówi tylko, czego dotyczy, więc ekran blokady nie pokazuje wartości zdrowotnych. Włączone: dodaje wartości, na przykład poranny wynik w podsumowaniu. Dotknięcie powiadomienia w obu przypadkach otwiera właściwe miejsce w aplikacji.",
|
||||
"toggleAria": "Pokazuj wartości zdrowotne w powiadomieniach",
|
||||
"statusDetailed": "Z wartościami",
|
||||
"statusGeneric": "Bez wartości",
|
||||
"detailedToast": "Powiadomienia zawierają teraz wartości zdrowotne.",
|
||||
"genericToast": "Powiadomienia nie zawierają już wartości zdrowotnych.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie później."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11261,7 +11282,13 @@
|
||||
},
|
||||
"push": {
|
||||
"title": "Twoje poranne podsumowanie",
|
||||
"bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy."
|
||||
"bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy.",
|
||||
"titleScore": "Poranny wynik {score}",
|
||||
"band": {
|
||||
"green": "Dziś wszystko wygląda stabilnie.",
|
||||
"yellow": "Dziś warto przyjrzeć się kilku rzeczom.",
|
||||
"red": "Dziś niektóre obszary wymagają uwagi."
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
|
||||
@@ -109,6 +109,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +146,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +185,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -232,6 +235,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
@@ -260,6 +264,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -289,6 +294,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
next: {
|
||||
medication: {
|
||||
@@ -310,6 +316,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
changed: ["medication"],
|
||||
}),
|
||||
@@ -393,6 +400,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -432,6 +440,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
@@ -460,6 +469,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -505,11 +515,46 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: { detail: "generic" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists content.detail and keeps the siblings", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
notificationPrefs: { mood: { reminderHour: 7 } },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ content: { detail: "detailed" } }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { content: { detail: string }; mood: { reminderHour: number } };
|
||||
};
|
||||
expect(env.data.content).toEqual({ detail: "detailed" });
|
||||
expect(env.data.mood).toEqual({ reminderHour: 7 });
|
||||
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { notificationPrefs: { content: { detail: string } } };
|
||||
};
|
||||
expect(written.data.notificationPrefs.content).toEqual({
|
||||
detail: "detailed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown content.detail with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ content: { detail: "everything" } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
|
||||
},
|
||||
metadata: {
|
||||
source: "deploy-webhook",
|
||||
url: "/admin/system-status",
|
||||
applicationName: event.applicationName,
|
||||
applicationUuid: event.applicationUuid,
|
||||
deploymentUuid: event.deploymentUuid,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ArrowRight, Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -27,7 +27,21 @@ export default function MoodPageClient() {
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
// `?add=1` opens the entry sheet on the first paint: the mood reminder's
|
||||
// tap target (`tap-target.ts`). Read in the lazy initialiser so SSR and the
|
||||
// hydration render agree; the effect below strips the one-shot param so the
|
||||
// back button does not reopen the sheet. A later client-side navigation
|
||||
// back onto `?add=1` is handled render-side, the measurements-page pattern.
|
||||
const addParam = searchParams.get("add") === "1";
|
||||
const [dialogOpen, setDialogOpen] = useState(() => addParam);
|
||||
const [consumedAdd, setConsumedAdd] = useState(() => addParam);
|
||||
if (!addParam && consumedAdd) {
|
||||
setConsumedAdd(false);
|
||||
} else if (addParam && !consumedAdd) {
|
||||
setConsumedAdd(true);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
// The list's empty state carries the add action while there is nothing
|
||||
// to list, so the header drops its own then.
|
||||
const [listEmpty, setListEmpty] = useState(false);
|
||||
@@ -57,6 +71,14 @@ export default function MoodPageClient() {
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.has("add")) return;
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
next.delete("add");
|
||||
const search = next.toString();
|
||||
router.replace(search ? `/mood?${search}` : "/mood");
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
@@ -127,7 +149,7 @@ export default function MoodPageClient() {
|
||||
/>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
open={dialogOpen && canAddMood}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("dashboard.quickAddMood")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
content?: { detail: "generic" | "detailed" };
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Notifications: what a notification may show on a lock screen.
|
||||
*
|
||||
* Off (the default) keeps every notification to what it is about ("Your
|
||||
* morning briefing"); on adds the health figures a notification has, today
|
||||
* the morning score. Stored as `notificationPrefs.content.detail` through
|
||||
* the same PATCH the iOS app uses, with the optimistic-concurrency token, so
|
||||
* a stale web write cannot revert a sibling category.
|
||||
*/
|
||||
export function NotificationContentCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [optimistic, setOptimistic] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function scheduleClear() {
|
||||
if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = setTimeout(() => {
|
||||
clearTimerRef.current = null;
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const { data: prefs } = useQuery({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
queryFn: async () =>
|
||||
apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const detailed = optimistic ?? prefs?.content?.detail === "detailed";
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
setOptimistic(next);
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ content: { detail: next ? "detailed" : "generic" } },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
if (res.ok) {
|
||||
setMsg(
|
||||
next
|
||||
? t("notifications.content.detailedToast")
|
||||
: t("notifications.content.genericToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
} else if (res.status === 409) {
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
} else {
|
||||
setMsg(t("notifications.content.saveError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
scheduleClear();
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="settings-notification-content-title"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={LockKeyhole}
|
||||
title={t("notifications.content.title")}
|
||||
titleId="settings-notification-content-title"
|
||||
description={t("notifications.content.description")}
|
||||
status={
|
||||
<label className="flex min-h-11 items-center gap-3">
|
||||
<Switch
|
||||
checked={detailed}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.content.toggleAria")}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{detailed
|
||||
? t("notifications.content.statusDetailed")
|
||||
: t("notifications.content.statusGeneric")}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.content.detail")}</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-sm"
|
||||
: "text-muted-foreground text-sm"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { Bell, Smartphone } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { NotificationContentCard } from "@/components/settings/notification-content-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,18 @@ export function NotificationsSection() {
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Group 2 — How notifications look on the phone. Not module-gated:
|
||||
it applies to every notification the account receives. */}
|
||||
<section className="space-y-4">
|
||||
<SectionHeading
|
||||
icon={Smartphone}
|
||||
title={t("settings.sections.notifications.displayHeading")}
|
||||
/>
|
||||
<div id="notification-content" className="scroll-mt-28">
|
||||
<NotificationContentCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</section>
|
||||
{/* Cross-links to the other two notification surfaces. At the END of
|
||||
the page: a screen that opens with two ways off it never says what
|
||||
it is for. */}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* `ok` ledger row; the frequency-cap check suppresses the second — one push per
|
||||
* user per local day, no "you haven't opened the app" second push, ever.
|
||||
*
|
||||
* Privacy + calm posture (load-bearing): the body is `digest.line`, which
|
||||
* Privacy + calm posture (load-bearing): the title carries the score only
|
||||
* when the user chose detailed notifications; the body is `digest.line`, which
|
||||
* carries no number by construction (a briefing lead sentence or the top
|
||||
* signal's headline without a figure in it, "your score is ready", or the
|
||||
* honest all-clear — never a reading, a dose or the score itself, since the
|
||||
@@ -39,6 +40,8 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import { SCORE_BANDS } from "@/lib/analytics/score/record";
|
||||
import { notificationsShowFigures } from "@/lib/validations/notification-prefs";
|
||||
|
||||
/**
|
||||
* The local-morning window this nudge may fire in, [earliest, latest). Both the
|
||||
@@ -115,14 +118,37 @@ export function digestHasSubstance(digest: DailyDigest): boolean {
|
||||
* still provisional (last night's sleep not yet in), the honest sleep-pending
|
||||
* wording is appended so the read is never silently presented as final.
|
||||
*/
|
||||
function isScoreBand(band: string): band is (typeof SCORE_BANDS)[number] {
|
||||
return (SCORE_BANDS as readonly string[]).includes(band);
|
||||
}
|
||||
|
||||
export function buildDailyBriefingPush(
|
||||
digest: DailyDigest,
|
||||
t: ServerTranslator["t"],
|
||||
options: { showFigures?: boolean } = {},
|
||||
): { title: string; body: string } {
|
||||
const title = t("daily.push.title");
|
||||
// The score rides the title only when the user chose detailed
|
||||
// notifications (`notificationPrefs.content.detail`); the default keeps the
|
||||
// lock screen free of figures. The body stays `digest.line`, number-free by
|
||||
// construction, so it reads as the one-line label under the score.
|
||||
const title =
|
||||
options.showFigures && digest.score
|
||||
? t("daily.push.titleScore", {
|
||||
score: String(Math.round(digest.score.value)),
|
||||
})
|
||||
: t("daily.push.title");
|
||||
// With the score in the title, the floor line "your score is ready" only
|
||||
// repeats it; the band says something instead.
|
||||
const line =
|
||||
options.showFigures &&
|
||||
digest.score &&
|
||||
isScoreBand(digest.score.band) &&
|
||||
digest.line === t("daily.line.scoreReady")
|
||||
? t(`daily.push.band.${digest.score.band}`)
|
||||
: digest.line;
|
||||
const body = digest.sleepPending
|
||||
? t("daily.push.bodyProvisional", { line: digest.line })
|
||||
: digest.line;
|
||||
? t("daily.push.bodyProvisional", { line })
|
||||
: line;
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
@@ -217,7 +243,9 @@ export async function maybeDispatchDailyBriefing(
|
||||
}
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const { title, body } = buildDailyBriefingPush(digest, t);
|
||||
const { title, body } = buildDailyBriefingPush(digest, t, {
|
||||
showFigures: notificationsShowFigures(user.notificationPrefs),
|
||||
});
|
||||
|
||||
const outcome = await dispatch({
|
||||
// NON-URGENT by construction: no `urgent` flag, and DAILY_BRIEFING is not
|
||||
|
||||
+4
-1
@@ -427,7 +427,10 @@ async function evaluateOfflineGeoNotice(): Promise<void> {
|
||||
host: resolveGeoProviderHost(),
|
||||
docsUrl: GEOLITE2_DOCS_URL,
|
||||
},
|
||||
metadata: { source: "geo-offline-detection" },
|
||||
metadata: {
|
||||
source: "geo-offline-detection",
|
||||
url: "/admin/system-status",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -80,6 +80,8 @@ export async function notifyIllnessRedFlag(input: {
|
||||
messageKey,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
urgent: true,
|
||||
// Tap target: the illness episode the red flag belongs to.
|
||||
metadata: { url: `/illness/${encodeURIComponent(episodeId)}` },
|
||||
});
|
||||
|
||||
getEvent()?.addMeta("illness_red_flag_notified", episodeId);
|
||||
|
||||
@@ -133,6 +133,8 @@ export async function notifySafetyFloor(input: {
|
||||
eventType: "SYSTEM_ALERT",
|
||||
managedFanoutEvent: "SAFETY_FLOOR_ALERT",
|
||||
urgent: true,
|
||||
// Tap target: the readings the alert is about.
|
||||
metadata: { url: "/measurements" },
|
||||
});
|
||||
|
||||
getEvent()?.addMeta("safety_floor_notified", decision.reason);
|
||||
|
||||
@@ -209,6 +209,8 @@ describe("recordSyncFailure — at threshold", () => {
|
||||
affectedUserId: "u1",
|
||||
consecutiveFailures: 3,
|
||||
errorCode: "503",
|
||||
// The affected user is not the admin, so the tap opens the admin page.
|
||||
url: "/admin/integrations",
|
||||
});
|
||||
|
||||
// alertedAt is stamped to gate re-alerting for 24h.
|
||||
|
||||
@@ -1277,7 +1277,15 @@ async function maybeAlertAdmins(input: AlertInput): Promise<DispatchOutcome> {
|
||||
userId: admin.id,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
metadata: payload.metadata,
|
||||
metadata: {
|
||||
...payload.metadata,
|
||||
// Tap target: an admin whose own sync fails fixes it in their own
|
||||
// integration settings; anyone else's shows on the admin page.
|
||||
url:
|
||||
admin.id === input.userId
|
||||
? "/settings/integrations"
|
||||
: "/admin/integrations",
|
||||
},
|
||||
});
|
||||
outcome.dispatched ||= adminOutcome.dispatched;
|
||||
outcome.channelsAttempted += adminOutcome.channelsAttempted;
|
||||
|
||||
@@ -95,6 +95,7 @@ async function alertAdminsOfPinChange(
|
||||
},
|
||||
metadata: {
|
||||
source: "tls-pin-monitor",
|
||||
url: "/admin/system-status",
|
||||
host,
|
||||
servedPin: probe.pin,
|
||||
knownPins: knownPins.join(","),
|
||||
|
||||
@@ -335,6 +335,13 @@ const notificationPrefsResolved = z
|
||||
.enum(["weekly", "biweekly"])
|
||||
.describe("Nudge frequency cap: one per 7 or per 14 days."),
|
||||
}),
|
||||
content: z.object({
|
||||
detail: z
|
||||
.enum(["generic", "detailed"])
|
||||
.describe(
|
||||
'What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" adds figures such as the morning score. Tap targets and quick answers are the same in both.',
|
||||
),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "NotificationPrefs",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* {
|
||||
* "medication": { "clientManaged": boolean },
|
||||
* "mood": { "reminderHour": 0..23 },
|
||||
* "cycle": { "clientManaged": boolean }
|
||||
* "cycle": { "clientManaged": boolean },
|
||||
* "content": { "detail": "generic" | "detailed" }
|
||||
* }
|
||||
*
|
||||
* The schema is intentionally additive — every new category grows the
|
||||
@@ -139,6 +140,23 @@ const coachPrefsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* v1.42.2 — what a notification may show. `generic` (the default) says what
|
||||
* the notification is about and carries no health figure, so a lock screen
|
||||
* reads "Your morning briefing" rather than a score; `detailed` adds the
|
||||
* figures (the morning score). Tap targets and quick answers are the same in
|
||||
* both. Read by the producers that have a figure to show.
|
||||
*/
|
||||
export const NOTIFICATION_CONTENT_DETAILS = ["generic", "detailed"] as const;
|
||||
export type NotificationContentDetail =
|
||||
(typeof NOTIFICATION_CONTENT_DETAILS)[number];
|
||||
|
||||
const contentPrefsSchema = z
|
||||
.object({
|
||||
detail: z.enum(NOTIFICATION_CONTENT_DETAILS),
|
||||
})
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* v1.7.0 — per-device delivery override schema for the device PATCH.
|
||||
* NULL clears the override (the device inherits the user-level roaming
|
||||
@@ -159,6 +177,7 @@ export const notificationPrefsSchema = z
|
||||
cycle: cyclePrefsSchema,
|
||||
coach: coachPrefsSchema,
|
||||
measurementReminder: measurementReminderPrefsSchema,
|
||||
content: contentPrefsSchema,
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -225,6 +244,10 @@ export interface NotificationPrefs {
|
||||
*/
|
||||
clientManaged: boolean;
|
||||
};
|
||||
content: {
|
||||
/** v1.42.2 — whether notifications may carry health figures. */
|
||||
detail: NotificationContentDetail;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,6 +300,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
measurementReminder: {
|
||||
clientManaged: false,
|
||||
},
|
||||
content: {
|
||||
detail: "generic",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -325,6 +351,10 @@ export function resolveNotificationPrefs(
|
||||
...base.measurementReminder,
|
||||
...(incoming.measurementReminder ?? {}),
|
||||
},
|
||||
content: {
|
||||
...base.content,
|
||||
...(incoming.content ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -354,6 +384,7 @@ function applyDeliveryDefaultMapping(
|
||||
cycle: { ...prefs.cycle },
|
||||
coach: { ...prefs.coach },
|
||||
measurementReminder: { ...prefs.measurementReminder },
|
||||
content: { ...prefs.content },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -492,6 +523,7 @@ function cloneDefaults(): NotificationPrefs {
|
||||
measurementReminder: {
|
||||
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
|
||||
},
|
||||
content: { ...DEFAULT_NOTIFICATION_PREFS.content },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,5 +549,17 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
|
||||
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
|
||||
...(input.measurementReminder ?? {}),
|
||||
},
|
||||
content: {
|
||||
...DEFAULT_NOTIFICATION_PREFS.content,
|
||||
...(input.content ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.42.2 — producer-side helper. Whether this user's notifications may carry
|
||||
* health figures. A null / drifted row resolves to the default (`generic`).
|
||||
*/
|
||||
export function notificationsShowFigures(raw: unknown): boolean {
|
||||
return parseNotificationPrefs(raw).content.detail === "detailed";
|
||||
}
|
||||
Reference in new issue
Block a user