feat(cycle): second-audit feature, QoL, and wiring close

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 13:32:25 +02:00
1 parent 3375bbabac
commit c21131e26e
40 files changed
+1399 -446

No files matched your search

+72 -2
View File
@@ -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:
+33 -9
View File
@@ -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": {
+33 -9
View File
@@ -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": {
+33 -9
View File
@@ -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": {
+33 -9
View File
@@ -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": {
+33 -9
View File
@@ -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": {
+33 -9
View File
@@ -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": {
+3 -1
View File
@@ -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) {
+6 -2
View File
@@ -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,
+4 -1
View File
@@ -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";
+70 -73
View File
@@ -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.
@@ -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);
});
});
@@ -19,6 +19,7 @@ const basePrediction: CyclePrediction = {
fertileWindowStart: null,
fertileWindowEnd: null,
predictedOvulation: null,
ovulationConfirmed: false,
confidence: 0.8,
cyclesObserved: 6,
stillLearning: false,
+12 -3
View File
@@ -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({
<ReferenceLine
x={ovulationMs}
stroke={OVULATION_HUE}
strokeDasharray="4 3"
// Solid line for a confirmed ovulation (signal-detected),
// dashed for the calendar estimate (QA M1).
strokeDasharray={ovulationConfirmed ? undefined : "4 3"}
strokeWidth={1.5}
label={{
value: t("cycle.bbt.ovulationEstimate"),
value: ovulationConfirmed
? t("cycle.bbt.ovulationConfirmed")
: t("cycle.bbt.ovulationEstimate"),
position: "insideTopRight",
fontSize: 10,
fill: OVULATION_HUE,
// Muted text (not the amber hue) so the tiny label keeps
// AA contrast on the card; the line carries the colour (L1).
fill: "var(--muted-foreground)",
}}
/>
) : null}
+45 -22
View File
@@ -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}
<span
className={cn(
"relative z-10",
isToday && "text-primary",
)}
>
<span className={cn("relative z-10", isToday && "text-primary")}>
{cell.getDate()}
</span>
@@ -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 (
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
@@ -249,8 +264,16 @@ function CalendarLegend() {
<li key={it.labelKey} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={cn("h-2.5 w-2.5 rounded-full", it.dashed && "opacity-60")}
style={{ backgroundColor: it.hue }}
className={cn(
"rounded-full",
it.variant === "dot" ? "h-1 w-1" : "h-2.5 w-2.5",
it.variant === "dashed" && "opacity-60",
)}
style={
it.variant === "ring"
? { border: `1.5px solid ${it.hue}` }
: { backgroundColor: it.hue }
}
/>
{t(it.labelKey)}
</li>
+14 -5
View File
@@ -104,11 +104,15 @@ export function CyclePhaseHeadline({
</p>
);
}
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 (
<p
data-slot="cycle-phase-headline"
@@ -143,7 +148,9 @@ export function CyclePhaseCrosstab({
</p>
<ul className="divide-border divide-y">
{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,
})}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
+86 -4
View File
@@ -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<CycleGoal>(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}
/>
</div>
</div>
@@ -185,6 +187,26 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
<Separator />
{/* Reminders — the per-channel toggles live on the notifications page;
point users there rather than duplicating the toggle state here. */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.reminders")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.remindersDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<Separator />
{/* Data export / delete */}
<div className="space-y-2">
<p className="text-sm font-medium">{t("cycle.settings.dataTitle")}</p>
@@ -203,6 +225,66 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
</Link>
</Button>
</div>
{/* 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. */}
<div className="border-destructive/30 mt-3 space-y-2 rounded-md border p-3">
<p className="text-muted-foreground text-xs">
{t("cycle.settings.purgeDescription")}
</p>
{confirmingPurge ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium" role="alert">
{t("cycle.settings.purgeConfirm")}
</span>
<Button
variant="destructive"
size="sm"
disabled={purge.isPending}
onClick={async () => {
try {
await purge.mutateAsync();
} finally {
setConfirmingPurge(false);
}
}}
>
{purge.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.settings.purgeConfirmAction")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingPurge(false)}
>
{t("cycle.settings.purgeCancel")}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/40 hover:bg-destructive/10"
onClick={() => setConfirmingPurge(true)}
>
{t("cycle.settings.purgeData")}
</Button>
)}
{purge.isSuccess ? (
<span className="text-muted-foreground text-sm" role="status">
{t("cycle.settings.purgeDone")}
</span>
) : null}
{purge.isError ? (
<span className="text-destructive text-sm" role="alert">
{t("cycle.settings.purgeError")}
</span>
) : null}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
@@ -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"
/>
</div>
);
+4
View File
@@ -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"}
/>
</div>
);
+156 -70
View File
@@ -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<string, number | null>;
}
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<string, number | null>,
): 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<string | null>(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 (
<ResponsiveSheet
@@ -273,31 +364,33 @@ export function LogDaySheet({
{t("cycle.sheet.cancel")}
</Button>
<Button onClick={handleSave} disabled={busy}>
{logDay.isPending ? (
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{logDay.isPending ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
{saving ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
</Button>
</>
}
>
{/* One-tap period boundaries. */}
{date === today ? (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="flex-1 justify-start gap-2"
onClick={handleStartPeriod}
disabled={busy}
style={{ borderColor: FLOW_HUE }}
>
{startPeriod.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
)}
{t("cycle.startedPeriod")}
</Button>
{/* 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). */}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="flex-1 justify-start gap-2"
onClick={handleStartPeriod}
disabled={busy}
style={{ borderColor: FLOW_HUE }}
>
{startPeriod.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
)}
{t("cycle.startedPeriod")}
</Button>
{activePeriod ? (
<Button
variant="outline"
className="flex-1 justify-start gap-2"
@@ -309,8 +402,8 @@ export function LogDaySheet({
) : null}
{t("cycle.endedPeriod")}
</Button>
</div>
) : null}
) : null}
</div>
{/* Flow */}
<Field label={t("cycle.sheet.flow")}>
@@ -326,10 +419,7 @@ export function LogDaySheet({
))}
</div>
<div className="mt-2 flex items-center justify-between">
<Label
htmlFor="cycle-intermenstrual"
className="text-sm font-normal"
>
<Label htmlFor="cycle-intermenstrual" className="text-sm font-normal">
{t("cycle.sheet.intermenstrualBleeding")}
</Label>
<Switch
@@ -355,12 +445,12 @@ export function LogDaySheet({
const sev = symptoms.get(s.key) ?? null;
return (
<div key={s.key} className="flex items-center gap-1">
<Chip active={active} onClick={() => toggleSymptom(s.key)}>
<Chip
active={active}
onClick={() => toggleSymptom(s.key)}
>
<span className="flex items-center gap-1.5">
<s.icon
className="h-3.5 w-3.5"
aria-hidden="true"
/>
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(s.labelKey)}
</span>
</Chip>
@@ -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")}
/>
</Field>
@@ -484,9 +574,7 @@ export function LogDaySheet({
<Chip
key={v}
active={pregnancyTest === v}
onClick={() =>
setPregnancyTest((cur) => (cur === v ? null : v))
}
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
>
{t(`cycle.testResult.${v}`)}
</Chip>
@@ -518,9 +606,7 @@ export function LogDaySheet({
<Chip
key={v}
active={contraceptive === v}
onClick={() =>
setContraceptive((cur) => (cur === v ? null : v))
}
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
@@ -540,7 +626,7 @@ export function LogDaySheet({
/>
</Field>
{logDay.isError ? (
{logDay.isError || patchDay.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("cycle.sheet.saveError")}
</p>
+68 -3
View File
@@ -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 ? (
<Row
hue={OVULATION_HUE}
label={t("cycle.predictions.ovulation")}
label={
prediction.ovulationConfirmed
? t("cycle.predictions.ovulationConfirmed")
: t("cycle.predictions.ovulation")
}
value={formatDate(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 (
<Card>
@@ -196,7 +212,7 @@ function HistoryCard({
</CardDescription>
) : null}
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{!stats || stats.avgLengthDays == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
@@ -225,6 +241,55 @@ function HistoryCard({
) : null}
</dl>
)}
{observed.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.history.recentCycles")}
</p>
<ul
className="divide-border divide-y"
data-slot="cycle-history-list"
>
{observed.map((c) => {
const periodLen =
c.periodEndDate != null
? daySpan(c.startDate, c.periodEndDate)
: null;
return (
<li
key={c.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate)}
{" – "}
{c.endDate
? formatDate(c.endDate)
: t("cycle.history.ongoing")}
</span>
<span className="text-muted-foreground flex items-center gap-2 tabular-nums">
{c.lengthDays != null
? t("cycle.history.days", { count: c.lengthDays })
: "—"}
{periodLen != null ? (
<span className="text-xs">
{t("cycle.history.periodDays", { count: periodLen })}
</span>
) : null}
{c.ovulationConfirmed ? (
<Badge variant="secondary" className="gap-1 text-xs">
<Check className="h-3 w-3" aria-hidden="true" />
{t("cycle.history.ovulationChip")}
</Badge>
) : null}
</span>
</li>
);
})}
</ul>
</div>
) : null}
</CardContent>
</Card>
);
+2 -5
View File
@@ -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;
+70
View File
@@ -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<CycleDayLogDTO>(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"];
+24 -29
View File
@@ -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;
+17 -14
View File
@@ -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")}
</p>
<div className="space-y-2">
<Label htmlFor="full-name">{t("settings.identity.fullName")}</Label>
<Label htmlFor="full-name">
{t("settings.identity.fullName")}
</Label>
<Input
id="full-name"
value={fullName}
@@ -538,7 +537,9 @@ export function AccountSection() {
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="insurer">{t("settings.identity.insurer")}</Label>
<Label htmlFor="insurer">
{t("settings.identity.insurer")}
</Label>
<Input
id="insurer"
value={insurerName}
@@ -573,9 +574,7 @@ export function AccountSection() {
<p
role="alert"
className={`text-sm ${
saveMsgType === "success"
? "text-success"
: "text-destructive"
saveMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{saveMsg}
@@ -601,6 +600,11 @@ export function AccountSection() {
timezone dropdown. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
{/* 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. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
@@ -627,9 +631,7 @@ export function AccountSection() {
<p
role="alert"
className={`mt-2 text-right text-sm ${
passkeyMsgType === "success"
? "text-success"
: "text-destructive"
passkeyMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{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"));
}
@@ -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 (
<div className="bg-card rounded-lg border p-4 sm:p-6">
<SettingsCardHeader
icon={CalendarHeart}
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<div className="mt-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
</Label>
<Switch
id="cycle-tracking-enable"
checked={enabled}
disabled={toggle.isPending || data == null}
onCheckedChange={(v) => toggle.mutate(v)}
className="mt-0.5 shrink-0"
/>
</div>
{enabled ? (
<Button variant="outline" size="sm" asChild>
<Link href="/cycle">{t("settings.cycleTracking.manage")}</Link>
</Button>
) : null}
{toggle.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.cycleTracking.error")}
</p>
) : null}
</div>
</div>
);
}
+17 -9
View File
@@ -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<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
userId: string,
): Promise<CycleBackupSection> {
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<CycleRestoreCleared> {
// 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<string, string>();
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);
+15 -4
View File
@@ -238,8 +238,16 @@ interface WriteArgs {
}
async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
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<DayLogWriteResult> {
// 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
+6 -2
View File
@@ -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). */
+12 -3
View File
@@ -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,
+21 -6
View File
@@ -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<string, CyclePhase> {
const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength);
const phaseCycles = buildPhaseCycles(
cycles,
predictedNextStart,
lutealLength,
);
const out = new Map<string, CyclePhase>();
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,
+2 -5
View File
@@ -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,
+7 -2
View File
@@ -91,7 +91,8 @@ export const HK_OVULATION_TEST_VALUES: Record<string, OvulationTest> = {
"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" };
+5 -1
View File
@@ -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",
},
});
}
}
+11 -3
View File
@@ -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;
+10 -2
View File
@@ -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) });
+24 -10
View File
@@ -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<CycleProfileInput, "lutealPhaseLength">): number {
export function resolveLuteal(
profile: Pick<CycleProfileInput, "lutealPhaseLength">,
): 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,
+6 -1
View File
@@ -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";
+34 -34
View File
@@ -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) {
+175 -67
View File
@@ -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<ZodOpenApiObject["paths"]> = {
description: "New day-log created.",
content: {
"application/json": {
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogCreatedEnvelope"),
schema: dataEnvelope(
cycleDayLogDto,
"CycleDayLogCreatedEnvelope",
),
},
},
},
@@ -2611,7 +2673,10 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Calendar grid + forecast.",
content: {
"application/json": {
schema: dataEnvelope(cycleCalendarResponse, "CycleCalendarEnvelope"),
schema: dataEnvelope(
cycleCalendarResponse,
"CycleCalendarEnvelope",
),
},
},
},
@@ -2636,7 +2701,10 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Cycle history.",
content: {
"application/json": {
schema: dataEnvelope(cycleHistoryResponse, "CycleHistoryEnvelope"),
schema: dataEnvelope(
cycleHistoryResponse,
"CycleHistoryEnvelope",
),
},
},
},
@@ -2656,7 +2724,10 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Phase-correlation rows + headline.",
content: {
"application/json": {
schema: dataEnvelope(cycleInsightsResponse, "CycleInsightsEnvelope"),
schema: dataEnvelope(
cycleInsightsResponse,
"CycleInsightsEnvelope",
),
},
},
},
@@ -2683,6 +2754,36 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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<ZodOpenApiObject["paths"]> = {
"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<ZodOpenApiObject["paths"]> = {
description: "Link revoked.",
content: {
"application/json": {
schema: dataEnvelope(shareLinkRevokedResponse, "ShareLinkRevoked"),
schema: dataEnvelope(
shareLinkRevokedResponse,
"ShareLinkRevoked",
),
},
},
},
@@ -3553,7 +3660,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
description: "Layout reset; the default layout is returned.",
content: {
"application/json": {
schema: dataEnvelope(
insightsLayoutSchema,
"InsightsLayoutReset",
),
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutReset"),
},
},
},
+8 -9
View File
@@ -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