mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #276 from MBombeck/release/v1.15.1
v1.15.1 — cycle premium parity, reminder polish, fixes
This commit is contained in:
51 files changed
+3358
-233
No files matched your search
@@ -2,6 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.1] — 2026-06-06 — cycle premium parity, reminder polish, fixes
|
||||
|
||||
### Added
|
||||
|
||||
- **Know your phase.** A "what's happening now" card next to the cycle wheel names your current phase, describes it in plain language, and surfaces the symptoms you yourself tend to log in that phase — drawn from your own history, never generic claims — with a one-tap shortcut to log today. It stays in a calm "still learning" state until there's enough of your data to be honest about.
|
||||
- **Your own symptoms.** Add custom symptoms beyond the built-in set, with a name and an icon, straight from the log sheet. Custom symptom names are encrypted at rest, included in export, and removed by the cycle purge.
|
||||
- **Fertile-window reminder.** An opt-in reminder a couple of days before your predicted fertile window — shown only when your goal is to conceive, off by default, and discreet-mode aware. It is an estimate, never a contraceptive or "safe day" claim.
|
||||
- **A richer cycle calendar and history.** Period days now shade by flow intensity, a confirmed ovulation is marked distinctly from a predicted one, and a new cycle-length history chart shows your recent cycles with average, variability, and a regular/irregular read.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reminders read as plain text.** Medication, mood, and cycle reminder notifications no longer carry decorative emoji; emoji are kept only for system and failure alerts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blood-pressure-in-target reads accurately.** The "in target" share on the home health score now pairs systolic and diastolic readings the same way the rest of the app does, so imported readings whose timestamps differ by more than a few minutes are no longer dropped from the calculation.
|
||||
- **A dose is never shown as taken before its time.** Logging a dose can no longer attribute it to a later slot still in the future — a late morning dose will not appear as the evening dose taken early.
|
||||
|
||||
### Security
|
||||
|
||||
- **Closed a server-side request risk in Web Push.** Push subscription endpoints are now validated against internal-network addresses on save and again before delivery, and the cycle-insights endpoint is rate-limited.
|
||||
|
||||
## [1.15.0] — 2026-06-06 — cycle tracking
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.15.0
|
||||
version: 1.15.1
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+43
-5
@@ -4702,7 +4702,9 @@
|
||||
"eventCyclePeriodSoon": "Periode steht bevor",
|
||||
"eventCyclePeriodSoonDesc": "Ein paar Tage vor deiner vorhergesagten nächsten Periode.",
|
||||
"eventCyclePeriodConfirm": "Hat deine Periode begonnen?",
|
||||
"eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist."
|
||||
"eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist.",
|
||||
"eventCycleFertileSoon": "Fruchtbares Fenster steht bevor",
|
||||
"eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4901,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Körperlich",
|
||||
"emotional": "Emotional",
|
||||
"digestive": "Verdauung"
|
||||
"digestive": "Verdauung",
|
||||
"custom": "Eigene"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Krämpfe",
|
||||
@@ -4918,7 +4921,16 @@
|
||||
"foodCravings": "Heißhunger",
|
||||
"nausea": "Übelkeit",
|
||||
"diarrhea": "Durchfall",
|
||||
"constipation": "Verstopfung"
|
||||
"constipation": "Verstopfung",
|
||||
"custom": {
|
||||
"add": "Symptom hinzufügen",
|
||||
"label": "Name",
|
||||
"labelPlaceholder": "z. B. Schwindel",
|
||||
"icon": "Symbol",
|
||||
"limitReached": "Höchstzahl an eigenen Symptomen erreicht.",
|
||||
"remove": "{label} entfernen",
|
||||
"fallbackLabel": "Eigenes Symptom"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst."
|
||||
@@ -4965,6 +4977,18 @@
|
||||
"symptomPatternsEmpty": "Erfasse Symptome über einen Zyklus, um zu sehen, in welcher Phase sie sich häufen.",
|
||||
"symptomMostlyIn": "Meist in {phase} ({count}/{total} Tage)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Was gerade passiert",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Deine Periode ist der Beginn eines neuen Zyklus, während sich die Gebärmutterschleimhaut löst. Energie und Stimmung fühlen sich in diesen ersten Tagen oft niedriger an.",
|
||||
"FOLLICULAR": "Nach der Periode steigt der Östrogenspiegel allmählich, während der Körper eine Eizelle vorbereitet. Viele bemerken in dieser Phase eine gleichmäßigere Energie.",
|
||||
"OVULATORY": "Etwa zur Zyklusmitte wird eine Eizelle freigesetzt und die Fruchtbarkeit erreicht für ein kurzes Fenster ihren Höhepunkt. Diese Phase ist die kürzeste der vier.",
|
||||
"LUTEAL": "Nach dem Eisprung steigt das Progesteron und der Körper kommt zur Ruhe. In den Tagen vor der nächsten Periode bemerken manche prämenstruelle Veränderungen."
|
||||
},
|
||||
"commonHere": "Das bemerkst du hier oft",
|
||||
"trackNudge": "Heute eintragen",
|
||||
"stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen."
|
||||
},
|
||||
"disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.",
|
||||
"title": "Dein Zyklus",
|
||||
"subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.",
|
||||
@@ -5037,7 +5061,12 @@
|
||||
"empty": "Erfasse Daten, damit sich dein Kalender füllt.",
|
||||
"today": "Heute",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Symptome"
|
||||
"legendSymptom": "Symptome",
|
||||
"legendOvulationConfirmed": "Bestätigter Eisprung",
|
||||
"flowSpotting": "Schmierblutung",
|
||||
"flowLight": "leicht",
|
||||
"flowMedium": "mittel",
|
||||
"flowHeavy": "stark"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Was als Nächstes kommt",
|
||||
@@ -5072,7 +5101,12 @@
|
||||
"recentCycles": "Letzte Zyklen",
|
||||
"ongoing": "laufend",
|
||||
"periodDays": "Periode {count} T",
|
||||
"ovulationChip": "Eisprung"
|
||||
"ovulationChip": "Eisprung",
|
||||
"chartTitle": "Zykluslänge im Verlauf",
|
||||
"chartCaption": "Jeder Balken ist ein Zyklus: die rosa Basis ist deine Periode, der Rest die Zykluslänge.",
|
||||
"chartAria": "Zykluslängen-Diagramm deiner letzten {count} Zyklen, Durchschnitt {avg} Tage, eingestuft als {regularity}.",
|
||||
"legendPeriodSegment": "Periodentage",
|
||||
"legendRestSegment": "Rest des Zyklus"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Tag erfassen",
|
||||
@@ -5138,6 +5172,8 @@
|
||||
"reminders": "Erinnerungen",
|
||||
"remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.",
|
||||
"remindersLink": "Benachrichtigungseinstellungen",
|
||||
"fertileReminder": "Erinnerung an das fruchtbare Fenster",
|
||||
"fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus – aktiviere sie pro Kanal in den Benachrichtigungseinstellungen.",
|
||||
"purgeData": "Zyklusdaten löschen",
|
||||
"purgeConfirmAction": "Ja, alles löschen",
|
||||
"purgeDone": "Zyklusdaten gelöscht."
|
||||
@@ -5174,6 +5210,8 @@
|
||||
"periodSoonBody": "Deine nächste Periode wird in etwa zwei Tagen erwartet.",
|
||||
"periodConfirmTitle": "Hat deine Periode begonnen?",
|
||||
"periodConfirmBody": "Deine Periode wurde für etwa jetzt vorhergesagt. Trage sie ein, damit die Vorhersagen genau bleiben.",
|
||||
"fertileSoonTitle": "Fruchtbares Fenster steht bevor",
|
||||
"fertileSoonBody": "Dein geschätztes fruchtbares Fenster beginnt voraussichtlich in etwa zwei Tagen. Dies ist eine Schätzung und keine medizinische oder verhütende Garantie.",
|
||||
"discreetTitle": "HealthLog-Erinnerung",
|
||||
"discreetBody": "Du hast eine Erinnerung in HealthLog."
|
||||
}
|
||||
|
||||
+43
-5
@@ -4702,7 +4702,9 @@
|
||||
"eventCyclePeriodSoon": "Period coming up",
|
||||
"eventCyclePeriodSoonDesc": "Sent a couple of days before your predicted next period.",
|
||||
"eventCyclePeriodConfirm": "Did your period start?",
|
||||
"eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged."
|
||||
"eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged.",
|
||||
"eventCycleFertileSoon": "Fertile window coming up",
|
||||
"eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4901,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Physical",
|
||||
"emotional": "Emotional",
|
||||
"digestive": "Digestive"
|
||||
"digestive": "Digestive",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Cramps",
|
||||
@@ -4918,7 +4921,16 @@
|
||||
"foodCravings": "Food cravings",
|
||||
"nausea": "Nausea",
|
||||
"diarrhea": "Diarrhea",
|
||||
"constipation": "Constipation"
|
||||
"constipation": "Constipation",
|
||||
"custom": {
|
||||
"add": "Add symptom",
|
||||
"label": "Name",
|
||||
"labelPlaceholder": "e.g. Dizziness",
|
||||
"icon": "Icon",
|
||||
"limitReached": "Custom symptom limit reached.",
|
||||
"remove": "Remove {label}",
|
||||
"fallbackLabel": "Custom symptom"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles."
|
||||
@@ -4965,6 +4977,18 @@
|
||||
"symptomPatternsEmpty": "Log symptoms across a cycle to see which phase they cluster in.",
|
||||
"symptomMostlyIn": "Mostly in {phase} ({count}/{total} days)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "What's happening now",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Your period is the start of a new cycle, as the uterine lining sheds. Energy and mood often feel lower in these first days.",
|
||||
"FOLLICULAR": "After your period, oestrogen gradually rises as the body prepares an egg. Many people notice steadier energy through this stretch.",
|
||||
"OVULATORY": "Around mid-cycle an egg is released and fertility peaks for a short window. This phase is the briefest of the four.",
|
||||
"LUTEAL": "After ovulation, progesterone rises and the body settles in. In the days before your next period some people notice premenstrual changes."
|
||||
},
|
||||
"commonHere": "You often notice this here",
|
||||
"trackNudge": "Log today",
|
||||
"stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days."
|
||||
},
|
||||
"disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.",
|
||||
"title": "Your cycle",
|
||||
"subtitle": "Log your period and symptoms, see what comes next.",
|
||||
@@ -5037,7 +5061,12 @@
|
||||
"empty": "Start logging to see your calendar fill in.",
|
||||
"today": "Today",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Symptoms"
|
||||
"legendSymptom": "Symptoms",
|
||||
"legendOvulationConfirmed": "Confirmed ovulation",
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "light",
|
||||
"flowMedium": "medium",
|
||||
"flowHeavy": "heavy"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "What comes next",
|
||||
@@ -5072,7 +5101,12 @@
|
||||
"recentCycles": "Recent cycles",
|
||||
"ongoing": "ongoing",
|
||||
"periodDays": "period {count} d",
|
||||
"ovulationChip": "Ovulation"
|
||||
"ovulationChip": "Ovulation",
|
||||
"chartTitle": "Cycle length over time",
|
||||
"chartCaption": "Each bar is one cycle: the rose base is your period, the rest is the cycle length.",
|
||||
"chartAria": "Cycle-length chart of your last {count} cycles, averaging {avg} days, classified as {regularity}.",
|
||||
"legendPeriodSegment": "Period days",
|
||||
"legendRestSegment": "Rest of cycle"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Log your day",
|
||||
@@ -5143,6 +5177,8 @@
|
||||
"reminders": "Reminders",
|
||||
"remindersDescription": "Turn period reminders on per channel in notification settings.",
|
||||
"remindersLink": "Notification settings",
|
||||
"fertileReminder": "Fertile-window reminder",
|
||||
"fertileReminderDescription": "Get a heads-up a few days before your estimated fertile window. Off by default — turn it on per channel in notification settings.",
|
||||
"purgeData": "Delete cycle data",
|
||||
"purgeConfirmAction": "Yes, delete everything",
|
||||
"purgeDone": "Cycle data deleted."
|
||||
@@ -5174,6 +5210,8 @@
|
||||
"periodSoonBody": "Your next period is predicted in about two days.",
|
||||
"periodConfirmTitle": "Did your period start?",
|
||||
"periodConfirmBody": "Your period was predicted around now. Log it to keep predictions accurate.",
|
||||
"fertileSoonTitle": "Fertile window coming up",
|
||||
"fertileSoonBody": "Your estimated fertile window is predicted to start in about two days. This is an estimate, not a medical or contraceptive guarantee.",
|
||||
"discreetTitle": "HealthLog reminder",
|
||||
"discreetBody": "You have a reminder in HealthLog."
|
||||
}
|
||||
|
||||
+43
-5
@@ -4702,7 +4702,9 @@
|
||||
"eventCyclePeriodSoon": "Período próximo",
|
||||
"eventCyclePeriodSoonDesc": "Se envía un par de días antes de tu próximo período previsto.",
|
||||
"eventCyclePeriodConfirm": "¿Empezó tu período?",
|
||||
"eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período."
|
||||
"eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período.",
|
||||
"eventCycleFertileSoon": "Ventana fértil próxima",
|
||||
"eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4901,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Físico",
|
||||
"emotional": "Emocional",
|
||||
"digestive": "Digestivo"
|
||||
"digestive": "Digestivo",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Calambres",
|
||||
@@ -4918,7 +4921,16 @@
|
||||
"foodCravings": "Antojos",
|
||||
"nausea": "Náuseas",
|
||||
"diarrhea": "Diarrea",
|
||||
"constipation": "Estreñimiento"
|
||||
"constipation": "Estreñimiento",
|
||||
"custom": {
|
||||
"add": "Añadir síntoma",
|
||||
"label": "Nombre",
|
||||
"labelPlaceholder": "p. ej. Mareo",
|
||||
"icon": "Icono",
|
||||
"limitReached": "Se alcanzó el límite de síntomas personalizados.",
|
||||
"remove": "Eliminar {label}",
|
||||
"fallbackLabel": "Síntoma personalizado"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos."
|
||||
@@ -4965,6 +4977,18 @@
|
||||
"symptomPatternsEmpty": "Registra síntomas a lo largo de un ciclo para ver en qué fase se concentran.",
|
||||
"symptomMostlyIn": "Sobre todo en {phase} ({count}/{total} días)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Qué está pasando ahora",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Tu menstruación es el inicio de un nuevo ciclo, mientras el revestimiento uterino se desprende. La energía y el ánimo suelen sentirse más bajos en estos primeros días.",
|
||||
"FOLLICULAR": "Tras la menstruación, el estrógeno sube de forma gradual mientras el cuerpo prepara un óvulo. Muchas personas notan una energía más estable durante esta etapa.",
|
||||
"OVULATORY": "Hacia la mitad del ciclo se libera un óvulo y la fertilidad alcanza su punto máximo durante una ventana corta. Esta fase es la más breve de las cuatro.",
|
||||
"LUTEAL": "Después de la ovulación, la progesterona sube y el cuerpo se asienta. En los días previos a la siguiente menstruación, algunas personas notan cambios premenstruales."
|
||||
},
|
||||
"commonHere": "Esto lo notas a menudo aquí",
|
||||
"trackNudge": "Registrar hoy",
|
||||
"stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días."
|
||||
},
|
||||
"disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.",
|
||||
"title": "Tu ciclo",
|
||||
"subtitle": "Registra tu periodo y síntomas, y mira qué viene después.",
|
||||
@@ -5037,7 +5061,12 @@
|
||||
"empty": "Empieza a registrar para ver tu calendario.",
|
||||
"today": "Hoy",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Síntomas"
|
||||
"legendSymptom": "Síntomas",
|
||||
"legendOvulationConfirmed": "Ovulación confirmada",
|
||||
"flowSpotting": "manchado",
|
||||
"flowLight": "ligero",
|
||||
"flowMedium": "medio",
|
||||
"flowHeavy": "abundante"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Qué viene después",
|
||||
@@ -5072,7 +5101,12 @@
|
||||
"recentCycles": "Ciclos recientes",
|
||||
"ongoing": "en curso",
|
||||
"periodDays": "período {count} d",
|
||||
"ovulationChip": "Ovulación"
|
||||
"ovulationChip": "Ovulación",
|
||||
"chartTitle": "Duración del ciclo en el tiempo",
|
||||
"chartCaption": "Cada barra es un ciclo: la base rosa es tu periodo, el resto es la duración del ciclo.",
|
||||
"chartAria": "Gráfico de duración de tus últimos {count} ciclos, con una media de {avg} días, clasificado como {regularity}.",
|
||||
"legendPeriodSegment": "Días de periodo",
|
||||
"legendRestSegment": "Resto del ciclo"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registrar tu día",
|
||||
@@ -5138,6 +5172,8 @@
|
||||
"reminders": "Recordatorios",
|
||||
"remindersDescription": "Activa los recordatorios de período por canal en los ajustes de notificaciones.",
|
||||
"remindersLink": "Ajustes de notificaciones",
|
||||
"fertileReminder": "Recordatorio de ventana fértil",
|
||||
"fertileReminderDescription": "Recibe un aviso unos días antes de tu ventana fértil estimada. Desactivado por defecto: actívalo por canal en los ajustes de notificaciones.",
|
||||
"purgeData": "Eliminar datos del ciclo",
|
||||
"purgeConfirmAction": "Sí, eliminar todo",
|
||||
"purgeDone": "Datos del ciclo eliminados."
|
||||
@@ -5174,6 +5210,8 @@
|
||||
"periodSoonBody": "Se prevé tu próximo periodo en unos dos días.",
|
||||
"periodConfirmTitle": "¿Ha comenzado tu periodo?",
|
||||
"periodConfirmBody": "Tu periodo estaba previsto para ahora. Regístralo para mantener las predicciones precisas.",
|
||||
"fertileSoonTitle": "Ventana fértil próxima",
|
||||
"fertileSoonBody": "Se prevé que tu ventana fértil estimada comience en unos dos días. Es una estimación, no una garantía médica ni anticonceptiva.",
|
||||
"discreetTitle": "Recordatorio de HealthLog",
|
||||
"discreetBody": "Tienes un recordatorio en HealthLog."
|
||||
}
|
||||
|
||||
+43
-5
@@ -4702,7 +4702,9 @@
|
||||
"eventCyclePeriodSoon": "Règles à venir",
|
||||
"eventCyclePeriodSoonDesc": "Envoyé quelques jours avant vos prochaines règles prévues.",
|
||||
"eventCyclePeriodConfirm": "Vos règles ont-elles commencé ?",
|
||||
"eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée."
|
||||
"eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée.",
|
||||
"eventCycleFertileSoon": "Fenêtre fertile à venir",
|
||||
"eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4901,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Physique",
|
||||
"emotional": "Émotionnel",
|
||||
"digestive": "Digestif"
|
||||
"digestive": "Digestif",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Crampes",
|
||||
@@ -4918,7 +4921,16 @@
|
||||
"foodCravings": "Fringales",
|
||||
"nausea": "Nausées",
|
||||
"diarrhea": "Diarrhée",
|
||||
"constipation": "Constipation"
|
||||
"constipation": "Constipation",
|
||||
"custom": {
|
||||
"add": "Ajouter un symptôme",
|
||||
"label": "Nom",
|
||||
"labelPlaceholder": "ex. Vertige",
|
||||
"icon": "Icône",
|
||||
"limitReached": "Limite de symptômes personnalisés atteinte.",
|
||||
"remove": "Supprimer {label}",
|
||||
"fallbackLabel": "Symptôme personnalisé"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles."
|
||||
@@ -4965,6 +4977,18 @@
|
||||
"symptomPatternsEmpty": "Notez vos symptômes sur un cycle pour voir dans quelle phase ils se regroupent.",
|
||||
"symptomMostlyIn": "Surtout en {phase} ({count}/{total} jours)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Ce qui se passe maintenant",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Tes règles marquent le début d'un nouveau cycle, lorsque la muqueuse utérine se détache. L'énergie et l'humeur semblent souvent plus basses durant ces premiers jours.",
|
||||
"FOLLICULAR": "Après les règles, l'œstrogène augmente progressivement pendant que le corps prépare un ovule. Beaucoup ressentent une énergie plus stable durant cette période.",
|
||||
"OVULATORY": "Vers le milieu du cycle, un ovule est libéré et la fertilité culmine sur une courte fenêtre. Cette phase est la plus brève des quatre.",
|
||||
"LUTEAL": "Après l'ovulation, la progestérone augmente et le corps se pose. Dans les jours précédant les prochaines règles, certaines personnes remarquent des changements prémenstruels."
|
||||
},
|
||||
"commonHere": "Tu le remarques souvent ici",
|
||||
"trackNudge": "Noter aujourd'hui",
|
||||
"stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées."
|
||||
},
|
||||
"disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.",
|
||||
"title": "Ton cycle",
|
||||
"subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.",
|
||||
@@ -5037,7 +5061,12 @@
|
||||
"empty": "Commence à enregistrer pour remplir ton calendrier.",
|
||||
"today": "Aujourd'hui",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Symptômes"
|
||||
"legendSymptom": "Symptômes",
|
||||
"legendOvulationConfirmed": "Ovulation confirmée",
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "léger",
|
||||
"flowMedium": "moyen",
|
||||
"flowHeavy": "abondant"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Ce qui arrive ensuite",
|
||||
@@ -5072,7 +5101,12 @@
|
||||
"recentCycles": "Cycles récents",
|
||||
"ongoing": "en cours",
|
||||
"periodDays": "règles {count} j",
|
||||
"ovulationChip": "Ovulation"
|
||||
"ovulationChip": "Ovulation",
|
||||
"chartTitle": "Durée du cycle au fil du temps",
|
||||
"chartCaption": "Chaque barre est un cycle : la base rose est vos règles, le reste est la durée du cycle.",
|
||||
"chartAria": "Graphique de durée de vos {count} derniers cycles, d'une moyenne de {avg} jours, classé comme {regularity}.",
|
||||
"legendPeriodSegment": "Jours de règles",
|
||||
"legendRestSegment": "Reste du cycle"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Enregistrer ta journée",
|
||||
@@ -5138,6 +5172,8 @@
|
||||
"reminders": "Rappels",
|
||||
"remindersDescription": "Activez les rappels de règles par canal dans les paramètres de notification.",
|
||||
"remindersLink": "Paramètres de notification",
|
||||
"fertileReminder": "Rappel de fenêtre fertile",
|
||||
"fertileReminderDescription": "Recevez un avertissement quelques jours avant votre fenêtre fertile estimée. Désactivé par défaut : activez-le par canal dans les paramètres de notification.",
|
||||
"purgeData": "Supprimer les données de cycle",
|
||||
"purgeConfirmAction": "Oui, tout supprimer",
|
||||
"purgeDone": "Données de cycle supprimées."
|
||||
@@ -5174,6 +5210,8 @@
|
||||
"periodSoonBody": "Vos prochaines règles sont prévues dans environ deux jours.",
|
||||
"periodConfirmTitle": "Vos règles ont-elles commencé ?",
|
||||
"periodConfirmBody": "Vos règles étaient prévues pour maintenant. Enregistrez-les pour garder des prévisions précises.",
|
||||
"fertileSoonTitle": "Fenêtre fertile à venir",
|
||||
"fertileSoonBody": "Votre fenêtre fertile estimée devrait commencer dans environ deux jours. Il s'agit d'une estimation, et non d'une garantie médicale ou contraceptive.",
|
||||
"discreetTitle": "Rappel HealthLog",
|
||||
"discreetBody": "Vous avez un rappel dans HealthLog."
|
||||
}
|
||||
|
||||
+43
-5
@@ -4702,7 +4702,9 @@
|
||||
"eventCyclePeriodSoon": "Ciclo in arrivo",
|
||||
"eventCyclePeriodSoonDesc": "Inviato un paio di giorni prima del ciclo previsto.",
|
||||
"eventCyclePeriodConfirm": "Il ciclo è iniziato?",
|
||||
"eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo."
|
||||
"eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo.",
|
||||
"eventCycleFertileSoon": "Finestra fertile in arrivo",
|
||||
"eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4901,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Fisico",
|
||||
"emotional": "Emotivo",
|
||||
"digestive": "Digestivo"
|
||||
"digestive": "Digestivo",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Crampi",
|
||||
@@ -4918,7 +4921,16 @@
|
||||
"foodCravings": "Voglie alimentari",
|
||||
"nausea": "Nausea",
|
||||
"diarrhea": "Diarrea",
|
||||
"constipation": "Stitichezza"
|
||||
"constipation": "Stitichezza",
|
||||
"custom": {
|
||||
"add": "Aggiungi sintomo",
|
||||
"label": "Nome",
|
||||
"labelPlaceholder": "es. Vertigini",
|
||||
"icon": "Icona",
|
||||
"limitReached": "Limite di sintomi personalizzati raggiunto.",
|
||||
"remove": "Rimuovi {label}",
|
||||
"fallbackLabel": "Sintomo personalizzato"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli."
|
||||
@@ -4965,6 +4977,18 @@
|
||||
"symptomPatternsEmpty": "Registra i sintomi durante un ciclo per vedere in quale fase si concentrano.",
|
||||
"symptomMostlyIn": "Soprattutto in {phase} ({count}/{total} giorni)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Cosa sta succedendo ora",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Le mestruazioni sono l'inizio di un nuovo ciclo, mentre il rivestimento uterino si sfalda. In questi primi giorni energia e umore spesso si sentono più bassi.",
|
||||
"FOLLICULAR": "Dopo le mestruazioni gli estrogeni salgono gradualmente mentre il corpo prepara un ovulo. Molte persone notano un'energia più stabile in questa fase.",
|
||||
"OVULATORY": "Verso metà ciclo viene rilasciato un ovulo e la fertilità raggiunge il picco per una breve finestra. Questa fase è la più breve delle quattro.",
|
||||
"LUTEAL": "Dopo l'ovulazione il progesterone sale e il corpo si assesta. Nei giorni prima del ciclo successivo alcune persone notano cambiamenti premestruali."
|
||||
},
|
||||
"commonHere": "Questo lo noti spesso qui",
|
||||
"trackNudge": "Registra oggi",
|
||||
"stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate."
|
||||
},
|
||||
"disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.",
|
||||
"title": "Il tuo ciclo",
|
||||
"subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.",
|
||||
@@ -5037,7 +5061,12 @@
|
||||
"empty": "Inizia a registrare per riempire il calendario.",
|
||||
"today": "Oggi",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Sintomi"
|
||||
"legendSymptom": "Sintomi",
|
||||
"legendOvulationConfirmed": "Ovulazione confermata",
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "leggero",
|
||||
"flowMedium": "medio",
|
||||
"flowHeavy": "abbondante"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Cosa arriva dopo",
|
||||
@@ -5072,7 +5101,12 @@
|
||||
"recentCycles": "Cicli recenti",
|
||||
"ongoing": "in corso",
|
||||
"periodDays": "ciclo {count} g",
|
||||
"ovulationChip": "Ovulazione"
|
||||
"ovulationChip": "Ovulazione",
|
||||
"chartTitle": "Durata del ciclo nel tempo",
|
||||
"chartCaption": "Ogni barra è un ciclo: la base rosa è il ciclo mestruale, il resto è la durata del ciclo.",
|
||||
"chartAria": "Grafico della durata dei tuoi ultimi {count} cicli, con una media di {avg} giorni, classificato come {regularity}.",
|
||||
"legendPeriodSegment": "Giorni di mestruazioni",
|
||||
"legendRestSegment": "Resto del ciclo"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registra la tua giornata",
|
||||
@@ -5138,6 +5172,8 @@
|
||||
"reminders": "Promemoria",
|
||||
"remindersDescription": "Attiva i promemoria del ciclo per canale nelle impostazioni di notifica.",
|
||||
"remindersLink": "Impostazioni di notifica",
|
||||
"fertileReminder": "Promemoria finestra fertile",
|
||||
"fertileReminderDescription": "Ricevi un avviso alcuni giorni prima della tua finestra fertile stimata. Disattivato per impostazione predefinita: attivalo per canale nelle impostazioni di notifica.",
|
||||
"purgeData": "Elimina i dati del ciclo",
|
||||
"purgeConfirmAction": "Sì, elimina tutto",
|
||||
"purgeDone": "Dati del ciclo eliminati."
|
||||
@@ -5174,6 +5210,8 @@
|
||||
"periodSoonBody": "Il tuo prossimo ciclo è previsto tra circa due giorni.",
|
||||
"periodConfirmTitle": "Il ciclo è iniziato?",
|
||||
"periodConfirmBody": "Il tuo ciclo era previsto per adesso. Registralo per mantenere accurate le previsioni.",
|
||||
"fertileSoonTitle": "Finestra fertile in arrivo",
|
||||
"fertileSoonBody": "La tua finestra fertile stimata dovrebbe iniziare tra circa due giorni. È una stima, non una garanzia medica o contraccettiva.",
|
||||
"discreetTitle": "Promemoria HealthLog",
|
||||
"discreetBody": "Hai un promemoria in HealthLog."
|
||||
}
|
||||
|
||||
+43
-5
@@ -4702,7 +4702,9 @@
|
||||
"eventCyclePeriodSoon": "Zbliża się okres",
|
||||
"eventCyclePeriodSoonDesc": "Wysyłane kilka dni przed przewidywanym okresem.",
|
||||
"eventCyclePeriodConfirm": "Czy okres się zaczął?",
|
||||
"eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu."
|
||||
"eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu.",
|
||||
"eventCycleFertileSoon": "Zbliża się okno płodności",
|
||||
"eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4901,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Fizyczne",
|
||||
"emotional": "Emocjonalne",
|
||||
"digestive": "Trawienne"
|
||||
"digestive": "Trawienne",
|
||||
"custom": "Własne"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Skurcze",
|
||||
@@ -4918,7 +4921,16 @@
|
||||
"foodCravings": "Zachcianki",
|
||||
"nausea": "Nudności",
|
||||
"diarrhea": "Biegunka",
|
||||
"constipation": "Zaparcia"
|
||||
"constipation": "Zaparcia",
|
||||
"custom": {
|
||||
"add": "Dodaj objaw",
|
||||
"label": "Nazwa",
|
||||
"labelPlaceholder": "np. Zawroty głowy",
|
||||
"icon": "Ikona",
|
||||
"limitReached": "Osiągnięto limit własnych objawów.",
|
||||
"remove": "Usuń {label}",
|
||||
"fallbackLabel": "Objaw własny"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli."
|
||||
@@ -4965,6 +4977,18 @@
|
||||
"symptomPatternsEmpty": "Zapisuj objawy przez cały cykl, aby zobaczyć, w której fazie się grupują.",
|
||||
"symptomMostlyIn": "Głównie w fazie {phase} ({count}/{total} dni)"
|
||||
},
|
||||
"phaseEducation": {
|
||||
"title": "Co się teraz dzieje",
|
||||
"whatsHappening": {
|
||||
"MENSTRUAL": "Miesiączka to początek nowego cyklu, gdy złuszcza się błona śluzowa macicy. W tych pierwszych dniach energia i nastrój często są niższe.",
|
||||
"FOLLICULAR": "Po miesiączce poziom estrogenu stopniowo rośnie, gdy organizm przygotowuje komórkę jajową. Wiele osób odczuwa w tej fazie bardziej stabilną energię.",
|
||||
"OVULATORY": "W połowie cyklu uwalniana jest komórka jajowa, a płodność osiąga szczyt przez krótkie okno czasu. Ta faza jest najkrótsza z czterech.",
|
||||
"LUTEAL": "Po owulacji rośnie poziom progesteronu i organizm się wycisza. W dniach przed kolejną miesiączką niektóre osoby zauważają zmiany przedmiesiączkowe."
|
||||
},
|
||||
"commonHere": "To często zauważasz tutaj",
|
||||
"trackNudge": "Zapisz dziś",
|
||||
"stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni."
|
||||
},
|
||||
"disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.",
|
||||
"title": "Twój cykl",
|
||||
"subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.",
|
||||
@@ -5037,7 +5061,12 @@
|
||||
"empty": "Zacznij zapisywać, aby wypełnić kalendarz.",
|
||||
"today": "Dziś",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Objawy"
|
||||
"legendSymptom": "Objawy",
|
||||
"legendOvulationConfirmed": "Potwierdzona owulacja",
|
||||
"flowSpotting": "plamienie",
|
||||
"flowLight": "lekkie",
|
||||
"flowMedium": "średnie",
|
||||
"flowHeavy": "obfite"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Co dalej",
|
||||
@@ -5072,7 +5101,12 @@
|
||||
"recentCycles": "Ostatnie cykle",
|
||||
"ongoing": "w toku",
|
||||
"periodDays": "okres {count} d",
|
||||
"ovulationChip": "Owulacja"
|
||||
"ovulationChip": "Owulacja",
|
||||
"chartTitle": "Długość cyklu w czasie",
|
||||
"chartCaption": "Każdy słupek to jeden cykl: różowa podstawa to miesiączka, reszta to długość cyklu.",
|
||||
"chartAria": "Wykres długości {count} ostatnich cykli, średnio {avg} dni, sklasyfikowany jako {regularity}.",
|
||||
"legendPeriodSegment": "Dni miesiączki",
|
||||
"legendRestSegment": "Pozostała część cyklu"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Zapisz swój dzień",
|
||||
@@ -5138,6 +5172,8 @@
|
||||
"reminders": "Przypomnienia",
|
||||
"remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.",
|
||||
"remindersLink": "Ustawienia powiadomień",
|
||||
"fertileReminder": "Przypomnienie o oknie płodności",
|
||||
"fertileReminderDescription": "Otrzymaj powiadomienie kilka dni przed szacowanym oknem płodności. Domyślnie wyłączone – włącz je dla każdego kanału w ustawieniach powiadomień.",
|
||||
"purgeData": "Usuń dane cyklu",
|
||||
"purgeConfirmAction": "Tak, usuń wszystko",
|
||||
"purgeDone": "Dane cyklu usunięte."
|
||||
@@ -5174,6 +5210,8 @@
|
||||
"periodSoonBody": "Twoja następna miesiączka jest przewidywana za około dwa dni.",
|
||||
"periodConfirmTitle": "Czy miesiączka się zaczęła?",
|
||||
"periodConfirmBody": "Twoja miesiączka była przewidywana mniej więcej teraz. Zapisz ją, aby prognozy pozostały dokładne.",
|
||||
"fertileSoonTitle": "Zbliża się okno płodności",
|
||||
"fertileSoonBody": "Szacowane okno płodności rozpocznie się prawdopodobnie za około dwa dni. To szacunek, a nie gwarancja medyczna ani antykoncepcyjna.",
|
||||
"discreetTitle": "Przypomnienie HealthLog",
|
||||
"discreetBody": "Masz przypomnienie w HealthLog."
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.15.1 — seed the global `custom` cycle-symptom category.
|
||||
--
|
||||
-- Per-user custom symptoms (the v1.13 custom-mood-tag precedent) hang under a
|
||||
-- single global category row, distinguished from the seeded catalogue by a
|
||||
-- non-null `cycle_symptoms.user_id`. The owner column + `label_encrypted`
|
||||
-- already exist (migration 0129); this only adds the parent category so a
|
||||
-- custom symptom has a stable `category_id` to reference.
|
||||
--
|
||||
-- Idempotent: upserts by `key` (deterministic id `csc_custom`), so re-running
|
||||
-- the migration is a no-op. No table or column changes.
|
||||
INSERT INTO "cycle_symptom_categories"
|
||||
("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('csc_custom', 'custom', 'cycle.symptomCategory.custom', 'Tag', 100, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
|
||||
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
|
||||
@@ -37,6 +37,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
// Per-user custom symptoms carry an intent-revealing free-text label
|
||||
// (encrypted at rest). A purge that promises "nothing reproductive
|
||||
// persists" must drop them too; their links cascade off the row delete.
|
||||
const customSymptoms = await tx.cycleSymptom.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const predictions = await tx.cyclePrediction.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
@@ -62,7 +68,13 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const pushRows = await tx.pushAttempt.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] },
|
||||
eventType: {
|
||||
in: [
|
||||
"CYCLE_PERIOD_SOON",
|
||||
"CYCLE_PERIOD_CONFIRM",
|
||||
"CYCLE_FERTILE_SOON",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
// Reset the reproductive INTENT carried on the profile (goal + cycle/period/
|
||||
@@ -79,6 +91,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
return {
|
||||
dayLogs: dayLogs.count,
|
||||
customSymptoms: customSymptoms.count,
|
||||
predictions: predictions.count,
|
||||
cycles: cycles.count,
|
||||
auditRows: auditRows.count,
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
|
||||
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
|
||||
import {
|
||||
@@ -54,6 +55,14 @@ export const GET = apiHandler(async () => {
|
||||
if (!gate.enabled) return gate.response;
|
||||
const profile = gate.profile;
|
||||
|
||||
// This route runs the FDR phase-contrast over a 365-day window — the most
|
||||
// compute-heavy read in the cycle vertical. Cap repeated hits so a single
|
||||
// authenticated session can't spin it for a self-inflicted compute DoS.
|
||||
const rl = await checkRateLimit(`cycle:insights:${user.id}`, 30, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* v1.15.1 — update / delete a per-user custom cycle-symptom.
|
||||
*
|
||||
* Both handlers resolve the `custom:`-prefixed key against the CALLER's own
|
||||
* rows only — another user's key (or a catalogue key) is a 404, so a symptom
|
||||
* can never be edited or removed across the ownership boundary. Gated
|
||||
* (`cycle.disabled` 403). The decrypted label is NEVER logged.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
updateCustomSymptomSchema,
|
||||
decryptCustomLabel,
|
||||
encryptCustomLabel,
|
||||
isCustomSymptomKey,
|
||||
MAX_CUSTOM_SYMPTOMS_PER_USER,
|
||||
} from "@/lib/cycle/custom-symptoms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ key: string }> };
|
||||
|
||||
/** `PATCH /api/cycle/symptoms/custom/:key` — rename / recolour / (de)activate. */
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`cycle:symptom:custom:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { key } = await params;
|
||||
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
|
||||
|
||||
const parsed = updateCustomSymptomSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.symptom.custom.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const owned = await prisma.cycleSymptom.findFirst({
|
||||
where: { key, userId: user.id },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
if (!owned) return apiError("Custom symptom not found", 404);
|
||||
|
||||
// Reactivation (soft-hidden → active) re-enters the active set, so it must
|
||||
// re-clear the per-user cap — a hidden row doesn't count toward it, and
|
||||
// skipping the recheck here would let a user exceed the limit by hiding
|
||||
// then re-enabling rows.
|
||||
if (parsed.data.isActive === true && !owned.isActive) {
|
||||
const activeCount = await prisma.cycleSymptom.count({
|
||||
where: { userId: user.id, isActive: true },
|
||||
});
|
||||
if (activeCount >= MAX_CUSTOM_SYMPTOMS_PER_USER) {
|
||||
return apiError(
|
||||
`Custom symptom limit reached (${MAX_CUSTOM_SYMPTOMS_PER_USER})`,
|
||||
422,
|
||||
{ errorCode: "cycle.symptom.custom.limit" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.cycleSymptom.update({
|
||||
where: { id: owned.id },
|
||||
data: {
|
||||
...(parsed.data.label !== undefined
|
||||
? { labelEncrypted: encryptCustomLabel(parsed.data.label) }
|
||||
: {}),
|
||||
...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}),
|
||||
...(parsed.data.isActive !== undefined
|
||||
? { isActive: parsed.data.isActive }
|
||||
: {}),
|
||||
},
|
||||
select: { key: true, icon: true, isActive: true, labelEncrypted: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "cycle.symptom.custom.update" } });
|
||||
|
||||
return apiSuccess({
|
||||
key: updated.key,
|
||||
label: decryptCustomLabel(updated.labelEncrypted),
|
||||
icon: updated.icon,
|
||||
isActive: updated.isActive,
|
||||
custom: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* `DELETE /api/cycle/symptoms/custom/:key` — soft-deactivate by default
|
||||
* (history intact); `?purge=true` hard-deletes the row and cascades its links.
|
||||
*/
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`cycle:symptom:custom:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const { key } = await params;
|
||||
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
|
||||
|
||||
const owned = await prisma.cycleSymptom.findFirst({
|
||||
where: { key, userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return apiError("Custom symptom not found", 404);
|
||||
|
||||
const purge = request.nextUrl.searchParams.get("purge") === "true";
|
||||
if (purge) {
|
||||
// FK cascade removes the `cycle_symptom_links` rows too.
|
||||
await prisma.cycleSymptom.delete({ where: { id: owned.id } });
|
||||
} else {
|
||||
await prisma.cycleSymptom.update({
|
||||
where: { id: owned.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("cycle.symptom.custom.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { purge },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.symptom.custom.delete" },
|
||||
meta: { purge },
|
||||
});
|
||||
|
||||
return apiSuccess({ key, purged: purge });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* v1.15.1 — custom cycle-symptom CRUD route guards: create cap + encrypted
|
||||
* label, ownership 404 on edit/delete, non-custom-key rejection, soft-delete
|
||||
* vs purge, and the gate refusal.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleSymptom: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({
|
||||
requireCycleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 29 }),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH, DELETE } from "../[key]/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
|
||||
};
|
||||
|
||||
function jsonReq(url: string, method: string, body: unknown) {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: {} as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/symptoms/custom", () => {
|
||||
it("returns the caller's active customs with decrypted labels", async () => {
|
||||
db.cycleSymptom.findMany.mockResolvedValue([
|
||||
{ key: "custom:abc", icon: "Brain", labelEncrypted: "enc:Dizziness" },
|
||||
]);
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { symptoms: { key: string; label: string; custom: boolean }[] };
|
||||
};
|
||||
expect(body.data.symptoms).toEqual([
|
||||
{ key: "custom:abc", label: "Dizziness", icon: "Brain", custom: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/symptoms/custom", () => {
|
||||
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(2);
|
||||
db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "Dizziness",
|
||||
icon: "Brain",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as {
|
||||
data: { custom: boolean; label: string; key: string };
|
||||
};
|
||||
expect(body.data).toMatchObject({
|
||||
custom: true,
|
||||
label: "Dizziness",
|
||||
key: "custom:abc",
|
||||
});
|
||||
// Label is stored encrypted, never plaintext.
|
||||
expect(db.cycleSymptom.create.mock.calls[0][0].data.labelEncrypted).toBe(
|
||||
"enc:Dizziness",
|
||||
);
|
||||
// Owner-scoped + hung under the custom category.
|
||||
expect(db.cycleSymptom.create.mock.calls[0][0].data.userId).toBe("user-1");
|
||||
expect(db.cycleSymptom.create.mock.calls[0][0].data.categoryId).toBe(
|
||||
"csc_custom",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects over the per-user cap with 422", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when cycle tracking is disabled (403)", async () => {
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError("Cycle tracking is not enabled", 403),
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
const params = (key: string) => ({ params: Promise.resolve({ key }) });
|
||||
|
||||
it("404s when the key is not the caller's own custom symptom", async () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue(null);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:x", "PATCH", {
|
||||
isActive: false,
|
||||
}),
|
||||
params("custom:x"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s a non-custom key without touching the DB", async () => {
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/cycle/symptoms/custom/cramps"),
|
||||
params("cramps"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(db.cycleSymptom.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks the cap on reactivation (hidden → active) and 422s when full", async () => {
|
||||
// A soft-hidden row doesn't count toward the active cap; flipping it back
|
||||
// to active must re-clear the limit so hide-then-reenable can't bypass it.
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false });
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
|
||||
isActive: true,
|
||||
}),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.cycleSymptom.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not recheck the cap when reactivation is not requested", async () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: true });
|
||||
db.cycleSymptom.update.mockResolvedValue({
|
||||
key: "custom:abc",
|
||||
icon: "Tag",
|
||||
isActive: true,
|
||||
labelEncrypted: "enc:Renamed",
|
||||
});
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
|
||||
label: "Renamed",
|
||||
}),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
|
||||
const soft = await DELETE(
|
||||
new NextRequest("http://localhost/api/cycle/symptoms/custom/custom:abc"),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(soft.status).toBe(200);
|
||||
expect(db.cycleSymptom.update).toHaveBeenCalledWith({
|
||||
where: { id: "id1" },
|
||||
data: { isActive: false },
|
||||
});
|
||||
expect(db.cycleSymptom.delete).not.toHaveBeenCalled();
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: {} as never,
|
||||
});
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
|
||||
const purge = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/cycle/symptoms/custom/custom:abc?purge=true",
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(purge.status).toBe(200);
|
||||
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* v1.15.1 — per-user custom cycle-symptom catalogue.
|
||||
*
|
||||
* GET /api/cycle/symptoms/custom — the caller's active custom symptoms,
|
||||
* labels decrypted, so the log-day sheet can merge them into the
|
||||
* seeded chip grid.
|
||||
* POST /api/cycle/symptoms/custom — create one (`{ label, icon?,
|
||||
* categoryKey? }`). Mints a `custom:<uuid>` key, encrypts the label at
|
||||
* rest, stores the row under the global `custom` category owned by the
|
||||
* caller. Capped per user.
|
||||
*
|
||||
* Gated (`cycle.disabled` 403) + owner-scoped — a Bearer token for a disabled
|
||||
* account never reaches the custom catalogue. The label is intent-revealing
|
||||
* free text, so it is NEVER surfaced in a wide-event / audit excerpt (only the
|
||||
* icon + key are annotated, mirroring the mood custom-tag precedent).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
createCustomSymptomSchema,
|
||||
decryptCustomLabel,
|
||||
encryptCustomLabel,
|
||||
mintCustomSymptomKey,
|
||||
CUSTOM_SYMPTOM_CATEGORY_ID,
|
||||
MAX_CUSTOM_SYMPTOMS_PER_USER,
|
||||
} from "@/lib/cycle/custom-symptoms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rows = await prisma.cycleSymptom.findMany({
|
||||
where: { userId: user.id, isActive: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { key: true, icon: true, labelEncrypted: true },
|
||||
});
|
||||
|
||||
const symptoms = rows.map((r) => ({
|
||||
key: r.key,
|
||||
label: decryptCustomLabel(r.labelEncrypted),
|
||||
icon: r.icon,
|
||||
custom: true,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.symptom.custom.read" },
|
||||
meta: { count: symptoms.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ symptoms });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Cap mutation rate so a single session can't flood the encrypted-label
|
||||
// catalogue (each create runs an encrypt + audit write).
|
||||
const rl = await checkRateLimit(`cycle:symptom:custom:${user.id}`, 30, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, try again later", 429);
|
||||
}
|
||||
|
||||
const parsed = createCustomSymptomSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.symptom.custom.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const activeCount = await prisma.cycleSymptom.count({
|
||||
where: { userId: user.id, isActive: true },
|
||||
});
|
||||
if (activeCount >= MAX_CUSTOM_SYMPTOMS_PER_USER) {
|
||||
return apiError(
|
||||
`Custom symptom limit reached (${MAX_CUSTOM_SYMPTOMS_PER_USER})`,
|
||||
422,
|
||||
{ errorCode: "cycle.symptom.custom.limit" },
|
||||
);
|
||||
}
|
||||
|
||||
const key = mintCustomSymptomKey();
|
||||
const created = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
categoryId: CUSTOM_SYMPTOM_CATEGORY_ID,
|
||||
key,
|
||||
// Catalogue rows resolve `labelKey` against the locale; a custom symptom
|
||||
// renders its decrypted `label` instead, so labelKey just mirrors the
|
||||
// key for a stable, non-empty value.
|
||||
labelKey: key,
|
||||
labelEncrypted: encryptCustomLabel(parsed.data.label),
|
||||
icon: parsed.data.icon ?? null,
|
||||
sortOrder: activeCount,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { key: true, icon: true },
|
||||
});
|
||||
|
||||
// Audit the create (same tier as a mood-tag custom) — counts + the icon
|
||||
// only, NEVER the decrypted label.
|
||||
await auditLog("cycle.symptom.custom.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { icon: created.icon },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.symptom.custom.create" },
|
||||
meta: { icon: created.icon },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
key: created.key,
|
||||
label: parsed.data.label,
|
||||
icon: created.icon,
|
||||
custom: true,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -4,14 +4,13 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
generateAlerts,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import {
|
||||
pairByTimestamp,
|
||||
pearsonCorrelation,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
@@ -185,31 +184,26 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── BP target adherence ──────────────────────────────────
|
||||
// Keeps the 5-minute tolerance contract — sys + dia must pair on
|
||||
// the same reading session. The aggregator pulled the bounded raw
|
||||
// rows; we reuse `pairByTimestamp` against them so the output is
|
||||
// byte-identical to the legacy path.
|
||||
// ── BP target adherence (trailing 90 days) ───────────────
|
||||
// Route through the canonical `computeBpInTargetPct` so this figure
|
||||
// shares ONE definition with the dashboard tile, the insight cards, the
|
||||
// AI snapshot and the targets endpoint (per the bp-in-target.ts
|
||||
// contract — the inline copy that used to live here drifted: it paired
|
||||
// sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia
|
||||
// timestamps drift past 5 min on Withings / Apple-Health imports, which
|
||||
// skewed the share iOS renders on the Home Health Score). The helper
|
||||
// adds the same-Berlin-day pairing fallback so those readings pair, and
|
||||
// keeps the ESH-2023 band + 90/50 hypotension floor (both sys AND dia at
|
||||
// or below the age-band ceiling). Window is the aggregator's 90-day raw
|
||||
// pull — a "recent control" figure, not the all-time headline.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
const sysData: DataPoint[] = aggregate.bpRawRows.sys.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
const diaData: DataPoint[] = aggregate.bpRawRows.dia.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000);
|
||||
|
||||
if (sysPairs.length > 0) {
|
||||
// v1.4.16 A2 — one-sided ceiling semantics with hypotension
|
||||
// floor. See lib/analytics/bp-in-target.ts.
|
||||
const inTarget = sysPairs.filter((p) =>
|
||||
isBpReadingInTarget(p.a, p.b, bpTargets),
|
||||
).length;
|
||||
bpPctInTarget = Math.round((inTarget / sysPairs.length) * 100);
|
||||
}
|
||||
bpPctInTarget =
|
||||
computeBpInTargetPct(
|
||||
aggregate.bpRawRows.sys,
|
||||
aggregate.bpRawRows.dia,
|
||||
bpTargets,
|
||||
)?.pct ?? null;
|
||||
}
|
||||
|
||||
// ── Correlations: weight × Sys BP ─────────────────────────
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* cares about the Prisma `where` fragment derived from the new knob.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -391,6 +391,19 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Pin the clock past the 07:00 / 19:00 CEST slots of 2026-06-15 so the
|
||||
// dose-safety future-slot guard treats these taken writes as landing on
|
||||
// a current/past slot. Otherwise the fixtures sit days ahead of the
|
||||
// real test clock and the guard refuses to snap a taken write forward.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
|
||||
const { consumeOneDose } = await import(
|
||||
"@/lib/medications/inventory/service"
|
||||
|
||||
@@ -159,6 +159,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// `takenAt`-only / defaulted-now write must not snap across the wide
|
||||
// ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: scheduledFor !== undefined,
|
||||
// Dose-safety: a taken write (any non-skip on this route) must never
|
||||
// snap forward onto a future slot — see resolve-slot-instant.ts.
|
||||
isTakenWrite: isExplicitTaken,
|
||||
});
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk.
|
||||
* Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -186,6 +186,13 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Pin the clock past both fixture slots (07:00 / 19:00 CEST on
|
||||
// 2026-06-15) so the dose-safety future-slot guard treats these taken
|
||||
// writes as landing on a current/past slot, not a future one. Without
|
||||
// the pin these fixtures are dated days ahead of the real test clock
|
||||
// and the guard (correctly) refuses to snap a taken write forward.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
@@ -194,6 +201,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
|
||||
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
|
||||
// echo (no takenAt, skipped false) — must NOT clear takenAt.
|
||||
|
||||
@@ -238,6 +238,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// `takenAt`-only / defaulted-now write must not snap across the
|
||||
// wide ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Dose-safety: a taken write (has `takenAt`, not skipped) must never
|
||||
// snap forward onto a future slot. A pending sync echo (no `takenAt`)
|
||||
// legitimately maps to a future slot, so the guard stays off for it.
|
||||
isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
|
||||
});
|
||||
|
||||
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
@@ -4,15 +4,15 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { webPushSubscriptionSchema } from "@/lib/validations/notifications";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
keys: z.object({
|
||||
p256dh: z.string().min(1),
|
||||
auth: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
// The endpoint is later dialled by `web-push`, which does its own internal
|
||||
// fetch that bypasses safeFetch — so the SSRF floor has to be enforced here
|
||||
// at input time. `webPushSubscriptionSchema` requires https + isPublicUrl
|
||||
// (blocks loopback/link-local/metadata/internal hosts). The sender applies
|
||||
// the same check again at egress time as defence-in-depth.
|
||||
const subscribeSchema = webPushSubscriptionSchema;
|
||||
|
||||
const unsubscribeSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
|
||||
@@ -798,3 +798,33 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* v1.15.1 — the cycle-length history chart reveal.
|
||||
*
|
||||
* The hand-rolled SVG bars grow up from the baseline on first paint, a calm
|
||||
* staggered rise (per-bar `--bar-delay`) that matches the wheel's 0-KB SVG
|
||||
* identity. Gated on the chart's own `data-animate="true"` (the view only sets
|
||||
* it when motion is allowed) so a background history refetch never replays it,
|
||||
* and zeroed entirely under prefers-reduced-motion (bars paint at full height).
|
||||
*/
|
||||
.cycle-history-bar {
|
||||
transform-box: fill-box;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
[data-animate="true"] .cycle-history-bar {
|
||||
transform: scaleY(0);
|
||||
animation: cycleHistoryBarRise 520ms var(--ring-ease, ease-out) forwards;
|
||||
animation-delay: var(--bar-delay, 0ms);
|
||||
}
|
||||
@keyframes cycleHistoryBarRise {
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-animate="true"] .cycle-history-bar {
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -73,4 +73,56 @@ describe("<CycleCalendar>", () => {
|
||||
);
|
||||
expect(html).toContain('role="columnheader"');
|
||||
});
|
||||
|
||||
it("exposes the flow level on a logged period day as a stable data-attr", () => {
|
||||
const days = [
|
||||
{ ...dayBase("2026-06-10"), isPeriodLogged: true, flow: "HEAVY" },
|
||||
];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-flow-level="HEAVY"');
|
||||
// The flow grade is named in the aria text (never colour-only).
|
||||
expect(html).toContain("heavy");
|
||||
});
|
||||
|
||||
it("marks a logged period day with no flow grade as UNGRADED", () => {
|
||||
const days = [{ ...dayBase("2026-06-10"), isPeriodLogged: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-flow-level="UNGRADED"');
|
||||
});
|
||||
|
||||
it("renders a predicted-ovulation day as a predicted dot", () => {
|
||||
const days = [{ ...dayBase("2026-06-14"), isPredictedOvulation: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-ovulation="predicted"');
|
||||
expect(html).toContain("Ovulation");
|
||||
});
|
||||
|
||||
it("renders a CONFIRMED-ovulation day as the distinct oval, not the dot", () => {
|
||||
const days = [{ ...dayBase("2026-06-14"), isPredictedOvulation: true }];
|
||||
const html = render(
|
||||
<CycleCalendar
|
||||
days={days}
|
||||
today={today}
|
||||
confirmedOvulation="2026-06-14"
|
||||
onSelectDay={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-ovulation="confirmed"');
|
||||
expect(html).not.toContain('data-ovulation="predicted"');
|
||||
expect(html).toContain("Confirmed ovulation");
|
||||
});
|
||||
|
||||
it("renders the fertile window as a data-attr-tagged soft band", () => {
|
||||
const days = [{ ...dayBase("2026-06-12"), isFertileWindow: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-fertile="true"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CycleHistoryChart } from "../cycle-history-chart";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CycleHistoryResponse, MenstrualCycleDTO } from "../types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function cycle(over: Partial<MenstrualCycleDTO>): MenstrualCycleDTO {
|
||||
return {
|
||||
id: over.id ?? "c1",
|
||||
startDate: over.startDate ?? "2026-01-01",
|
||||
endDate: over.endDate ?? "2026-01-28",
|
||||
periodEndDate: over.periodEndDate ?? "2026-01-05",
|
||||
lengthDays: over.lengthDays ?? 28,
|
||||
ovulationDate: over.ovulationDate ?? null,
|
||||
ovulationConfirmed: over.ovulationConfirmed ?? false,
|
||||
isPredicted: over.isPredicted ?? false,
|
||||
syncVersion: 1,
|
||||
updatedAt: "2026-01-28T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
const REGULAR_HISTORY: CycleHistoryResponse = {
|
||||
cycles: [
|
||||
cycle({ id: "a", startDate: "2026-01-01", lengthDays: 28 }),
|
||||
cycle({ id: "b", startDate: "2026-01-29", lengthDays: 29 }),
|
||||
cycle({
|
||||
id: "c",
|
||||
startDate: "2026-02-27",
|
||||
lengthDays: 27,
|
||||
ovulationConfirmed: true,
|
||||
ovulationDate: "2026-03-13",
|
||||
}),
|
||||
],
|
||||
stats: {
|
||||
avgLengthDays: 28,
|
||||
lengthVariabilityDays: 1,
|
||||
avgPeriodLengthDays: 5,
|
||||
regularity: "REGULAR",
|
||||
},
|
||||
};
|
||||
|
||||
describe("<CycleHistoryChart>", () => {
|
||||
it("renders one bar per observed cycle with stable data-attrs", () => {
|
||||
const html = render(<CycleHistoryChart history={REGULAR_HISTORY} />);
|
||||
expect(html).toContain('data-slot="cycle-history-chart"');
|
||||
// Three observed cycles → three bars.
|
||||
const bars = html.match(/data-cycle-bar="true"/g) ?? [];
|
||||
expect(bars).toHaveLength(3);
|
||||
// The period segment is drawn for each bar with a logged period end.
|
||||
expect(html).toContain('data-period-segment="true"');
|
||||
// The mean baseline rule is present.
|
||||
expect(html).toContain('data-avg-line="true"');
|
||||
});
|
||||
|
||||
it("surfaces the regularity classification on a stat chip", () => {
|
||||
const html = render(<CycleHistoryChart history={REGULAR_HISTORY} />);
|
||||
expect(html).toContain('data-regularity="REGULAR"');
|
||||
expect(html).toContain("Regular");
|
||||
});
|
||||
|
||||
it("draws a confirmed-ovulation tick only for confirmed cycles", () => {
|
||||
const html = render(<CycleHistoryChart history={REGULAR_HISTORY} />);
|
||||
const ticks = html.match(/data-ovulation-tick="true"/g) ?? [];
|
||||
// Only the third cycle carries a confirmed ovulation date.
|
||||
expect(ticks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("excludes predicted cycles from the chart", () => {
|
||||
const withPredicted: CycleHistoryResponse = {
|
||||
...REGULAR_HISTORY,
|
||||
cycles: [
|
||||
...REGULAR_HISTORY.cycles,
|
||||
cycle({ id: "future", startDate: "2026-03-26", isPredicted: true }),
|
||||
],
|
||||
};
|
||||
const html = render(<CycleHistoryChart history={withPredicted} />);
|
||||
const bars = html.match(/data-cycle-bar="true"/g) ?? [];
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows the empty state when there is no data", () => {
|
||||
const html = render(<CycleHistoryChart history={undefined} />);
|
||||
expect(html).toContain("No cycles recorded yet.");
|
||||
expect(html).not.toContain('data-cycle-bar="true"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { PhaseEducationCard } from "../phase-education-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { SymptomPhaseRow } from "../use-cycle";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const lutealCramps: SymptomPhaseRow = {
|
||||
symptomKey: "cramps",
|
||||
counts: { MENSTRUAL: 1, FOLLICULAR: 0, OVULATORY: 0, LUTEAL: 6 },
|
||||
total: 7,
|
||||
topPhase: "LUTEAL",
|
||||
topShare: 6 / 7,
|
||||
};
|
||||
const follicularFatigue: SymptomPhaseRow = {
|
||||
symptomKey: "fatigue",
|
||||
counts: { MENSTRUAL: 0, FOLLICULAR: 5, OVULATORY: 0, LUTEAL: 1 },
|
||||
total: 6,
|
||||
topPhase: "FOLLICULAR",
|
||||
topShare: 5 / 6,
|
||||
};
|
||||
|
||||
const okGate = {
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
cyclesObserved: 4,
|
||||
onLogToday: () => {},
|
||||
};
|
||||
|
||||
describe("<PhaseEducationCard>", () => {
|
||||
it("renders phase name + descriptive line + the log nudge when the gate passes", () => {
|
||||
const html = render(
|
||||
<PhaseEducationCard
|
||||
phase="LUTEAL"
|
||||
symptomPatterns={[lutealCramps, follicularFatigue]}
|
||||
{...okGate}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Luteal");
|
||||
expect(html).toContain("progesterone"); // curated whatsHappening line
|
||||
expect(html).toContain("Log today");
|
||||
expect(html).toContain('data-phase="LUTEAL"');
|
||||
});
|
||||
|
||||
it("shows only the user's OWN symptoms that cluster in the active phase", () => {
|
||||
const html = render(
|
||||
<PhaseEducationCard
|
||||
phase="LUTEAL"
|
||||
symptomPatterns={[lutealCramps, follicularFatigue]}
|
||||
{...okGate}
|
||||
/>,
|
||||
);
|
||||
// cramps clusters in luteal → shown; fatigue clusters in follicular → not.
|
||||
expect(html).toContain("Cramps");
|
||||
expect(html).not.toContain("Fatigue");
|
||||
});
|
||||
|
||||
it("falls back to the still-learning line when fewer than three cycles", () => {
|
||||
const html = render(
|
||||
<PhaseEducationCard
|
||||
phase="LUTEAL"
|
||||
symptomPatterns={[lutealCramps]}
|
||||
predictionEnabled
|
||||
rawChartMode={false}
|
||||
cyclesObserved={2}
|
||||
onLogToday={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Still learning your cycle");
|
||||
expect(html).not.toContain("progesterone");
|
||||
expect(html).not.toContain("Cramps");
|
||||
});
|
||||
|
||||
it("falls back to still-learning in raw-chart mode / prediction disabled", () => {
|
||||
const raw = render(
|
||||
<PhaseEducationCard
|
||||
phase="LUTEAL"
|
||||
symptomPatterns={[lutealCramps]}
|
||||
predictionEnabled
|
||||
rawChartMode
|
||||
cyclesObserved={9}
|
||||
onLogToday={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(raw).toContain("Still learning your cycle");
|
||||
|
||||
const off = render(
|
||||
<PhaseEducationCard
|
||||
phase="LUTEAL"
|
||||
symptomPatterns={[lutealCramps]}
|
||||
predictionEnabled={false}
|
||||
rawChartMode={false}
|
||||
cyclesObserved={9}
|
||||
onLogToday={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(off).toContain("Still learning your cycle");
|
||||
});
|
||||
|
||||
it("omits the chip row in the compact variant", () => {
|
||||
const html = render(
|
||||
<PhaseEducationCard
|
||||
compact
|
||||
phase="LUTEAL"
|
||||
symptomPatterns={[lutealCramps]}
|
||||
{...okGate}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("progesterone");
|
||||
expect(html).not.toContain("cycle-phase-education-chips");
|
||||
// The compact variant still carries the log nudge.
|
||||
expect(html).toContain("Log today");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,12 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CalendarDay } from "./types";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import {
|
||||
FERTILE_HUE,
|
||||
FLOW_HUE,
|
||||
OVULATION_HUE,
|
||||
flowOpacity,
|
||||
} from "./phase-tokens";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the month calendar.
|
||||
@@ -49,6 +54,12 @@ export interface CycleCalendarProps {
|
||||
days: CalendarDay[];
|
||||
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
|
||||
today: string;
|
||||
/**
|
||||
* YYYY-MM-DD of the CONFIRMED (retrospective) ovulation estimate, or null.
|
||||
* Set only when the prediction carries `ovulationConfirmed` — that day then
|
||||
* renders as the distinct light oval instead of the predicted dot.
|
||||
*/
|
||||
confirmedOvulation?: string | null;
|
||||
onSelectDay: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -56,6 +67,7 @@ export interface CycleCalendarProps {
|
||||
export function CycleCalendar({
|
||||
days,
|
||||
today,
|
||||
confirmedOvulation,
|
||||
onSelectDay,
|
||||
className,
|
||||
}: CycleCalendarProps) {
|
||||
@@ -143,19 +155,48 @@ export function CycleCalendar({
|
||||
const date = ymd(cell);
|
||||
const info = byDate.get(date);
|
||||
const isToday = date === today;
|
||||
// A CONFIRMED-ovulation day is the predicted-ovulation day that also
|
||||
// matches the prediction's retrospective estimate. It supersedes the
|
||||
// predicted dot with the distinct light oval (Apple's idiom).
|
||||
const isConfirmedOvulation =
|
||||
!!info?.isPredictedOvulation &&
|
||||
confirmedOvulation != null &&
|
||||
date === confirmedOvulation;
|
||||
const isPredictedOvulationDot =
|
||||
!!info?.isPredictedOvulation && !isConfirmedOvulation;
|
||||
|
||||
const markers: string[] = [];
|
||||
if (info?.isPeriodLogged)
|
||||
markers.push(t("cycle.calendar.legendPeriod"));
|
||||
if (info?.isPeriodLogged) {
|
||||
// Restate the flow grade in the aria text so the shading is never
|
||||
// colour-only (the legend covers it; the cell names it too).
|
||||
const flowKey =
|
||||
info.flow && info.flow !== "NONE"
|
||||
? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}`
|
||||
: null;
|
||||
markers.push(
|
||||
flowKey
|
||||
? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})`
|
||||
: t("cycle.calendar.legendPeriod"),
|
||||
);
|
||||
}
|
||||
if (info?.isPredictedPeriod)
|
||||
markers.push(t("cycle.calendar.legendPredicted"));
|
||||
if (info?.isFertileWindow)
|
||||
markers.push(t("cycle.calendar.legendFertile"));
|
||||
if (info?.isPredictedOvulation)
|
||||
if (isConfirmedOvulation)
|
||||
markers.push(t("cycle.calendar.legendOvulationConfirmed"));
|
||||
else if (isPredictedOvulationDot)
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
const flowLevel =
|
||||
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
|
||||
? info.flow
|
||||
: info?.isPeriodLogged
|
||||
? "UNGRADED"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -164,32 +205,71 @@ export function CycleCalendar({
|
||||
role="gridcell"
|
||||
aria-label={aria}
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
data-flow-level={flowLevel}
|
||||
data-fertile={info?.isFertileWindow ? "true" : undefined}
|
||||
data-predicted={
|
||||
info?.isPredictedPeriod && !info?.isPeriodLogged
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
data-ovulation={
|
||||
isConfirmedOvulation
|
||||
? "confirmed"
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
"relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
info?.isFertileWindow && "ring-1 ring-inset",
|
||||
isToday && "font-semibold",
|
||||
)}
|
||||
style={
|
||||
info?.isFertileWindow
|
||||
? ({ "--tw-ring-color": FERTILE_HUE } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Logged-period filled pip behind the number. */}
|
||||
{/* Fertile window: a soft full-cell band (calm fill, not a hard
|
||||
ring) so the window reads as a continuous range across days. */}
|
||||
{info?.isFertileWindow ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0.5 rounded-md opacity-15"
|
||||
style={{ backgroundColor: FERTILE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Logged-period filled pip behind the number — shaded by flow on
|
||||
the single-hue opacity ladder (SPOTTING→HEAVY). */}
|
||||
{info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md opacity-20"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
className="absolute inset-1 rounded-md"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
opacity: flowOpacity(info.flow),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Confirmed-ovulation oval: a restrained light oval ringed in the
|
||||
ovulatory hue, deliberately distinct from the predicted dot. */}
|
||||
{isConfirmedOvulation ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
|
||||
style={
|
||||
{
|
||||
backgroundColor: OVULATION_HUE,
|
||||
opacity: 0.22,
|
||||
"--tw-ring-color": OVULATION_HUE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span className={cn("relative z-10", isToday && "text-primary")}>
|
||||
{cell.getDate()}
|
||||
</span>
|
||||
|
||||
{/* Predicted-period band: a soft underline, never a dot. */}
|
||||
{/* Predicted-period band: a soft hatched underline, never a dot. */}
|
||||
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -201,12 +281,13 @@ export function CycleCalendar({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Marker row: ovulation + symptom dots. */}
|
||||
{/* Marker row: predicted-ovulation + symptom dots. The confirmed
|
||||
oval lives behind the number, so it needs no row dot. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
|
||||
>
|
||||
{info?.isPredictedOvulation ? (
|
||||
{isPredictedOvulationDot ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: OVULATION_HUE }}
|
||||
@@ -229,12 +310,13 @@ export function CycleCalendar({
|
||||
function CalendarLegend() {
|
||||
const { t } = useTranslations();
|
||||
// Each swatch mirrors its grid affordance: period = filled, predicted =
|
||||
// dashed, fertile = a ring (not a filled dot), ovulation = filled, symptom =
|
||||
// the small grey marker dot the grid draws (QA M4).
|
||||
// dashed, fertile = a soft band fill, predicted-ovulation = a filled dot,
|
||||
// confirmed-ovulation = a ringed light oval (distinct from the dot), symptom
|
||||
// = the small grey marker dot the grid draws (QA M4).
|
||||
const items: {
|
||||
hue: string;
|
||||
labelKey: string;
|
||||
variant: "fill" | "dashed" | "ring" | "dot";
|
||||
variant: "fill" | "dashed" | "band" | "oval" | "dot";
|
||||
}[] = [
|
||||
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
|
||||
{
|
||||
@@ -245,12 +327,17 @@ function CalendarLegend() {
|
||||
{
|
||||
hue: FERTILE_HUE,
|
||||
labelKey: "cycle.calendar.legendFertile",
|
||||
variant: "ring",
|
||||
variant: "band",
|
||||
},
|
||||
{
|
||||
hue: OVULATION_HUE,
|
||||
labelKey: "cycle.calendar.legendOvulation",
|
||||
variant: "fill",
|
||||
variant: "dot",
|
||||
},
|
||||
{
|
||||
hue: OVULATION_HUE,
|
||||
labelKey: "cycle.calendar.legendOvulationConfirmed",
|
||||
variant: "oval",
|
||||
},
|
||||
{
|
||||
hue: "var(--muted-foreground)",
|
||||
@@ -261,17 +348,32 @@ function CalendarLegend() {
|
||||
return (
|
||||
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
|
||||
{items.map((it) => (
|
||||
<li key={it.labelKey} className="flex items-center gap-1.5">
|
||||
<li
|
||||
key={it.labelKey}
|
||||
data-legend={it.labelKey}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
it.variant === "dot" ? "h-1 w-1" : "h-2.5 w-2.5",
|
||||
it.variant === "oval"
|
||||
? "h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset"
|
||||
: "rounded-full",
|
||||
it.variant === "dot" && it.hue === "var(--muted-foreground)"
|
||||
? "h-1 w-1"
|
||||
: it.variant === "oval"
|
||||
? ""
|
||||
: "h-2.5 w-2.5",
|
||||
it.variant === "dashed" && "opacity-60",
|
||||
it.variant === "band" && "opacity-30",
|
||||
)}
|
||||
style={
|
||||
it.variant === "ring"
|
||||
? { border: `1.5px solid ${it.hue}` }
|
||||
it.variant === "oval"
|
||||
? ({
|
||||
backgroundColor: it.hue,
|
||||
opacity: 0.3,
|
||||
"--tw-ring-color": it.hue,
|
||||
} as React.CSSProperties)
|
||||
: { backgroundColor: it.hue }
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CycleHistoryResponse, MenstrualCycleDTO } from "./types";
|
||||
import {
|
||||
FLOW_HUE,
|
||||
OVULATION_HUE,
|
||||
PHASE_HUE,
|
||||
HISTORY_REST_OPACITY,
|
||||
HISTORY_PERIOD_OPACITY,
|
||||
} from "./phase-tokens";
|
||||
|
||||
/**
|
||||
* v1.15.1 — the cycle-length history chart.
|
||||
*
|
||||
* A HAND-ROLLED SVG (0-KB runtime, no chart lib — matching `cycle-ring.tsx`'s
|
||||
* identity rather than reaching for Recharts) that draws one vertical bar per
|
||||
* observed cycle, segmented period-vs-rest: the lower span is the menstrual
|
||||
* (period) portion in the rose `FLOW_HUE`, the upper span is the rest of the
|
||||
* cycle in the calm luteal hue, and a thin ovulation tick marks the confirmed
|
||||
* ovulation day when present. A mean baseline rule runs across the field so
|
||||
* variability reads at a glance. Stat chips above carry the average length,
|
||||
* the variability spread, and the regularity classification.
|
||||
*
|
||||
* Why hand-rolled SVG over Recharts: the geometry is trivial (N rects + a
|
||||
* baseline line), the visual must match the wheel's bespoke SVG signature, and
|
||||
* a thin bar chart through Recharts' ResponsiveContainer would pull a chart
|
||||
* lib, an animation engine, and a tooltip surface we don't want here — the
|
||||
* 0-KB approach is genuinely better for identity and weight.
|
||||
*
|
||||
* a11y: `role="img"` + a summary aria-label restating avg + regularity; each
|
||||
* bar carries a `<title>` with its own length so the SVG is never colour-only.
|
||||
* Markers are `data-*`-attr driven (`data-cycle-bar`, `data-ovulation-tick`,
|
||||
* `data-regularity`) so Playwright asserts stable attrs, not viewport text.
|
||||
*
|
||||
* Motion: the shared bar-rise reveal plays ONLY when the host passes
|
||||
* `animate` AND `prefers-reduced-motion` is not set (it sets `data-animate`
|
||||
* on the SVG, which the CSS keys the per-bar grow on). Otherwise bars paint
|
||||
* at full height immediately.
|
||||
*/
|
||||
|
||||
const VIEW_W = 320;
|
||||
const VIEW_H = 120;
|
||||
const PAD_TOP = 8;
|
||||
const PAD_BOTTOM = 4;
|
||||
const MAX_BARS = 12;
|
||||
const REST_HUE = "var(--cycle-phase-luteal)";
|
||||
|
||||
export interface CycleHistoryChartProps {
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** Play the staggered bar-rise once (gated on reduced-motion internally). */
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Bar {
|
||||
id: string;
|
||||
lengthDays: number;
|
||||
periodDays: number | null;
|
||||
ovulationDay: number | null;
|
||||
}
|
||||
|
||||
/** Inclusive day span between two `YYYY-MM-DD` dates (noon-UTC anchored). */
|
||||
function daySpan(from: string, to: string): number {
|
||||
const a = Date.parse(`${from}T12:00:00Z`);
|
||||
const b = Date.parse(`${to}T12:00:00Z`);
|
||||
return Math.round((b - a) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
function toBar(c: MenstrualCycleDTO): Bar | null {
|
||||
if (c.lengthDays == null || c.lengthDays <= 0) return null;
|
||||
const periodDays =
|
||||
c.periodEndDate != null ? daySpan(c.startDate, c.periodEndDate) : null;
|
||||
const ovulationDay =
|
||||
c.ovulationConfirmed && c.ovulationDate != null
|
||||
? daySpan(c.startDate, c.ovulationDate)
|
||||
: null;
|
||||
return {
|
||||
id: c.id,
|
||||
lengthDays: c.lengthDays,
|
||||
periodDays: periodDays != null ? Math.min(periodDays, c.lengthDays) : null,
|
||||
ovulationDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function CycleHistoryChart({
|
||||
history,
|
||||
animate = false,
|
||||
className,
|
||||
}: CycleHistoryChartProps) {
|
||||
const { t } = useTranslations();
|
||||
const reduced = prefersReducedMotion();
|
||||
const shouldAnimate = animate && !reduced;
|
||||
|
||||
const stats = history?.stats;
|
||||
|
||||
// Oldest-to-newest, capped — read left-to-right like a timeline.
|
||||
const bars = useMemo<Bar[]>(() => {
|
||||
const observed = (history?.cycles ?? []).filter((c) => !c.isPredicted);
|
||||
return observed
|
||||
.map(toBar)
|
||||
.filter((b): b is Bar => b != null)
|
||||
.slice(0, MAX_BARS)
|
||||
.reverse();
|
||||
}, [history?.cycles]);
|
||||
|
||||
const hasData = bars.length > 0 && stats?.avgLengthDays != null;
|
||||
|
||||
const maxLen = useMemo(
|
||||
() => Math.max(...bars.map((b) => b.lengthDays), stats?.avgLengthDays ?? 1),
|
||||
[bars, stats?.avgLengthDays],
|
||||
);
|
||||
|
||||
const regularity = stats?.regularity ?? "LEARNING";
|
||||
|
||||
return (
|
||||
<Card data-slot="cycle-history-chart" className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("cycle.history.chartTitle")}
|
||||
</CardTitle>
|
||||
{hasData ? (
|
||||
<CardDescription>
|
||||
{t("cycle.history.chartCaption")}
|
||||
</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!hasData ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.history.none")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Stat chips — avg / variability / regularity classification. */}
|
||||
<ul className="flex flex-wrap gap-2" data-slot="cycle-history-stats">
|
||||
<StatChip
|
||||
hue={REST_HUE}
|
||||
label={t("cycle.history.avgLength")}
|
||||
value={t("cycle.history.days", {
|
||||
count: stats!.avgLengthDays!,
|
||||
})}
|
||||
/>
|
||||
{stats!.lengthVariabilityDays != null ? (
|
||||
<StatChip
|
||||
hue="var(--muted-foreground)"
|
||||
label={t("cycle.history.variability")}
|
||||
value={t("cycle.history.daysPlusMinus", {
|
||||
count: stats!.lengthVariabilityDays,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
<StatChip
|
||||
hue={
|
||||
regularity === "REGULAR"
|
||||
? PHASE_HUE.FOLLICULAR
|
||||
: regularity === "IRREGULAR"
|
||||
? OVULATION_HUE
|
||||
: "var(--muted-foreground)"
|
||||
}
|
||||
data-regularity={regularity}
|
||||
label={t("cycle.history.regularity")}
|
||||
value={t(`cycle.history.regularity${regularity}`)}
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<BarField
|
||||
bars={bars}
|
||||
maxLen={maxLen}
|
||||
avg={stats!.avgLengthDays!}
|
||||
shouldAnimate={shouldAnimate}
|
||||
ariaLabel={t("cycle.history.chartAria", {
|
||||
count: bars.length,
|
||||
avg: Math.round(stats!.avgLengthDays!),
|
||||
regularity: t(`cycle.history.regularity${regularity}`),
|
||||
})}
|
||||
periodLabel={t("cycle.history.legendPeriodSegment")}
|
||||
ovulationLabel={t("cycle.calendar.legendOvulationConfirmed")}
|
||||
barTitle={(b) =>
|
||||
t("cycle.history.days", { count: b.lengthDays })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Inline legend mirroring the bar segments. */}
|
||||
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
{t("cycle.history.legendPeriodSegment")}
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 rounded-sm opacity-60"
|
||||
style={{ backgroundColor: REST_HUE }}
|
||||
/>
|
||||
{t("cycle.history.legendRestSegment")}
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-0.5 rounded-full"
|
||||
style={{ backgroundColor: OVULATION_HUE }}
|
||||
/>
|
||||
{t("cycle.calendar.legendOvulationConfirmed")}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BarField({
|
||||
bars,
|
||||
maxLen,
|
||||
avg,
|
||||
shouldAnimate,
|
||||
ariaLabel,
|
||||
periodLabel,
|
||||
ovulationLabel,
|
||||
barTitle,
|
||||
}: {
|
||||
bars: Bar[];
|
||||
maxLen: number;
|
||||
avg: number;
|
||||
shouldAnimate: boolean;
|
||||
ariaLabel: string;
|
||||
periodLabel: string;
|
||||
ovulationLabel: string;
|
||||
barTitle: (b: Bar) => string;
|
||||
}) {
|
||||
const usableH = VIEW_H - PAD_TOP - PAD_BOTTOM;
|
||||
const baseY = VIEW_H - PAD_BOTTOM;
|
||||
// Column layout: even slots with a calm gap, bars centred in each slot.
|
||||
const slot = VIEW_W / bars.length;
|
||||
const barW = Math.min(slot * 0.62, 22);
|
||||
const scale = (days: number) => (days / maxLen) * usableH;
|
||||
const avgY = baseY - scale(avg);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className="h-32 w-full"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
data-animate={shouldAnimate ? "true" : undefined}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* Mean baseline rule — variability reads against it. */}
|
||||
<line
|
||||
x1={0}
|
||||
x2={VIEW_W}
|
||||
y1={avgY}
|
||||
y2={avgY}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
data-avg-line="true"
|
||||
/>
|
||||
{bars.map((b, i) => {
|
||||
const cx = i * slot + slot / 2;
|
||||
const x = cx - barW / 2;
|
||||
const totalH = scale(b.lengthDays);
|
||||
const topY = baseY - totalH;
|
||||
const periodH =
|
||||
b.periodDays != null ? scale(b.periodDays) : 0;
|
||||
const periodTopY = baseY - periodH;
|
||||
const restH = totalH - periodH;
|
||||
const ovuY =
|
||||
b.ovulationDay != null ? baseY - scale(b.ovulationDay) : null;
|
||||
return (
|
||||
<g
|
||||
key={b.id}
|
||||
className="cycle-history-bar"
|
||||
data-cycle-bar="true"
|
||||
style={{ "--bar-delay": `${i * 55}ms` } as React.CSSProperties}
|
||||
>
|
||||
<title>{`${barTitle(b)}`}</title>
|
||||
{/* Rest-of-cycle segment (upper). */}
|
||||
{restH > 0 ? (
|
||||
<rect
|
||||
x={x}
|
||||
y={topY}
|
||||
width={barW}
|
||||
height={Math.max(restH, 0)}
|
||||
rx={2}
|
||||
fill={REST_HUE}
|
||||
fillOpacity={HISTORY_REST_OPACITY}
|
||||
/>
|
||||
) : null}
|
||||
{/* Period segment (lower) — rose, the bleeding portion. */}
|
||||
{periodH > 0 ? (
|
||||
<rect
|
||||
x={x}
|
||||
y={periodTopY}
|
||||
width={barW}
|
||||
height={periodH}
|
||||
rx={2}
|
||||
fill={FLOW_HUE}
|
||||
fillOpacity={HISTORY_PERIOD_OPACITY}
|
||||
data-period-segment="true"
|
||||
>
|
||||
<title>{periodLabel}</title>
|
||||
</rect>
|
||||
) : null}
|
||||
{/* Confirmed-ovulation tick — a short cross-bar at the ovu day. */}
|
||||
{ovuY != null ? (
|
||||
<rect
|
||||
x={x - 1.5}
|
||||
y={ovuY - 1}
|
||||
width={barW + 3}
|
||||
height={2}
|
||||
rx={1}
|
||||
fill={OVULATION_HUE}
|
||||
data-ovulation-tick="true"
|
||||
>
|
||||
<title>{ovulationLabel}</title>
|
||||
</rect>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChip({
|
||||
hue,
|
||||
label,
|
||||
value,
|
||||
...rest
|
||||
}: {
|
||||
hue: string;
|
||||
label: string;
|
||||
value: string;
|
||||
} & React.HTMLAttributes<HTMLLIElement>) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"bg-muted/40 flex items-center gap-2 rounded-md px-3 py-2",
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-muted-foreground text-[11px] leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-foreground text-sm font-semibold tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -205,6 +205,29 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Fertile-window reminder — surfaced ONLY under the conception goal.
|
||||
The toggle itself is the default-OFF per-channel CYCLE_FERTILE_SOON
|
||||
switch on the notifications page; here we just point a TTC user to
|
||||
it, so the affordance never appears (and fertile language never
|
||||
shows) for the other goals (the inclusive-framing rule). */}
|
||||
{goal === "TRYING_TO_CONCEIVE" ? (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("cycle.settings.fertileReminder")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.settings.fertileReminderDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||
<Link href="/notifications">
|
||||
{t("cycle.settings.remindersLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Data export / delete */}
|
||||
|
||||
@@ -12,7 +12,9 @@ import { CycleRing } from "./cycle-ring";
|
||||
import { BbtChart } from "./bbt-chart";
|
||||
import { CycleCalendar } from "./cycle-calendar";
|
||||
import { CycleDisclaimer } from "./cycle-disclaimer";
|
||||
import { CycleHistoryChart } from "./cycle-history-chart";
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { PhaseEducationCard } from "./phase-education-card";
|
||||
import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
@@ -96,6 +98,10 @@ export function CycleView() {
|
||||
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
|
||||
// key for the no-prediction calendar tab (QA H-1).
|
||||
const goal = calendar.data?.profile.goal;
|
||||
// Honesty-gate inputs for the phase-education card (Clue precedent): the
|
||||
// predictive phase framing only shows when prediction is on, not in
|
||||
// raw-chart mode, and at least three cycles are observed.
|
||||
const calProfile = calendar.data?.profile;
|
||||
const disclaimerText =
|
||||
calendar.data?.prediction?.disclaimer ??
|
||||
t(
|
||||
@@ -123,6 +129,8 @@ export function CycleView() {
|
||||
|
||||
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
|
||||
{/* Wheel + compact phase card — grouped so they stick together. */}
|
||||
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
|
||||
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
|
||||
<div
|
||||
data-slot="cycle-wheel-tile"
|
||||
@@ -133,7 +141,7 @@ export function CycleView() {
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6 lg:sticky lg:top-6",
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
@@ -182,6 +190,22 @@ export function CycleView() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Compact phase-education card beneath the wheel — chip row omitted
|
||||
here (compact); the full card lives on the calendar tab. */}
|
||||
{!loading && !calendarError ? (
|
||||
<PhaseEducationCard
|
||||
compact
|
||||
animate={play}
|
||||
phase={wheel.phase}
|
||||
symptomPatterns={insights.data?.symptomPatterns ?? []}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
cyclesObserved={calProfile?.cyclesObserved ?? 0}
|
||||
onLogToday={() => openSheet(today)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="calendar">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="calendar" className="flex-1">
|
||||
@@ -199,6 +223,16 @@ export function CycleView() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calendar" className="mt-4 space-y-4">
|
||||
{!loading && !calendarError ? (
|
||||
<PhaseEducationCard
|
||||
phase={wheel.phase}
|
||||
symptomPatterns={insights.data?.symptomPatterns ?? []}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
cyclesObserved={calProfile?.cyclesObserved ?? 0}
|
||||
onLogToday={() => openSheet(today)}
|
||||
/>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{loading ? (
|
||||
@@ -211,6 +245,11 @@ export function CycleView() {
|
||||
<CycleCalendar
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
confirmedOvulation={
|
||||
calendar.data?.prediction?.ovulationConfirmed
|
||||
? (calendar.data.prediction.predictedOvulation ?? null)
|
||||
: null
|
||||
}
|
||||
onSelectDay={openSheet}
|
||||
/>
|
||||
)}
|
||||
@@ -236,6 +275,7 @@ export function CycleView() {
|
||||
history={history.data}
|
||||
fallbackDisclaimer={disclaimerText}
|
||||
/>
|
||||
<CycleHistoryChart history={history.data} animate={play} />
|
||||
<BbtChart
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Droplets, Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
BatteryLow,
|
||||
Brain,
|
||||
CircleDot,
|
||||
Cookie,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
Flame,
|
||||
Frown,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
Loader2,
|
||||
MoonStar,
|
||||
PersonStanding,
|
||||
Pill,
|
||||
Plus,
|
||||
Snowflake,
|
||||
Soup,
|
||||
Stethoscope,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import {
|
||||
useCreateCustomSymptom,
|
||||
useCustomSymptoms,
|
||||
useCycleDayLog,
|
||||
useDeleteCustomSymptom,
|
||||
useDeleteDayLog,
|
||||
useEndPeriod,
|
||||
useLogDay,
|
||||
usePatchDayLog,
|
||||
useStartPeriod,
|
||||
CustomSymptomError,
|
||||
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE,
|
||||
type CustomSymptomDTO,
|
||||
type CycleDayLogPatch,
|
||||
} from "./use-cycle";
|
||||
import type {
|
||||
@@ -75,6 +115,38 @@ const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [
|
||||
];
|
||||
const SEVERITY_LEVELS = [1, 2, 3, 4] as const;
|
||||
|
||||
/**
|
||||
* Resolve a custom symptom's stored Lucide icon NAME to its component. Scoped
|
||||
* to the `CUSTOM_SYMPTOM_ICON_ALLOWLIST` (server-validated on create) so the
|
||||
* import set stays tight; an unknown / null name falls back to `Tag`.
|
||||
*/
|
||||
const CUSTOM_ICON_BY_NAME: Record<string, LucideIcon> = {
|
||||
Tag,
|
||||
Activity,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
Brain,
|
||||
Zap,
|
||||
Flame,
|
||||
Snowflake,
|
||||
Droplet,
|
||||
CircleDot,
|
||||
BatteryLow,
|
||||
MoonStar,
|
||||
PersonStanding,
|
||||
Drama,
|
||||
Frown,
|
||||
Cookie,
|
||||
Soup,
|
||||
Pill,
|
||||
Thermometer,
|
||||
Stethoscope,
|
||||
};
|
||||
|
||||
function customIcon(name: string | null): LucideIcon {
|
||||
return (name && CUSTOM_ICON_BY_NAME[name]) || Tag;
|
||||
}
|
||||
|
||||
/** The sheet's editable form state, lifted out so the two save-payload builders
|
||||
* are pure + unit-testable (the clear-on-edit semantics are the QA W-2 fix). */
|
||||
export interface DayLogFormState {
|
||||
@@ -211,6 +283,8 @@ export function LogDaySheet({
|
||||
// Pre-fill from the persisted day-log so a re-log never nulls untouched
|
||||
// (or HealthKit-sourced) fields — the v1.15 web data-loss fix.
|
||||
const dayLog = useCycleDayLog(open ? date : null);
|
||||
// The caller's custom symptoms, merged into the seeded chip grid.
|
||||
const customSymptoms = useCustomSymptoms();
|
||||
|
||||
const [flow, setFlow] = useState<FlowLevel | null>(null);
|
||||
const [intermenstrual, setIntermenstrual] = useState(false);
|
||||
@@ -440,53 +514,53 @@ export function LogDaySheet({
|
||||
{t(cat.labelKey)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cat.symptoms.map((s) => {
|
||||
const active = symptoms.has(s.key);
|
||||
const sev = symptoms.get(s.key) ?? null;
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-1">
|
||||
<Chip
|
||||
active={active}
|
||||
onClick={() => toggleSymptom(s.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
</Chip>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={sev === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", {
|
||||
level: lvl,
|
||||
})}
|
||||
onClick={() => setSymptomSeverity(s.key, lvl)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
sev === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{cat.symptoms.map((s) => (
|
||||
<SymptomChip
|
||||
key={s.key}
|
||||
symptomKey={s.key}
|
||||
icon={s.icon}
|
||||
label={t(s.labelKey)}
|
||||
active={symptoms.has(s.key)}
|
||||
severity={symptoms.get(s.key) ?? null}
|
||||
onToggle={toggleSymptom}
|
||||
onSeverity={setSymptomSeverity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom symptoms — the user's own, under a `custom` category,
|
||||
plus the dashed ghost-chip that mints a new one. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("cycle.symptomCategory.custom")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(customSymptoms.data?.symptoms ?? []).map((s) => (
|
||||
<SymptomChip
|
||||
key={s.key}
|
||||
symptomKey={s.key}
|
||||
icon={customIcon(s.icon)}
|
||||
label={s.label ?? t("cycle.symptom.custom.fallbackLabel")}
|
||||
active={symptoms.has(s.key)}
|
||||
severity={symptoms.get(s.key) ?? null}
|
||||
custom={s}
|
||||
onToggle={toggleSymptom}
|
||||
onSeverity={setSymptomSeverity}
|
||||
onClear={(key) => {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AddSymptomChip onCreated={toggleSymptom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
@@ -649,3 +723,234 @@ function Field({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One symptom chip + its inline 1-4 severity selector (shared by the seeded
|
||||
* catalogue chips and the custom chips so both render identically). A custom
|
||||
* chip additionally carries a tiny remove (×) affordance that soft-hides the
|
||||
* symptom from the catalogue.
|
||||
*/
|
||||
function SymptomChip({
|
||||
symptomKey,
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
severity,
|
||||
custom,
|
||||
onToggle,
|
||||
onSeverity,
|
||||
onClear,
|
||||
}: {
|
||||
symptomKey: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
active: boolean;
|
||||
severity: number | null;
|
||||
custom?: CustomSymptomDTO;
|
||||
onToggle: (key: string) => void;
|
||||
onSeverity: (key: string, severity: number) => void;
|
||||
onClear?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const deleteCustom = useDeleteCustomSymptom();
|
||||
|
||||
async function handleRemove() {
|
||||
if (!custom) return;
|
||||
onClear?.(custom.key);
|
||||
await deleteCustom.mutateAsync(custom.key);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex items-center">
|
||||
<Chip active={active} onClick={() => onToggle(symptomKey)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
</Chip>
|
||||
{custom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={deleteCustom.isPending}
|
||||
aria-label={t("cycle.symptom.custom.remove", { label })}
|
||||
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<X className="size-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={severity === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
|
||||
onClick={() => onSeverity(symptomKey, lvl)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
severity === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashed ghost-chip in the symptom grid that opens a compact label + icon
|
||||
* popover to mint a new custom symptom. Matches the chip grid (same rounded-
|
||||
* full pill, same size) so it reads as part of the grid, not a foreign button.
|
||||
*/
|
||||
function AddSymptomChip({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateCustomSymptom();
|
||||
const [openForm, setOpenForm] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [icon, setIcon] = useState<string>("Tag");
|
||||
|
||||
function reset() {
|
||||
setLabel("");
|
||||
setIcon("Tag");
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const created = await create.mutateAsync({ label: trimmed, icon });
|
||||
// Auto-select the freshly-minted symptom so the user doesn't have to tap
|
||||
// the new chip after adding it.
|
||||
onCreated(created.key);
|
||||
reset();
|
||||
setOpenForm(false);
|
||||
} catch {
|
||||
// Keep the popover open; `create.error` drives the inline message
|
||||
// (cap copy on the limit errorCode, generic otherwise).
|
||||
}
|
||||
}
|
||||
|
||||
// Only the per-user cap surfaces the "limit reached" copy — a transient or
|
||||
// validation error must not masquerade as the cap being hit.
|
||||
const limitReached =
|
||||
create.error instanceof CustomSymptomError &&
|
||||
create.error.errorCode === CUSTOM_SYMPTOM_LIMIT_ERROR_CODE;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={openForm}
|
||||
onOpenChange={(o) => {
|
||||
setOpenForm(o);
|
||||
if (!o) reset();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("cycle.symptom.custom.add")}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-symptom-label" className="text-xs font-medium">
|
||||
{t("cycle.symptom.custom.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-symptom-label"
|
||||
value={label}
|
||||
maxLength={40}
|
||||
autoFocus
|
||||
placeholder={t("cycle.symptom.custom.labelPlaceholder")}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">
|
||||
{t("cycle.symptom.custom.icon")}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("cycle.symptom.custom.icon")}
|
||||
>
|
||||
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = customIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{limitReached ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("cycle.symptom.custom.limitReached")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpenForm(false);
|
||||
}}
|
||||
>
|
||||
{t("cycle.sheet.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!label.trim() || create.isPending}
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.symptom.custom.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Sparkles, Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PHASE_WHATS_HAPPENING_KEY } from "@/lib/cycle/phase-copy";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import type { CyclePhase } from "./types";
|
||||
import type { SymptomPhaseRow } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.1 — the per-phase "what's happening now" education card.
|
||||
*
|
||||
* Anchored to the wheel: a hue-tinted `wellness-tile` surface keyed to the
|
||||
* ACTIVE phase (`--tile-hue`), so it reads as part of the ring, not a generic
|
||||
* tip card. Three zones:
|
||||
* 1. phase name + hue dot,
|
||||
* 2. a short, factual, descriptive-only "what's happening" line (curated i18n,
|
||||
* never clinical advice / a medical claim),
|
||||
* 3. a "you often notice this here" chip row sourced from the user's OWN
|
||||
* symptom-by-phase history (`symptomPatterns`), filtered to symptoms that
|
||||
* cluster in THIS phase — never a generic population claim — plus a
|
||||
* "log today" nudge into the existing day-log sheet.
|
||||
*
|
||||
* Honesty gate (Clue precedent): when prediction is disabled / raw-chart mode
|
||||
* or fewer than three observed cycles, the predictive framing is suppressed and
|
||||
* the card shows a calm "still learning" line instead of phase copy + chips.
|
||||
*
|
||||
* Motion: NO competing animation. The card rides the wheel's once-per-session
|
||||
* reveal — `animate` drives the shared `wellness-tile-rise` keyframe (a single
|
||||
* calm entrance), which the global `prefers-reduced-motion` block already
|
||||
* zeroes. No bespoke transitions.
|
||||
*/
|
||||
|
||||
const MIN_CYCLES_FOR_PREDICTION = 3;
|
||||
|
||||
export interface PhaseEducationCardProps {
|
||||
/** The active phase from the calendar read, or null when none is resolved. */
|
||||
phase: CyclePhase | null;
|
||||
/** The user's own symptom-by-phase rows (from `useCycleInsights`). */
|
||||
symptomPatterns: SymptomPhaseRow[];
|
||||
/** Honesty-gate inputs from the calendar profile. */
|
||||
predictionEnabled: boolean;
|
||||
rawChartMode: boolean;
|
||||
cyclesObserved: number;
|
||||
/** Open the day-log sheet for today (the "log today" nudge). */
|
||||
onLogToday: () => void;
|
||||
/** Ride the wheel's once-per-session reveal — no independent animation. */
|
||||
animate?: boolean;
|
||||
/** Compact variant beneath the wheel (tighter padding, no chip row). */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhaseEducationCard({
|
||||
phase,
|
||||
symptomPatterns,
|
||||
predictionEnabled,
|
||||
rawChartMode,
|
||||
cyclesObserved,
|
||||
onLogToday,
|
||||
animate = false,
|
||||
compact = false,
|
||||
className,
|
||||
}: PhaseEducationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Honesty gate: with prediction off / raw-chart mode / too few cycles, the
|
||||
// phase label is not trustworthy — suppress the framing and stay calm.
|
||||
const stillLearning =
|
||||
!phase ||
|
||||
!predictionEnabled ||
|
||||
rawChartMode ||
|
||||
cyclesObserved < MIN_CYCLES_FOR_PREDICTION;
|
||||
|
||||
// Flatten the catalog once: symptom key → { labelKey, icon }. Reuses the same
|
||||
// catalog the symptom-patterns card draws from so the chip labels + icons
|
||||
// match exactly.
|
||||
const byKey = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{
|
||||
labelKey: string;
|
||||
icon: (typeof CYCLE_SYMPTOM_CATALOG)[number]["symptoms"][number]["icon"];
|
||||
}
|
||||
>();
|
||||
for (const cat of CYCLE_SYMPTOM_CATALOG) {
|
||||
for (const s of cat.symptoms) {
|
||||
map.set(s.key, { labelKey: s.labelKey, icon: s.icon });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
// The "common here" chips are the user's OWN symptoms that cluster in the
|
||||
// ACTIVE phase (topPhase === phase). Most-concentrated first; capped so the
|
||||
// row stays scannable. Empty when no pattern points at this phase yet.
|
||||
const chips = useMemo(() => {
|
||||
if (stillLearning || !phase) return [];
|
||||
return symptomPatterns
|
||||
.filter((row) => row.topPhase === phase)
|
||||
.sort((a, b) => b.topShare - a.topShare || b.total - a.total)
|
||||
.slice(0, 5);
|
||||
}, [symptomPatterns, phase, stillLearning]);
|
||||
|
||||
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
|
||||
const phaseName = phase ? t(`cycle.phase.${phase}`) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="cycle-phase-education"
|
||||
data-revealed={animate ? "true" : undefined}
|
||||
data-phase={phase ?? "none"}
|
||||
aria-label={t("cycle.phaseEducation.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className={cn(
|
||||
"wellness-tile rounded-xl",
|
||||
compact ? "px-4 py-4" : "px-5 py-5",
|
||||
animate && "wellness-tile-rise",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("cycle.phaseEducation.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stillLearning ? (
|
||||
<p
|
||||
data-slot="cycle-phase-education-learning"
|
||||
className="text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
{t("cycle.phaseEducation.stillLearning")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
{phaseName}
|
||||
</h3>
|
||||
|
||||
{/* Zone 2 — the curated, descriptive-only "what's happening" line. */}
|
||||
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">
|
||||
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
|
||||
</p>
|
||||
|
||||
{/* Zone 3 — the user's OWN clustered symptoms for this phase. The
|
||||
compact variant under the wheel omits the chip row to stay light;
|
||||
the calendar-tab variant shows it. */}
|
||||
{!compact && chips.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t("cycle.phaseEducation.commonHere")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="cycle-phase-education-chips"
|
||||
className="mt-2 flex flex-wrap gap-1.5"
|
||||
>
|
||||
{chips.map((row) => {
|
||||
const meta = byKey.get(row.symptomKey);
|
||||
const Icon = meta?.icon;
|
||||
const name = meta ? t(meta.labelKey) : row.symptomKey;
|
||||
return (
|
||||
<li
|
||||
key={row.symptomKey}
|
||||
className="border-foreground/15 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className="h-3 w-3 shrink-0"
|
||||
style={{ color: hue }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The "log today" nudge — opens the existing day-log sheet. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLogToday}
|
||||
className="bg-background/55 mt-4 gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("cycle.phaseEducation.trackNudge")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* semantic is never colour-only: every surface that uses a hue also renders
|
||||
* the phase word + an aria-label.
|
||||
*/
|
||||
import type { CyclePhase } from "./types";
|
||||
import type { CyclePhase, FlowLevel } from "./types";
|
||||
|
||||
export const PHASE_HUE: Record<CyclePhase, string> = {
|
||||
MENSTRUAL: "var(--cycle-phase-menstrual)",
|
||||
@@ -22,3 +22,42 @@ export const FLOW_HUE = "var(--cycle-phase-menstrual)";
|
||||
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
|
||||
/** Ovulation marker tint (amber-gold family). */
|
||||
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
|
||||
|
||||
/**
|
||||
* Flow-intensity shading — a SINGLE-HUE OPACITY LADDER on the menstrual rose
|
||||
* (`FLOW_HUE`), never a rainbow of hues. Heavier flow = a denser fill of the
|
||||
* same colour, so the calendar reads as one calm field that deepens with
|
||||
* intensity. The ladder is the alpha applied to the period cell's filled pip;
|
||||
* every step keeps the day number (foreground, z-10) above it at WCAG AA. The
|
||||
* marker is never colour-only — each period day still restates its flow level
|
||||
* in the cell aria-label + carries a `data-flow-level` attribute for e2e.
|
||||
*
|
||||
* SPOTTING (lightest) → HEAVY (densest). NONE keeps no fill (not a period day).
|
||||
*/
|
||||
export const FLOW_OPACITY: Record<Exclude<FlowLevel, "NONE">, number> = {
|
||||
SPOTTING: 0.14,
|
||||
LIGHT: 0.26,
|
||||
MEDIUM: 0.42,
|
||||
HEAVY: 0.62,
|
||||
};
|
||||
|
||||
/** The default period-day fill alpha when a logged day carries no flow grade. */
|
||||
export const FLOW_OPACITY_DEFAULT = 0.32;
|
||||
|
||||
/**
|
||||
* History-chart bar fill alphas — the two-segment stacked bar (rest-of-cycle
|
||||
* upper, period lower) keeps its intensity here so the chart and the calendar
|
||||
* draw their fills from one token source rather than inline literals. The
|
||||
* period segment sits denser than the rest span so the bleeding portion reads
|
||||
* as the anchor of each bar.
|
||||
*/
|
||||
export const HISTORY_REST_OPACITY = 0.5;
|
||||
export const HISTORY_PERIOD_OPACITY = 0.85;
|
||||
|
||||
/** Resolve the flow-shading alpha for a logged period day (graceful fallback). */
|
||||
export function flowOpacity(flow: string | null | undefined): number {
|
||||
if (flow && flow !== "NONE" && flow in FLOW_OPACITY) {
|
||||
return FLOW_OPACITY[flow as Exclude<FlowLevel, "NONE">];
|
||||
}
|
||||
return FLOW_OPACITY_DEFAULT;
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* v1.15.0 — the seeded cycle-symptom catalog, mirrored client-side.
|
||||
*
|
||||
* The catalog ships seeded-only in v1.15.0 (no per-user custom CRUD yet),
|
||||
* so the keys are fixed at the migration seed (0129). The `key` is the
|
||||
* The keys are fixed at the migration seed (0129). The `key` is the
|
||||
* snake_case DB key the day-log write resolves against; the `labelKey` is
|
||||
* the camelCase i18n leaf under `cycle.symptom.*`; the `icon` is the
|
||||
* Lucide name from the seed. Keep this in lockstep with the seed in
|
||||
* `prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql` and
|
||||
* the `cycle.symptom.*` / `cycle.symptomCategory.*` i18n leaves.
|
||||
*
|
||||
* v1.15.1 layers per-user custom symptoms on top (the `custom` category,
|
||||
* seeded by migration 0134): the log-day sheet fetches them from
|
||||
* `/api/cycle/symptoms/custom` and merges them into this seeded grid. They
|
||||
* are NOT in this static catalog — their labels are user free text held
|
||||
* encrypted at rest, resolved server-side, never an i18n key.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
|
||||
@@ -110,6 +110,85 @@ export function useCycleHistory(limit = 24) {
|
||||
});
|
||||
}
|
||||
|
||||
/** One per-user custom symptom: a `custom:<uuid>` key + decrypted label. */
|
||||
export interface CustomSymptomDTO {
|
||||
key: string;
|
||||
label: string | null;
|
||||
icon: string | null;
|
||||
custom: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's own custom symptoms, labels decrypted server-side. The log-day
|
||||
* sheet merges these into the seeded chip grid under the `custom` category.
|
||||
*/
|
||||
export function useCustomSymptoms() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleCustomSymptoms(),
|
||||
queryFn: () =>
|
||||
fetch("/api/cycle/symptoms/custom").then((r) =>
|
||||
unwrap<{ symptoms: CustomSymptomDTO[] }>(r),
|
||||
),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** The `errorCode` the create POST returns when the per-user cap is hit. */
|
||||
export const CUSTOM_SYMPTOM_LIMIT_ERROR_CODE = "cycle.symptom.custom.limit";
|
||||
|
||||
/** An error that preserves the envelope `errorCode` so callers can branch. */
|
||||
export class CustomSymptomError extends Error {
|
||||
constructor(
|
||||
public readonly errorCode: string | null,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(errorCode ?? `Request failed: ${status}`);
|
||||
this.name = "CustomSymptomError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a custom symptom (mint `custom:<uuid>`, encrypt the label). */
|
||||
export function useCreateCustomSymptom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { label: string; icon?: string | null }) => {
|
||||
const res = await fetch("/api/cycle/symptoms/custom", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Preserve the envelope errorCode so the UI can tell the cap hit
|
||||
// (cycle.symptom.custom.limit) apart from a transient/validation error.
|
||||
const errorCode = await res
|
||||
.json()
|
||||
.then((j) => (j?.meta?.errorCode as string | undefined) ?? null)
|
||||
.catch(() => null);
|
||||
throw new CustomSymptomError(errorCode, res.status);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data as CustomSymptomDTO;
|
||||
},
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleCustomSymptoms() }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-hide a custom symptom by its key (history-preserving). */
|
||||
export function useDeleteCustomSymptom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
const res = await fetch(
|
||||
`/api/cycle/symptoms/custom/${encodeURIComponent(key)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
return unwrap<{ key: string; purged: boolean }>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleProfile() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleProfile(),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.15.1 — custom cycle-symptom helpers: key mint/detect, label encrypt /
|
||||
* decrypt fail-soft, and the Zod create/update schemas.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => {
|
||||
if (!s.startsWith("enc:")) throw new Error("bad ciphertext");
|
||||
return s.replace(/^enc:/, "");
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
CUSTOM_SYMPTOM_KEY_PREFIX,
|
||||
MAX_CUSTOM_SYMPTOMS_PER_USER,
|
||||
createCustomSymptomSchema,
|
||||
decryptCustomLabel,
|
||||
encryptCustomLabel,
|
||||
isCustomSymptomKey,
|
||||
mintCustomSymptomKey,
|
||||
updateCustomSymptomSchema,
|
||||
} from "@/lib/cycle/custom-symptoms";
|
||||
|
||||
describe("custom-symptom keys", () => {
|
||||
it("mints a custom:<uuid> key the detector recognises", () => {
|
||||
const key = mintCustomSymptomKey();
|
||||
expect(key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX)).toBe(true);
|
||||
expect(isCustomSymptomKey(key)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a bare catalogue slug as custom", () => {
|
||||
expect(isCustomSymptomKey("cramps")).toBe(false);
|
||||
expect(isCustomSymptomKey("headache")).toBe(false);
|
||||
});
|
||||
|
||||
it("mints unique keys", () => {
|
||||
expect(mintCustomSymptomKey()).not.toBe(mintCustomSymptomKey());
|
||||
});
|
||||
});
|
||||
|
||||
describe("label encryption", () => {
|
||||
it("round-trips a label through encrypt/decrypt", () => {
|
||||
const enc = encryptCustomLabel("Schwindel");
|
||||
expect(enc).toBe("enc:Schwindel");
|
||||
expect(decryptCustomLabel(enc)).toBe("Schwindel");
|
||||
});
|
||||
|
||||
it("decrypts a corrupt/missing ciphertext fail-soft to null", () => {
|
||||
expect(decryptCustomLabel(null)).toBeNull();
|
||||
expect(decryptCustomLabel("not-encrypted")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create schema", () => {
|
||||
it("accepts a label + allow-listed icon", () => {
|
||||
const p = createCustomSymptomSchema.safeParse({
|
||||
label: "Dizziness",
|
||||
icon: "Brain",
|
||||
});
|
||||
expect(p.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty label and an over-long one", () => {
|
||||
expect(createCustomSymptomSchema.safeParse({ label: "" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "x".repeat(41) }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an icon outside the allow-list", () => {
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "X", icon: "Skull" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a categoryKey other than custom", () => {
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "X", categoryKey: "physical" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update schema", () => {
|
||||
it("requires at least one field", () => {
|
||||
expect(updateCustomSymptomSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a partial update", () => {
|
||||
expect(
|
||||
updateCustomSymptomSchema.safeParse({ isActive: false }).success,
|
||||
).toBe(true);
|
||||
expect(updateCustomSymptomSchema.safeParse({ label: "New" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-user cap", () => {
|
||||
it("pins a sane ceiling", () => {
|
||||
expect(MAX_CUSTOM_SYMPTOMS_PER_USER).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* v1.15.1 — `resolveSymptomIds` resolves a mix of seeded catalogue keys AND
|
||||
* the caller's own `custom:` keys (the day-log write path for custom symptoms).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleSymptom: { findMany: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/profile", () => ({
|
||||
getOrCreateCycleProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveSymptomIds } from "@/lib/cycle/day-log-write";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveSymptomIds", () => {
|
||||
it("queries the catalogue (userId null) AND the caller's own rows", async () => {
|
||||
db.cycleSymptom.findMany.mockResolvedValue([
|
||||
{ id: "cs_cramps", key: "cramps" },
|
||||
{ id: "id_custom", key: "custom:abc" },
|
||||
]);
|
||||
|
||||
const out = await resolveSymptomIds("user-1", ["cramps", "custom:abc"]);
|
||||
|
||||
expect(out).toEqual([
|
||||
{ key: "cramps", id: "cs_cramps" },
|
||||
{ key: "custom:abc", id: "id_custom" },
|
||||
]);
|
||||
const where = db.cycleSymptom.findMany.mock.calls[0][0].where;
|
||||
expect(where.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
|
||||
expect(where.key.in).toEqual(["cramps", "custom:abc"]);
|
||||
expect(where.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("drops unknown keys (returns only resolved rows)", async () => {
|
||||
db.cycleSymptom.findMany.mockResolvedValue([
|
||||
{ id: "id_custom", key: "custom:abc" },
|
||||
]);
|
||||
const out = await resolveSymptomIds("user-1", [
|
||||
"custom:abc",
|
||||
"custom:not-mine",
|
||||
]);
|
||||
expect(out).toEqual([{ key: "custom:abc", id: "id_custom" }]);
|
||||
});
|
||||
|
||||
it("short-circuits on an empty key set", async () => {
|
||||
const out = await resolveSymptomIds("user-1", []);
|
||||
expect(out).toEqual([]);
|
||||
expect(db.cycleSymptom.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.15.1 — client-safe constants + Zod schemas for custom cycle symptoms.
|
||||
*
|
||||
* Split out of `custom-symptoms.ts` so a `"use client"` component (the log
|
||||
* sheet's chip grid) can read the icon allow-list and key helpers WITHOUT
|
||||
* pulling the server-only crypto / `node:crypto` the mint + label-encryption
|
||||
* helpers depend on into the browser bundle. The server module re-exports
|
||||
* everything here, so existing server import sites are unaffected.
|
||||
*/
|
||||
|
||||
/** Reserved key prefix for a user-minted custom symptom. */
|
||||
export const CUSTOM_SYMPTOM_KEY_PREFIX = "custom:";
|
||||
|
||||
/** Stable category key + seeded id every custom symptom hangs under. */
|
||||
export const CUSTOM_SYMPTOM_CATEGORY_KEY = "custom";
|
||||
export const CUSTOM_SYMPTOM_CATEGORY_ID = "csc_custom";
|
||||
|
||||
/** Per-user ceiling on custom symptoms (422 over the cap). */
|
||||
export const MAX_CUSTOM_SYMPTOMS_PER_USER = 50;
|
||||
|
||||
/**
|
||||
* Allow-list of Lucide icon names a custom symptom may use. Kept to names the
|
||||
* iOS client maps to an SF Symbol so a custom symptom never falls back to the
|
||||
* generic glyph on one platform. An unknown name → 422. `Tag` is the default.
|
||||
*/
|
||||
export const CUSTOM_SYMPTOM_ICON_ALLOWLIST = [
|
||||
"Tag",
|
||||
"Activity",
|
||||
"Heart",
|
||||
"HeartPulse",
|
||||
"Brain",
|
||||
"Zap",
|
||||
"Flame",
|
||||
"Snowflake",
|
||||
"Droplet",
|
||||
"CircleDot",
|
||||
"BatteryLow",
|
||||
"MoonStar",
|
||||
"PersonStanding",
|
||||
"Drama",
|
||||
"Frown",
|
||||
"Cookie",
|
||||
"Soup",
|
||||
"Pill",
|
||||
"Thermometer",
|
||||
"Stethoscope",
|
||||
] as const;
|
||||
|
||||
const ICON_SET = new Set<string>(CUSTOM_SYMPTOM_ICON_ALLOWLIST);
|
||||
|
||||
/** Is `key` a custom-symptom key (vs a bare catalogue slug)? */
|
||||
export function isCustomSymptomKey(key: string): boolean {
|
||||
return key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX);
|
||||
}
|
||||
|
||||
const labelSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Label must not be empty")
|
||||
.max(40, "Label must be at most 40 characters");
|
||||
|
||||
const iconSchema = z
|
||||
.string()
|
||||
.refine((v) => ICON_SET.has(v), "Unknown icon")
|
||||
.nullish();
|
||||
|
||||
/** POST body — create a custom symptom. */
|
||||
export const createCustomSymptomSchema = z.object({
|
||||
label: labelSchema,
|
||||
icon: iconSchema,
|
||||
// Reserved for a future per-symptom category choice; v1 pins everything to
|
||||
// the `custom` category, so a supplied value other than `custom` is rejected.
|
||||
categoryKey: z.literal(CUSTOM_SYMPTOM_CATEGORY_KEY).optional(),
|
||||
});
|
||||
|
||||
/** PATCH body — update a custom symptom (every field optional). */
|
||||
export const updateCustomSymptomSchema = z
|
||||
.object({
|
||||
label: labelSchema.optional(),
|
||||
icon: iconSchema,
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.label !== undefined || v.icon !== undefined || v.isActive !== undefined,
|
||||
"At least one field is required",
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
|
||||
import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared";
|
||||
|
||||
/**
|
||||
* v1.15.1 — per-user custom cycle-symptom helpers (the v1.13 custom-mood-tag
|
||||
* precedent, mirrored 1:1).
|
||||
*
|
||||
* Custom symptoms live in the same `cycle_symptoms` table as the seeded
|
||||
* catalogue, distinguished by a non-null `userId`. Their key carries a
|
||||
* reserved `custom:` prefix so it never collides with a bare catalogue slug,
|
||||
* and the write path (`day-log-write.ts:resolveSymptomIds`) already resolves a
|
||||
* key against the catalogue rows (userId NULL) PLUS the caller's own rows —
|
||||
* so a `custom:` key routes to the user's symptom by construction. The label
|
||||
* is held AES-256-GCM-encrypted at rest (`labelEncrypted`): a symptom name is
|
||||
* intent-revealing free text, the highest-sensitivity category in the
|
||||
* post-Dobbs threat model.
|
||||
*
|
||||
* This module carries the SERVER-ONLY helpers (uuid mint + label crypto). The
|
||||
* client-safe constants + Zod schemas live in `custom-symptoms-shared.ts` and
|
||||
* are re-exported here so existing server import sites need no change.
|
||||
*/
|
||||
|
||||
export {
|
||||
CUSTOM_SYMPTOM_KEY_PREFIX,
|
||||
CUSTOM_SYMPTOM_CATEGORY_KEY,
|
||||
CUSTOM_SYMPTOM_CATEGORY_ID,
|
||||
MAX_CUSTOM_SYMPTOMS_PER_USER,
|
||||
CUSTOM_SYMPTOM_ICON_ALLOWLIST,
|
||||
isCustomSymptomKey,
|
||||
createCustomSymptomSchema,
|
||||
updateCustomSymptomSchema,
|
||||
} from "@/lib/cycle/custom-symptoms-shared";
|
||||
|
||||
/** Mint a fresh, collision-proof custom-symptom key. */
|
||||
export function mintCustomSymptomKey(): string {
|
||||
return `${CUSTOM_SYMPTOM_KEY_PREFIX}${randomUUID()}`;
|
||||
}
|
||||
|
||||
/** Encrypt a custom label for storage in `label_encrypted`. */
|
||||
export function encryptCustomLabel(label: string): string {
|
||||
return encrypt(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored custom label. Returns null on a missing or corrupt
|
||||
* ciphertext so one bad row never throws the whole effective-set read.
|
||||
*/
|
||||
export function decryptCustomLabel(encoded: string | null): string | null {
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
return decrypt(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* v1.15.1 — per-phase education copy → i18n leaf mapping.
|
||||
*
|
||||
* The `PhaseEducationCard` renders a short, factual "what's happening" line for
|
||||
* the active cycle phase. The copy is curated, descriptive-only (no clinical
|
||||
* advice, no medical claims) and lives entirely under `cycle.phaseEducation.*`
|
||||
* in the message bundles — this module only maps a phase to its leaf key so the
|
||||
* i18n-call-site-coverage guard sees the literal `t()` arguments at the call
|
||||
* site and the copy stays translatable across all six locales.
|
||||
*/
|
||||
import type { CyclePhase } from "@/lib/cycle/types";
|
||||
|
||||
/**
|
||||
* The phase → i18n leaf for the "what's happening now" descriptive line. The
|
||||
* keys are spelled out as string literals (not interpolated) so the
|
||||
* call-site-coverage walker resolves each one against `messages/en.json`.
|
||||
*/
|
||||
export const PHASE_WHATS_HAPPENING_KEY: Record<CyclePhase, string> = {
|
||||
MENSTRUAL: "cycle.phaseEducation.whatsHappening.MENSTRUAL",
|
||||
FOLLICULAR: "cycle.phaseEducation.whatsHappening.FOLLICULAR",
|
||||
OVULATORY: "cycle.phaseEducation.whatsHappening.OVULATORY",
|
||||
LUTEAL: "cycle.phaseEducation.whatsHappening.LUTEAL",
|
||||
};
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
buildCycleReminderPayload,
|
||||
cycleReminderLocalDate,
|
||||
evaluateCycleReminder,
|
||||
evaluateFertileReminder,
|
||||
runCycleReminderTick,
|
||||
CYCLE_REMINDER_LOCAL_HOUR,
|
||||
PERIOD_SOON_LEAD_DAYS,
|
||||
PERIOD_CONFIRM_GRACE_DAYS,
|
||||
FERTILE_SOON_LEAD_DAYS,
|
||||
} from "../cycle-reminder";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
|
||||
@@ -57,11 +59,17 @@ interface FakeUser {
|
||||
cycleTrackingEnabled: boolean | null;
|
||||
predictionEnabled: boolean;
|
||||
discreetNotifications: boolean;
|
||||
goal?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface FakeState {
|
||||
predictions: Array<{ userId: string; nextPeriodStart: string; user: FakeUser }>;
|
||||
predictions: Array<{
|
||||
userId: string;
|
||||
nextPeriodStart: string;
|
||||
fertileWindowStart?: string | null;
|
||||
user: FakeUser;
|
||||
}>;
|
||||
/** Observed (non-predicted) cycle start dates per userId. */
|
||||
observedStarts: Record<string, string[]>;
|
||||
/** Existing `ok` push attempts: `${userId}:${eventType}` already sent today. */
|
||||
@@ -110,6 +118,7 @@ function baseUser(over: Partial<FakeUser> = {}): FakeUser {
|
||||
cycleTrackingEnabled: null,
|
||||
predictionEnabled: true,
|
||||
discreetNotifications: false,
|
||||
goal: "GENERAL_HEALTH",
|
||||
},
|
||||
...over,
|
||||
};
|
||||
@@ -214,7 +223,48 @@ describe("evaluateCycleReminder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFertileReminder", () => {
|
||||
it("fires exactly lead-days before the fertile-window start", () => {
|
||||
expect(
|
||||
evaluateFertileReminder({
|
||||
today: "2026-05-18",
|
||||
fertileWindowStart: "2026-05-20",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(FERTILE_SOON_LEAD_DAYS).toBe(2);
|
||||
});
|
||||
|
||||
it("does not fire one day off the lead", () => {
|
||||
expect(
|
||||
evaluateFertileReminder({
|
||||
today: "2026-05-19",
|
||||
fertileWindowStart: "2026-05-20",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateFertileReminder({
|
||||
today: "2026-05-17",
|
||||
fertileWindowStart: "2026-05-20",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCycleReminderPayload", () => {
|
||||
it("carries non-contraceptive fertile framing when not discreet", () => {
|
||||
const fertile = buildCycleReminderPayload("CYCLE_FERTILE_SOON", "en", false);
|
||||
expect(fertile.title.toLowerCase()).toContain("fertile");
|
||||
// Honesty boundary: never a contraceptive / safe-day claim.
|
||||
expect(fertile.body.toLowerCase()).toContain("not");
|
||||
expect(fertile.body.toLowerCase()).not.toContain("safe day");
|
||||
});
|
||||
|
||||
it("collapses the fertile event to the generic body when discreet", () => {
|
||||
const fertile = buildCycleReminderPayload("CYCLE_FERTILE_SOON", "en", true);
|
||||
expect(fertile.title.toLowerCase()).not.toContain("fertile");
|
||||
expect(fertile.title).toContain("HealthLog");
|
||||
});
|
||||
|
||||
it("names the event when not discreet", () => {
|
||||
const soon = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", false);
|
||||
expect(soon.title.length).toBeGreaterThan(0);
|
||||
@@ -272,6 +322,86 @@ describe("runCycleReminderTick", () => {
|
||||
expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_PERIOD_SOON");
|
||||
});
|
||||
|
||||
it("dispatches FERTILE_SOON two days before the fertile window for the TTC goal", async () => {
|
||||
const state: FakeState = {
|
||||
predictions: [
|
||||
{
|
||||
userId: "u1",
|
||||
// Period start far out so only the fertile window is in-window.
|
||||
nextPeriodStart: "2026-06-02",
|
||||
fertileWindowStart: "2026-05-19",
|
||||
user: baseUser({
|
||||
cycleProfile: {
|
||||
cycleTrackingEnabled: null,
|
||||
predictionEnabled: true,
|
||||
discreetNotifications: false,
|
||||
goal: "TRYING_TO_CONCEIVE",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
observedStarts: {},
|
||||
alreadyOk: new Set(),
|
||||
};
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const summary = await run(state, dispatch);
|
||||
expect(summary.dispatchedFertileSoon).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_FERTILE_SOON");
|
||||
});
|
||||
|
||||
it("never dispatches FERTILE_SOON for a non-TTC goal (inclusive framing)", async () => {
|
||||
const state: FakeState = {
|
||||
predictions: [
|
||||
{
|
||||
userId: "u1",
|
||||
nextPeriodStart: "2026-06-02",
|
||||
fertileWindowStart: "2026-05-19",
|
||||
user: baseUser({
|
||||
cycleProfile: {
|
||||
cycleTrackingEnabled: null,
|
||||
predictionEnabled: true,
|
||||
discreetNotifications: false,
|
||||
goal: "AVOID_PREGNANCY",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
observedStarts: {},
|
||||
alreadyOk: new Set(),
|
||||
};
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const summary = await run(state, dispatch);
|
||||
expect(summary.dispatchedFertileSoon).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never dispatches FERTILE_SOON when fertileWindowStart is null", async () => {
|
||||
const state: FakeState = {
|
||||
predictions: [
|
||||
{
|
||||
userId: "u1",
|
||||
nextPeriodStart: "2026-06-02",
|
||||
fertileWindowStart: null,
|
||||
user: baseUser({
|
||||
cycleProfile: {
|
||||
cycleTrackingEnabled: null,
|
||||
predictionEnabled: true,
|
||||
discreetNotifications: false,
|
||||
goal: "TRYING_TO_CONCEIVE",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
observedStarts: {},
|
||||
alreadyOk: new Set(),
|
||||
};
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const summary = await run(state, dispatch);
|
||||
expect(summary.dispatchedFertileSoon).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches PERIOD_CONFIRM on the predicted day with no logged period", async () => {
|
||||
const state: FakeState = {
|
||||
predictions: [
|
||||
@@ -485,7 +615,10 @@ describe("runCycleReminderTick — windowing query", () => {
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
const arg = findMany.mock.calls[0][0] as unknown as {
|
||||
where: {
|
||||
nextPeriodStart: { gte: string; lte: string };
|
||||
OR: Array<{
|
||||
nextPeriodStart?: { gte: string; lte: string };
|
||||
fertileWindowStart?: { gte: string; lte: string };
|
||||
}>;
|
||||
user: {
|
||||
cycleProfile: {
|
||||
cycleTrackingEnabled: boolean;
|
||||
@@ -497,12 +630,22 @@ describe("runCycleReminderTick — windowing query", () => {
|
||||
// Tracking + prediction gate is in the query, not JS.
|
||||
expect(arg.where.user.cycleProfile.cycleTrackingEnabled).toBe(true);
|
||||
expect(arg.where.user.cycleProfile.NOT.predictionEnabled).toBe(false);
|
||||
// The window is a YYYY-MM-DD string range straddling "now". At
|
||||
// 2026-05-17 it spans [today-grace-1 .. today+lead+1] in UTC.
|
||||
expect(arg.where.nextPeriodStart.gte).toBe("2026-05-13");
|
||||
expect(arg.where.nextPeriodStart.lte).toBe("2026-05-20");
|
||||
expect(arg.where.nextPeriodStart.gte < arg.where.nextPeriodStart.lte).toBe(
|
||||
true,
|
||||
);
|
||||
// The query ORs a period-start window with a fertile-window window
|
||||
// (the fertile window sits ~2 weeks ahead of the period start, so it
|
||||
// needs its own date range). The period window is a YYYY-MM-DD string
|
||||
// range straddling "now"; at 2026-05-17 it spans
|
||||
// [today-grace-1 .. today+lead+1] in UTC.
|
||||
const periodRange = arg.where.OR.find(
|
||||
(c) => c.nextPeriodStart != null,
|
||||
)?.nextPeriodStart;
|
||||
const fertileRange = arg.where.OR.find(
|
||||
(c) => c.fertileWindowStart != null,
|
||||
)?.fertileWindowStart;
|
||||
expect(periodRange?.gte).toBe("2026-05-13");
|
||||
expect(periodRange?.lte).toBe("2026-05-20");
|
||||
expect((periodRange?.gte ?? "") < (periodRange?.lte ?? "")).toBe(true);
|
||||
// Fertile window: [today-1 .. today+lead+1] in UTC.
|
||||
expect(fertileRange?.gte).toBe("2026-05-16");
|
||||
expect(fertileRange?.lte).toBe("2026-05-20");
|
||||
});
|
||||
});
|
||||
+148
-75
@@ -59,6 +59,14 @@ export const CYCLE_REMINDER_LOCAL_HOUR = 9;
|
||||
/** Days before the predicted next-period start that PERIOD_SOON fires. */
|
||||
export const PERIOD_SOON_LEAD_DAYS = 2;
|
||||
|
||||
/**
|
||||
* Days before the predicted fertile-window start that FERTILE_SOON fires.
|
||||
* Same calm two-day lead as PERIOD_SOON — enough notice to act on the
|
||||
* conception goal without nagging. Only ever reached for the
|
||||
* `TRYING_TO_CONCEIVE` goal (gated in `runCycleReminderTick`).
|
||||
*/
|
||||
export const FERTILE_SOON_LEAD_DAYS = 2;
|
||||
|
||||
/**
|
||||
* Days on/after the predicted next-period start that PERIOD_CONFIRM keeps
|
||||
* nudging while no period is logged. Day 0 (the predicted start) through
|
||||
@@ -66,13 +74,17 @@ export const PERIOD_SOON_LEAD_DAYS = 2;
|
||||
*/
|
||||
export const PERIOD_CONFIRM_GRACE_DAYS = 3;
|
||||
|
||||
export type CycleReminderEvent = "CYCLE_PERIOD_SOON" | "CYCLE_PERIOD_CONFIRM";
|
||||
export type CycleReminderEvent =
|
||||
| "CYCLE_PERIOD_SOON"
|
||||
| "CYCLE_PERIOD_CONFIRM"
|
||||
| "CYCLE_FERTILE_SOON";
|
||||
|
||||
export interface CycleReminderSummary {
|
||||
candidatesScanned: number;
|
||||
inWindow: number;
|
||||
dispatchedPeriodSoon: number;
|
||||
dispatchedPeriodConfirm: number;
|
||||
dispatchedFertileSoon: number;
|
||||
suppressedClientManaged: number;
|
||||
suppressedDiscreet: number;
|
||||
skippedAlreadyNotified: number;
|
||||
@@ -109,6 +121,7 @@ function emptySummary(): CycleReminderSummary {
|
||||
inWindow: 0,
|
||||
dispatchedPeriodSoon: 0,
|
||||
dispatchedPeriodConfirm: 0,
|
||||
dispatchedFertileSoon: 0,
|
||||
suppressedClientManaged: 0,
|
||||
suppressedDiscreet: 0,
|
||||
skippedAlreadyNotified: 0,
|
||||
@@ -181,6 +194,21 @@ export function evaluateCycleReminder(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision predicate for the fertile-window reminder: fire when the
|
||||
* user's local date is exactly `leadDays` before the predicted fertile
|
||||
* window start. DB-free + goal-agnostic by design — the TTC goal gate and
|
||||
* the `fertileWindowStart != null` check both live in the runner, where the
|
||||
* profile and forecast are loaded; this only owns the lead-day windowing so
|
||||
* the boundary (08:59 → no, the lead day → yes) stays unit-testable.
|
||||
*/
|
||||
export function evaluateFertileReminder(
|
||||
args: { today: string; fertileWindowStart: string },
|
||||
leadDays: number = FERTILE_SOON_LEAD_DAYS,
|
||||
): boolean {
|
||||
return dayDiff(args.fertileWindowStart, args.today) === leadDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the localised title + body for a cycle reminder push. When
|
||||
* `discreet` is true, both collapse to the generic "HealthLog reminder"
|
||||
@@ -205,6 +233,12 @@ export function buildCycleReminderPayload(
|
||||
body: t("cycleReminders.periodSoonBody"),
|
||||
};
|
||||
}
|
||||
if (event === "CYCLE_FERTILE_SOON") {
|
||||
return {
|
||||
title: t("cycleReminders.fertileSoonTitle"),
|
||||
body: t("cycleReminders.fertileSoonBody"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t("cycleReminders.periodConfirmTitle"),
|
||||
body: t("cycleReminders.periodConfirmBody"),
|
||||
@@ -267,21 +301,29 @@ export async function runCycleReminderTick(
|
||||
// 1. the user's `cycleProfile` must have `cycleTrackingEnabled` (an
|
||||
// account that never opted in can never receive a cycle push), and
|
||||
// `predictionEnabled` must not be explicitly off,
|
||||
// 2. `nextPeriodStart` (a `YYYY-MM-DD` string) must fall inside the
|
||||
// reminder window [today − grace, today + lead]. The string range is
|
||||
// computed against a tz-generous span (±1 day past the exact
|
||||
// lead/grace) so a user whose local date differs from UTC by a day is
|
||||
// never excluded at the query layer — `evaluateCycleReminder` still
|
||||
// applies the exact per-user-timezone window below.
|
||||
// 2. either `nextPeriodStart` OR `fertileWindowStart` (both `YYYY-MM-DD`
|
||||
// strings) must fall inside its reminder window [today − grace,
|
||||
// today + lead]. The string range is computed against a tz-generous
|
||||
// span (±1 day past the exact lead/grace) so a user whose local date
|
||||
// differs from UTC by a day is never excluded at the query layer —
|
||||
// `evaluateCycleReminder` / `evaluateFertileReminder` still apply the
|
||||
// exact per-user-timezone window below. The fertile window sits ~2
|
||||
// weeks ahead of the period start, so it needs its own date range
|
||||
// rather than riding the period-start filter.
|
||||
//
|
||||
// The pure-string `gte`/`lte` range is correct because `YYYY-MM-DD`
|
||||
// ordering is lexicographic-equals-chronological.
|
||||
const windowFloor = utcDateString(now, -(PERIOD_CONFIRM_GRACE_DAYS + 1));
|
||||
const windowCeil = utcDateString(now, PERIOD_SOON_LEAD_DAYS + 1);
|
||||
const fertileCeil = utcDateString(now, FERTILE_SOON_LEAD_DAYS + 1);
|
||||
const fertileFloor = utcDateString(now, -1);
|
||||
|
||||
const predictions = await prisma.cyclePrediction.findMany({
|
||||
where: {
|
||||
nextPeriodStart: { gte: windowFloor, lte: windowCeil },
|
||||
OR: [
|
||||
{ nextPeriodStart: { gte: windowFloor, lte: windowCeil } },
|
||||
{ fertileWindowStart: { gte: fertileFloor, lte: fertileCeil } },
|
||||
],
|
||||
user: {
|
||||
cycleProfile: {
|
||||
cycleTrackingEnabled: true,
|
||||
@@ -292,6 +334,7 @@ export async function runCycleReminderTick(
|
||||
select: {
|
||||
userId: true,
|
||||
nextPeriodStart: true,
|
||||
fertileWindowStart: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -301,6 +344,7 @@ export async function runCycleReminderTick(
|
||||
notificationPrefs: true,
|
||||
cycleProfile: {
|
||||
select: {
|
||||
goal: true,
|
||||
cycleTrackingEnabled: true,
|
||||
predictionEnabled: true,
|
||||
discreetNotifications: true,
|
||||
@@ -339,6 +383,86 @@ export async function runCycleReminderTick(
|
||||
|
||||
summary.inWindow += 1;
|
||||
|
||||
const timezoneForBounds = timezone;
|
||||
// Local day's UTC bounds for the ledger idempotency lookup, computed
|
||||
// once and shared by every event we may fire for this user this tick.
|
||||
// `end` is the last millisecond of the day, so add 1 ms for half-open.
|
||||
const { start: dayStartUtc, end: dayEndInclusive } = getUserTodayBounds(
|
||||
now,
|
||||
timezoneForBounds,
|
||||
);
|
||||
const dayEndExclusive = new Date(dayEndInclusive.getTime() + 1);
|
||||
|
||||
// Shared dispatch tail: clientManaged suppression → idempotency →
|
||||
// discreet body → dispatch → counter. Reused verbatim by the
|
||||
// period-soon / period-confirm nudge and the fertile-window nudge so a
|
||||
// change to the suppression cascade can never drift between them.
|
||||
const dispatchEvent = async (
|
||||
event: CycleReminderEvent,
|
||||
): Promise<void> => {
|
||||
// clientManaged suppression — iOS owns the local reminders. Mirror
|
||||
// the medication path: skip the server push, emit the annotation.
|
||||
if (isCycleReminderClientManaged(user.notificationPrefs)) {
|
||||
summary.suppressedClientManaged += 1;
|
||||
getEvent()?.addMeta(
|
||||
"cycle_reminder_suppressed_client_managed",
|
||||
`${event}:${today}`,
|
||||
);
|
||||
getEvent()?.addMeta("cycle_reminder_suppressed_meta", {
|
||||
user_id: user.id,
|
||||
event,
|
||||
local_date: today,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: one push per event per local day.
|
||||
const seen = await alreadyNotifiedToday(
|
||||
prisma,
|
||||
user.id,
|
||||
event,
|
||||
dayStartUtc,
|
||||
dayEndExclusive,
|
||||
);
|
||||
if (seen) {
|
||||
summary.skippedAlreadyNotified += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const discreet = profile?.discreetNotifications === true;
|
||||
if (discreet) summary.suppressedDiscreet += 1;
|
||||
|
||||
const { title, body } = buildCycleReminderPayload(
|
||||
event,
|
||||
user.locale,
|
||||
discreet,
|
||||
);
|
||||
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: event,
|
||||
userId: user.id,
|
||||
title,
|
||||
message: body,
|
||||
// In discreet mode the senders mask the lock-screen routing
|
||||
// metadata so no cycle event is named on the lock screen.
|
||||
discreet,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
localDate: today,
|
||||
},
|
||||
});
|
||||
|
||||
if (!outcome.dispatched) {
|
||||
summary.skippedNoChannel += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "CYCLE_PERIOD_SOON") summary.dispatchedPeriodSoon += 1;
|
||||
else if (event === "CYCLE_FERTILE_SOON")
|
||||
summary.dispatchedFertileSoon += 1;
|
||||
else summary.dispatchedPeriodConfirm += 1;
|
||||
};
|
||||
|
||||
// Has the user already logged an observed (non-predicted) cycle
|
||||
// starting on/around the predicted day? If so, the confirm nudge is
|
||||
// moot. We look for any observed cycle whose start is within the
|
||||
@@ -349,83 +473,32 @@ export async function runCycleReminderTick(
|
||||
pred.nextPeriodStart,
|
||||
);
|
||||
|
||||
const event = evaluateCycleReminder({
|
||||
const periodEvent = evaluateCycleReminder({
|
||||
today,
|
||||
nextPeriodStart: pred.nextPeriodStart,
|
||||
periodAlreadyLogged,
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
// Fertile-window nudge — TWICE gated: the conception goal AND a
|
||||
// non-null `fertileWindowStart` (the engine only populates it for the
|
||||
// TTC goal). Never surfaces fertile language to AVOID_PREGNANCY /
|
||||
// GENERAL_HEALTH / PERIMENOPAUSE (the inclusive-framing rule).
|
||||
const fertileDue =
|
||||
profile?.goal === "TRYING_TO_CONCEIVE" &&
|
||||
pred.fertileWindowStart != null &&
|
||||
evaluateFertileReminder({
|
||||
today,
|
||||
fertileWindowStart: pred.fertileWindowStart,
|
||||
});
|
||||
|
||||
if (!periodEvent && !fertileDue) {
|
||||
if (periodAlreadyLogged) summary.skippedAlreadyLogged += 1;
|
||||
else summary.skippedOutsideWindow += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// clientManaged suppression — iOS owns the local reminders. Mirror the
|
||||
// medication path: skip the server push, emit the annotation.
|
||||
if (isCycleReminderClientManaged(user.notificationPrefs)) {
|
||||
summary.suppressedClientManaged += 1;
|
||||
getEvent()?.addMeta(
|
||||
"cycle_reminder_suppressed_client_managed",
|
||||
`${event}:${today}`,
|
||||
);
|
||||
getEvent()?.addMeta("cycle_reminder_suppressed_meta", {
|
||||
user_id: user.id,
|
||||
event,
|
||||
local_date: today,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency: one push per event per local day. Compute the local
|
||||
// day's UTC bounds for the ledger lookup. `end` is the last
|
||||
// millisecond of the day, so add 1 ms to make the range half-open.
|
||||
const { start: dayStartUtc, end: dayEndInclusive } = getUserTodayBounds(
|
||||
now,
|
||||
timezone,
|
||||
);
|
||||
const seen = await alreadyNotifiedToday(
|
||||
prisma,
|
||||
user.id,
|
||||
event,
|
||||
dayStartUtc,
|
||||
new Date(dayEndInclusive.getTime() + 1),
|
||||
);
|
||||
if (seen) {
|
||||
summary.skippedAlreadyNotified += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const discreet = profile?.discreetNotifications === true;
|
||||
if (discreet) summary.suppressedDiscreet += 1;
|
||||
|
||||
const { title, body } = buildCycleReminderPayload(
|
||||
event,
|
||||
user.locale,
|
||||
discreet,
|
||||
);
|
||||
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: event,
|
||||
userId: user.id,
|
||||
title,
|
||||
message: body,
|
||||
// In discreet mode the senders mask the lock-screen routing
|
||||
// metadata so no cycle event is named on the lock screen.
|
||||
discreet,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
localDate: today,
|
||||
},
|
||||
});
|
||||
|
||||
if (!outcome.dispatched) {
|
||||
summary.skippedNoChannel += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event === "CYCLE_PERIOD_SOON") summary.dispatchedPeriodSoon += 1;
|
||||
else summary.dispatchedPeriodConfirm += 1;
|
||||
if (periodEvent) await dispatchEvent(periodEvent);
|
||||
if (fertileDue) await dispatchEvent("CYCLE_FERTILE_SOON");
|
||||
} catch (err: unknown) {
|
||||
summary.failed += 1;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -87,6 +87,70 @@ describe("resolveCanonicalSlotInstant — multi-time", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — taken write never snaps to a future slot (dose-safety)", () => {
|
||||
// Daily 07:00 / 19:00 med. A late morning dose taken at 15:00 CEST must
|
||||
// not snap forward onto the 19:00 slot (which is still in the future) and
|
||||
// render as "taken" before its time — the iOS v0.13 dose-safety report.
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
|
||||
]);
|
||||
// iOS posts the reminder-action write carrying the slot's `scheduledFor`
|
||||
// (19:00) while the dose is actually actioned earlier (17:00) — the
|
||||
// explicit instant names the future evening slot exactly.
|
||||
const slot1900Instant = new Date("2026-06-15T17:00:00.000Z"); // 19:00 CEST
|
||||
const beforeEvening = new Date("2026-06-15T15:00:00.000Z"); // 17:00 CEST
|
||||
|
||||
it("LEGACY (no flag): an explicit 19:00 write snaps to the 19:00 slot", () => {
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: slot1900Instant,
|
||||
});
|
||||
const slot1900 = localHmAsUtc(slot1900Instant, TZ, 19, 0);
|
||||
expect(result?.toISOString()).toBe(slot1900.toISOString());
|
||||
});
|
||||
|
||||
it("taken write naming the 19:00 slot, but actioned at 17:00, does NOT snap onto the future slot", () => {
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: slot1900Instant,
|
||||
isTakenWrite: true,
|
||||
now: beforeEvening,
|
||||
});
|
||||
// 19:00 is still in the future relative to now (17:00) → excluded;
|
||||
// 07:00 is 12h from the incoming instant → beyond tolerance → null.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("taken write AT the 19:00 slot still snaps (slot is not in the future)", () => {
|
||||
const evening = new Date("2026-06-15T17:02:00.000Z"); // 19:02 CEST
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: evening,
|
||||
isTakenWrite: true,
|
||||
now: evening,
|
||||
});
|
||||
const slot1900 = localHmAsUtc(evening, TZ, 19, 0);
|
||||
expect(result?.toISOString()).toBe(slot1900.toISOString());
|
||||
});
|
||||
|
||||
it("taken write a few minutes early still snaps onto its slot (skew grace)", () => {
|
||||
// 06:57 CEST, slot 07:00 — within the 5-minute forward grace.
|
||||
const earlyTake = new Date("2026-06-15T04:57:00.000Z");
|
||||
const result = resolveCanonicalSlotInstant({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
incoming: earlyTake,
|
||||
isTakenWrite: true,
|
||||
now: earlyTake,
|
||||
});
|
||||
const slot0700 = localHmAsUtc(earlyTake, TZ, 7, 0);
|
||||
expect(result?.toISOString()).toBe(slot0700.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — defaulted-now midday (phantom morning dose)", () => {
|
||||
// The phantom morning-dose bug: a 07:00 / 19:00 med whose 07:00 slot got
|
||||
// auto-marked taken because a slot-less midday "taken now" write
|
||||
|
||||
@@ -39,6 +39,19 @@ const ONE_MINUTE_MS = 60_000;
|
||||
const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
|
||||
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
|
||||
|
||||
/**
|
||||
* Forward grace for a TAKEN write (dose-safety, iOS v0.13 report).
|
||||
*
|
||||
* A `.taken` intake must never be attributed to a slot whose instant is in
|
||||
* the future — a dose the user has not yet taken would otherwise render as
|
||||
* "taken" before its time (a late morning dose snapping forward onto the
|
||||
* evening slot of a twice-daily med). We still allow a small forward window
|
||||
* so a dose taken a few minutes early, or with minor client/server clock
|
||||
* skew, snaps onto its own slot as intended. Anything further into the
|
||||
* future is rejected as a snap target and the write keeps its own row.
|
||||
*/
|
||||
const TAKEN_FORWARD_GRACE_MS = 5 * ONE_MINUTE_MS;
|
||||
|
||||
/**
|
||||
* Default snap tolerance when a schedule has no usable window span. ±2h
|
||||
* is wide enough to absorb the iOS-vs-server `localHmAsUtc` minute drift
|
||||
@@ -90,6 +103,23 @@ export interface ResolveSlotInput {
|
||||
* keep the legacy snap behaviour unchanged.
|
||||
*/
|
||||
instantIsExplicit?: boolean;
|
||||
/**
|
||||
* Whether this write records a TAKEN dose (has a `takenAt`). When `true`,
|
||||
* the snap will never pick a slot whose instant is meaningfully in the
|
||||
* future (> `now` + `TAKEN_FORWARD_GRACE_MS`) — a taken dose can never be
|
||||
* attributed to a slot the user has not reached yet. A future slot is
|
||||
* simply not a candidate, so the write either snaps to a past/current
|
||||
* slot within tolerance or falls through to `null` (standalone row).
|
||||
*
|
||||
* Defaults to `false` so read-only / projection callers (UI display,
|
||||
* pending-row dedup) keep snapping to future slots as before.
|
||||
*/
|
||||
isTakenWrite?: boolean;
|
||||
/**
|
||||
* Reference "now" for the `isTakenWrite` future-slot guard. Defaults to
|
||||
* the current time; injectable for deterministic tests.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +137,8 @@ export function resolveCanonicalSlotInstant(
|
||||
): Date | null {
|
||||
const { medication, userTz, incoming } = input;
|
||||
const instantIsExplicit = input.instantIsExplicit ?? true;
|
||||
const isTakenWrite = input.isTakenWrite ?? false;
|
||||
const nowMs = (input.now ?? new Date()).getTime();
|
||||
const schedules = medication.schedules ?? [];
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
@@ -170,6 +202,15 @@ export function resolveCanonicalSlotInstant(
|
||||
// and keep the canonical engine only for DECIDING which slots exist
|
||||
// (PRN / cyclic / rrule / rolling gating).
|
||||
const slotInstant = canonicalSlotInstant(occ.at, occ.timeOfDay, userTz);
|
||||
|
||||
// Dose-safety: a taken write may never be attributed to a slot that is
|
||||
// still in the future (beyond a small clock-skew/early-dose grace). A
|
||||
// late morning dose must not snap forward onto the evening slot of a
|
||||
// twice-daily med and render as "taken" before its time.
|
||||
if (isTakenWrite && slotInstant.getTime() > nowMs + TAKEN_FORWARD_GRACE_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deltaMs = Math.abs(slotInstant.getTime() - incoming.getTime());
|
||||
if (deltaMs > tolerance) continue;
|
||||
if (best === null || deltaMs < best.deltaMs) {
|
||||
|
||||
@@ -322,6 +322,14 @@ export interface ResolveSlotForWriteInput {
|
||||
* Defaults to `true` to keep the legacy snap for callers that omit it.
|
||||
*/
|
||||
instantIsExplicit?: boolean;
|
||||
/**
|
||||
* Whether this write records a TAKEN dose. When `true`, the snap never
|
||||
* targets a future slot (dose-safety: a taken dose can't be attributed to
|
||||
* a slot the user hasn't reached). Defaults to `false`.
|
||||
*/
|
||||
isTakenWrite?: boolean;
|
||||
/** Reference "now" for the taken future-slot guard; defaults to current time. */
|
||||
now?: Date;
|
||||
/** Inject a Prisma client/tx in tests; defaults to the app client. */
|
||||
client?: PrismaLike;
|
||||
}
|
||||
@@ -375,5 +383,7 @@ export async function resolveSlotInstantForWrite(
|
||||
incoming: input.incoming,
|
||||
lastIntakeAt,
|
||||
instantIsExplicit: input.instantIsExplicit ?? true,
|
||||
isTakenWrite: input.isTakenWrite ?? false,
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
stripDecorativeEmoji,
|
||||
shouldStripEmoji,
|
||||
plainPushText,
|
||||
} from "@/lib/notifications/strip-emoji";
|
||||
|
||||
describe("stripDecorativeEmoji", () => {
|
||||
it("removes the colour-coded reminder phase markers and tidies whitespace", () => {
|
||||
expect(stripDecorativeEmoji("🟢 Reminder: Ramipril")).toBe(
|
||||
"Reminder: Ramipril",
|
||||
);
|
||||
expect(stripDecorativeEmoji("🔴 Missed: Metformin")).toBe(
|
||||
"Missed: Metformin",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips clock / skip / check emoji and regional indicators", () => {
|
||||
expect(stripDecorativeEmoji("🕐 1h ⏭ Skip ✅")).toBe("1h Skip");
|
||||
});
|
||||
|
||||
it("leaves plain text untouched", () => {
|
||||
expect(stripDecorativeEmoji("Due soon: Vitamin D (1 tablet)")).toBe(
|
||||
"Due soon: Vitamin D (1 tablet)",
|
||||
);
|
||||
});
|
||||
|
||||
it("spares ™ © ® so brand names survive", () => {
|
||||
expect(stripDecorativeEmoji("Tylenol® reminder")).toBe("Tylenol® reminder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldStripEmoji / plainPushText", () => {
|
||||
it("strips for routine reminder events", () => {
|
||||
expect(shouldStripEmoji("MEDICATION_REMINDER")).toBe(true);
|
||||
expect(shouldStripEmoji("MOOD_REMINDER")).toBe(true);
|
||||
expect(plainPushText("🟢 Take Ramipril", "MEDICATION_REMINDER")).toBe(
|
||||
"Take Ramipril",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps emoji for system / failure alerts", () => {
|
||||
expect(shouldStripEmoji("SYSTEM_ALERT")).toBe(false);
|
||||
expect(plainPushText("⚠️ Sync failed", "SYSTEM_ALERT")).toBe(
|
||||
"⚠️ Sync failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
export interface ApnsPayload {
|
||||
/** APNs `aps.alert.title` + `aps.alert.body`. */
|
||||
@@ -615,7 +616,10 @@ export async function sendViaApns(
|
||||
deviceToken: device.apnsToken,
|
||||
environment: attemptEnv,
|
||||
payload: {
|
||||
alert: { title: payload.title, body: stripHtml(payload.message) },
|
||||
alert: {
|
||||
title: plainPushText(payload.title, payload.eventType),
|
||||
body: plainPushText(stripHtml(payload.message), payload.eventType),
|
||||
},
|
||||
data: {
|
||||
eventType: routingEvent,
|
||||
// In discreet mode also drop cycle-semantic metadata (e.g. `phase`)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
/**
|
||||
* Send notification via ntfy (simple HTTP POST).
|
||||
@@ -26,7 +27,7 @@ export async function sendViaNtfy(
|
||||
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Title: payload.title,
|
||||
Title: plainPushText(payload.title, payload.eventType),
|
||||
Priority:
|
||||
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
|
||||
// Discreet mode (cycle privacy): the X-Tags header is visible on the
|
||||
@@ -41,8 +42,11 @@ export async function sendViaNtfy(
|
||||
headers["Authorization"] = `Bearer ${config.authToken}`;
|
||||
}
|
||||
|
||||
// Strip HTML tags for ntfy (plain text only)
|
||||
const body = payload.message.replace(/<[^>]*>/g, "");
|
||||
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
|
||||
const body = plainPushText(
|
||||
payload.message.replace(/<[^>]*>/g, ""),
|
||||
payload.eventType,
|
||||
);
|
||||
|
||||
// requirePublicHost adds the DNS-rebinding pin (issue #217) on top
|
||||
// of the input-time isPublicUrl guard already enforced when the
|
||||
|
||||
@@ -6,6 +6,8 @@ import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getVapidConfig } from "@/lib/notifications/vapid-config";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
/**
|
||||
* Send Web Push notification to all subscribed devices of a user.
|
||||
@@ -77,8 +79,11 @@ export async function sendViaWebPush(
|
||||
}
|
||||
|
||||
const pushPayload = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.message.replace(/<[^>]*>/g, ""),
|
||||
title: plainPushText(payload.title, payload.eventType),
|
||||
body: plainPushText(
|
||||
payload.message.replace(/<[^>]*>/g, ""),
|
||||
payload.eventType,
|
||||
),
|
||||
// Discreet mode (cycle privacy): the coalescing `tag` must not name the
|
||||
// event. Web-Push payloads are VAPID-encrypted (only the SW sees this),
|
||||
// but the discreet contract still requires no event name on the wire —
|
||||
@@ -95,6 +100,16 @@ export async function sendViaWebPush(
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
// Egress-time SSRF floor: `web-push` dials this endpoint with its own
|
||||
// internal fetch (no safeFetch). The subscribe route already rejects
|
||||
// non-public endpoints, but re-check here to cover any row stored
|
||||
// before that guard landed. A non-public endpoint is dropped, never
|
||||
// dialled — treat like an expired subscription.
|
||||
if (!isPublicUrl(sub.endpoint)) {
|
||||
expiredIds.push(sub.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const p256dh = decrypt(sub.p256dh);
|
||||
const auth = decrypt(sub.auth);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Strip decorative emoji from push notification content.
|
||||
*
|
||||
* Operator + iOS contract (2026-06): routine reminder pushes
|
||||
* (medication-due, mood-nudge, daily-briefing, cycle reminders) must be
|
||||
* plain text — no emoji, no decorative bubbles. iOS renders the APNs
|
||||
* `title`/`body` verbatim, so the colour-coded phase markers (🟢🟡🟠🔴) the
|
||||
* reminder strings carry for the Telegram channel leak onto the lock
|
||||
* screen. Emoji stay acceptable only for system / failure alerts
|
||||
* (`SYSTEM_ALERT`), where they flag severity.
|
||||
*
|
||||
* The push senders (APNs, Web Push, ntfy) run reminder content through
|
||||
* this; Telegram keeps the emoji (idiomatic there, and its inline keyboard
|
||||
* relies on glyph-labelled action buttons). `shouldStripEmoji` keeps the
|
||||
* SYSTEM_ALERT carve-out in one place.
|
||||
*
|
||||
* The range set targets the actual emoji blocks (supplementary
|
||||
* pictographs, misc symbols, dingbats, geometric/arrow emoji, regional
|
||||
* indicators, variation selectors, keycap) and deliberately spares the
|
||||
* BMP punctuation that doubles as text — ™ © ® and the like — so a brand
|
||||
* name in a medication title is not mangled.
|
||||
*/
|
||||
const EMOJI_RE =
|
||||
/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{1F1E6}-\u{1F1FF}\u{20E3}\u{231A}\u{231B}\u{23E9}-\u{23FA}]/gu;
|
||||
|
||||
/** Remove decorative emoji and collapse the whitespace they leave behind. */
|
||||
export function stripDecorativeEmoji(text: string): string {
|
||||
return text.replace(EMOJI_RE, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a push channel should strip emoji for this event. Everything
|
||||
* except `SYSTEM_ALERT` is a routine notification and goes plain. Accepts a
|
||||
* bare string so it composes with senders that carry `eventType` widened to
|
||||
* `string` on the wire payload.
|
||||
*/
|
||||
export function shouldStripEmoji(eventType: string): boolean {
|
||||
return eventType !== "SYSTEM_ALERT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the reminder plain-text rule to a single push field: strip emoji
|
||||
* unless the event is a system / failure alert.
|
||||
*/
|
||||
export function plainPushText(text: string, eventType: string): string {
|
||||
return shouldStripEmoji(eventType) ? stripDecorativeEmoji(text) : text;
|
||||
}
|
||||
@@ -34,6 +34,13 @@ export const EVENT_TYPES = [
|
||||
// named on the lock screen.
|
||||
"CYCLE_PERIOD_SOON",
|
||||
"CYCLE_PERIOD_CONFIRM",
|
||||
// v1.15.1 — fertile-window reminder. Default OFF (see EVENT_DEFAULT_ENABLED)
|
||||
// and TTC-gated in the cron: it only fires when `CycleProfile.goal ===
|
||||
// "TRYING_TO_CONCEIVE"`, so fertile language never reaches the
|
||||
// AVOID_PREGNANCY / GENERAL_HEALTH / PERIMENOPAUSE goals (the inclusive-
|
||||
// framing rule). Fires a few days before `CyclePrediction.fertileWindowStart`
|
||||
// and honours discreet mode like the other cycle reminders.
|
||||
"CYCLE_FERTILE_SOON",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -65,6 +72,10 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
|
||||
// also has to be flipped on before the dispatcher surfaces the channel.
|
||||
CYCLE_PERIOD_SOON: false,
|
||||
CYCLE_PERIOD_CONFIRM: false,
|
||||
// v1.15.1 — default OFF and additionally TTC-gated in the cron, so the
|
||||
// server never surfaces fertile-window language unless the user has both
|
||||
// chosen the conception goal and opted the reminder in.
|
||||
CYCLE_FERTILE_SOON: false,
|
||||
};
|
||||
|
||||
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
|
||||
|
||||
@@ -518,6 +518,9 @@ export const queryKeys = {
|
||||
cyclePrefs: () => ["cycle", "prefs"] as const,
|
||||
cycleInsights: () => ["cycle", "insights"] as const,
|
||||
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
|
||||
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
|
||||
* merges into the seeded chip grid. */
|
||||
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user