mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
3375bbabac
commit
c21131e26e
40 files changed
+1399
-446
No files matched your search
+72
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user