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 (