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() {
{t(it.labelKey)}
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")}
+
+
+
+
+ {t("cycle.settings.remindersLink")}
+
+
+
+
+
+
{/* 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")}
+
+ {
+ try {
+ await purge.mutateAsync();
+ } finally {
+ setConfirmingPurge(false);
+ }
+ }}
+ >
+ {purge.isPending ? (
+
+ ) : null}
+ {t("cycle.settings.purgeConfirmAction")}
+
+ setConfirmingPurge(false)}
+ >
+ {t("cycle.settings.purgeCancel")}
+
+
+ ) : (
+
setConfirmingPurge(true)}
+ >
+ {t("cycle.settings.purgeData")}
+
+ )}
+ {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 (
- {logDay.isPending ? (
+ {saving ? (
) : null}
- {logDay.isPending ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
+ {saving ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
>
}
>
- {/* One-tap period boundaries. */}
- {date === today ? (
-
-
- {startPeriod.isPending ? (
-
- ) : (
-
- )}
- {t("cycle.startedPeriod")}
-
+ {/* 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). */}
+
+
+ {startPeriod.isPending ? (
+
+ ) : (
+
+ )}
+ {t("cycle.startedPeriod")}
+
+ {activePeriod ? (
-
- ) : null}
+ ) : null}
+
{/* Flow */}
@@ -326,10 +419,7 @@ export function LogDaySheet({
))}
-
+
{t("cycle.sheet.intermenstrualBleeding")}
- toggleSymptom(s.key)}>
+ toggleSymptom(s.key)}
+ >
-
+
{t(s.labelKey)}
@@ -380,7 +470,7 @@ export function LogDaySheet({
})}
onClick={() => setSymptomSeverity(s.key, lvl)}
className={cn(
- "focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2",
+ "focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
sev === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
@@ -411,7 +501,7 @@ export function LogDaySheet({
value={bbt}
onChange={(e) => setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
- className="border-input bg-background focus-visible:ring-ring/50 w-32 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-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
aria-label={t("cycle.sheet.temperature")}
/>
@@ -484,9 +574,7 @@ export function LogDaySheet({
- setPregnancyTest((cur) => (cur === v ? null : v))
- }
+ onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
>
{t(`cycle.testResult.${v}`)}
@@ -518,9 +606,7 @@ export function LogDaySheet({
- setContraceptive((cur) => (cur === v ? null : v))
- }
+ onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
>
{t(`cycle.contraceptive.${v}`)}
@@ -540,7 +626,7 @@ export function LogDaySheet({
/>
- {logDay.isError ? (
+ {logDay.isError || patchDay.isError ? (
{t("cycle.sheet.saveError")}
diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx
index 623f3be84..5dfd7b204 100644
--- a/src/components/cycle/predictions-panel.tsx
+++ b/src/components/cycle/predictions-panel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Sparkles } from "lucide-react";
+import { Check, Sparkles } from "lucide-react";
import {
Card,
@@ -107,7 +107,11 @@ export function PredictionsPanel({
{prediction.predictedOvulation ? (
) : null}
@@ -178,6 +182,13 @@ function Learning({ title, body }: { title: string; body: string }) {
);
}
+/** Inclusive day span between two `YYYY-MM-DD` dates (noon-UTC anchored). */
+function daySpan(from: string, to: string): number {
+ const a = Date.parse(`${from}T12:00:00Z`);
+ const b = Date.parse(`${to}T12:00:00Z`);
+ return Math.round((b - a) / 86_400_000) + 1;
+}
+
function HistoryCard({
history,
}: {
@@ -185,6 +196,11 @@ function HistoryCard({
}) {
const { t } = useTranslations();
const stats = history?.stats;
+ // Most-recent observed cycles (the spread the regularity number summarises —
+ // seeing it builds trust; the data was already on the wire). QA H2.
+ const observed = (history?.cycles ?? [])
+ .filter((c) => !c.isPredicted)
+ .slice(0, 6);
return (
@@ -196,7 +212,7 @@ function HistoryCard({
) : null}
-
+
{!stats || stats.avgLengthDays == null ? (
{t("cycle.history.none")}
@@ -225,6 +241,55 @@ function HistoryCard({
) : null}
)}
+
+ {observed.length > 0 ? (
+
+
+ {t("cycle.history.recentCycles")}
+
+
+ {observed.map((c) => {
+ const periodLen =
+ c.periodEndDate != null
+ ? daySpan(c.startDate, c.periodEndDate)
+ : null;
+ return (
+
+
+ {formatDate(c.startDate)}
+ {" – "}
+ {c.endDate
+ ? formatDate(c.endDate)
+ : t("cycle.history.ongoing")}
+
+
+ {c.lengthDays != null
+ ? t("cycle.history.days", { count: c.lengthDays })
+ : "—"}
+ {periodLen != null ? (
+
+ {t("cycle.history.periodDays", { count: periodLen })}
+
+ ) : null}
+ {c.ovulationConfirmed ? (
+
+
+ {t("cycle.history.ovulationChip")}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ ) : null}
);
diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts
index 2889b076b..25fbf63f7 100644
--- a/src/components/cycle/types.ts
+++ b/src/components/cycle/types.ts
@@ -5,11 +5,7 @@
* type the unwrapped `(await res.json()).data`.
*/
-export type CyclePhase =
- | "MENSTRUAL"
- | "FOLLICULAR"
- | "OVULATORY"
- | "LUTEAL";
+export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
export type FlowLevel = "NONE" | "SPOTTING" | "LIGHT" | "MEDIUM" | "HEAVY";
@@ -57,6 +53,7 @@ export interface CyclePrediction {
fertileWindowStart: string | null;
fertileWindowEnd: string | null;
predictedOvulation: string | null;
+ ovulationConfirmed: boolean;
confidence: number;
cyclesObserved: number;
stillLearning: boolean;
diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts
index 33b061864..fd83a0746 100644
--- a/src/components/cycle/use-cycle.ts
+++ b/src/components/cycle/use-cycle.ts
@@ -18,10 +18,16 @@ import {
} from "@/lib/query-keys";
import type {
CalendarResponse,
+ CervicalMucus,
+ ContraceptiveKind,
CycleDayLogDTO,
CycleDayLogInput,
CycleHistoryResponse,
CycleProfileDTO,
+ CycleSymptomSelection,
+ FlowLevel,
+ HomeTestResult,
+ OvulationTest,
} from "./types";
import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab";
@@ -137,6 +143,49 @@ export function useLogDay() {
});
}
+/**
+ * The PATCH body for editing an existing day-log. Every field nullable so the
+ * web sheet can CLEAR a previously-set value (the POST merge can only add or
+ * keep — it never nulls an omitted field, so an edit that deselects a chip must
+ * route through PATCH with an explicit null).
+ */
+export interface CycleDayLogPatch {
+ flow?: FlowLevel | null;
+ intermenstrualBleeding?: boolean;
+ basalBodyTempC?: number | null;
+ ovulationTest?: OvulationTest | null;
+ cervicalMucus?: CervicalMucus | null;
+ sexualActivity?: boolean;
+ protectedSex?: boolean | null;
+ pregnancyTest?: HomeTestResult | null;
+ progesteroneTest?: HomeTestResult | null;
+ contraceptive?: ContraceptiveKind | null;
+ symptoms?: CycleSymptomSelection[];
+ note?: string | null;
+}
+
+/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
+export function usePatchDayLog() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({
+ id,
+ patch,
+ }: {
+ id: string;
+ patch: CycleDayLogPatch;
+ }) => {
+ const res = await fetch(`/api/cycle/day-logs/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(patch),
+ });
+ return unwrap(res);
+ },
+ onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
+ });
+}
+
export function useStartPeriod() {
const qc = useQueryClient();
return useMutation({
@@ -192,6 +241,27 @@ export function useDeleteDayLog() {
});
}
+/**
+ * Hard-delete EVERY cycle row the user owns (day-logs, cycles, predictions,
+ * the cycle audit trail, and the cycle reminder-delivery ledger rows). The
+ * privacy "purge" the post-Dobbs threat model promises — distinct from the
+ * per-row soft-delete. Invalidates the whole cycle prefix + the nav gate.
+ */
+export function useDeleteAllCycleData() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ const res = await fetch("/api/cycle/all", { method: "DELETE" });
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
+ return unwrap<{ purged: boolean }>(res);
+ },
+ onSuccess: () => {
+ void invalidateKeys(qc, cycleDependentKeys);
+ void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
+ },
+ });
+}
+
export interface CyclePrefsPatch {
enabled?: boolean;
goal?: CycleProfileDTO["goal"];
diff --git a/src/components/cycle/wheel-state.ts b/src/components/cycle/wheel-state.ts
index 51c82d005..e86e17e36 100644
--- a/src/components/cycle/wheel-state.ts
+++ b/src/components/cycle/wheel-state.ts
@@ -25,20 +25,14 @@ export interface WheelState {
}
/**
- * The first day (`YYYY-MM-DD`) of the current cycle: the start of the most
- * recent MENSTRUAL-anchored run at/before `today` with no phase gap. Shares
- * the exact backward-walk `deriveWheelState` uses so the BBT chart scopes to
- * the same cycle the wheel shows. Returns null when today has no phase (no
- * active cycle).
+ * Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
+ * the first day of the most recent MENSTRUAL block at/before today with no phase
+ * gap. The ONE source of truth both `currentCycleStartDate` and
+ * `deriveWheelState` use, so the BBT chart, the day-of-cycle count, and the ring
+ * spans can never disagree on where the current cycle begins. Assumes
+ * `sorted[todayIdx].phase != null` (the callers check first).
*/
-export function currentCycleStartDate(
- days: CalendarDay[],
- today: string,
-): string | null {
- const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
- const todayIdx = sorted.findIndex((d) => d.date === today);
- if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
-
+function findCycleStartIndex(sorted: CalendarDay[], todayIdx: number): number {
let startIdx = todayIdx;
for (let i = todayIdx; i >= 0; i--) {
const d = sorted[i];
@@ -51,7 +45,22 @@ export function currentCycleStartDate(
break;
}
}
- return sorted[startIdx].date;
+ return startIdx;
+}
+
+/**
+ * The first day (`YYYY-MM-DD`) of the current cycle, or null when today has no
+ * phase (no active cycle). Scopes the BBT chart to the same cycle the wheel
+ * shows.
+ */
+export function currentCycleStartDate(
+ days: CalendarDay[],
+ today: string,
+): string | null {
+ const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
+ const todayIdx = sorted.findIndex((d) => d.date === today);
+ if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
+ return sorted[findCycleStartIndex(sorted, todayIdx)].date;
}
export function deriveWheelState(
@@ -71,21 +80,7 @@ export function deriveWheelState(
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
}
- // Walk back to the start of the current MENSTRUAL-anchored run: the first
- // day of the most recent MENSTRUAL block at/before today with no gap.
- let startIdx = todayIdx;
- for (let i = todayIdx; i >= 0; i--) {
- const d = sorted[i];
- if (d.phase == null) break;
- startIdx = i;
- if (
- d.phase === "MENSTRUAL" &&
- (i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
- ) {
- break;
- }
- }
-
+ const startIdx = findCycleStartIndex(sorted, todayIdx);
const run = sorted.slice(startIdx, todayIdx + 1);
const dayOfCycle = run.length;
diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx
index 975c76fe1..8e546b13d 100644
--- a/src/components/settings/account-section.tsx
+++ b/src/components/settings/account-section.tsx
@@ -26,11 +26,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from "@/components/ui/avatar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -56,6 +52,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
+import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface PasskeyInfo {
@@ -526,7 +523,9 @@ export function AccountSection() {
{t("settings.identity.description")}
-
{t("settings.identity.fullName")}
+
+ {t("settings.identity.fullName")}
+
-
{t("settings.identity.insurer")}
+
+ {t("settings.identity.insurer")}
+
{saveMsg}
@@ -601,6 +600,11 @@ export function AccountSection() {
timezone dropdown. */}
+ {/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
+ lets any account opt in (or opt out) before the gated /cycle page is
+ reachable. */}
+
+
{/* Passkeys card */}
{passkeyMsg}
@@ -1074,7 +1076,8 @@ function AvatarSection() {
const res = await fetch("/api/user/avatar", { method: "POST", body });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
- if (res.status === 415) throw new Error(t("settings.avatar.invalidType"));
+ if (res.status === 415)
+ throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
diff --git a/src/components/settings/cycle-tracking-card.tsx b/src/components/settings/cycle-tracking-card.tsx
new file mode 100644
index 000000000..2f1bb4930
--- /dev/null
+++ b/src/components/settings/cycle-tracking-card.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import { CalendarHeart } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+/**
+ * v1.15.0 — the cycle-tracking enable on-ramp.
+ *
+ * The cycle vertical auto-enables for accounts set to female (gender-derived
+ * default), but everyone else needs a way IN: the gated `/cycle` page bounces
+ * them home and the in-page settings (where the goal/prefs live) sit behind
+ * that same gate. This card reads the UNGATED `/api/auth/me/cycle-prefs` so any
+ * account can flip `cycleTrackingEnabled` on (or a female account can opt out)
+ * before the gated page is reachable. PATCHes the same prefs route the iOS app
+ * uses; invalidates the nav gate so the sidebar/bottom-nav entry appears.
+ */
+
+interface CyclePrefsShape {
+ cycleTrackingEnabled: boolean;
+}
+
+export function CycleTrackingCard({
+ isAuthenticated,
+}: {
+ isAuthenticated: boolean;
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const { data } = useQuery({
+ queryKey: queryKeys.cyclePrefs(),
+ enabled: isAuthenticated,
+ queryFn: async () => {
+ const res = await fetch("/api/auth/me/cycle-prefs");
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
+ return (await res.json()).data as CyclePrefsShape;
+ },
+ });
+
+ const toggle = useMutation({
+ mutationFn: async (enabled: boolean) => {
+ const res = await fetch("/api/auth/me/cycle-prefs", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ });
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
+ return (await res.json()).data as CyclePrefsShape;
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
+ // The nav gate reads the resolved enable state off /auth/me.
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
+ },
+ });
+
+ const enabled = data?.cycleTrackingEnabled ?? false;
+
+ return (
+
+
+
+
+
+ {t("settings.cycleTracking.enable")}
+
+ toggle.mutate(v)}
+ className="mt-0.5 shrink-0"
+ />
+
+ {enabled ? (
+
+ {t("settings.cycleTracking.manage")}
+
+ ) : null}
+ {toggle.isError ? (
+
+ {t("settings.cycleTracking.error")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts
index 35377c9bc..34ef32e7d 100644
--- a/src/lib/cycle/backup.ts
+++ b/src/lib/cycle/backup.ts
@@ -62,10 +62,7 @@ export interface CycleBackupSection {
* client) so both writers share one read.
*/
export async function buildCycleBackupSection(
- prisma: Pick<
- PrismaClient,
- "cycleProfile" | "menstrualCycle" | "cycleDayLog"
- >,
+ prisma: Pick,
userId: string,
): Promise {
const [profile, cycles, dayLogs] = await Promise.all([
@@ -77,7 +74,9 @@ export async function buildCycleBackupSection(
prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null },
orderBy: { date: "asc" },
- include: { symptomLinks: { include: { symptom: { select: { key: true } } } } },
+ include: {
+ symptomLinks: { include: { symptom: { select: { key: true } } } },
+ },
}),
]);
@@ -199,9 +198,15 @@ export async function restoreCycleData(
payload: BackupPayload,
): Promise {
// Wipe (child links cascade off the day-log delete).
- const dayLogs = await tx.cycleDayLog.deleteMany({ where: { userId: ownerId } });
- const cycles = await tx.menstrualCycle.deleteMany({ where: { userId: ownerId } });
- const profile = await tx.cycleProfile.deleteMany({ where: { userId: ownerId } });
+ const dayLogs = await tx.cycleDayLog.deleteMany({
+ where: { userId: ownerId },
+ });
+ const cycles = await tx.menstrualCycle.deleteMany({
+ where: { userId: ownerId },
+ });
+ const profile = await tx.cycleProfile.deleteMany({
+ where: { userId: ownerId },
+ });
// Recreate the profile (one row / user).
if (payload.cycleProfile) {
@@ -249,7 +254,10 @@ export async function restoreCycleData(
const symptomIdByKey = new Map();
if (allKeys.length > 0) {
const rows = await tx.cycleSymptom.findMany({
- where: { key: { in: allKeys }, OR: [{ userId: null }, { userId: ownerId }] },
+ where: {
+ key: { in: allKeys },
+ OR: [{ userId: null }, { userId: ownerId }],
+ },
select: { id: true, key: true },
});
for (const r of rows) symptomIdByKey.set(r.key, r.id);
diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts
index 5fb63635c..16b9c2234 100644
--- a/src/lib/cycle/day-log-write.ts
+++ b/src/lib/cycle/day-log-write.ts
@@ -238,8 +238,16 @@ interface WriteArgs {
}
async function writeDayLog(args: WriteArgs): Promise {
- const { userId, entry, tz, cycleId, source, where, existing, encryptSensitive } =
- args;
+ const {
+ userId,
+ entry,
+ tz,
+ cycleId,
+ source,
+ where,
+ existing,
+ encryptSensitive,
+ } = args;
// Decrypt the existing note (fail-soft) for the change/duplicate split —
// re-encrypting the same plaintext yields a different ciphertext (random
@@ -285,11 +293,14 @@ async function writeDayLog(args: WriteArgs): Promise {
// Note resolution with partial merge: an omitted `note` keeps the stored
// value; an explicit null clears it.
const incomingNote =
- entry.note !== undefined ? (entry.note ?? null) : decryptNoteSoft(existing?.notesEncrypted ?? null);
+ entry.note !== undefined
+ ? (entry.note ?? null)
+ : decryptNoteSoft(existing?.notesEncrypted ?? null);
const notesEncrypted = incomingNote ? encrypt(incomingNote) : null;
// Non-sensitive fields, partial-merged against the stored row.
- const flow = entry.flow !== undefined ? (entry.flow ?? null) : (existing?.flow ?? null);
+ const flow =
+ entry.flow !== undefined ? (entry.flow ?? null) : (existing?.flow ?? null);
const intermenstrualBleeding =
entry.intermenstrualBleeding !== undefined
? entry.intermenstrualBleeding
diff --git a/src/lib/cycle/day-math.ts b/src/lib/cycle/day-math.ts
index 68014e15c..420db647f 100644
--- a/src/lib/cycle/day-math.ts
+++ b/src/lib/cycle/day-math.ts
@@ -33,7 +33,9 @@ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
*/
export function parseDayMs(date: string): number {
if (!ISO_DATE_RE.test(date)) {
- throw new Error(`cycle/day-math: malformed date "${date}" (expected YYYY-MM-DD)`);
+ throw new Error(
+ `cycle/day-math: malformed date "${date}" (expected YYYY-MM-DD)`,
+ );
}
const ms = Date.parse(`${date}T12:00:00Z`);
if (Number.isNaN(ms)) {
@@ -73,7 +75,9 @@ export function roundHalf(x: number, k = 0): number {
const factor = 10 ** k;
// Math.round is round-half-up toward +Infinity; mirror it away-from-zero for
// negatives so the rule matches Swift's .toNearestOrAwayFromZero exactly.
- return x < 0 ? -Math.round(-x * factor) / factor : Math.round(x * factor) / factor;
+ return x < 0
+ ? -Math.round(-x * factor) / factor
+ : Math.round(x * factor) / factor;
}
/** `true` when `date` is on or before `other` (string compare is safe for ISO). */
diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts
index 781b59a2e..b9a3ca7b5 100644
--- a/src/lib/cycle/dto.ts
+++ b/src/lib/cycle/dto.ts
@@ -109,13 +109,19 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
basalBodyTempC: row.basalBodyTempC,
ovulationTest: row.ovulationTest,
cervicalMucus: row.cervicalMucus,
- sexualActivity: hasEnvelope ? (enc.sexualActivity ?? false) : row.sexualActivity,
+ sexualActivity: hasEnvelope
+ ? (enc.sexualActivity ?? false)
+ : row.sexualActivity,
protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex,
- pregnancyTest: hasEnvelope ? (enc.pregnancyTest ?? null) : row.pregnancyTest,
+ pregnancyTest: hasEnvelope
+ ? (enc.pregnancyTest ?? null)
+ : row.pregnancyTest,
progesteroneTest: hasEnvelope
? (enc.progesteroneTest ?? null)
: row.progesteroneTest,
- contraceptive: hasEnvelope ? (enc.contraceptive ?? null) : row.contraceptive,
+ contraceptive: hasEnvelope
+ ? (enc.contraceptive ?? null)
+ : row.contraceptive,
symptoms: (row.symptomLinks ?? []).map((l) => ({
key: l.symptom.key,
severity: l.severity ?? null,
@@ -157,6 +163,8 @@ export interface CyclePredictionDTO {
fertileWindowStart: string | null;
fertileWindowEnd: string | null;
predictedOvulation: string | null;
+ /** Whether `predictedOvulation` was confirmed by a signal layer (BBT / symptothermal). */
+ ovulationConfirmed: boolean;
confidence: number;
cyclesObserved: number;
stillLearning: boolean;
@@ -183,6 +191,7 @@ export function toCyclePredictionDTO(
fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null,
fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null,
predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null,
+ ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
stillLearning: result.stillLearning,
diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts
index fe03a0478..cc7f33e7b 100644
--- a/src/lib/cycle/engine-adapter.ts
+++ b/src/lib/cycle/engine-adapter.ts
@@ -53,7 +53,11 @@ export type CalendarDayLogRow = Pick<
export function toCycleInputs(
cycles: readonly Pick<
MenstrualCycle,
- "startDate" | "endDate" | "periodEndDate" | "ovulationDate" | "ovulationConfirmed"
+ | "startDate"
+ | "endDate"
+ | "periodEndDate"
+ | "ovulationDate"
+ | "ovulationConfirmed"
>[],
): CycleInput[] {
return cycles.map((c) => ({
@@ -160,7 +164,11 @@ export function phaseForDate(
predictedNextStart: string | null,
lutealLength: number,
): { phase: CyclePhase | null; dayOfCycle: number | null } {
- const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength);
+ const phaseCycles = buildPhaseCycles(
+ cycles,
+ predictedNextStart,
+ lutealLength,
+ );
for (const pc of phaseCycles) {
const r = phaseForDay(date, pc);
if (r.phase !== null) return r;
@@ -189,7 +197,11 @@ export function buildPhaseDayMap(
from: string,
to: string,
): Map {
- const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength);
+ const phaseCycles = buildPhaseCycles(
+ cycles,
+ predictedNextStart,
+ lutealLength,
+ );
const out = new Map();
const span = dayDiff(to, from);
for (let i = 0; i <= span; i++) {
@@ -276,7 +288,11 @@ export function buildCalendar(
goalAllowsFertile &&
prediction?.fertileWindowStart != null &&
prediction.fertileWindowEnd != null &&
- isWithin(date, prediction.fertileWindowStart, prediction.fertileWindowEnd);
+ isWithin(
+ date,
+ prediction.fertileWindowStart,
+ prediction.fertileWindowEnd,
+ );
const isPredictedOvulation =
goalAllowsFertile &&
@@ -289,8 +305,7 @@ export function buildCalendar(
isPredictedPeriod,
isFertileWindow,
isPredictedOvulation,
- isPeriodLogged:
- log?.flow != null && log.flow !== "NONE",
+ isPeriodLogged: log?.flow != null && log.flow !== "NONE",
flow: log?.flow ?? null,
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
diff --git a/src/lib/cycle/export-data.ts b/src/lib/cycle/export-data.ts
index c13b73f5c..bbbe6c203 100644
--- a/src/lib/cycle/export-data.ts
+++ b/src/lib/cycle/export-data.ts
@@ -82,8 +82,7 @@ export async function buildCycleExportSummary(
const observed: CycleExportCycle[] = cycles.map((c, i) => {
const next = cycles[i + 1];
const lengthDays =
- c.lengthDays ??
- (next ? dayDiff(next.startDate, c.startDate) : null);
+ c.lengthDays ?? (next ? dayDiff(next.startDate, c.startDate) : null);
const periodLengthDays =
c.periodEndDate !== null
? dayDiff(c.periodEndDate, c.startDate) + 1
@@ -134,9 +133,7 @@ export async function buildCycleExportSummary(
return {
lastPeriodStart,
- recentCycles: observed
- .slice(-RECENT_CYCLES_CAP)
- .reverse(),
+ recentCycles: observed.slice(-RECENT_CYCLES_CAP).reverse(),
observedCycleCount: observed.length,
averageCycleLengthDays,
cycleLengthVariabilityDays,
diff --git a/src/lib/cycle/healthkit-mapping.ts b/src/lib/cycle/healthkit-mapping.ts
index c918f0203..14af64126 100644
--- a/src/lib/cycle/healthkit-mapping.ts
+++ b/src/lib/cycle/healthkit-mapping.ts
@@ -91,7 +91,8 @@ export const HK_OVULATION_TEST_VALUES: Record = {
"1": "NEGATIVE",
HKCategoryValueOvulationTestResultNegative: "NEGATIVE",
"2": "POSITIVE_LH_SURGE",
- HKCategoryValueOvulationTestResultLuteinizingHormoneSurge: "POSITIVE_LH_SURGE",
+ HKCategoryValueOvulationTestResultLuteinizingHormoneSurge:
+ "POSITIVE_LH_SURGE",
"3": "INDETERMINATE",
HKCategoryValueOvulationTestResultIndeterminate: "INDETERMINATE",
"4": "ESTROGEN_SURGE",
@@ -241,7 +242,11 @@ export type HkCycleRoute =
* nudge the CycleProfile goal. Carries both payloads so the importer
* applies them in one pass.
*/
- | { kind: "day-log+profile"; fields: CycleDayLogFields; profile: CycleProfileFields }
+ | {
+ kind: "day-log+profile";
+ fields: CycleDayLogFields;
+ profile: CycleProfileFields;
+ }
/** No cycle destination (unrecognised value, or deferred pregnancy-mode). */
| { kind: "skip" };
diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts
index c81d58578..ef3e9ea00 100644
--- a/src/lib/cycle/import-accumulator.ts
+++ b/src/lib/cycle/import-accumulator.ts
@@ -271,7 +271,11 @@ export class CycleImportAccumulator {
// invisible — record it on the audit trail.
await auditLog("cycle.profile.goal-nudged", {
userId: this.userId,
- details: { from: "GENERAL_HEALTH", to: "AVOID_PREGNANCY", via: "apple-health-import" },
+ details: {
+ from: "GENERAL_HEALTH",
+ to: "AVOID_PREGNANCY",
+ via: "apple-health-import",
+ },
});
}
}
diff --git a/src/lib/cycle/phase-crosstab.ts b/src/lib/cycle/phase-crosstab.ts
index 096a9aa3e..896376fb1 100644
--- a/src/lib/cycle/phase-crosstab.ts
+++ b/src/lib/cycle/phase-crosstab.ts
@@ -99,7 +99,10 @@ export const MOOD_CHANNEL_KEY = "MOOD";
*/
export const PHASE_CROSSTAB_METRICS: Record<
string,
- { type: MeasurementType | typeof MOOD_CHANNEL_KEY; display: PhaseCrosstabDisplay }
+ {
+ type: MeasurementType | typeof MOOD_CHANNEL_KEY;
+ display: PhaseCrosstabDisplay;
+ }
> = {
restingHeartRate: { type: "RESTING_HEART_RATE", display: "bpm" },
heartRateVariability: { type: "HEART_RATE_VARIABILITY", display: "ms" },
@@ -193,8 +196,13 @@ export function computePhaseMetricCrosstab(args: {
const candidates: PhaseCrosstabCandidate[] = [];
- for (const [metricKey, cfg] of Object.entries(PHASE_CROSSTAB_METRICS) as Array<
- [PhaseCrosstabMetricKey, (typeof PHASE_CROSSTAB_METRICS)[PhaseCrosstabMetricKey]]
+ for (const [metricKey, cfg] of Object.entries(
+ PHASE_CROSSTAB_METRICS,
+ ) as Array<
+ [
+ PhaseCrosstabMetricKey,
+ (typeof PHASE_CROSSTAB_METRICS)[PhaseCrosstabMetricKey],
+ ]
>) {
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
if (metricByDay.size === 0) continue;
diff --git a/src/lib/cycle/phase.ts b/src/lib/cycle/phase.ts
index c351db77a..6facb6aaa 100644
--- a/src/lib/cycle/phase.ts
+++ b/src/lib/cycle/phase.ts
@@ -19,7 +19,11 @@
import { addDays, dayDiff } from "./day-math";
import { clampLuteal } from "./prediction";
-import { LUTEAL_DEFAULT, POPULATION_DEFAULT_PERIOD, type CyclePhase } from "./types";
+import {
+ LUTEAL_DEFAULT,
+ POPULATION_DEFAULT_PERIOD,
+ type CyclePhase,
+} from "./types";
/** A resolved cycle window the phase mapper needs. */
export interface PhaseCycle {
@@ -95,7 +99,11 @@ export function phaseSeries(
cycle: PhaseCycle,
): { date: string; phase: CyclePhase | null; dayOfCycle: number | null }[] {
const span = dayDiff(to, from);
- const out: { date: string; phase: CyclePhase | null; dayOfCycle: number | null }[] = [];
+ const out: {
+ date: string;
+ phase: CyclePhase | null;
+ dayOfCycle: number | null;
+ }[] = [];
for (let i = 0; i <= span; i++) {
const date = addDays(from, i);
out.push({ date, ...phaseForDay(date, cycle) });
diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts
index 736396cac..3727c2914 100644
--- a/src/lib/cycle/prediction.ts
+++ b/src/lib/cycle/prediction.ts
@@ -91,9 +91,7 @@ export function mad(values: readonly number[], centre: number): number {
function completedLengths(cycles: readonly CycleInput[]): number[] {
// Sort by startDate ascending (the canonical order), then diff consecutive
// starts. A cycle is completed iff a later start exists.
- const starts = cycles
- .map((c) => c.startDate)
- .sort((a, b) => dayDiff(a, b));
+ const starts = cycles.map((c) => c.startDate).sort((a, b) => dayDiff(a, b));
const lengths: number[] = [];
for (let i = 0; i < starts.length - 1; i++) {
lengths.push(dayDiff(starts[i + 1], starts[i]));
@@ -297,9 +295,13 @@ export function detectTempShift(
* Infertile phase confirmed on peak+3; ovulation is taken at the peak day for
* the agreement check.
*/
-export function detectMucusPeak(dayLogs: readonly DayLogInput[]): string | null {
+export function detectMucusPeak(
+ dayLogs: readonly DayLogInput[],
+): string | null {
const peakDays = dayLogs
- .filter((l) => l.cervicalMucus === "EGG_WHITE" || l.cervicalMucus === "WATERY")
+ .filter(
+ (l) => l.cervicalMucus === "EGG_WHITE" || l.cervicalMucus === "WATERY",
+ )
.map((l) => l.date)
.sort((a, b) => dayDiff(a, b));
return peakDays.length > 0 ? peakDays[peakDays.length - 1] : null;
@@ -317,7 +319,9 @@ export function confirmSymptothermal(
if (!shift) return null;
const peak = detectMucusPeak(dayLogs);
if (!peak) return null;
- if (Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS) {
+ if (
+ Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS
+ ) {
return shift.ovulationDate;
}
return null;
@@ -383,7 +387,10 @@ function adherenceFactors(
): { cAdherence: number; logSparsity: number } {
// expectedDays = days since the most recent cycle start, capped at length.
const sinceStart = Math.max(0, dayDiff(today, lastStart));
- const expectedDays = Math.min(sinceStart, Math.max(1, Math.round(estimatedLength)));
+ const expectedDays = Math.min(
+ sinceStart,
+ Math.max(1, Math.round(estimatedLength)),
+ );
// loggedDays = day logs with any non-null observation in that span.
let loggedDays = 0;
for (const log of dayLogs) {
@@ -428,7 +435,9 @@ export function clampLuteal(raw: number): number {
return clamp(raw, LUTEAL_MIN, LUTEAL_MAX);
}
-export function resolveLuteal(profile: Pick): number {
+export function resolveLuteal(
+ profile: Pick,
+): number {
return clampLuteal(profile.lutealPhaseLength ?? LUTEAL_DEFAULT);
}
@@ -551,7 +560,8 @@ export function predictCycle(
today,
);
const adherencePenalty = 1 + logSparsity;
- let halfWidth = roundHalf(Z_BAND * est.sigma * adherencePenalty) * confirmMultiplier;
+ let halfWidth =
+ roundHalf(Z_BAND * est.sigma * adherencePenalty) * confirmMultiplier;
// Single-cycle cold-start gets a fixed band bonus (§5).
if (est.cyclesObserved === 1) halfWidth += COLD_START_BAND_BONUS;
halfWidth = clamp(Math.round(halfWidth), HALF_WIDTH_MIN, HALF_WIDTH_MAX);
@@ -598,7 +608,11 @@ interface FinalizeArgs {
/** Assemble the result struct, deriving the band + fertile window. */
function finalize(a: FinalizeArgs): CyclePredictionResult {
- const halfWidth = clamp(Math.round(a.halfWidth), HALF_WIDTH_MIN, HALF_WIDTH_MAX);
+ const halfWidth = clamp(
+ Math.round(a.halfWidth),
+ HALF_WIDTH_MIN,
+ HALF_WIDTH_MAX,
+ );
const confidence = roundHalf(a.confidence, 2);
return {
method: a.method,
diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts
index fdb3926ea..cab9114ee 100644
--- a/src/lib/cycle/types.ts
+++ b/src/lib/cycle/types.ts
@@ -27,7 +27,12 @@ export type OvulationTest =
| "ESTROGEN_SURGE"
| "INDETERMINATE";
-export type CervicalMucus = "DRY" | "STICKY" | "CREAMY" | "WATERY" | "EGG_WHITE";
+export type CervicalMucus =
+ | "DRY"
+ | "STICKY"
+ | "CREAMY"
+ | "WATERY"
+ | "EGG_WHITE";
export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts
index c057e21dd..70f1f5ad5 100644
--- a/src/lib/notifications/senders/apns.ts
+++ b/src/lib/notifications/senders/apns.ts
@@ -612,41 +612,41 @@ export async function sendViaApns(
let workingEnv: "production" | "sandbox" | null = null;
for (const attemptEnv of envSequence) {
result = await sendApnsPush({
- deviceToken: device.apnsToken,
- environment: attemptEnv,
- payload: {
- alert: { title: payload.title, body: stripHtml(payload.message) },
- data: {
- eventType: routingEvent,
- // In discreet mode also drop cycle-semantic metadata (e.g. `phase`)
- // so a future cycle push can't name a cycle concept on the wire even
- // though the allowlist permits `phase` (QA LOW).
- ...pickIosMetadata(
- payload.discreet
- ? stripCycleMetadata(payload.metadata)
- : payload.metadata,
- ),
+ deviceToken: device.apnsToken,
+ environment: attemptEnv,
+ payload: {
+ alert: { title: payload.title, body: stripHtml(payload.message) },
+ data: {
+ eventType: routingEvent,
+ // In discreet mode also drop cycle-semantic metadata (e.g. `phase`)
+ // so a future cycle push can't name a cycle concept on the wire even
+ // though the allowlist permits `phase` (QA LOW).
+ ...pickIosMetadata(
+ payload.discreet
+ ? stripCycleMetadata(payload.metadata)
+ : payload.metadata,
+ ),
+ },
+ threadId: routingEvent,
+ // The iOS app registers a `UNNotificationCategory` per event-type
+ // so the same string doubles as the category identifier. iOS
+ // ignores categories it doesn't know about, so adding the key
+ // here is safe for event-types that don't have an actionable
+ // category registered yet — they fall back to a plain alert.
+ category: routingEvent,
+ // Future-proof for an iOS Notification Service Extension. iOS
+ // ignores the flag when no extension is registered, so it's a
+ // no-op on today's binary but unblocks NSE work without a
+ // server-side change.
+ mutableContent: true,
+ ...(isTimeSensitive
+ ? {
+ interruptionLevel: "time-sensitive" as const,
+ priority: 10 as const,
+ }
+ : {}),
},
- threadId: routingEvent,
- // The iOS app registers a `UNNotificationCategory` per event-type
- // so the same string doubles as the category identifier. iOS
- // ignores categories it doesn't know about, so adding the key
- // here is safe for event-types that don't have an actionable
- // category registered yet — they fall back to a plain alert.
- category: routingEvent,
- // Future-proof for an iOS Notification Service Extension. iOS
- // ignores the flag when no extension is registered, so it's a
- // no-op on today's binary but unblocks NSE work without a
- // server-side change.
- mutableContent: true,
- ...(isTimeSensitive
- ? {
- interruptionLevel: "time-sensitive" as const,
- priority: 10 as const,
- }
- : {}),
- },
- collapseId: routingEvent,
+ collapseId: routingEvent,
});
if (result.ok) {
diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts
index 80b792194..e8dfda118 100644
--- a/src/lib/openapi/routes.ts
+++ b/src/lib/openapi/routes.ts
@@ -362,7 +362,7 @@ const deviceRegisterRequest = z
.nullable()
.optional()
.describe(
- "v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. \"server\" forces server APNs for this device; \"client\" forces local. Stored + echoed; cron suppression stays user-level.",
+ 'v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level.',
),
})
.meta({
@@ -692,7 +692,9 @@ const syncMeasurementUpsert = measurementResource
.number()
.int()
.positive()
- .describe("LWW reconciliation counter; echo to keep the mirror monotonic."),
+ .describe(
+ "LWW reconciliation counter; echo to keep the mirror monotonic.",
+ ),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({ id: "SyncMeasurementUpsert" });
@@ -735,7 +737,9 @@ const syncMoodUpsert = z
const syncMoodTombstone = z
.object({
- id: z.string().describe("Server id — the identity key the client dedups on for mood."),
+ id: z
+ .string()
+ .describe("Server id — the identity key the client dedups on for mood."),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
@@ -767,7 +771,11 @@ const syncIntakeUpsert = z
const syncIntakeTombstone = z
.object({
- id: z.string().describe("Server id — the identity key the client dedups on for intakes."),
+ id: z
+ .string()
+ .describe(
+ "Server id — the identity key the client dedups on for intakes.",
+ ),
syncVersion: z.number().int().nonnegative(),
deletedAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
@@ -909,13 +917,11 @@ const adminDiagnosticData = z
// schema description AND enforced by the route + a DB CHECK constraint
// so iOS code-gen surfaces the mutual exclusion.
-const medicationCategoryEnum = z
- .enum(MEDICATION_CATEGORY_VALUES)
- .meta({
- id: "MedicationCategory",
- description:
- "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.",
- });
+const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({
+ id: "MedicationCategory",
+ description:
+ "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.",
+});
const medicationTreatmentClassEnum = z
.enum(MEDICATION_TREATMENT_CLASS_VALUES)
@@ -987,14 +993,14 @@ const medicationScheduleResource = z
.int()
.nullable()
.describe(
- "v1.7.0 cyclic \"on\" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.",
+ 'v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.',
),
cyclicOffWeeks: z
.number()
.int()
.nullable()
.describe(
- "v1.7.0 cyclic \"off\" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.",
+ 'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.',
),
})
.meta({
@@ -1048,15 +1054,13 @@ const medicationResource = z
.describe(
"v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s staleness is accepted.",
),
- startsOn: z
- .iso
+ startsOn: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation.",
),
- endsOn: z
- .iso
+ endsOn: z.iso
.datetime({ offset: true })
.nullable()
.describe(
@@ -1181,20 +1185,26 @@ const complianceResult = z
.number()
.int()
.nonnegative()
- .describe("Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`."),
+ .describe(
+ "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.",
+ ),
taken: z.number().int().nonnegative(),
skipped: z
.number()
.int()
.nonnegative()
- .describe("Doses the user explicitly skipped — excluded from the `rate` denominator."),
+ .describe(
+ "Doses the user explicitly skipped — excluded from the `rate` denominator.",
+ ),
missed: z.number().int().nonnegative(),
rate: z
.number()
.int()
.min(0)
.max(100)
- .describe("Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator."),
+ .describe(
+ "Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator.",
+ ),
streak: z
.number()
.int()
@@ -1213,22 +1223,30 @@ const dailyComplianceEntry = z
.number()
.int()
.nonnegative()
- .describe("Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers."),
+ .describe(
+ "Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.",
+ ),
expectedCount: z
.number()
.int()
.nonnegative()
- .describe("True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`)."),
+ .describe(
+ "True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`).",
+ ),
due: z
.boolean()
- .describe("`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses."),
+ .describe(
+ "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses.",
+ ),
taken: z.number().int().nonnegative(),
skipped: z.number().int().nonnegative(),
onTime: z
.number()
.int()
.nonnegative()
- .describe("Doses taken in the on-time band, including the `early` bucket (early counts as compliant)."),
+ .describe(
+ "Doses taken in the on-time band, including the `early` bucket (early counts as compliant).",
+ ),
late: z.number().int().nonnegative(),
veryLate: z.number().int().nonnegative(),
early: z
@@ -1236,7 +1254,9 @@ const dailyComplianceEntry = z
.int()
.nonnegative()
.optional()
- .describe("Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate."),
+ .describe(
+ "Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate.",
+ ),
})
.meta({
id: "DailyComplianceEntry",
@@ -1266,7 +1286,9 @@ const complianceDisplay = z
nextDueAt: z.iso
.datetime({ offset: true })
.nullable()
- .describe("The open cycle's due instant. Null when `state` is `none`."),
+ .describe(
+ "The open cycle's due instant. Null when `state` is `none`.",
+ ),
graceUntil: z.iso
.datetime({ offset: true })
.nullable()
@@ -1295,7 +1317,9 @@ const medicationComplianceResponse = z
compliance30: complianceResult,
dailyCompliance: z
.record(z.string(), dailyComplianceEntry)
- .describe("Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid."),
+ .describe(
+ "Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.",
+ ),
complianceDisplay,
})
.meta({
@@ -1420,10 +1444,14 @@ const derivedCoverage = z
historyDays: z
.number()
.int()
- .describe("Distinct days of history backing the value (the gating floor)."),
+ .describe(
+ "Distinct days of history backing the value (the gating floor).",
+ ),
missing: z
.array(z.string())
- .describe("Named inputs still missing — drives the 'track N more' nudge."),
+ .describe(
+ "Named inputs still missing — drives the 'track N more' nudge.",
+ ),
})
.meta({ id: "DerivedCoverage" });
@@ -1431,7 +1459,9 @@ const derivedConfidence = z
.object({
score: z
.number()
- .describe("0..100 confidence; feeds the shared coverage meter unchanged."),
+ .describe(
+ "0..100 confidence; feeds the shared coverage meter unchanged.",
+ ),
band: z
.enum(["high", "medium", "low", "draft"])
.describe("Confidence band the meter renders."),
@@ -1502,7 +1532,9 @@ const derivedMetricResponse = z
reason: z
.string()
.nullable()
- .describe("Why the value could not be produced; null when status is 'ok'."),
+ .describe(
+ "Why the value could not be produced; null when status is 'ok'.",
+ ),
assessment: derivedAssessment
.nullable()
.describe(
@@ -1554,13 +1586,20 @@ const discoveredCorrelation = z
.describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."),
outcome: z
.string()
- .describe("Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY."),
- n: z.number().int().describe("Paired-day count after the day+1 lag join (≥ 20)."),
+ .describe(
+ "Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.",
+ ),
+ n: z
+ .number()
+ .int()
+ .describe("Paired-day count after the day+1 lag join (≥ 20)."),
r: z.number().describe("Pearson r over the lag-joined daily series."),
pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."),
qValue: z
.number()
- .describe("Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold)."),
+ .describe(
+ "Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).",
+ ),
interpretation: z
.string()
.describe("Conservative, descriptive interpretation — never causal."),
@@ -1578,7 +1617,10 @@ const correlationDiscoveryResponse = z
.int()
.describe("Behaviour × outcome pairs assessed (for the honest footer)."),
fdrQ: z.number().describe("The FDR target the surface used."),
- minPairs: z.number().int().describe("Minimum paired-day count enforced per pair."),
+ minPairs: z
+ .number()
+ .int()
+ .describe("Minimum paired-day count enforced per pair."),
})
.meta({
id: "CorrelationDiscoveryResponse",
@@ -1675,7 +1717,9 @@ const medicationComplianceStatusResponse = z
),
cached: z
.boolean()
- .describe("True when the envelope is served from cache (incl. last-good)."),
+ .describe(
+ "True when the envelope is served from cache (incl. last-good).",
+ ),
updatedAt: z.iso
.datetime({ offset: true })
.nullable()
@@ -1769,13 +1813,11 @@ const analyticsRangeResponse = z
"Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).",
});
-const insightsPregenerateRequest = z
- .object({})
- .meta({
- id: "InsightsPregenerateRequest",
- description:
- "No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.",
- });
+const insightsPregenerateRequest = z.object({}).meta({
+ id: "InsightsPregenerateRequest",
+ description:
+ "No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.",
+});
const insightsPregenerateResponse = z
.object({
@@ -1905,7 +1947,7 @@ const medicationExtractRequest = z
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe(
- "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the server's UTC day.",
+ 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.',
),
})
.meta({
@@ -2059,7 +2101,9 @@ const capabilitiesResponse = z
snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."),
germanAtcDefaultLocales: z
.array(z.string())
- .describe("App locales that default the additive BfArM ATC coding on."),
+ .describe(
+ "App locales that default the additive BfArM ATC coding on.",
+ ),
restBaseUrl: z
.string()
.describe("Base path of the read-only FHIR R4 REST face."),
@@ -2068,24 +2112,32 @@ const capabilitiesResponse = z
.describe("Bearer scope a narrow token needs to read the FHIR face."),
resourceTypes: z
.array(z.string())
- .describe("FHIR resource types the REST face serves (read + search)."),
+ .describe(
+ "FHIR resource types the REST face serves (read + search).",
+ ),
operations: z
.array(z.string())
.describe("Whole-record operations exposed (e.g. $everything)."),
searchParams: z
.array(z.string())
- .describe("Search parameters honoured uniformly across the search routes."),
+ .describe(
+ "Search parameters honoured uniformly across the search routes.",
+ ),
})
.describe(
"FHIR coding constants + the read-only REST face descriptor (v1.11).",
),
share: z
.object({
- supported: z.boolean().describe("Whether clinician share links are served."),
+ supported: z
+ .boolean()
+ .describe("Whether clinician share links are served."),
maxDays: z
.number()
.int()
- .describe("Maximum lifetime of a share link, in days. No never-expiring share."),
+ .describe(
+ "Maximum lifetime of a share link, in days. No never-expiring share.",
+ ),
resourceTypes: z
.array(z.string())
.describe("FHIR resource types a share link may be scoped to serve."),
@@ -2176,12 +2228,18 @@ const cyclePhaseEnumOpenapi = z
.enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"])
.meta({ id: "CyclePhase" });
-flowLevelEnum.meta({ id: "FlowLevel", description: "Menstrual-flow intensity." });
+flowLevelEnum.meta({
+ id: "FlowLevel",
+ description: "Menstrual-flow intensity.",
+});
ovulationTestEnum.meta({
id: "OvulationTest",
description: "Ovulation predictor-kit (OPK) reading.",
});
-cervicalMucusEnum.meta({ id: "CervicalMucus", description: "Cervical-mucus quality." });
+cervicalMucusEnum.meta({
+ id: "CervicalMucus",
+ description: "Cervical-mucus quality.",
+});
homeTestResultEnum.meta({
id: "HomeTestResult",
description: "At-home test result (pregnancy / progesterone).",
@@ -2282,6 +2340,7 @@ const cyclePredictionDto = z
fertileWindowStart: z.string().nullable(),
fertileWindowEnd: z.string().nullable(),
predictedOvulation: z.string().nullable(),
+ ovulationConfirmed: z.boolean(),
confidence: z.number(),
cyclesObserved: z.number().int(),
stillLearning: z.boolean(),
@@ -2290,7 +2349,7 @@ const cyclePredictionDto = z
.meta({
id: "CyclePredictionDTO",
description:
- "The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is TRYING_TO_CONCEIVE.",
+ "The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY.",
});
const cycleCalendarDayDto = z.object({
@@ -2492,7 +2551,10 @@ export const openApiPaths: NonNullable = {
description: "New day-log created.",
content: {
"application/json": {
- schema: dataEnvelope(cycleDayLogDto, "CycleDayLogCreatedEnvelope"),
+ schema: dataEnvelope(
+ cycleDayLogDto,
+ "CycleDayLogCreatedEnvelope",
+ ),
},
},
},
@@ -2611,7 +2673,10 @@ export const openApiPaths: NonNullable = {
description: "Calendar grid + forecast.",
content: {
"application/json": {
- schema: dataEnvelope(cycleCalendarResponse, "CycleCalendarEnvelope"),
+ schema: dataEnvelope(
+ cycleCalendarResponse,
+ "CycleCalendarEnvelope",
+ ),
},
},
},
@@ -2636,7 +2701,10 @@ export const openApiPaths: NonNullable = {
description: "Cycle history.",
content: {
"application/json": {
- schema: dataEnvelope(cycleHistoryResponse, "CycleHistoryEnvelope"),
+ schema: dataEnvelope(
+ cycleHistoryResponse,
+ "CycleHistoryEnvelope",
+ ),
},
},
},
@@ -2656,7 +2724,10 @@ export const openApiPaths: NonNullable = {
description: "Phase-correlation rows + headline.",
content: {
"application/json": {
- schema: dataEnvelope(cycleInsightsResponse, "CycleInsightsEnvelope"),
+ schema: dataEnvelope(
+ cycleInsightsResponse,
+ "CycleInsightsEnvelope",
+ ),
},
},
},
@@ -2683,6 +2754,36 @@ export const openApiPaths: NonNullable = {
},
},
},
+ "/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: dataEnvelope(
+ z.object({
+ purged: z.boolean(),
+ dayLogs: z.number().int(),
+ predictions: z.number().int(),
+ cycles: z.number().int(),
+ auditRows: z.number().int(),
+ pushRows: z.number().int(),
+ }),
+ "CyclePurgeEnvelope",
+ ),
+ },
+ },
+ },
+ ...cycleDisabledResponse,
+ ...stdResponses,
+ },
+ },
+ },
"/api/cycle/profile": {
get: {
tags: ["Cycle"],
@@ -2814,7 +2915,10 @@ export const openApiPaths: NonNullable = {
"Share link created. `token` carries the raw `hls_` value and is unrecoverable after this response.",
content: {
"application/json": {
- schema: dataEnvelope(shareLinkCreatedResponse, "ShareLinkCreated"),
+ schema: dataEnvelope(
+ shareLinkCreatedResponse,
+ "ShareLinkCreated",
+ ),
},
},
},
@@ -2851,7 +2955,10 @@ export const openApiPaths: NonNullable = {
description: "Link revoked.",
content: {
"application/json": {
- schema: dataEnvelope(shareLinkRevokedResponse, "ShareLinkRevoked"),
+ schema: dataEnvelope(
+ shareLinkRevokedResponse,
+ "ShareLinkRevoked",
+ ),
},
},
},
@@ -3553,7 +3660,8 @@ export const openApiPaths: NonNullable = {
"/api/medications/extract": {
post: {
tags: ["Medications"],
- summary: "Extract scheduling fields from a free-text medication description",
+ summary:
+ "Extract scheduling fields from a free-text medication description",
description:
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.",
requestBody: {
@@ -3597,8 +3705,7 @@ export const openApiPaths: NonNullable = {
requestParams: {
path: z.object({ id: z.string() }),
query: z.object({
- days: z
- .coerce
+ days: z.coerce
.number()
.int()
.min(1)
@@ -3701,7 +3808,7 @@ export const openApiPaths: NonNullable = {
tags: ["Auth"],
summary: "Read per-user Coach opt-out flag",
description:
- "Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights \"Hide Coach\" Switch and the layout FAB short-circuit.",
+ 'Returns the user\'s per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights "Hide Coach" Switch and the layout FAB short-circuit.',
responses: {
"200": {
description: "Resolved flag.",
@@ -3815,7 +3922,11 @@ export const openApiPaths: NonNullable = {
schema: dataEnvelope(
z.object({
avatarUrl: z.string(),
- contentType: z.enum(["image/jpeg", "image/png", "image/webp"]),
+ contentType: z.enum([
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ ]),
updatedAt: z.iso.datetime({ offset: true }),
}),
"UploadAvatarResponse",
@@ -3976,10 +4087,7 @@ export const openApiPaths: NonNullable = {
description: "Layout reset; the default layout is returned.",
content: {
"application/json": {
- schema: dataEnvelope(
- insightsLayoutSchema,
- "InsightsLayoutReset",
- ),
+ schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutReset"),
},
},
},
diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts
index 74ccee9d7..30717c68b 100644
--- a/src/lib/query-keys.ts
+++ b/src/lib/query-keys.ts
@@ -58,7 +58,7 @@ export const queryKeys = {
* lists below stay byte-identical.
*/
analytics: (slice?: "summaries") =>
- (slice ? (["analytics", slice] as const) : (["analytics"] as const)),
+ slice ? (["analytics", slice] as const) : (["analytics"] as const),
/**
* v1.9.0 — single-metric period-over-period range read
* (`GET /api/analytics/range`). A dedicated cache slot per `(type, range)`
@@ -319,8 +319,7 @@ export const queryKeys = {
action: string | undefined,
level: string | undefined,
range: string | undefined,
- ) =>
- ["admin", "app-logs", traceId, action, level, range] as const,
+ ) => ["admin", "app-logs", traceId, action, level, range] as const,
adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const,
adminBackups: () => ["admin", "backups"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
@@ -497,8 +496,7 @@ export const queryKeys = {
* `["trend-series"]` invalidation prefix below so a measurement write
* refreshes the caption in lockstep with the chart.
*/
- insightsTrendSeries: (types: string) =>
- ["trend-series", types] as const,
+ insightsTrendSeries: (types: string) => ["trend-series", types] as const,
/**
* v1.15.0 — cycle-tracking surfaces. `cycle()` is the root prefix every
@@ -514,6 +512,10 @@ export const queryKeys = {
["cycle", "calendar", from, to] as const,
cycleHistory: (limit: number) => ["cycle", "history", limit] as const,
cycleProfile: () => ["cycle", "profile"] as const,
+ /** The UNGATED enable/prefs read (`/api/auth/me/cycle-prefs`) — the settings
+ * on-ramp reads this so a non-FEMALE account can opt in before the gated
+ * cycle page is reachable. */
+ cyclePrefs: () => ["cycle", "prefs"] as const,
cycleInsights: () => ["cycle", "insights"] as const,
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
};
@@ -598,10 +600,7 @@ export const medicationDependentKeys = [
* log. `insightsRoot()` rides along because phase-correlation cards depend
* on the same rows.
*/
-export const cycleDependentKeys = [
- queryKeys.cycle(),
- queryKeys.insightsRoot(),
-];
+export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
/**
* Invalidate every key in the bundle in parallel. Use this from mutation