From c21131e26eb4b90fab1d52ff0cbdb05f32785477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 13:32:25 +0200 Subject: [PATCH] feat(cycle): second-audit feature, QoL, and wiring close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the second-round audit UI cluster: - Cycle reminders: add the missing notification-event labels so the period-soon / period-confirm toggles read correctly on the notifications page, and point to them from cycle settings. - Per-cycle history: render a recent-cycles list (range, length, period length, confirmed-ovulation chip) — the cycles were fetched but only the aggregate stats showed. - Confirmed ovulation: carry it on the prediction DTO and surface it as a solid BBT-chart marker + a predictions row + a history chip. - Log-day sheet: deselecting an enum now clears it (edits route through the nullable PATCH); period start/end work on any selected date for retroactive correction; "end period" shows only while one is open. Extract the payload builders as pure, unit-tested functions. - Cycle-tracking enable toggle in account settings so any account can opt in before the gated page is reachable; wire the cycle data-purge button to the hard-delete route and document the route in the OpenAPI spec. - Calendar legend: add the symptom dot, render fertile as a ring to match the grid; tighten the luteal prior bound to the engine clamp; mute the BBT ovulation-label colour for contrast. - Share the cycle-start backward-walk between the wheel and the chart. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 74 +++++- messages/de.json | 42 ++- messages/en.json | 42 ++- messages/es.json | 42 ++- messages/fr.json | 42 ++- messages/it.json | 42 ++- messages/pl.json | 42 ++- src/app/api/cycle/cycles/route.ts | 4 +- src/app/api/cycle/day-logs/[id]/route.ts | 8 +- src/app/api/cycle/day-logs/bulk/route.ts | 5 +- src/app/api/cycle/insights/route.ts | 143 +++++------ .../cycle/__tests__/log-day-sheet.test.ts | 93 +++++++ .../__tests__/predictions-panel.test.tsx | 1 + src/components/cycle/bbt-chart.tsx | 15 +- src/components/cycle/cycle-calendar.tsx | 67 +++-- src/components/cycle/cycle-phase-crosstab.tsx | 19 +- src/components/cycle/cycle-settings.tsx | 90 ++++++- src/components/cycle/cycle-view.tsx | 4 + src/components/cycle/log-day-sheet.tsx | 226 +++++++++++----- src/components/cycle/predictions-panel.tsx | 71 ++++- src/components/cycle/types.ts | 7 +- src/components/cycle/use-cycle.ts | 70 +++++ src/components/cycle/wheel-state.ts | 53 ++-- src/components/settings/account-section.tsx | 31 ++- .../settings/cycle-tracking-card.tsx | 101 ++++++++ src/lib/cycle/backup.ts | 26 +- src/lib/cycle/day-log-write.ts | 19 +- src/lib/cycle/day-math.ts | 8 +- src/lib/cycle/dto.ts | 15 +- src/lib/cycle/engine-adapter.ts | 27 +- src/lib/cycle/export-data.ts | 7 +- src/lib/cycle/healthkit-mapping.ts | 9 +- src/lib/cycle/import-accumulator.ts | 6 +- src/lib/cycle/phase-crosstab.ts | 14 +- src/lib/cycle/phase.ts | 12 +- src/lib/cycle/prediction.ts | 34 ++- src/lib/cycle/types.ts | 7 +- src/lib/notifications/senders/apns.ts | 68 ++--- src/lib/openapi/routes.ts | 242 +++++++++++++----- src/lib/query-keys.ts | 17 +- 40 files changed, 1399 insertions(+), 446 deletions(-) create mode 100644 src/components/cycle/__tests__/log-day-sheet.test.ts create mode 100644 src/components/settings/cycle-tracking-card.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b040cb1f1..69ee6184d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -648,6 +648,25 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/cycle/all: + delete: + tags: + - Cycle + summary: Hard-purge all cycle data (v1.15.0) + description: "One-click privacy purge: HARD-deletes every cycle day-log (+ symptom links by cascade), menstrual cycle, + prediction, the cycle audit trail, and the cycle reminder rows in the push-attempts ledger — no dated + reproductive trace survives. The CycleProfile row is left in place. Gated + owner-scoped + audited." + responses: + "200": + description: Purge counts. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePurgeEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 /api/cycle/profile: get: tags: @@ -5287,6 +5306,8 @@ components: anyOf: - type: string - type: "null" + ovulationConfirmed: + type: boolean confidence: type: number cyclesObserved: @@ -5305,13 +5326,14 @@ components: - fertileWindowStart - fertileWindowEnd - predictedOvulation + - ovulationConfirmed - confidence - cyclesObserved - stillLearning - disclaimer additionalProperties: false - description: The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is - TRYING_TO_CONCEIVE. + description: The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are + server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY. CyclePredictionMethod: type: string enum: @@ -5557,6 +5579,54 @@ components: additionalProperties: false description: One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal. + CyclePurgeEnvelope: + type: object + properties: + data: + type: object + properties: + purged: + type: boolean + dayLogs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + predictions: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + cycles: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + auditRows: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + pushRows: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - purged + - dayLogs + - predictions + - cycles + - auditRows + - pushRows + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CycleProfileEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 24eaca7b6..7402b80c2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3878,7 +3878,14 @@ "statusExpired": "Abgelaufen", "inactiveTitle": "Widerrufen und abgelaufen ({count})" }, - "genderOther": "Divers" + "genderOther": "Divers", + "cycleTracking": { + "title": "Zyklus-Tracking", + "description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.", + "enable": "Zyklus-Tracking aktivieren", + "manage": "Zyklus-Tracking öffnen", + "error": "Zyklus-Tracking konnte nicht aktualisiert werden." + } }, "admin": { "title": "Administration", @@ -4691,7 +4698,11 @@ "eventPersonalRecord": "Persönlicher Rekord", "eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.", "eventMoodReminder": "Stimmungs-Erinnerung", - "eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren." + "eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.", + "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." }, "i18n": { "maintainershipBanner": { @@ -5022,7 +5033,8 @@ "loading": "Kalender wird geladen…", "empty": "Erfasse Daten, damit sich dein Kalender füllt.", "today": "Heute", - "dayAria": "{date}" + "dayAria": "{date}", + "legendSymptom": "Symptome" }, "predictions": { "title": "Was als Nächstes kommt", @@ -5039,7 +5051,8 @@ "stillLearningBody": "Erfasse mindestens drei vollständige Zyklen, dann werden deine Prognosen genauer. Bis dahin ist dies eine grobe Schätzung.", "rawMode": "Prognose ist aus", "rawModeBody": "Du hast Prognosen ausgeschaltet — es werden nur deine erfassten Daten gezeigt, ohne Vorhersage.", - "none": "Noch keine Prognose. Erfasse eine Periode, um zu starten." + "none": "Noch keine Prognose. Erfasse eine Periode, um zu starten.", + "ovulationConfirmed": "Eisprung (bestätigt)" }, "history": { "title": "Deine Zyklen", @@ -5052,7 +5065,11 @@ "regularityLEARNING": "Lernend", "days": "{count} Tage", "daysPlusMinus": "± {count} Tage", - "none": "Noch keine Zyklen erfasst." + "none": "Noch keine Zyklen erfasst.", + "recentCycles": "Letzte Zyklen", + "ongoing": "laufend", + "periodDays": "Periode {count} T", + "ovulationChip": "Eisprung" }, "sheet": { "title": "Tag erfassen", @@ -5107,14 +5124,20 @@ "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", "save": "Speichern", "purgeTitle": "Alle Zyklusdaten löschen", - "purgeDescription": "Entfernt dauerhaft jede Periode, jeden Tageseintrag und jede Vorhersage von diesem Konto. Dies kann nicht rückgängig gemacht werden.", + "purgeDescription": "Entfernt dauerhaft jeden erfassten Tag, Zyklus, jede Vorhersage und jeden Erinnerungseintrag. Kann nicht rückgängig gemacht werden.", "purgeButton": "Alle Zyklusdaten löschen", "purgeConfirmTitle": "Alle Zyklusdaten löschen?", "purgeConfirmBody": "Dies löscht dauerhaft jede Periode, jeden Tageseintrag, jedes Symptom und jede Vorhersage. Dies kann nicht rückgängig gemacht werden.", - "purgeConfirm": "Alles löschen", + "purgeConfirm": "Alle Zyklusdaten dauerhaft löschen?", "purgeCancel": "Abbrechen", "purgeSuccess": "Alle Zyklusdaten gelöscht.", - "purgeError": "Zyklusdaten konnten nicht gelöscht werden. Bitte erneut versuchen." + "purgeError": "Zyklusdaten konnten nicht gelöscht werden.", + "reminders": "Erinnerungen", + "remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.", + "remindersLink": "Benachrichtigungseinstellungen", + "purgeData": "Zyklusdaten löschen", + "purgeConfirmAction": "Ja, alles löschen", + "purgeDone": "Zyklusdaten gelöscht." }, "testResult": { "POSITIVE": "Positiv", @@ -5139,7 +5162,8 @@ "empty": "Noch keine Temperaturwerte", "emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.", "ovulationEstimate": "Gesch. Eisprung", - "tooltipTemp": "{temp} °C" + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Bestätigter Eisprung" } }, "cycleReminders": { diff --git a/messages/en.json b/messages/en.json index 703bebb65..11fbafbae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3878,7 +3878,14 @@ "statusExpired": "Expired", "inactiveTitle": "Revoked and expired ({count})" }, - "genderOther": "Other" + "genderOther": "Other", + "cycleTracking": { + "title": "Cycle tracking", + "description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.", + "enable": "Enable cycle tracking", + "manage": "Open cycle tracking", + "error": "Couldn't update cycle tracking." + } }, "admin": { "title": "Administration", @@ -4691,7 +4698,11 @@ "eventPersonalRecord": "Personal record", "eventPersonalRecordDesc": "Sent when a reading beats your previous best.", "eventMoodReminder": "Mood reminder", - "eventMoodReminderDesc": "Daily nudge to log your current mood." + "eventMoodReminderDesc": "Daily nudge to log your current mood.", + "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." }, "i18n": { "maintainershipBanner": { @@ -5022,7 +5033,8 @@ "loading": "Loading your calendar…", "empty": "Start logging to see your calendar fill in.", "today": "Today", - "dayAria": "{date}" + "dayAria": "{date}", + "legendSymptom": "Symptoms" }, "predictions": { "title": "What comes next", @@ -5039,7 +5051,8 @@ "stillLearningBody": "Log at least three full cycles and your predictions sharpen. Until then this is a rough estimate.", "rawMode": "Prediction is off", "rawModeBody": "You turned predictions off — only your logged data is shown, with no forecast.", - "none": "No prediction yet. Log a period to get started." + "none": "No prediction yet. Log a period to get started.", + "ovulationConfirmed": "Ovulation (confirmed)" }, "history": { "title": "Your cycles", @@ -5052,7 +5065,11 @@ "regularityLEARNING": "Learning", "days": "{count} days", "daysPlusMinus": "± {count} days", - "none": "No cycles recorded yet." + "none": "No cycles recorded yet.", + "recentCycles": "Recent cycles", + "ongoing": "ongoing", + "periodDays": "period {count} d", + "ovulationChip": "Ovulation" }, "sheet": { "title": "Log your day", @@ -5112,14 +5129,20 @@ "saveError": "Could not save. Try again.", "save": "Save", "purgeTitle": "Delete all cycle data", - "purgeDescription": "Permanently remove every period, day-log, and prediction from this account. This cannot be undone.", + "purgeDescription": "Permanently removes every logged day, cycle, prediction, and reminder record. This cannot be undone.", "purgeButton": "Delete all cycle data", "purgeConfirmTitle": "Delete all cycle data?", "purgeConfirmBody": "This permanently deletes every period, day-log, symptom, and prediction. This cannot be undone.", - "purgeConfirm": "Delete everything", + "purgeConfirm": "Delete all cycle data permanently?", "purgeCancel": "Cancel", "purgeSuccess": "All cycle data deleted.", - "purgeError": "Could not delete cycle data. Try again." + "purgeError": "Couldn't delete cycle data.", + "reminders": "Reminders", + "remindersDescription": "Turn period reminders on per channel in notification settings.", + "remindersLink": "Notification settings", + "purgeData": "Delete cycle data", + "purgeConfirmAction": "Yes, delete everything", + "purgeDone": "Cycle data deleted." }, "contraceptive": { "NONE": "None", @@ -5139,7 +5162,8 @@ "empty": "No temperature readings yet", "emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.", "ovulationEstimate": "Est. ovulation", - "tooltipTemp": "{temp} °C" + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Confirmed ovulation" } }, "cycleReminders": { diff --git a/messages/es.json b/messages/es.json index 402202490..2ba83b30d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3878,7 +3878,14 @@ "statusExpired": "Caducado", "inactiveTitle": "Revocados y caducados ({count})" }, - "genderOther": "Otro" + "genderOther": "Otro", + "cycleTracking": { + "title": "Seguimiento del ciclo", + "description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.", + "enable": "Activar el seguimiento del ciclo", + "manage": "Abrir seguimiento del ciclo", + "error": "No se pudo actualizar el seguimiento del ciclo." + } }, "admin": { "title": "Administration", @@ -4691,7 +4698,11 @@ "eventPersonalRecord": "Récord personal", "eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.", "eventMoodReminder": "Recordatorio de ánimo", - "eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual." + "eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.", + "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." }, "i18n": { "maintainershipBanner": { @@ -5022,7 +5033,8 @@ "loading": "Cargando tu calendario…", "empty": "Empieza a registrar para ver tu calendario.", "today": "Hoy", - "dayAria": "{date}" + "dayAria": "{date}", + "legendSymptom": "Síntomas" }, "predictions": { "title": "Qué viene después", @@ -5039,7 +5051,8 @@ "stillLearningBody": "Registra al menos tres ciclos completos y tus predicciones mejorarán. Hasta entonces es una estimación aproximada.", "rawMode": "Predicción desactivada", "rawModeBody": "Has desactivado las predicciones — solo se muestran tus datos registrados, sin pronóstico.", - "none": "Aún no hay predicción. Registra un periodo para empezar." + "none": "Aún no hay predicción. Registra un periodo para empezar.", + "ovulationConfirmed": "Ovulación (confirmada)" }, "history": { "title": "Tus ciclos", @@ -5052,7 +5065,11 @@ "regularityLEARNING": "Aprendiendo", "days": "{count} días", "daysPlusMinus": "± {count} días", - "none": "Aún no hay ciclos registrados." + "none": "Aún no hay ciclos registrados.", + "recentCycles": "Ciclos recientes", + "ongoing": "en curso", + "periodDays": "período {count} d", + "ovulationChip": "Ovulación" }, "sheet": { "title": "Registrar tu día", @@ -5107,14 +5124,20 @@ "saveError": "No se pudo guardar. Inténtalo de nuevo.", "save": "Guardar", "purgeTitle": "Eliminar todos los datos del ciclo", - "purgeDescription": "Elimina de forma permanente todos los periodos, registros diarios y predicciones de esta cuenta. No se puede deshacer.", + "purgeDescription": "Elimina de forma permanente cada día registrado, ciclo, predicción y registro de recordatorio. No se puede deshacer.", "purgeButton": "Eliminar todos los datos del ciclo", "purgeConfirmTitle": "¿Eliminar todos los datos del ciclo?", "purgeConfirmBody": "Esto elimina de forma permanente todos los periodos, registros diarios, síntomas y predicciones. No se puede deshacer.", - "purgeConfirm": "Eliminar todo", + "purgeConfirm": "¿Eliminar permanentemente todos los datos del ciclo?", "purgeCancel": "Cancelar", "purgeSuccess": "Todos los datos del ciclo eliminados.", - "purgeError": "No se pudieron eliminar los datos del ciclo. Inténtalo de nuevo." + "purgeError": "No se pudieron eliminar los datos del ciclo.", + "reminders": "Recordatorios", + "remindersDescription": "Activa los recordatorios de período por canal en los ajustes de notificaciones.", + "remindersLink": "Ajustes de notificaciones", + "purgeData": "Eliminar datos del ciclo", + "purgeConfirmAction": "Sí, eliminar todo", + "purgeDone": "Datos del ciclo eliminados." }, "testResult": { "POSITIVE": "Positivo", @@ -5139,7 +5162,8 @@ "empty": "Aún no hay mediciones de temperatura", "emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.", "ovulationEstimate": "Ovulación est.", - "tooltipTemp": "{temp} °C" + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Ovulación confirmada" } }, "cycleReminders": { diff --git a/messages/fr.json b/messages/fr.json index a761e393e..74e6719d9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3878,7 +3878,14 @@ "statusExpired": "Expiré", "inactiveTitle": "Révoqués et expirés ({count})" }, - "genderOther": "Autre" + "genderOther": "Autre", + "cycleTracking": { + "title": "Suivi du cycle", + "description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.", + "enable": "Activer le suivi du cycle", + "manage": "Ouvrir le suivi du cycle", + "error": "Impossible de mettre à jour le suivi du cycle." + } }, "admin": { "title": "Administration", @@ -4691,7 +4698,11 @@ "eventPersonalRecord": "Record personnel", "eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.", "eventMoodReminder": "Rappel d'humeur", - "eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment." + "eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.", + "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." }, "i18n": { "maintainershipBanner": { @@ -5022,7 +5033,8 @@ "loading": "Chargement de ton calendrier…", "empty": "Commence à enregistrer pour remplir ton calendrier.", "today": "Aujourd'hui", - "dayAria": "{date}" + "dayAria": "{date}", + "legendSymptom": "Symptômes" }, "predictions": { "title": "Ce qui arrive ensuite", @@ -5039,7 +5051,8 @@ "stillLearningBody": "Enregistre au moins trois cycles complets et tes prévisions s'affineront. D'ici là, ceci reste une estimation approximative.", "rawMode": "Prévision désactivée", "rawModeBody": "Tu as désactivé les prévisions — seules tes données enregistrées sont affichées, sans prévision.", - "none": "Pas encore de prévision. Enregistre des règles pour commencer." + "none": "Pas encore de prévision. Enregistre des règles pour commencer.", + "ovulationConfirmed": "Ovulation (confirmée)" }, "history": { "title": "Tes cycles", @@ -5052,7 +5065,11 @@ "regularityLEARNING": "En apprentissage", "days": "{count} jours", "daysPlusMinus": "± {count} jours", - "none": "Aucun cycle enregistré pour l'instant." + "none": "Aucun cycle enregistré pour l'instant.", + "recentCycles": "Cycles récents", + "ongoing": "en cours", + "periodDays": "règles {count} j", + "ovulationChip": "Ovulation" }, "sheet": { "title": "Enregistrer ta journée", @@ -5107,14 +5124,20 @@ "saveError": "Enregistrement impossible. Réessaie.", "save": "Enregistrer", "purgeTitle": "Supprimer toutes les données de cycle", - "purgeDescription": "Supprime définitivement chaque règle, journal quotidien et prédiction de ce compte. Cette action est irréversible.", + "purgeDescription": "Supprime définitivement chaque jour enregistré, cycle, prédiction et rappel. Action irréversible.", "purgeButton": "Supprimer toutes les données de cycle", "purgeConfirmTitle": "Supprimer toutes les données de cycle ?", "purgeConfirmBody": "Cela supprime définitivement chaque règle, journal quotidien, symptôme et prédiction. Cette action est irréversible.", - "purgeConfirm": "Tout supprimer", + "purgeConfirm": "Supprimer définitivement toutes les données de cycle ?", "purgeCancel": "Annuler", "purgeSuccess": "Toutes les données de cycle ont été supprimées.", - "purgeError": "Impossible de supprimer les données de cycle. Réessayez." + "purgeError": "Impossible de supprimer les données de cycle.", + "reminders": "Rappels", + "remindersDescription": "Activez les rappels de règles par canal dans les paramètres de notification.", + "remindersLink": "Paramètres de notification", + "purgeData": "Supprimer les données de cycle", + "purgeConfirmAction": "Oui, tout supprimer", + "purgeDone": "Données de cycle supprimées." }, "testResult": { "POSITIVE": "Positif", @@ -5139,7 +5162,8 @@ "empty": "Pas encore de relevés de température", "emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.", "ovulationEstimate": "Ovulation est.", - "tooltipTemp": "{temp} °C" + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Ovulation confirmée" } }, "cycleReminders": { diff --git a/messages/it.json b/messages/it.json index ef73dcdc9..86513e04e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3878,7 +3878,14 @@ "statusExpired": "Scaduto", "inactiveTitle": "Revocati e scaduti ({count})" }, - "genderOther": "Altro" + "genderOther": "Altro", + "cycleTracking": { + "title": "Monitoraggio del ciclo", + "description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.", + "enable": "Attiva il monitoraggio del ciclo", + "manage": "Apri il monitoraggio del ciclo", + "error": "Impossibile aggiornare il monitoraggio del ciclo." + } }, "admin": { "title": "Administration", @@ -4691,7 +4698,11 @@ "eventPersonalRecord": "Record personale", "eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.", "eventMoodReminder": "Promemoria umore", - "eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale." + "eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.", + "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." }, "i18n": { "maintainershipBanner": { @@ -5022,7 +5033,8 @@ "loading": "Caricamento del calendario…", "empty": "Inizia a registrare per riempire il calendario.", "today": "Oggi", - "dayAria": "{date}" + "dayAria": "{date}", + "legendSymptom": "Sintomi" }, "predictions": { "title": "Cosa arriva dopo", @@ -5039,7 +5051,8 @@ "stillLearningBody": "Registra almeno tre cicli completi e le previsioni miglioreranno. Fino ad allora è una stima approssimativa.", "rawMode": "Previsione disattivata", "rawModeBody": "Hai disattivato le previsioni — vengono mostrati solo i tuoi dati registrati, senza previsione.", - "none": "Nessuna previsione ancora. Registra un ciclo per iniziare." + "none": "Nessuna previsione ancora. Registra un ciclo per iniziare.", + "ovulationConfirmed": "Ovulazione (confermata)" }, "history": { "title": "I tuoi cicli", @@ -5052,7 +5065,11 @@ "regularityLEARNING": "In apprendimento", "days": "{count} giorni", "daysPlusMinus": "± {count} giorni", - "none": "Nessun ciclo registrato ancora." + "none": "Nessun ciclo registrato ancora.", + "recentCycles": "Cicli recenti", + "ongoing": "in corso", + "periodDays": "ciclo {count} g", + "ovulationChip": "Ovulazione" }, "sheet": { "title": "Registra la tua giornata", @@ -5107,14 +5124,20 @@ "saveError": "Impossibile salvare. Riprova.", "save": "Salva", "purgeTitle": "Elimina tutti i dati del ciclo", - "purgeDescription": "Rimuove definitivamente ogni mestruazione, registro giornaliero e previsione da questo account. L'operazione è irreversibile.", + "purgeDescription": "Rimuove definitivamente ogni giorno registrato, ciclo, previsione e promemoria. Operazione irreversibile.", "purgeButton": "Elimina tutti i dati del ciclo", "purgeConfirmTitle": "Eliminare tutti i dati del ciclo?", "purgeConfirmBody": "Questo elimina definitivamente ogni mestruazione, registro giornaliero, sintomo e previsione. L'operazione è irreversibile.", - "purgeConfirm": "Elimina tutto", + "purgeConfirm": "Eliminare definitivamente tutti i dati del ciclo?", "purgeCancel": "Annulla", "purgeSuccess": "Tutti i dati del ciclo eliminati.", - "purgeError": "Impossibile eliminare i dati del ciclo. Riprova." + "purgeError": "Impossibile eliminare i dati del ciclo.", + "reminders": "Promemoria", + "remindersDescription": "Attiva i promemoria del ciclo per canale nelle impostazioni di notifica.", + "remindersLink": "Impostazioni di notifica", + "purgeData": "Elimina i dati del ciclo", + "purgeConfirmAction": "Sì, elimina tutto", + "purgeDone": "Dati del ciclo eliminati." }, "testResult": { "POSITIVE": "Positivo", @@ -5139,7 +5162,8 @@ "empty": "Ancora nessuna misurazione della temperatura", "emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.", "ovulationEstimate": "Ovulazione stim.", - "tooltipTemp": "{temp} °C" + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Ovulazione confermata" } }, "cycleReminders": { diff --git a/messages/pl.json b/messages/pl.json index 7143aa461..8f4104949 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3878,7 +3878,14 @@ "statusExpired": "Wygasły", "inactiveTitle": "Unieważnione i wygasłe ({count})" }, - "genderOther": "Inna" + "genderOther": "Inna", + "cycleTracking": { + "title": "Śledzenie cyklu", + "description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.", + "enable": "Włącz śledzenie cyklu", + "manage": "Otwórz śledzenie cyklu", + "error": "Nie udało się zaktualizować śledzenia cyklu." + } }, "admin": { "title": "Administracja", @@ -4691,7 +4698,11 @@ "eventPersonalRecord": "Rekord osobisty", "eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.", "eventMoodReminder": "Przypomnienie o nastroju", - "eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój." + "eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.", + "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." }, "i18n": { "maintainershipBanner": { @@ -5022,7 +5033,8 @@ "loading": "Ładowanie kalendarza…", "empty": "Zacznij zapisywać, aby wypełnić kalendarz.", "today": "Dziś", - "dayAria": "{date}" + "dayAria": "{date}", + "legendSymptom": "Objawy" }, "predictions": { "title": "Co dalej", @@ -5039,7 +5051,8 @@ "stillLearningBody": "Zapisz co najmniej trzy pełne cykle, a prognozy się wyostrzą. Do tego czasu to zgrubne oszacowanie.", "rawMode": "Prognoza wyłączona", "rawModeBody": "Wyłączyłaś prognozy — pokazywane są tylko zapisane dane, bez przewidywań.", - "none": "Brak prognozy. Zapisz miesiączkę, aby zacząć." + "none": "Brak prognozy. Zapisz miesiączkę, aby zacząć.", + "ovulationConfirmed": "Owulacja (potwierdzona)" }, "history": { "title": "Twoje cykle", @@ -5052,7 +5065,11 @@ "regularityLEARNING": "Uczenie", "days": "{count} dni", "daysPlusMinus": "± {count} dni", - "none": "Brak zapisanych cykli." + "none": "Brak zapisanych cykli.", + "recentCycles": "Ostatnie cykle", + "ongoing": "w toku", + "periodDays": "okres {count} d", + "ovulationChip": "Owulacja" }, "sheet": { "title": "Zapisz swój dzień", @@ -5107,14 +5124,20 @@ "saveError": "Nie udało się zapisać. Spróbuj ponownie.", "save": "Zapisz", "purgeTitle": "Usuń wszystkie dane cyklu", - "purgeDescription": "Trwale usuwa każdą miesiączkę, wpis dzienny i prognozę z tego konta. Tej operacji nie można cofnąć.", + "purgeDescription": "Trwale usuwa każdy zapisany dzień, cykl, prognozę i przypomnienie. Tej operacji nie można cofnąć.", "purgeButton": "Usuń wszystkie dane cyklu", "purgeConfirmTitle": "Usunąć wszystkie dane cyklu?", "purgeConfirmBody": "Spowoduje to trwałe usunięcie każdej miesiączki, wpisu dziennego, objawu i prognozy. Tej operacji nie można cofnąć.", - "purgeConfirm": "Usuń wszystko", + "purgeConfirm": "Trwale usunąć wszystkie dane cyklu?", "purgeCancel": "Anuluj", "purgeSuccess": "Usunięto wszystkie dane cyklu.", - "purgeError": "Nie udało się usunąć danych cyklu. Spróbuj ponownie." + "purgeError": "Nie udało się usunąć danych cyklu.", + "reminders": "Przypomnienia", + "remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.", + "remindersLink": "Ustawienia powiadomień", + "purgeData": "Usuń dane cyklu", + "purgeConfirmAction": "Tak, usuń wszystko", + "purgeDone": "Dane cyklu usunięte." }, "testResult": { "POSITIVE": "Pozytywny", @@ -5139,7 +5162,8 @@ "empty": "Brak pomiarów temperatury", "emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.", "ovulationEstimate": "Szac. owulacja", - "tooltipTemp": "{temp} °C" + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Potwierdzona owulacja" } }, "cycleReminders": { diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index d714c010b..ae66aded0 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -75,7 +75,9 @@ export const GET = apiHandler(async (request: NextRequest) => { regularity = "LEARNING"; } else { regularity = - lengthVariabilityDays <= REGULAR_MAD_THRESHOLD ? "REGULAR" : "IRREGULAR"; + lengthVariabilityDays <= REGULAR_MAD_THRESHOLD + ? "REGULAR" + : "IRREGULAR"; } } if (periodLengths.length > 0) { diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 0795d599b..87d4e32f7 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -122,11 +122,15 @@ export const PATCH = apiHandler( }), sexualActivity: encryptSensitive ? false : merged.sexualActivity, protectedSex: encryptSensitive ? null : merged.protectedSex, - pregnancyTest: (encryptSensitive ? null : merged.pregnancyTest) as never, + pregnancyTest: (encryptSensitive + ? null + : merged.pregnancyTest) as never, progesteroneTest: (encryptSensitive ? null : merged.progesteroneTest) as never, - contraceptive: (encryptSensitive ? null : merged.contraceptive) as never, + contraceptive: (encryptSensitive + ? null + : merged.contraceptive) as never, sensitiveEncrypted: encryptSensitive ? encrypt(JSON.stringify(merged)) : null, diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index 084dd5318..d498ccade 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -28,7 +28,10 @@ import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; -import { cycleBulkSchema, MAX_CYCLE_BULK_ENTRIES } from "@/lib/validations/cycle"; +import { + cycleBulkSchema, + MAX_CYCLE_BULK_ENTRIES, +} from "@/lib/validations/cycle"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index dc6acbc0d..74ab80fba 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -19,10 +19,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; -import { - predictCycle, - type NightlyTempInput, -} from "@/lib/cycle"; +import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { buildPhaseDayMap, @@ -65,77 +62,77 @@ export const GET = apiHandler(async () => { prefsRow, moodRows, ] = await Promise.all([ - prisma.menstrualCycle.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { startDate: "asc" }, - }), - // Bound the day-log read to the rendered window (`from` = today − 365d), - // which is already earlier than the symptothermal lookback. Cycle-length - // stats run off MenstrualCycle rows so the day-logs can be windowed (QA: - // perf — unbounded full-history read). - prisma.cycleDayLog.findMany({ - where: { userId: user.id, deletedAt: null, date: { gte: from } }, - orderBy: { date: "asc" }, - select: { - date: true, - flow: true, - basalBodyTempC: true, - ovulationTest: true, - cervicalMucus: true, + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + // Bound the day-log read to the rendered window (`from` = today − 365d), + // which is already earlier than the symptothermal lookback. Cycle-length + // stats run off MenstrualCycle rows so the day-logs can be windowed (QA: + // perf — unbounded full-history read). + prisma.cycleDayLog.findMany({ + where: { userId: user.id, deletedAt: null, date: { gte: from } }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + }, + }), + // Passive wrist temperature feeds the temperature-trend ovulation layer + // (used only to sharpen the prediction's next-start the phase-day map + // extends to). Not a crosstab input. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), }, - }), - // Passive wrist temperature feeds the temperature-trend ovulation layer - // (used only to sharpen the prediction's next-start the phase-day map - // extends to). Not a crosstab input. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: "WRIST_TEMPERATURE", - measuredAt: { - gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), - }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + // The outcome metrics the phase contrast compares — soft-delete-scoped, + // canonical-source deduped per day inside `metricDayMap`. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), }, - orderBy: { measuredAt: "asc" }, - select: { measuredAt: true, value: true }, - }), - // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, - }), - prisma.user.findUnique({ - where: { id: user.id }, - select: { sourcePriorityJson: true }, - }), - // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a - // Measurement row, so read it here and inject it into the crosstab as a - // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). - prisma.moodEntry.findMany({ - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }), - ]); + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }), + // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a + // Measurement row, so read it here and inject it into the crosstab as a + // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). + prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }), + ]); // The latest open cycle runs to the predicted next-period start so the // trailing window is phase-labelled even before the next period is logged. diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts new file mode 100644 index 000000000..a7a130f29 --- /dev/null +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; + +import { + buildDayLogPatch, + buildDayLogInput, + type DayLogFormState, +} from "../log-day-sheet"; + +function blank(): DayLogFormState { + return { + flow: null, + intermenstrual: false, + bbt: "", + opk: null, + mucus: null, + intercourse: false, + protectedSex: false, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + note: "", + symptoms: new Map(), + }; +} + +describe("buildDayLogPatch (edit → clear semantics, QA W-2)", () => { + it("sends explicit null for every deselected enum so an edit clears it", () => { + const patch = buildDayLogPatch(blank()); + // A blank form on an EXISTING row must null every field, not omit it — + // omission would leave the stored value behind (the bug this fixes). + expect(patch.flow).toBeNull(); + expect(patch.ovulationTest).toBeNull(); + expect(patch.cervicalMucus).toBeNull(); + expect(patch.pregnancyTest).toBeNull(); + expect(patch.progesteroneTest).toBeNull(); + expect(patch.contraceptive).toBeNull(); + expect(patch.basalBodyTempC).toBeNull(); + expect(patch.note).toBeNull(); + // Keys are PRESENT (not omitted) so the server actually applies the clear. + expect("flow" in patch).toBe(true); + expect("cervicalMucus" in patch).toBe(true); + }); + + it("carries the set values through and gates protection on intercourse", () => { + const patch = buildDayLogPatch({ + ...blank(), + flow: "MEDIUM", + opk: "POSITIVE_LH_SURGE", + bbt: "36.62", + intercourse: true, + protectedSex: true, + note: " hello ", + symptoms: new Map([["cramps", 3]]), + }); + expect(patch.flow).toBe("MEDIUM"); + expect(patch.ovulationTest).toBe("POSITIVE_LH_SURGE"); + expect(patch.basalBodyTempC).toBe(36.62); + expect(patch.protectedSex).toBe(true); + expect(patch.note).toBe("hello"); + expect(patch.symptoms).toEqual([{ key: "cramps", severity: 3 }]); + }); + + it("nulls protection when no intercourse is logged", () => { + const patch = buildDayLogPatch({ + ...blank(), + intercourse: false, + protectedSex: true, + }); + expect(patch.sexualActivity).toBe(false); + expect(patch.protectedSex).toBeNull(); + }); +}); + +describe("buildDayLogInput (new row → omit empties)", () => { + it("omits unset enums but posts an explicit empty note", () => { + const input = buildDayLogInput(blank(), "2026-06-06"); + expect("flow" in input).toBe(false); + expect("ovulationTest" in input).toBe(false); + expect("basalBodyTempC" in input).toBe(false); + expect(input.note).toBe(""); + expect(input.date).toBe("2026-06-06"); + expect(input.source).toBe("MANUAL"); + }); + + it("includes set values for a new row", () => { + const input = buildDayLogInput( + { ...blank(), flow: "LIGHT", bbt: "36.5" }, + "2026-06-06", + ); + expect(input.flow).toBe("LIGHT"); + expect(input.basalBodyTempC).toBe(36.5); + }); +}); diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx index a941b560c..af2b832d4 100644 --- a/src/components/cycle/__tests__/predictions-panel.test.tsx +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -19,6 +19,7 @@ const basePrediction: CyclePrediction = { fertileWindowStart: null, fertileWindowEnd: null, predictedOvulation: null, + ovulationConfirmed: false, confidence: 0.8, cyclesObserved: 6, stillLearning: false, diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index d3406906f..ed0555b4d 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -61,6 +61,8 @@ export interface BbtChartProps { today: string; /** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */ predictedOvulation: string | null; + /** Whether the ovulation day was confirmed by a signal layer (solid marker). */ + ovulationConfirmed?: boolean; /** Read-Your-Body mode: suppress phase tint + ovulation marker. */ rawChartMode: boolean; } @@ -74,6 +76,7 @@ export function BbtChart({ days, today, predictedOvulation, + ovulationConfirmed = false, rawChartMode, }: BbtChartProps) { const { t } = useTranslations(); @@ -215,13 +218,19 @@ export function BbtChart({ ) : null} diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index ed4b43d83..ff5dcb18c 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -61,10 +61,7 @@ export function CycleCalendar({ }: CycleCalendarProps) { const { t } = useTranslations(); const weekdays = useMemo(() => shortWeekdays(), []); - const byDate = useMemo( - () => new Map(days.map((d) => [d.date, d])), - [days], - ); + const byDate = useMemo(() => new Map(days.map((d) => [d.date, d])), [days]); // Anchor the visible month on today's month. const [monthAnchor, setMonthAnchor] = useState(() => { @@ -101,9 +98,7 @@ export function CycleCalendar({ }); function shiftMonth(delta: number) { - setMonthAnchor( - (a) => new Date(a.getFullYear(), a.getMonth() + delta, 1), - ); + setMonthAnchor((a) => new Date(a.getFullYear(), a.getMonth() + delta, 1)); } return ( @@ -149,7 +144,8 @@ export function CycleCalendar({ const info = byDate.get(date); const isToday = date === today; const markers: string[] = []; - if (info?.isPeriodLogged) markers.push(t("cycle.calendar.legendPeriod")); + if (info?.isPeriodLogged) + markers.push(t("cycle.calendar.legendPeriod")); if (info?.isPredictedPeriod) markers.push(t("cycle.calendar.legendPredicted")); if (info?.isFertileWindow) @@ -171,7 +167,7 @@ export function CycleCalendar({ 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:outline-none focus-visible:ring-2", + "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", )} @@ -189,12 +185,7 @@ export function CycleCalendar({ style={{ backgroundColor: FLOW_HUE }} /> ) : null} - + {cell.getDate()} @@ -237,11 +228,35 @@ export function CycleCalendar({ function CalendarLegend() { const { t } = useTranslations(); - const items: { hue: string; labelKey: string; dashed?: boolean }[] = [ - { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod" }, - { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPredicted", dashed: true }, - { hue: FERTILE_HUE, labelKey: "cycle.calendar.legendFertile" }, - { hue: OVULATION_HUE, labelKey: "cycle.calendar.legendOvulation" }, + // 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). + const items: { + hue: string; + labelKey: string; + variant: "fill" | "dashed" | "ring" | "dot"; + }[] = [ + { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" }, + { + hue: FLOW_HUE, + labelKey: "cycle.calendar.legendPredicted", + variant: "dashed", + }, + { + hue: FERTILE_HUE, + labelKey: "cycle.calendar.legendFertile", + variant: "ring", + }, + { + hue: OVULATION_HUE, + labelKey: "cycle.calendar.legendOvulation", + variant: "fill", + }, + { + hue: "var(--muted-foreground)", + labelKey: "cycle.calendar.legendSymptom", + variant: "dot", + }, ]; return (
    @@ -249,8 +264,16 @@ function CalendarLegend() {
  • diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index f2b52ee55..74b5377ac 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -104,11 +104,15 @@ export function CyclePhaseHeadline({

    ); } - const metricLabel = t(METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey); + const metricLabel = t( + METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, + ); const unit = t(UNIT_KEY[headline.display]); const up = headline.delta >= 0; const dir = t( - up ? "cycle.insights.headline.dirHigher" : "cycle.insights.headline.dirLower", + up + ? "cycle.insights.headline.dirHigher" + : "cycle.insights.headline.dirLower", ); const params = { metric: metricLabel, @@ -116,7 +120,8 @@ export function CyclePhaseHeadline({ unit, dir, }; - const key = HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic"; + const key = + HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic"; return (

      {rows.map((row) => { - const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey); + const metricLabel = t( + METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, + ); const unit = t(UNIT_KEY[row.display]); // `delta` = lutealAvg − follicularAvg: positive = the vital runs // higher in the luteal phase. The number stays NEUTRAL — higher is @@ -166,7 +173,9 @@ export function CyclePhaseCrosstab({ className="text-foreground min-w-0 flex-1 truncate" title={metricLabel} > - {t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })} + {t("cycle.insights.crosstab.pairLabel", { + metric: metricLabel, + })} {deltaText} diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index 4636e00b4..e93df3d6f 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -18,7 +18,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { useTranslations } from "@/lib/i18n/context"; import type { CycleGoal, CycleProfileDTO } from "./types"; -import { useUpdateCyclePrefs } from "./use-cycle"; +import { useUpdateCyclePrefs, useDeleteAllCycleData } from "./use-cycle"; /** * v1.15.0 — the single-page cycle settings form. @@ -50,6 +50,8 @@ function numOrEmpty(v: number | null): string { export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { const { t } = useTranslations(); const update = useUpdateCyclePrefs(); + const purge = useDeleteAllCycleData(); + const [confirmingPurge, setConfirmingPurge] = useState(false); const [goal, setGoal] = useState(profile.goal); const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode); @@ -159,8 +161,8 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { label={t("cycle.settings.lutealPhaseLength")} value={lutealLen} onChange={setLutealLen} - min={8} - max={20} + min={10} + max={16} /> @@ -185,6 +187,26 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { + {/* Reminders — the per-channel toggles live on the notifications page; + point users there rather than duplicating the toggle state here. */} +
      +
      +

      + {t("cycle.settings.reminders")} +

      +

      + {t("cycle.settings.remindersDescription")} +

      +
      + +
      + + + {/* Data export / delete */}

      {t("cycle.settings.dataTitle")}

      @@ -203,6 +225,66 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
      + + {/* Cycle-only hard purge (post-Dobbs threat model): removes every + cycle row + the cycle audit trail + reminder ledger, distinct + from the account-level delete. Two-step confirm — no accidental + destructive tap. */} +
      +

      + {t("cycle.settings.purgeDescription")} +

      + {confirmingPurge ? ( +
      + + {t("cycle.settings.purgeConfirm")} + + + +
      + ) : ( + + )} + {purge.isSuccess ? ( + + {t("cycle.settings.purgeDone")} + + ) : null} + {purge.isError ? ( + + {t("cycle.settings.purgeError")} + + ) : null} +
      @@ -287,7 +369,7 @@ function PriorField({ max={max} value={value} onChange={(e) => onChange(e.target.value)} - className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2" + className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" />
      ); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index ccc514337..0febea984 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -241,6 +241,9 @@ export function CycleView() { predictedOvulation={ calendar.data?.prediction?.predictedOvulation ?? null } + ovulationConfirmed={ + calendar.data?.prediction?.ovulationConfirmed ?? false + } rawChartMode={calendar.data?.profile.rawChartMode ?? false} /> @@ -290,6 +293,7 @@ export function CycleView() { onOpenChange={setSheetOpen} date={selectedDate} today={today} + activePeriod={wheel.phase === "MENSTRUAL"} /> ); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index e245de509..2ef9e9eb8 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -17,7 +17,9 @@ import { useDeleteDayLog, useEndPeriod, useLogDay, + usePatchDayLog, useStartPeriod, + type CycleDayLogPatch, } from "./use-cycle"; import type { CervicalMucus, @@ -73,13 +75,99 @@ const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [ ]; const SEVERITY_LEVELS = [1, 2, 3, 4] as const; +/** 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 { + flow: FlowLevel | null; + intermenstrual: boolean; + /** Raw text from the BBT input; "" / non-finite resolves to null. */ + bbt: string; + opk: OvulationTest | null; + mucus: CervicalMucus | null; + intercourse: boolean; + protectedSex: boolean; + pregnancyTest: HomeTestResult | null; + progesteroneTest: HomeTestResult | null; + contraceptive: ContraceptiveKind | null; + note: string; + symptoms: Map; +} + +function resolveBbt(bbt: string): number | null { + const n = bbt.trim() === "" ? null : Number(bbt); + return n != null && Number.isFinite(n) ? n : null; +} + +function symptomList( + symptoms: Map, +): CycleSymptomSelection[] { + return Array.from(symptoms.entries()).map(([key, severity]) => ({ + key, + severity, + })); +} + +/** + * The PATCH payload for an EDIT: every enum carries an explicit `null` when + * deselected so the server clears it (the POST merge can only add/keep — QA + * W-2). Pure + exported for the unit test. + */ +export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch { + return { + flow: s.flow ?? null, + intermenstrualBleeding: s.intermenstrual, + basalBodyTempC: resolveBbt(s.bbt), + ovulationTest: s.opk ?? null, + cervicalMucus: s.mucus ?? null, + sexualActivity: s.intercourse, + protectedSex: s.intercourse ? s.protectedSex : null, + pregnancyTest: s.pregnancyTest ?? null, + progesteroneTest: s.progesteroneTest ?? null, + contraceptive: s.contraceptive ?? null, + symptoms: symptomList(s.symptoms), + note: s.note.trim() ? s.note.trim() : null, + }; +} + +/** The POST payload for a NEW row: omit empty enums (a fresh row has nothing to + * clear); `note` posts an explicit "" so an emptied note never persists. */ +export function buildDayLogInput( + s: DayLogFormState, + date: string, +): CycleDayLogInput { + const bbtVal = resolveBbt(s.bbt); + return { + date, + loggedAt: new Date().toISOString(), + source: "MANUAL", + ...(s.flow ? { flow: s.flow } : {}), + intermenstrualBleeding: s.intermenstrual, + ...(bbtVal != null ? { basalBodyTempC: bbtVal } : {}), + ...(s.opk ? { ovulationTest: s.opk } : {}), + ...(s.mucus ? { cervicalMucus: s.mucus } : {}), + sexualActivity: s.intercourse, + protectedSex: s.intercourse ? s.protectedSex : null, + ...(s.pregnancyTest ? { pregnancyTest: s.pregnancyTest } : {}), + ...(s.progesteroneTest ? { progesteroneTest: s.progesteroneTest } : {}), + ...(s.contraceptive ? { contraceptive: s.contraceptive } : {}), + symptoms: symptomList(s.symptoms), + ...(s.note.trim() ? { note: s.note.trim() } : { note: "" }), + }; +} + export interface LogDaySheetProps { open: boolean; onOpenChange: (open: boolean) => void; /** The date being logged (YYYY-MM-DD). */ date: string; - /** Today (YYYY-MM-DD) — gates the one-tap period-start affordance. */ + /** Today (YYYY-MM-DD). */ today: string; + /** + * Whether a period is currently open (today is in the MENSTRUAL phase) — gates + * the one-tap "end period" affordance so it never shows when no period is in + * progress (QA M2). + */ + activePeriod?: boolean; } function Chip({ @@ -97,7 +185,7 @@ function Chip({ aria-pressed={active} onClick={onClick} className={cn( - "focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2", + "focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none", active ? "border-primary bg-primary/10 text-primary" : "border-border text-foreground hover:bg-accent", @@ -112,10 +200,11 @@ export function LogDaySheet({ open, onOpenChange, date, - today, + activePeriod = false, }: LogDaySheetProps) { const { t } = useTranslations(); const logDay = useLogDay(); + const patchDay = usePatchDayLog(); const startPeriod = useStartPeriod(); const endPeriod = useEndPeriod(); const deleteDay = useDeleteDayLog(); @@ -150,16 +239,16 @@ export function LogDaySheet({ // open+date+row-identity, so a re-open or a late-arriving fetch repaints the // controls exactly once. `dto === null` (nothing logged) resets to blank. const dto = open ? (dayLog.data ?? null) : undefined; - const formKey = open ? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}` : null; + const formKey = open + ? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}` + : null; const [lastFormKey, setLastFormKey] = useState(null); if (open && formKey !== lastFormKey && !dayLog.isLoading) { setLastFormKey(formKey); setRowId(dto?.id ?? null); setFlow((dto?.flow ?? null) as FlowLevel | null); setIntermenstrual(dto?.intermenstrualBleeding ?? false); - setSymptoms( - new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity])), - ); + setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity]))); setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : ""); setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); @@ -192,35 +281,35 @@ export function LogDaySheet({ } async function handleSave() { - const bbtNum = bbt.trim() === "" ? undefined : Number(bbt); - const symptomList: CycleSymptomSelection[] = Array.from( - symptoms.entries(), - ).map(([key, severity]) => ({ key, severity })); - const input: CycleDayLogInput = { - date, - loggedAt: new Date().toISOString(), - source: "MANUAL", - ...(flow ? { flow } : {}), - intermenstrualBleeding: intermenstrual, - ...(bbtNum != null && Number.isFinite(bbtNum) - ? { basalBodyTempC: bbtNum } - : {}), - ...(opk ? { ovulationTest: opk } : {}), - ...(mucus ? { cervicalMucus: mucus } : {}), - sexualActivity: intercourse, - protectedSex: intercourse ? protectedSex : null, - ...(pregnancyTest ? { pregnancyTest } : {}), - ...(progesteroneTest ? { progesteroneTest } : {}), - ...(contraceptive ? { contraceptive } : {}), - symptoms: symptomList, - ...(note.trim() ? { note: note.trim() } : { note: "" }), + const state: DayLogFormState = { + flow, + intermenstrual, + bbt, + opk, + mucus, + intercourse, + protectedSex, + pregnancyTest, + progesteroneTest, + contraceptive, + note, + symptoms, }; - await logDay.mutateAsync(input); + if (rowId) { + // Editing an existing row → PATCH with explicit nulls so a deselected + // chip actually CLEARS (the POST merge can only add/keep — QA W-2). + await patchDay.mutateAsync({ id: rowId, patch: buildDayLogPatch(state) }); + onOpenChange(false); + return; + } + await logDay.mutateAsync(buildDayLogInput(state, date)); onOpenChange(false); } + // Period boundaries operate on the SELECTED date, so a forgotten day-1 can be + // corrected from the calendar retroactively (QA M3) — not today-only. async function handleStartPeriod() { - await startPeriod.mutateAsync(today); + await startPeriod.mutateAsync(date); onOpenChange(false); } @@ -237,9 +326,11 @@ export function LogDaySheet({ const busy = logDay.isPending || + patchDay.isPending || startPeriod.isPending || endPeriod.isPending || deleteDay.isPending; + const saving = logDay.isPending || patchDay.isPending; return ( } > - {/* One-tap period boundaries. */} - {date === today ? ( -
      - + {/* One-tap period boundaries — available on any selected date so a + forgotten day-1 can be corrected retroactively (M3). "End period" + shows only while a period is actually open (M2). */} +
      + + {activePeriod ? (
      - ) : null} + ) : null} +
      {/* Flow */} @@ -326,10 +419,7 @@ export function LogDaySheet({ ))}
      -