Merge pull request #322 from MBombeck/release/v1.16.15

release/v1.16.15 — an honest calendar that learns before it predicts
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-14 16:01:05 +02:00
commit 3a1da2fb79
39 files changed
+1947 -90

No files matched your search

+23
View File
@@ -2,6 +2,29 @@
## [Unreleased]
## [1.16.15] — 2026-06-14 — an honest calendar that learns before it predicts
A focused release on the cycle calendar: it leans on observed data, names its
uncertainty plainly, and reads your charts the way the Sensiplan method does.
No breaking changes.
### Added
- **Mark a reading as disturbed.** A temperature taken after a fever, a late night, or any off day can be flagged "disturbed" and is dropped from the evaluation, so a single skewed point no longer drags the curve.
- **A positive ovulation test anchors the prediction.** When you log a positive test, it refines the predicted ovulation day — it sharpens the estimate but never overrides a temperature shift you have already confirmed.
- **Choose your secondary fertility sign.** Advanced cycle settings now let you track either cervical mucus (the default) or cervix observation alongside temperature, so the symptothermal evaluation follows the sign you actually record.
- **Warmer phase descriptions.** Each cycle phase gained a second paragraph of plain-language context, so the calendar explains what is happening, not just which phase you are in.
### Changed
- **The calendar learns before it predicts.** Below three logged cycles it shows a calm "still learning your cycle" state instead of a confident fertile-window or ovulation guess — and the same gate now applies on the API, so every client reads the same honest signal rather than painting a window the rest of the app holds back.
- **Temperature evaluation no longer under-detects ovulation.** The symptothermal reading gained the two Sensiplan exception rules — the slow rise and the single fall-back day, each confirmed on a fourth reading — so a real shift is recognised in the patterns that the simple rule used to miss.
- **The mucus peak settles before it is trusted.** A mucus peak is now confirmed only after three drier days follow it, so a stray late entry no longer shifts the peak backward.
### Fixed
- **Longer cycles are no longer clipped.** Cycles up to roughly sixty days are evaluated in full instead of being cut short, so a naturally long cycle reads correctly.
## [1.16.14] — 2026-06-14 — one number everywhere: the score, the coach, and the doctor-report agree
### Changed
+126 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.14
version: 1.16.15
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -117,6 +117,8 @@ paths:
type: number
minimum: 30
maximum: 45
temperatureExcluded:
type: boolean
ovulationTest:
type: string
enum:
@@ -132,6 +134,21 @@ paths:
- CREAMY
- WATERY
- EGG_WHITE
cervixPosition:
type: string
enum:
- LOW
- HIGH
cervixFirmness:
type: string
enum:
- FIRM
- SOFT
cervixOpening:
type: string
enum:
- CLOSED
- OPEN
sexualActivity:
type: boolean
protectedSex:
@@ -252,6 +269,8 @@ paths:
minimum: 30
maximum: 45
- type: "null"
temperatureExcluded:
type: boolean
ovulationTest:
anyOf:
- type: string
@@ -271,6 +290,27 @@ paths:
- WATERY
- EGG_WHITE
- type: "null"
cervixPosition:
anyOf:
- type: string
enum:
- LOW
- HIGH
- type: "null"
cervixFirmness:
anyOf:
- type: string
enum:
- FIRM
- SOFT
- type: "null"
cervixOpening:
anyOf:
- type: string
enum:
- CLOSED
- OPEN
- type: "null"
sexualActivity:
type: boolean
protectedSex:
@@ -412,6 +452,8 @@ paths:
type: number
minimum: 30
maximum: 45
temperatureExcluded:
type: boolean
ovulationTest:
type: string
enum:
@@ -427,6 +469,21 @@ paths:
- CREAMY
- WATERY
- EGG_WHITE
cervixPosition:
type: string
enum:
- LOW
- HIGH
cervixFirmness:
type: string
enum:
- FIRM
- SOFT
cervixOpening:
type: string
enum:
- CLOSED
- OPEN
sexualActivity:
type: boolean
protectedSex:
@@ -724,6 +781,11 @@ paths:
- TRYING_TO_CONCEIVE
- PERIMENOPAUSE
- OFF
secondarySymptom:
type: string
enum:
- MUCUS
- CERVIX
rawChartMode:
type: boolean
predictionEnabled:
@@ -6240,6 +6302,8 @@ components:
anyOf:
- type: number
- type: "null"
temperatureExcluded:
type: boolean
ovulationTest:
anyOf:
- type: string
@@ -6259,6 +6323,27 @@ components:
- WATERY
- EGG_WHITE
- type: "null"
cervixPosition:
anyOf:
- type: string
enum:
- LOW
- HIGH
- type: "null"
cervixFirmness:
anyOf:
- type: string
enum:
- FIRM
- SOFT
- type: "null"
cervixOpening:
anyOf:
- type: string
enum:
- CLOSED
- OPEN
- type: "null"
sexualActivity:
type: boolean
protectedSex:
@@ -6333,8 +6418,12 @@ components:
- flow
- intermenstrualBleeding
- basalBodyTempC
- temperatureExcluded
- ovulationTest
- cervicalMucus
- cervixPosition
- cervixFirmness
- cervixOpening
- sexualActivity
- protectedSex
- pregnancyTest
@@ -6608,6 +6697,8 @@ components:
anyOf:
- $ref: "#/components/schemas/CyclePredictionDTO"
- type: "null"
stillLearning:
type: boolean
days:
type: array
items:
@@ -6645,6 +6736,8 @@ components:
anyOf:
- type: number
- type: "null"
temperatureExcluded:
type: boolean
ovulationTest:
anyOf:
- type: string
@@ -6664,6 +6757,27 @@ components:
- WATERY
- EGG_WHITE
- type: "null"
cervixPosition:
anyOf:
- type: string
enum:
- LOW
- HIGH
- type: "null"
cervixFirmness:
anyOf:
- type: string
enum:
- FIRM
- SOFT
- type: "null"
cervixOpening:
anyOf:
- type: string
enum:
- CLOSED
- OPEN
- type: "null"
required:
- date
- phase
@@ -6675,8 +6789,12 @@ components:
- hasSymptoms
- confidence
- basalBodyTempC
- temperatureExcluded
- ovulationTest
- cervicalMucus
- cervixPosition
- cervixFirmness
- cervixOpening
additionalProperties: false
meta:
type: object
@@ -6691,6 +6809,7 @@ components:
required:
- profile
- prediction
- stillLearning
- days
- meta
additionalProperties: false
@@ -7141,6 +7260,11 @@ components:
- OFF
cycleTrackingEnabled:
type: boolean
secondarySymptom:
type: string
enum:
- MUCUS
- CERVIX
rawChartMode:
type: boolean
predictionEnabled:
@@ -7174,6 +7298,7 @@ components:
required:
- goal
- cycleTrackingEnabled
- secondarySymptom
- rawChartMode
- predictionEnabled
- discreetNotifications
+31
View File
@@ -5741,6 +5741,12 @@
"OVULATORY": "Etwa zur Zyklusmitte wird eine Eizelle freigesetzt und die Fruchtbarkeit erreicht für ein kurzes Fenster ihren Höhepunkt. Diese Phase ist die kürzeste der vier.",
"LUTEAL": "Nach dem Eisprung steigt das Progesteron und der Körper kommt zur Ruhe. In den Tagen vor der nächsten Periode bemerken manche prämenstruelle Veränderungen."
},
"context": {
"MENSTRUAL": "Die Hormonspiegel sind jetzt am niedrigsten, weshalb sich diese Tage wie ein ruhiger Neustart anfühlen können. Manche bemerken Krämpfe, leichteren Schlaf oder den Wunsch, einen Gang zurückzuschalten; andere kommen fast unverändert durch. All das liegt im normalen Rahmen, und wie es sich anfühlt, kann von Zyklus zu Zyklus wechseln.",
"FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen – der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten – von Person zu Person und von Zyklus zu Zyklus.",
"OVULATORY": "Das Östrogen erreicht kurz vor dem Eisprung seinen Höhepunkt, oft begleitet von einem kurzen Stimmungshoch und einem leichten Anstieg der Basaltemperatur in den ein, zwei Tagen danach. Der Zervixschleim wird in diesem Fenster meist klarer und dehnbarer. Ein kurzes Ziehen auf einer Seite bemerken manche; viele spüren gar nichts, und beides ist völlig normal.",
"LUTEAL": "Das Progesteron führt durch die zweite Zyklushälfte, und die Basaltemperatur liegt meist etwas höher als zuvor im Zyklus. Manche bemerken im Lauf der Tage Veränderungen bei Schlaf, Appetit, Stimmung oder Energie, während andere durchgehend ausgeglichen bleiben. Das Muster ist persönlich und sieht von Monat zu Monat oft anders aus."
},
"commonHere": "Das bemerkst du hier oft",
"trackNudge": "Heute eintragen",
"stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen."
@@ -5797,6 +5803,18 @@
"EGG_WHITE": "Eiweißartig",
"label": "Zervixschleim"
},
"cervixPosition": {
"LOW": "Tief",
"HIGH": "Hoch"
},
"cervixFirmness": {
"FIRM": "Fest",
"SOFT": "Weich"
},
"cervixOpening": {
"CLOSED": "Geschlossen",
"OPEN": "Geöffnet"
},
"goal": {
"GENERAL_HEALTH": "Allgemeine Gesundheit",
"AVOID_PREGNANCY": "Verhütung",
@@ -5824,6 +5842,7 @@
"nextMonth": "Nächster Monat",
"loading": "Kalender wird geladen…",
"empty": "Erfasse Daten, damit sich dein Kalender füllt.",
"learning": "Wir lernen deinen Zyklus – erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.",
"today": "Heute",
"dayAria": "{date}",
"legendSymptom": "Symptome",
@@ -5881,8 +5900,14 @@
"symptoms": "Symptome",
"temperature": "Basaltemperatur",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Messwert als gestört markieren",
"temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.",
"ovulationTest": "Ovulationstest",
"mucus": "Zervixschleim",
"cervix": "Gebärmutterhals",
"cervixPosition": "Position",
"cervixFirmness": "Festigkeit",
"cervixOpening": "Öffnung",
"intercourse": "Geschlechtsverkehr",
"protected": "Geschützt",
"note": "Notiz",
@@ -5912,6 +5937,12 @@
"priorsDescription": "Diese Vorgaben helfen deinen Prognosen, bevor du viele Zyklen erfasst hast. Leer lassen, um die Standardwerte zu nutzen.",
"rawChartMode": "Roh-Diagramm-Modus",
"rawChartModeDescription": "Zeige nur deine erfassten Daten, ohne Prognosen oder Deutung.",
"secondarySymptomLabel": "Zweites Fruchtbarkeitszeichen",
"secondarySymptomDescription": "Das zweite Zeichen, das für die symptothermale Doppelkontrolle mit deiner Temperatur kombiniert wird. Die meisten beobachten den Zervixschleim; wechsle zur Gebärmutterhals-Beobachtung, wenn du das bevorzugst.",
"secondarySymptom": {
"MUCUS": "Zervixschleim",
"CERVIX": "Gebärmutterhals"
},
"prediction": "Prognosen",
"predictionDescription": "Prognostiziere deine nächste Periode und, wenn relevant, dein fruchtbares Fenster.",
"encryption": "Sensible Einträge verschlüsseln",
+31
View File
@@ -5741,6 +5741,12 @@
"OVULATORY": "Around mid-cycle an egg is released and fertility peaks for a short window. This phase is the briefest of the four.",
"LUTEAL": "After ovulation, progesterone rises and the body settles in. In the days before your next period some people notice premenstrual changes."
},
"context": {
"MENSTRUAL": "Hormone levels are at their lowest, which is why these days can feel like a quiet reset. Some people notice cramps, lighter sleep or a wish to slow down; others sail through with little change. All of that sits within the normal range, and how it feels can shift from one cycle to the next.",
"FOLLICULAR": "With oestrogen on the rise, many people describe these days as bright and open — sleep often comes more easily and the mind feels clearer. Basal body temperature usually stays in its lower band until just before ovulation. The length of this phase varies most from person to person, and from cycle to cycle.",
"OVULATORY": "Oestrogen peaks just before the egg is released, often alongside a brief lift in mood and a small rise in basal body temperature in the day or two that follow. Cervical fluid tends to become clearer and more stretchy around this window. A short twinge on one side is something some people notice; many feel nothing at all, and both are perfectly normal.",
"LUTEAL": "Progesterone leads the second half and basal body temperature usually settles a little higher than earlier in the cycle. Some people notice shifts in sleep, appetite, mood or energy as the days go on, while others feel steady throughout. The pattern is personal, and it often looks different from one month to the next."
},
"commonHere": "You often notice this here",
"trackNudge": "Log today",
"stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days."
@@ -5797,6 +5803,18 @@
"EGG_WHITE": "Egg white",
"label": "Cervical mucus"
},
"cervixPosition": {
"LOW": "Low",
"HIGH": "High"
},
"cervixFirmness": {
"FIRM": "Firm",
"SOFT": "Soft"
},
"cervixOpening": {
"CLOSED": "Closed",
"OPEN": "Open"
},
"goal": {
"GENERAL_HEALTH": "General health",
"AVOID_PREGNANCY": "Avoiding pregnancy",
@@ -5824,6 +5842,7 @@
"nextMonth": "Next month",
"loading": "Loading your calendar…",
"empty": "Start logging to see your calendar fill in.",
"learning": "Learning your cycle — log at least three full cycles and the fertile window, ovulation and phases fill in. Until then the calendar shows only what you've logged.",
"today": "Today",
"dayAria": "{date}",
"legendSymptom": "Symptoms",
@@ -5881,8 +5900,14 @@
"symptoms": "Symptoms",
"temperature": "Basal body temperature",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Mark reading as disturbed",
"temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.",
"ovulationTest": "Ovulation test",
"mucus": "Cervical mucus",
"cervix": "Cervix",
"cervixPosition": "Position",
"cervixFirmness": "Firmness",
"cervixOpening": "Opening",
"intercourse": "Intercourse",
"protected": "Protected",
"intermenstrualBleeding": "Spotting between periods",
@@ -5917,6 +5942,12 @@
"priorsDescription": "These priors help your predictions before you have logged many cycles. Leave blank to use the defaults.",
"rawChartMode": "Raw chart mode",
"rawChartModeDescription": "Show only your logged data with no predictions or interpretation.",
"secondarySymptomLabel": "Secondary fertility sign",
"secondarySymptomDescription": "The second sign paired with your temperature for the symptothermal double-check. Most people track cervical mucus; switch to cervix observation if you prefer.",
"secondarySymptom": {
"MUCUS": "Cervical mucus",
"CERVIX": "Cervix observation"
},
"prediction": "Predictions",
"predictionDescription": "Forecast your next period and, when relevant, your fertile window.",
"encryption": "Encrypt sensitive entries",
+31
View File
@@ -5741,6 +5741,12 @@
"OVULATORY": "Hacia la mitad del ciclo se libera un óvulo y la fertilidad alcanza su punto máximo durante una ventana corta. Esta fase es la más breve de las cuatro.",
"LUTEAL": "Después de la ovulación, la progesterona sube y el cuerpo se asienta. En los días previos a la siguiente menstruación, algunas personas notan cambios premenstruales."
},
"context": {
"MENSTRUAL": "Los niveles hormonales están en su punto más bajo, por eso estos días pueden sentirse como un reinicio tranquilo. Algunas personas notan cólicos, un sueño más ligero o ganas de bajar el ritmo; otras los atraviesan casi sin cambios. Todo ello entra dentro de lo normal, y cómo se siente puede variar de un ciclo a otro.",
"FOLLICULAR": "Con el estrógeno en aumento, muchas personas describen estos días como luminosos y abiertos: el sueño suele llegar con más facilidad y la mente se siente más despejada. La temperatura basal suele mantenerse en su franja más baja hasta justo antes de la ovulación. La duración de esta fase es la que más varía de una persona a otra y de un ciclo a otro.",
"OVULATORY": "El estrógeno alcanza su máximo justo antes de liberarse el óvulo, a menudo junto con un breve ánimo más alto y una ligera subida de la temperatura basal en el día o los dos días siguientes. El flujo cervical tiende a volverse más transparente y elástico en torno a esta ventana. Una breve punzada en un lado es algo que algunas personas notan; muchas no sienten nada, y ambas cosas son del todo normales.",
"LUTEAL": "La progesterona guía la segunda mitad y la temperatura basal suele asentarse algo más alta que al inicio del ciclo. Algunas personas notan cambios en el sueño, el apetito, el ánimo o la energía a medida que pasan los días, mientras que otras se sienten estables todo el tiempo. El patrón es personal y a menudo se ve distinto de un mes a otro."
},
"commonHere": "Esto lo notas a menudo aquí",
"trackNudge": "Registrar hoy",
"stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días."
@@ -5797,6 +5803,18 @@
"EGG_WHITE": "Clara de huevo",
"label": "Moco cervical"
},
"cervixPosition": {
"LOW": "Bajo",
"HIGH": "Alto"
},
"cervixFirmness": {
"FIRM": "Firme",
"SOFT": "Blando"
},
"cervixOpening": {
"CLOSED": "Cerrado",
"OPEN": "Abierto"
},
"goal": {
"GENERAL_HEALTH": "Salud general",
"AVOID_PREGNANCY": "Evitar el embarazo",
@@ -5824,6 +5842,7 @@
"nextMonth": "Mes siguiente",
"loading": "Cargando tu calendario…",
"empty": "Empieza a registrar para ver tu calendario.",
"learning": "Estamos aprendiendo tu ciclo: registra al menos tres ciclos completos y se completarán la ventana fértil, la ovulación y las fases. Hasta entonces, el calendario solo muestra lo que has registrado.",
"today": "Hoy",
"dayAria": "{date}",
"legendSymptom": "Síntomas",
@@ -5881,8 +5900,14 @@
"symptoms": "Síntomas",
"temperature": "Temperatura basal",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Marcar la medición como alterada",
"temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.",
"ovulationTest": "Test de ovulación",
"mucus": "Moco cervical",
"cervix": "Cuello uterino",
"cervixPosition": "Posición",
"cervixFirmness": "Firmeza",
"cervixOpening": "Apertura",
"intercourse": "Relaciones",
"protected": "Protegidas",
"note": "Nota",
@@ -5912,6 +5937,12 @@
"priorsDescription": "Estos valores ayudan a tus predicciones antes de registrar muchos ciclos. Déjalos en blanco para usar los predeterminados.",
"rawChartMode": "Modo de gráfico en bruto",
"rawChartModeDescription": "Muestra solo tus datos registrados, sin predicciones ni interpretación.",
"secondarySymptomLabel": "Segundo signo de fertilidad",
"secondarySymptomDescription": "El segundo signo que se combina con tu temperatura para la doble comprobación sintotérmica. La mayoría observa el moco cervical; cambia a la observación del cuello uterino si lo prefieres.",
"secondarySymptom": {
"MUCUS": "Moco cervical",
"CERVIX": "Cuello uterino"
},
"prediction": "Predicciones",
"predictionDescription": "Pronostica tu próximo periodo y, cuando corresponda, tu ventana fértil.",
"encryption": "Cifrar entradas sensibles",
+31
View File
@@ -5741,6 +5741,12 @@
"OVULATORY": "Vers le milieu du cycle, un ovule est libéré et la fertilité culmine sur une courte fenêtre. Cette phase est la plus brève des quatre.",
"LUTEAL": "Après l'ovulation, la progestérone augmente et le corps se pose. Dans les jours précédant les prochaines règles, certaines personnes remarquent des changements prémenstruels."
},
"context": {
"MENSTRUAL": "Les taux hormonaux sont au plus bas, ce qui peut donner à ces jours des airs de remise à zéro tranquille. Certaines personnes remarquent des crampes, un sommeil plus léger ou l'envie de lever le pied ; d'autres les traversent presque sans changement. Tout cela reste dans la normale, et le ressenti peut varier d'un cycle à l'autre.",
"FOLLICULAR": "Avec l'œstrogène qui monte, beaucoup décrivent ces jours comme lumineux et ouverts : le sommeil vient souvent plus facilement et l'esprit semble plus clair. La température basale reste en général dans sa plage basse jusqu'à juste avant l'ovulation. La durée de cette phase est ce qui varie le plus d'une personne à l'autre, et d'un cycle à l'autre.",
"OVULATORY": "L'œstrogène culmine juste avant la libération de l'ovule, souvent accompagné d'un bref regain d'humeur et d'une légère hausse de la température basale dans le jour ou les deux qui suivent. La glaire cervicale tend à devenir plus claire et plus filante autour de cette fenêtre. Un bref tiraillement d'un côté, certaines personnes le remarquent ; beaucoup ne ressentent rien, et les deux sont tout à fait normaux.",
"LUTEAL": "La progestérone mène la seconde moitié et la température basale se stabilise en général un peu plus haut qu'au début du cycle. Certaines personnes remarquent des variations du sommeil, de l'appétit, de l'humeur ou de l'énergie au fil des jours, tandis que d'autres se sentent stables tout du long. Le schéma est personnel, et il se présente souvent différemment d'un mois à l'autre."
},
"commonHere": "Tu le remarques souvent ici",
"trackNudge": "Noter aujourd'hui",
"stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées."
@@ -5797,6 +5803,18 @@
"EGG_WHITE": "Blanc d'œuf",
"label": "Glaire cervicale"
},
"cervixPosition": {
"LOW": "Bas",
"HIGH": "Haut"
},
"cervixFirmness": {
"FIRM": "Ferme",
"SOFT": "Souple"
},
"cervixOpening": {
"CLOSED": "Fermé",
"OPEN": "Ouvert"
},
"goal": {
"GENERAL_HEALTH": "Santé générale",
"AVOID_PREGNANCY": "Éviter une grossesse",
@@ -5824,6 +5842,7 @@
"nextMonth": "Mois suivant",
"loading": "Chargement de ton calendrier…",
"empty": "Commence à enregistrer pour remplir ton calendrier.",
"learning": "Nous apprenons ton cycle — enregistre au moins trois cycles complets et la fenêtre fertile, l'ovulation et les phases se compléteront. D'ici là, le calendrier n'affiche que ce que tu as enregistré.",
"today": "Aujourd'hui",
"dayAria": "{date}",
"legendSymptom": "Symptômes",
@@ -5881,8 +5900,14 @@
"symptoms": "Symptômes",
"temperature": "Température basale",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Marquer la mesure comme perturbée",
"temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.",
"ovulationTest": "Test d'ovulation",
"mucus": "Glaire cervicale",
"cervix": "Col de l'utérus",
"cervixPosition": "Position",
"cervixFirmness": "Fermeté",
"cervixOpening": "Ouverture",
"intercourse": "Rapports",
"protected": "Protégés",
"note": "Note",
@@ -5912,6 +5937,12 @@
"priorsDescription": "Ces valeurs aident tes prévisions avant d'avoir enregistré beaucoup de cycles. Laisse vide pour utiliser les valeurs par défaut.",
"rawChartMode": "Mode graphique brut",
"rawChartModeDescription": "Affiche uniquement tes données enregistrées, sans prévision ni interprétation.",
"secondarySymptomLabel": "Deuxième signe de fertilité",
"secondarySymptomDescription": "Le deuxième signe associé à ta température pour la double vérification sympto-thermique. La plupart des personnes observent la glaire cervicale ; passe à l'observation du col de l'utérus si tu préfères.",
"secondarySymptom": {
"MUCUS": "Glaire cervicale",
"CERVIX": "Col de l'utérus"
},
"prediction": "Prévisions",
"predictionDescription": "Prévois tes prochaines règles et, le cas échéant, ta fenêtre fertile.",
"encryption": "Chiffrer les entrées sensibles",
+31
View File
@@ -5741,6 +5741,12 @@
"OVULATORY": "Verso metà ciclo viene rilasciato un ovulo e la fertilità raggiunge il picco per una breve finestra. Questa fase è la più breve delle quattro.",
"LUTEAL": "Dopo l'ovulazione il progesterone sale e il corpo si assesta. Nei giorni prima del ciclo successivo alcune persone notano cambiamenti premestruali."
},
"context": {
"MENSTRUAL": "I livelli ormonali sono al minimo, ed è per questo che questi giorni possono sembrare un tranquillo ripartire da capo. Alcune persone notano crampi, un sonno più leggero o il desiderio di rallentare; altre li attraversano quasi senza cambiamenti. Tutto questo rientra nella norma, e come ci si sente può cambiare da un ciclo all'altro.",
"FOLLICULAR": "Con gli estrogeni in salita, molte persone descrivono questi giorni come luminosi e aperti: il sonno spesso arriva più facilmente e la mente sembra più limpida. La temperatura basale di solito resta nella sua fascia più bassa fino a poco prima dell'ovulazione. La durata di questa fase è ciò che varia di più da persona a persona e da ciclo a ciclo.",
"OVULATORY": "Gli estrogeni raggiungono il picco poco prima del rilascio dell'ovulo, spesso insieme a un breve rialzo dell'umore e a un leggero aumento della temperatura basale nel giorno o nei due successivi. Il muco cervicale tende a diventare più trasparente ed elastico intorno a questa finestra. Una breve fitta su un lato è qualcosa che alcune persone notano; molte non sentono nulla, ed entrambe le cose sono del tutto normali.",
"LUTEAL": "Il progesterone guida la seconda metà e la temperatura basale di solito si assesta un po' più in alto rispetto all'inizio del ciclo. Alcune persone notano cambiamenti nel sonno, nell'appetito, nell'umore o nell'energia con il passare dei giorni, mentre altre si sentono stabili per tutto il tempo. Lo schema è personale e spesso appare diverso da un mese all'altro."
},
"commonHere": "Questo lo noti spesso qui",
"trackNudge": "Registra oggi",
"stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate."
@@ -5797,6 +5803,18 @@
"EGG_WHITE": "Albume",
"label": "Muco cervicale"
},
"cervixPosition": {
"LOW": "Basso",
"HIGH": "Alto"
},
"cervixFirmness": {
"FIRM": "Duro",
"SOFT": "Morbido"
},
"cervixOpening": {
"CLOSED": "Chiuso",
"OPEN": "Aperto"
},
"goal": {
"GENERAL_HEALTH": "Salute generale",
"AVOID_PREGNANCY": "Evitare la gravidanza",
@@ -5824,6 +5842,7 @@
"nextMonth": "Mese successivo",
"loading": "Caricamento del calendario…",
"empty": "Inizia a registrare per riempire il calendario.",
"learning": "Stiamo imparando il tuo ciclo: registra almeno tre cicli completi e si completeranno la finestra fertile, l'ovulazione e le fasi. Fino ad allora il calendario mostra solo ciò che hai registrato.",
"today": "Oggi",
"dayAria": "{date}",
"legendSymptom": "Sintomi",
@@ -5881,8 +5900,14 @@
"symptoms": "Sintomi",
"temperature": "Temperatura basale",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Segna la misurazione come disturbata",
"temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.",
"ovulationTest": "Test di ovulazione",
"mucus": "Muco cervicale",
"cervix": "Cervice",
"cervixPosition": "Posizione",
"cervixFirmness": "Consistenza",
"cervixOpening": "Apertura",
"intercourse": "Rapporti",
"protected": "Protetti",
"note": "Nota",
@@ -5912,6 +5937,12 @@
"priorsDescription": "Questi valori aiutano le previsioni prima di aver registrato molti cicli. Lascia vuoto per usare i valori predefiniti.",
"rawChartMode": "Modalità grafico grezzo",
"rawChartModeDescription": "Mostra solo i tuoi dati registrati, senza previsioni né interpretazioni.",
"secondarySymptomLabel": "Secondo segno di fertilità",
"secondarySymptomDescription": "Il secondo segno abbinato alla tua temperatura per il doppio controllo sintotermico. La maggior parte osserva il muco cervicale; passa all'osservazione della cervice se preferisci.",
"secondarySymptom": {
"MUCUS": "Muco cervicale",
"CERVIX": "Cervice"
},
"prediction": "Previsioni",
"predictionDescription": "Prevedi il prossimo ciclo e, quando rilevante, la tua finestra fertile.",
"encryption": "Cifra le voci sensibili",
+31
View File
@@ -5741,6 +5741,12 @@
"OVULATORY": "W połowie cyklu uwalniana jest komórka jajowa, a płodność osiąga szczyt przez krótkie okno czasu. Ta faza jest najkrótsza z czterech.",
"LUTEAL": "Po owulacji rośnie poziom progesteronu i organizm się wycisza. W dniach przed kolejną miesiączką niektóre osoby zauważają zmiany przedmiesiączkowe."
},
"context": {
"MENSTRUAL": "Poziom hormonów jest teraz najniższy, dlatego te dni mogą sprawiać wrażenie spokojnego resetu. Niektóre osoby zauważają skurcze, lżejszy sen albo chęć zwolnienia tempa; inne przechodzą przez nie niemal bez zmian. To wszystko mieści się w normie, a odczucia potrafią zmieniać się z cyklu na cykl.",
"FOLLICULAR": "Wraz z rosnącym estrogenem wiele osób opisuje te dni jako jasne i pełne otwartości – sen często przychodzi łatwiej, a umysł wydaje się bardziej klarowny. Podstawowa temperatura ciała zwykle utrzymuje się w niższym zakresie aż do chwili tuż przed owulacją. Długość tej fazy różni się najbardziej – od osoby do osoby i od cyklu do cyklu.",
"OVULATORY": "Estrogen osiąga szczyt tuż przed uwolnieniem komórki jajowej, często z krótkim wzrostem nastroju i niewielkim podniesieniem podstawowej temperatury ciała w ciągu kolejnego dnia lub dwóch. Śluz szyjkowy w tym oknie zwykle staje się bardziej przejrzysty i ciągliwy. Krótkie ukłucie po jednej stronie niektóre osoby zauważają; wiele nie czuje nic, a oba warianty są zupełnie normalne.",
"LUTEAL": "Progesteron prowadzi przez drugą połowę cyklu, a podstawowa temperatura ciała zwykle ustala się nieco wyżej niż wcześniej. Niektóre osoby zauważają z dnia na dzień zmiany w śnie, apetycie, nastroju czy energii, podczas gdy inne czują się stabilnie przez cały czas. Ten wzorzec jest indywidualny i często wygląda inaczej z miesiąca na miesiąc."
},
"commonHere": "To często zauważasz tutaj",
"trackNudge": "Zapisz dziś",
"stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni."
@@ -5797,6 +5803,18 @@
"EGG_WHITE": "Jak białko jaja",
"label": "Śluz szyjkowy"
},
"cervixPosition": {
"LOW": "Nisko",
"HIGH": "Wysoko"
},
"cervixFirmness": {
"FIRM": "Twarda",
"SOFT": "Miękka"
},
"cervixOpening": {
"CLOSED": "Zamknięta",
"OPEN": "Otwarta"
},
"goal": {
"GENERAL_HEALTH": "Ogólne zdrowie",
"AVOID_PREGNANCY": "Unikanie ciąży",
@@ -5824,6 +5842,7 @@
"nextMonth": "Następny miesiąc",
"loading": "Ładowanie kalendarza…",
"empty": "Zacznij zapisywać, aby wypełnić kalendarz.",
"learning": "Uczymy się Twojego cyklu — zapisz co najmniej trzy pełne cykle, a okno płodne, owulacja i fazy się uzupełnią. Do tego czasu kalendarz pokazuje tylko to, co zapisałaś.",
"today": "Dziś",
"dayAria": "{date}",
"legendSymptom": "Objawy",
@@ -5881,8 +5900,14 @@
"symptoms": "Objawy",
"temperature": "Podstawowa temperatura ciała",
"temperaturePlaceholder": "°C",
"temperatureDisturbed": "Oznacz pomiar jako zakłócony",
"temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.",
"ovulationTest": "Test owulacyjny",
"mucus": "Śluz szyjkowy",
"cervix": "Szyjka macicy",
"cervixPosition": "Położenie",
"cervixFirmness": "Twardość",
"cervixOpening": "Rozwarcie",
"intercourse": "Współżycie",
"protected": "Zabezpieczone",
"note": "Notatka",
@@ -5912,6 +5937,12 @@
"priorsDescription": "Te wartości pomagają prognozom, zanim zapiszesz wiele cykli. Zostaw puste, aby użyć wartości domyślnych.",
"rawChartMode": "Tryb surowego wykresu",
"rawChartModeDescription": "Pokazuj tylko zapisane dane, bez prognoz i interpretacji.",
"secondarySymptomLabel": "Drugi objaw płodności",
"secondarySymptomDescription": "Drugi objaw łączony z temperaturą w podwójnej kontroli objawowo-termicznej. Większość obserwuje śluz szyjkowy; przełącz na obserwację szyjki macicy, jeśli wolisz.",
"secondarySymptom": {
"MUCUS": "Śluz szyjkowy",
"CERVIX": "Szyjka macicy"
},
"prediction": "Prognozy",
"predictionDescription": "Prognozuj następną miesiączkę oraz, gdy to istotne, okno płodne.",
"encryption": "Szyfruj wrażliwe wpisy",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.14",
"version": "1.16.15",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,10 @@
-- v1.17.0 — disturbed-temperature exclusion for the symptothermal engine.
--
-- A fever / illness / alcohol / late or short-sleep BBT reading can fake or
-- mask a temperature rise. `temperature_excluded = true` marks a day's basal
-- body temperature as a disturbed value: the Sensiplan cover-line baseline and
-- the shift evaluation both skip it, so a spike neither fabricates nor masks a
-- rise (the cover line is drawn over the last six *unbracketed* values).
-- Additive: every existing row backfills `false`.
ALTER TABLE "cycle_day_logs" ADD COLUMN "temperature_excluded" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,29 @@
-- v1.17.0 — symptothermal SECONDARY-symptom choice: cervical mucus (default)
-- OR cervix observation, mirroring the Sensiplan/NFP double-check where the
-- temperature primary sign pairs with one chosen second indicator.
--
-- `cycle_profiles.secondary_symptom` picks which second sign the engine uses
-- (MUCUS default | CERVIX); the casual user keeps temperature + mucus and never
-- sees the choice (it lives behind the advanced cycle settings).
--
-- `cycle_day_logs` gains the three standard Sensiplan cervix signs — position
-- (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — used as the
-- secondary indicator when secondary_symptom = CERVIX. Fertile = HIGH+SOFT+OPEN;
-- infertile (closure) = LOW+FIRM+CLOSED.
--
-- Additive + back-compatible: every existing profile backfills MUCUS (the prior
-- behaviour) and every existing day-log row keeps NULL cervix signs. iOS
-- inherits the fields without a migration of its own.
CREATE TYPE "secondary_symptom" AS ENUM ('MUCUS', 'CERVIX');
CREATE TYPE "cervix_position" AS ENUM ('LOW', 'HIGH');
CREATE TYPE "cervix_firmness" AS ENUM ('FIRM', 'SOFT');
CREATE TYPE "cervix_opening" AS ENUM ('CLOSED', 'OPEN');
ALTER TABLE "cycle_profiles"
ADD COLUMN "secondary_symptom" "secondary_symptom" NOT NULL DEFAULT 'MUCUS';
ALTER TABLE "cycle_day_logs"
ADD COLUMN "cervix_position" "cervix_position",
ADD COLUMN "cervix_firmness" "cervix_firmness",
ADD COLUMN "cervix_opening" "cervix_opening";
+64
View File
@@ -3615,6 +3615,47 @@ enum CyclePhase {
@@map("cycle_phase")
}
/// Which symptothermal SECONDARY sign the user observes alongside the
/// temperature primary sign for the Sensiplan double-check. `MUCUS`
/// (cervical-mucus quality) is the default; `CERVIX` swaps in the cervix
/// observation (position / firmness / opening). The temperature primary
/// sign is unchanged either way — only the second indicator of the
/// double-check differs. Mirrors the `drip` app's Settings →
/// Customization → Secondary symptom choice.
enum SecondarySymptom {
MUCUS
CERVIX
@@map("secondary_symptom")
}
/// Cervix vertical position, a Sensiplan cervix sign. Fertile = `HIGH`
/// (the cervix rises around ovulation); infertile = `LOW`.
enum CervixPosition {
LOW
HIGH
@@map("cervix_position")
}
/// Cervix firmness, a Sensiplan cervix sign. Fertile = `SOFT` (the cervix
/// softens around ovulation); infertile = `FIRM`.
enum CervixFirmness {
FIRM
SOFT
@@map("cervix_firmness")
}
/// Cervix os / opening, a Sensiplan cervix sign. Fertile = `OPEN`;
/// infertile = `CLOSED`.
enum CervixOpening {
CLOSED
OPEN
@@map("cervix_opening")
}
/// Which method produced a cached CyclePrediction. `CALENDAR` is the
/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the
/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the
@@ -3654,6 +3695,12 @@ model CycleProfile {
typicalPeriodLength Int? @map("typical_period_length")
lutealPhaseLength Int? @map("luteal_phase_length")
/// Which symptothermal SECONDARY sign the user observes for the Sensiplan
/// double-check: `MUCUS` (default) or `CERVIX`. Lives behind the advanced
/// cycle settings — the casual user keeps temperature + mucus and never
/// sees the choice. The temperature primary sign is unchanged either way.
secondarySymptom SecondarySymptom @default(MUCUS) @map("secondary_symptom")
/// When false, the app computes / stores NO predictions — pure logging
/// (the Read-Your-Body posture for users who distrust algorithmic
/// interpretation).
@@ -3749,11 +3796,28 @@ model CycleDayLog {
/// symptothermal confirmation. BBT also lands in Measurement
/// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy.
basalBodyTempC Float? @map("basal_body_temp_c")
/// Whether this day's BBT is a disturbed/unreliable reading (fever,
/// illness, alcohol, a late or short-sleep measurement) the user marked
/// excluded. When true the symptothermal engine drops the reading from the
/// cover-line baseline AND the shift evaluation, so a spike neither
/// fabricates nor masks a temperature rise (Sensiplan: the cover line is
/// drawn over the last six *unbracketed* values).
temperatureExcluded Boolean @default(false) @map("temperature_excluded")
/// Ovulation predictor-kit (OPK) reading.
ovulationTest OvulationTest? @map("ovulation_test")
/// Cervical-mucus quality.
cervicalMucus CervicalMucus? @map("cervical_mucus")
/// Cervix observation — the three standard Sensiplan cervix signs, used
/// as the symptothermal secondary indicator when
/// CycleProfile.secondarySymptom is CERVIX. All NULL = not observed.
/// The fertile combination is HIGH + SOFT + OPEN; the infertile
/// (closure) combination is LOW + FIRM + CLOSED. Queryable-plaintext like
/// the other physiology signs (mucus / BBT / OPK).
cervixPosition CervixPosition? @map("cervix_position")
cervixFirmness CervixFirmness? @map("cervix_firmness")
cervixOpening CervixOpening? @map("cervix_opening")
/// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption
/// is ON these are encrypted at the application layer (the dispatch
/// envelope), default-ON for this category.
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.16.14";
/* @sw-version-fallback */ "v1.16.15";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
+3
View File
@@ -92,6 +92,9 @@ export const PATCH = apiHandler(async (req: Request) => {
data: {
...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }),
...(p.goal !== undefined && { goal: p.goal }),
...(p.secondarySymptom !== undefined && {
secondarySymptom: p.secondarySymptom,
}),
...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }),
...(p.predictionEnabled !== undefined && {
predictionEnabled: p.predictionEnabled,
+19 -1
View File
@@ -99,8 +99,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
_count: { select: { symptomLinks: true } },
},
}),
@@ -125,8 +129,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
temperatureExcluded: l.temperatureExcluded,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
cervixPosition: l.cervixPosition,
cervixFirmness: l.cervixFirmness,
cervixOpening: l.cervixOpening,
hasSymptoms: l._count.symptomLinks > 0,
}));
@@ -137,7 +145,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
const { prediction, days } = buildCalendar(
const { prediction, stillLearning, days } = buildCalendar(
profile,
cycles,
dayLogs,
@@ -179,11 +187,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
goal: profile.goal,
rawChartMode: profile.rawChartMode,
predictionEnabled: profile.predictionEnabled,
// Single source of truth for the observed-cycle count: the engine's
// post-exclusion `cyclesObserved` (the same number the prediction and the
// `stillLearning` gate key off). The raw `cycles.length` fallback only
// applies when no prediction ran (raw-chart mode / prediction disabled),
// so a consumer never sees two divergent "cycles observed" values.
cyclesObserved: prediction?.cyclesObserved ?? cycles.length,
},
prediction: prediction
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
: null,
// Top-level cold-start flag (mirrors `prediction.stillLearning`): the
// calendar grid has suppressed fertile/ovulation/phase output, so the
// client renders a calm "learning your cycle" banner over the grid. Additive
// + back-compatible — older iOS builds ignore the field.
stillLearning,
days,
meta: { generatedAt },
});
+4
View File
@@ -90,8 +90,12 @@ export const GET = apiHandler(async () => {
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
symptomLinks: { select: { symptom: { select: { key: true } } } },
},
}),
@@ -11,8 +11,12 @@ function blank(): DayLogFormState {
flow: null,
intermenstrual: false,
bbt: "",
bbtDisturbed: false,
opk: null,
mucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
intercourse: false,
protectedSex: false,
pregnancyTest: null,
@@ -90,4 +94,45 @@ describe("buildDayLogInput (new row → omit empties)", () => {
expect(input.flow).toBe("LIGHT");
expect(input.basalBodyTempC).toBe(36.5);
});
it("carries the disturbed flag alongside a BBT reading", () => {
const input = buildDayLogInput(
{ ...blank(), bbt: "36.5", bbtDisturbed: true },
"2026-06-06",
);
expect(input.basalBodyTempC).toBe(36.5);
expect(input.temperatureExcluded).toBe(true);
const patch = buildDayLogPatch({ ...blank(), bbt: "36.5", bbtDisturbed: true });
expect(patch.temperatureExcluded).toBe(true);
});
it("carries the cervix signs through both payload builders", () => {
const state = {
...blank(),
cervixPosition: "HIGH" as const,
cervixFirmness: "SOFT" as const,
cervixOpening: "OPEN" as const,
};
const input = buildDayLogInput(state, "2026-06-06");
expect(input.cervixPosition).toBe("HIGH");
expect(input.cervixFirmness).toBe("SOFT");
expect(input.cervixOpening).toBe("OPEN");
// A blank edit nulls every cervix sign (clear semantics).
const patch = buildDayLogPatch(blank());
expect(patch.cervixPosition).toBeNull();
expect(patch.cervixFirmness).toBeNull();
expect(patch.cervixOpening).toBeNull();
});
it("never marks an absent BBT reading disturbed", () => {
// A disturbed flag with no temperature is meaningless — it must reset.
const patch = buildDayLogPatch({ ...blank(), bbt: "", bbtDisturbed: true });
expect(patch.basalBodyTempC).toBeNull();
expect(patch.temperatureExcluded).toBe(false);
const input = buildDayLogInput(
{ ...blank(), bbt: "", bbtDisturbed: true },
"2026-06-06",
);
expect("temperatureExcluded" in input).toBe(false);
});
});
+38 -1
View File
@@ -17,7 +17,11 @@ import { Switch } from "@/components/ui/switch";
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 type {
CycleGoal,
CycleProfileDTO,
SecondarySymptom,
} from "./types";
import { useUpdateCyclePrefs } from "./use-cycle";
/**
@@ -43,6 +47,8 @@ const GOALS: CycleGoal[] = [
"OFF",
];
const SECONDARY_SYMPTOMS: SecondarySymptom[] = ["MUCUS", "CERVIX"];
function numOrEmpty(v: number | null): string {
return v == null ? "" : String(v);
}
@@ -60,6 +66,9 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
profile.sensitiveCategoryEncryption,
);
const [discreet, setDiscreet] = useState(profile.discreetNotifications);
const [secondarySymptom, setSecondarySymptom] = useState<SecondarySymptom>(
profile.secondarySymptom,
);
const [cycleLen, setCycleLen] = useState(
numOrEmpty(profile.typicalCycleLength),
);
@@ -78,6 +87,7 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
async function handleSave() {
await update.mutateAsync({
goal,
secondarySymptom,
rawChartMode,
predictionEnabled,
sensitiveCategoryEncryption: encryption,
@@ -167,6 +177,33 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
<Separator />
{/* Advanced — symptothermal secondary symptom. The default is mucus;
the choice never forces itself on a casual user (progressive
disclosure: it sits here in the advanced cycle settings). */}
<div className="space-y-2">
<Label htmlFor="cycle-secondary-symptom">
{t("cycle.settings.secondarySymptomLabel")}
</Label>
<NativeSelect
id="cycle-secondary-symptom"
value={secondarySymptom}
onChange={(e) =>
setSecondarySymptom(e.target.value as SecondarySymptom)
}
>
{SECONDARY_SYMPTOMS.map((s) => (
<option key={s} value={s}>
{t(`cycle.settings.secondarySymptom.${s}`)}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-sm">
{t("cycle.settings.secondarySymptomDescription")}
</p>
</div>
<Separator />
{/* Privacy toggles */}
<ToggleRow
id="cycle-encryption"
+30 -11
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw } from "lucide-react";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -274,16 +274,35 @@ export function CycleView() {
) : calendarError ? (
<TabError onRetry={() => void calendar.refetch()} />
) : (
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ?? null)
: null
}
onSelectDay={openSheet}
/>
<>
{/* Cold start: the grid carries no fertile/ovulation/phase
overlays yet (the server suppresses them while learning),
so name the calm state rather than letting a bare grid
imply a confident forecast. */}
{calendar.data?.stillLearning ? (
<div
data-slot="cycle-calendar-learning"
className="text-muted-foreground bg-muted/40 mb-3 flex items-start gap-2 rounded-lg p-3 text-sm"
>
<Sparkles
className="text-primary mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span>{t("cycle.calendar.learning")}</span>
</div>
) : null}
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ??
null)
: null
}
onSelectDay={openSheet}
/>
</>
)}
</CardContent>
</Card>
+139 -15
View File
@@ -50,6 +50,7 @@ import {
useCreateCustomSymptom,
useCustomSymptoms,
useCycleDayLog,
useCycleProfile,
useDeleteCustomSymptom,
useDeleteDayLog,
useEndPeriod,
@@ -63,6 +64,9 @@ import {
} from "./use-cycle";
import type {
CervicalMucus,
CervixFirmness,
CervixOpening,
CervixPosition,
ContraceptiveKind,
CycleDayLogInput,
CycleSymptomSelection,
@@ -98,6 +102,9 @@ const MUCUS_VALUES: CervicalMucus[] = [
"WATERY",
"EGG_WHITE",
];
const CERVIX_POSITION_VALUES: CervixPosition[] = ["LOW", "HIGH"];
const CERVIX_FIRMNESS_VALUES: CervixFirmness[] = ["FIRM", "SOFT"];
const CERVIX_OPENING_VALUES: CervixOpening[] = ["CLOSED", "OPEN"];
const TEST_RESULTS: HomeTestResult[] = [
"POSITIVE",
"NEGATIVE",
@@ -154,8 +161,13 @@ export interface DayLogFormState {
intermenstrual: boolean;
/** Raw text from the BBT input; "" / non-finite resolves to null. */
bbt: string;
/** Whether the BBT reading is marked disturbed (fever/late) — engine skips it. */
bbtDisturbed: boolean;
opk: OvulationTest | null;
mucus: CervicalMucus | null;
cervixPosition: CervixPosition | null;
cervixFirmness: CervixFirmness | null;
cervixOpening: CervixOpening | null;
intercourse: boolean;
protectedSex: boolean;
pregnancyTest: HomeTestResult | null;
@@ -189,8 +201,13 @@ export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
flow: s.flow ?? null,
intermenstrualBleeding: s.intermenstrual,
basalBodyTempC: resolveBbt(s.bbt),
// Only meaningful with a BBT present; a cleared reading resets the flag.
temperatureExcluded: resolveBbt(s.bbt) != null ? s.bbtDisturbed : false,
ovulationTest: s.opk ?? null,
cervicalMucus: s.mucus ?? null,
cervixPosition: s.cervixPosition ?? null,
cervixFirmness: s.cervixFirmness ?? null,
cervixOpening: s.cervixOpening ?? null,
sexualActivity: s.intercourse,
protectedSex: s.intercourse ? s.protectedSex : null,
pregnancyTest: s.pregnancyTest ?? null,
@@ -214,9 +231,14 @@ export function buildDayLogInput(
source: "MANUAL",
...(s.flow ? { flow: s.flow } : {}),
intermenstrualBleeding: s.intermenstrual,
...(bbtVal != null ? { basalBodyTempC: bbtVal } : {}),
...(bbtVal != null
? { basalBodyTempC: bbtVal, temperatureExcluded: s.bbtDisturbed }
: {}),
...(s.opk ? { ovulationTest: s.opk } : {}),
...(s.mucus ? { cervicalMucus: s.mucus } : {}),
...(s.cervixPosition ? { cervixPosition: s.cervixPosition } : {}),
...(s.cervixFirmness ? { cervixFirmness: s.cervixFirmness } : {}),
...(s.cervixOpening ? { cervixOpening: s.cervixOpening } : {}),
sexualActivity: s.intercourse,
protectedSex: s.intercourse ? s.protectedSex : null,
...(s.pregnancyTest ? { pregnancyTest: s.pregnancyTest } : {}),
@@ -268,6 +290,34 @@ function Chip({
);
}
/** One labelled row of cervix-sign chips (position / firmness / opening). */
function CervixRow<T extends string>({
label,
values,
current,
labelFor,
onToggle,
}: {
label: string;
values: readonly T[];
current: T | null;
labelFor: (v: T) => string;
onToggle: (v: T) => void;
}) {
return (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs font-medium">{label}</p>
<div className="flex flex-wrap gap-2">
{values.map((v) => (
<Chip key={v} active={current === v} onClick={() => onToggle(v)}>
{labelFor(v)}
</Chip>
))}
</div>
</div>
);
}
export function LogDaySheet({
open,
onOpenChange,
@@ -285,6 +335,11 @@ export function LogDaySheet({
const dayLog = useCycleDayLog(open ? date : null);
// The caller's custom symptoms, merged into the seeded chip grid.
const customSymptoms = useCustomSymptoms();
// Drives which symptothermal secondary sign the sheet offers — mucus (default)
// or the cervix pickers (progressive disclosure: cervix entry only appears
// when the user chose CERVIX in the advanced cycle settings).
const cycleProfile = useCycleProfile();
const showCervix = cycleProfile.data?.secondarySymptom === "CERVIX";
const [flow, setFlow] = useState<FlowLevel | null>(null);
const [intermenstrual, setIntermenstrual] = useState(false);
@@ -292,8 +347,16 @@ export function LogDaySheet({
new Map(),
);
const [bbt, setBbt] = useState<string>("");
const [bbtDisturbed, setBbtDisturbed] = useState(false);
const [opk, setOpk] = useState<OvulationTest | null>(null);
const [mucus, setMucus] = useState<CervicalMucus | null>(null);
const [cervixPosition, setCervixPosition] = useState<CervixPosition | null>(
null,
);
const [cervixFirmness, setCervixFirmness] = useState<CervixFirmness | null>(
null,
);
const [cervixOpening, setCervixOpening] = useState<CervixOpening | null>(null);
const [intercourse, setIntercourse] = useState(false);
const [protectedSex, setProtectedSex] = useState(false);
const [pregnancyTest, setPregnancyTest] = useState<HomeTestResult | null>(
@@ -324,8 +387,12 @@ export function LogDaySheet({
setIntermenstrual(dto?.intermenstrualBleeding ?? false);
setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity])));
setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : "");
setBbtDisturbed(dto?.temperatureExcluded ?? false);
setOpk((dto?.ovulationTest ?? null) as OvulationTest | null);
setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null);
setCervixPosition((dto?.cervixPosition ?? null) as CervixPosition | null);
setCervixFirmness((dto?.cervixFirmness ?? null) as CervixFirmness | null);
setCervixOpening((dto?.cervixOpening ?? null) as CervixOpening | null);
setIntercourse(dto?.sexualActivity ?? false);
setProtectedSex(dto?.protectedSex ?? false);
setPregnancyTest(dto?.pregnancyTest ?? null);
@@ -359,8 +426,12 @@ export function LogDaySheet({
flow,
intermenstrual,
bbt,
bbtDisturbed,
opk,
mucus,
cervixPosition,
cervixFirmness,
cervixOpening,
intercourse,
protectedSex,
pregnancyTest,
@@ -601,6 +672,23 @@ export function LogDaySheet({
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")}
/>
{resolveBbt(bbt) != null && (
<div className="mt-3 flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm">
{t("cycle.sheet.temperatureDisturbed")}
</span>
<span className="text-muted-foreground text-xs">
{t("cycle.sheet.temperatureDisturbedHint")}
</span>
</div>
<Switch
checked={bbtDisturbed}
onCheckedChange={setBbtDisturbed}
aria-label={t("cycle.sheet.temperatureDisturbed")}
/>
</div>
)}
</Field>
{/* Ovulation test */}
@@ -618,20 +706,56 @@ export function LogDaySheet({
</div>
</Field>
{/* Cervical mucus */}
<Field label={t("cycle.sheet.mucus")}>
<div className="flex flex-wrap gap-2">
{MUCUS_VALUES.map((v) => (
<Chip
key={v}
active={mucus === v}
onClick={() => setMucus((cur) => (cur === v ? null : v))}
>
{t(`cycle.mucus.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Symptothermal secondary symptom: cervical mucus (default) OR the
cervix observation, per the user's advanced-settings choice. Only one
of the two is offered so the log sheet never overwhelms. */}
{showCervix ? (
<Field label={t("cycle.sheet.cervix")}>
<div className="space-y-3">
<CervixRow
label={t("cycle.sheet.cervixPosition")}
values={CERVIX_POSITION_VALUES}
current={cervixPosition}
labelFor={(v) => t(`cycle.cervixPosition.${v}`)}
onToggle={(v) =>
setCervixPosition((cur) => (cur === v ? null : v))
}
/>
<CervixRow
label={t("cycle.sheet.cervixFirmness")}
values={CERVIX_FIRMNESS_VALUES}
current={cervixFirmness}
labelFor={(v) => t(`cycle.cervixFirmness.${v}`)}
onToggle={(v) =>
setCervixFirmness((cur) => (cur === v ? null : v))
}
/>
<CervixRow
label={t("cycle.sheet.cervixOpening")}
values={CERVIX_OPENING_VALUES}
current={cervixOpening}
labelFor={(v) => t(`cycle.cervixOpening.${v}`)}
onToggle={(v) =>
setCervixOpening((cur) => (cur === v ? null : v))
}
/>
</div>
</Field>
) : (
<Field label={t("cycle.sheet.mucus")}>
<div className="flex flex-wrap gap-2">
{MUCUS_VALUES.map((v) => (
<Chip
key={v}
active={mucus === v}
onClick={() => setMucus((cur) => (cur === v ? null : v))}
>
{t(`cycle.mucus.${v}`)}
</Chip>
))}
</div>
</Field>
)}
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
+11 -2
View File
@@ -5,7 +5,10 @@ import { Sparkles } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { PHASE_WHATS_HAPPENING_KEY } from "@/lib/cycle/phase-copy";
import {
PHASE_WHATS_HAPPENING_KEY,
PHASE_CONTEXT_KEY,
} from "@/lib/cycle/phase-copy";
import { PHASE_HUE } from "./phase-tokens";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import type { CyclePhase } from "./types";
@@ -146,10 +149,16 @@ export function PhaseEducationCard({
{phaseName}
</h3>
{/* Zone 2 — the curated, descriptive-only "what's happening" line. */}
{/* Zone 2 — the curated, descriptive-only "what's happening" line,
followed by a deeper "context" line (what someone might notice +
gentle framing of normal variation). Both stay descriptive, never
clinical advice / a medical claim. */}
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
</p>
<p className="text-muted-foreground mt-2 text-sm leading-relaxed">
{t(PHASE_CONTEXT_KEY[phase as CyclePhase])}
</p>
{/* Zone 3 — the user's OWN clustered symptoms for this phase
("you often notice this here"). */}
+23
View File
@@ -68,6 +68,12 @@ export interface CalendarResponse {
cyclesObserved: number;
};
prediction: CyclePrediction | null;
/**
* Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
* are observed. When set, `days` carries no fertile window, ovulation dot, or
* phase band — render the calm "learning your cycle" state over the grid.
*/
stillLearning: boolean;
days: CalendarDay[];
meta: { generatedAt: string };
}
@@ -95,9 +101,18 @@ export interface CycleHistoryResponse {
};
}
/** Symptothermal secondary symptom — mucus (default) or cervix observation. */
export type SecondarySymptom = "MUCUS" | "CERVIX";
/** The three Sensiplan cervix signs. */
export type CervixPosition = "LOW" | "HIGH";
export type CervixFirmness = "FIRM" | "SOFT";
export type CervixOpening = "CLOSED" | "OPEN";
export interface CycleProfileDTO {
goal: CycleGoal;
cycleTrackingEnabled: boolean;
secondarySymptom: SecondarySymptom;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
@@ -132,8 +147,12 @@ export interface CycleDayLogInput {
flow?: FlowLevel;
intermenstrualBleeding?: boolean;
basalBodyTempC?: number;
temperatureExcluded?: boolean;
ovulationTest?: OvulationTest;
cervicalMucus?: CervicalMucus;
cervixPosition?: CervixPosition | null;
cervixFirmness?: CervixFirmness | null;
cervixOpening?: CervixOpening | null;
sexualActivity?: boolean;
protectedSex?: boolean | null;
pregnancyTest?: HomeTestResult;
@@ -153,8 +172,12 @@ export interface CycleDayLogDTO {
flow: FlowLevel | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
temperatureExcluded: boolean;
ovulationTest: OvulationTest | null;
cervicalMucus: CervicalMucus | null;
cervixPosition: CervixPosition | null;
cervixFirmness: CervixFirmness | null;
cervixOpening: CervixOpening | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: HomeTestResult | null;
+8
View File
@@ -29,6 +29,9 @@ import {
import type {
CalendarResponse,
CervicalMucus,
CervixFirmness,
CervixOpening,
CervixPosition,
ContraceptiveKind,
CycleDayLogDTO,
CycleDayLogInput,
@@ -237,8 +240,12 @@ export interface CycleDayLogPatch {
flow?: FlowLevel | null;
intermenstrualBleeding?: boolean;
basalBodyTempC?: number | null;
temperatureExcluded?: boolean;
ovulationTest?: OvulationTest | null;
cervicalMucus?: CervicalMucus | null;
cervixPosition?: CervixPosition | null;
cervixFirmness?: CervixFirmness | null;
cervixOpening?: CervixOpening | null;
sexualActivity?: boolean;
protectedSex?: boolean | null;
pregnancyTest?: HomeTestResult | null;
@@ -316,6 +323,7 @@ export function useDeleteDayLog() {
export interface CyclePrefsPatch {
enabled?: boolean;
goal?: CycleProfileDTO["goal"];
secondarySymptom?: CycleProfileDTO["secondarySymptom"];
rawChartMode?: boolean;
predictionEnabled?: boolean;
discreetNotifications?: boolean;
+6
View File
@@ -142,6 +142,7 @@ export async function buildCycleSnapshotBlock(
lutealPhaseLength: true,
typicalCycleLength: true,
typicalPeriodLength: true,
secondarySymptom: true,
},
});
if (!profile) return null;
@@ -153,6 +154,7 @@ export async function buildCycleSnapshotBlock(
lutealPhaseLength: profile.lutealPhaseLength,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
secondarySymptom: profile.secondarySymptom,
};
// Derive "today" from the user's timezone — matches how the calendar
@@ -176,8 +178,12 @@ export async function buildCycleSnapshotBlock(
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
},
}),
prisma.measurement.findMany({
+69 -6
View File
@@ -55,7 +55,7 @@ describe("buildCalendar", () => {
it("labels logged-flow days and emits a forecast for ≥2 cycles", () => {
const dayLogs: CalendarDayLogRow[] = [
{ date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, ovulationTest: null, cervicalMucus: null, hasSymptoms: true },
{ date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, cervixPosition: null, cervixFirmness: null, cervixOpening: null, hasSymptoms: true },
];
const { prediction, days } = buildCalendar(
profile(),
@@ -96,15 +96,22 @@ describe("buildCalendar", () => {
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
});
it("surfaces the fertile window for the conception goal", () => {
it("surfaces the fertile window for the conception goal once past cold start", () => {
// Four starts → three completed cycles → not still-learning.
const fourCycles = [
cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }),
cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }),
cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }),
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
];
const { days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
cycles,
fourCycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
"2026-03-20",
"2026-05-15",
"2026-04-10",
true,
);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
@@ -123,4 +130,60 @@ describe("buildCalendar", () => {
);
expect(prediction).toBeNull();
});
describe("still-learning gate (cold start)", () => {
// One logged period — zero completed cycles → priors-only forecast.
const oneCycle = [cycle("2026-02-26", { periodEndDate: "2026-03-02" })];
it("flags stillLearning and asserts no fertile/ovulation grid for ≤1 cycle (TTC)", () => {
const { prediction, stillLearning, days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
oneCycle,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
true, // goal allows fertile — only the learning gate must suppress it
);
expect(prediction).not.toBeNull();
expect(prediction!.cyclesObserved).toBeLessThan(3);
expect(prediction!.stillLearning).toBe(true);
expect(stillLearning).toBe(true);
// No confident fertile shading or ovulation dot from a population guess.
expect(days.every((d) => !d.isFertileWindow)).toBe(true);
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
// No asserted phase band either (population-28 frame is not yet earned).
expect(days.every((d) => d.phase === null)).toBe(true);
// The predicted next-period bar still shows (the panel shows it too).
expect(days.some((d) => d.isPredictedPeriod)).toBe(true);
});
it("emits normal fertile/ovulation/phase output once ≥3 cycles are observed", () => {
// Four ~28-day starts → three COMPLETED cycle lengths → cyclesObserved=3.
const fourCycles = [
cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }),
cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }),
cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }),
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
];
const { prediction, stillLearning, days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
fourCycles,
[],
[],
"2026-03-20",
"2026-05-15",
"2026-04-10",
true,
);
expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(3);
expect(prediction!.stillLearning).toBe(false);
expect(stillLearning).toBe(false);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
expect(days.some((d) => d.isPredictedOvulation)).toBe(true);
expect(days.some((d) => d.phase !== null)).toBe(true);
});
});
});
+503 -11
View File
@@ -4,6 +4,9 @@ import { addDays } from "../day-math";
import {
clampLuteal,
confirmSymptothermal,
detectCervixPeak,
detectLhSurgeOvulation,
detectMucusPeak,
detectTempShift,
detectTemperatureTrend,
estimateCycleLength,
@@ -13,9 +16,11 @@ import {
predictCycle,
resolveLuteal,
} from "../prediction";
import { toCyclePredictionDTO } from "../dto";
import type {
CycleInput,
CycleProfileInput,
CyclePredictionResult,
DayLogInput,
NightlyTempInput,
} from "../types";
@@ -88,6 +93,17 @@ describe("cycle/prediction — cycle-length estimator (§1, §5)", () => {
expect(est.lengthRounded).toBe(28);
});
it("keeps a legitimate long (oligomenorrhea) cycle inside the hard bound", () => {
// A run of long-but-real cycles (ACOG: > 35 d is oligomenorrhea, still a
// real cycle; perimenopause routinely runs longer). A 52-day length must
// not be force-excluded by the hard ceiling — only the MAD fence / missed-
// log heuristic should ever drop it. Here the whole run is long, so the
// robust median sits near 50 and 52 is well inside the fence.
const est = estimateCycleLength([48, 50, 52, 49, 51], "PERIMENOPAUSE");
expect(est.cyclesObserved).toBe(5); // none clipped by HARD_CYCLE_MAX
expect(est.lengthRounded).toBe(50);
});
it("perimenopause widens the fence (OUTLIER_K 4 keeps more)", () => {
const lengths = [28, 30, 26, 40, 24, 32];
const normal = estimateCycleLength(lengths, "TRYING_TO_CONCEIVE");
@@ -284,6 +300,24 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
}
function cervixDay(
date: string,
cervixPosition: DayLogInput["cervixPosition"],
cervixFirmness: DayLogInput["cervixFirmness"],
cervixOpening: DayLogInput["cervixOpening"],
): DayLogInput {
return {
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
cervixPosition,
cervixFirmness,
cervixOpening,
};
}
it("detectTempShift: 3-over-6 with the 3rd reading >= 0.2°C over the 6-day max", () => {
// 6 low baseline ~36.40, then 3 elevated clearing the max by 0.2.
const logs: DayLogInput[] = [];
@@ -314,6 +348,176 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
expect(detectTempShift(logs, 0.2)).toBeNull();
});
it("detectTempShift rule 0: the regular rule reports rule:0 + evaluation on the 3rd day", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62)); // 3rd clears 0.2 over 36.42
const shift = detectTempShift(logs, 0.2);
expect(shift?.rule).toBe(0);
expect(shift?.ovulationDate).toBe(addDays(start, 5));
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 8));
});
it("detectTempShift 1. Ausnahmeregel: slow/staircase rise confirms on the 4th reading", () => {
// 1. Ausnahmeregel: 3rd high reading is above the line but NOT 0.2°C above;
// a 4th reading above the line confirms (need only be above the line).
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
// cover line = 36.42. Staircase: all above the line, 3rd only +0.1.
logs.push(bbtDay(addDays(start, 6), 36.45));
logs.push(bbtDay(addDays(start, 7), 36.49));
logs.push(bbtDay(addDays(start, 8), 36.52)); // +0.1 → regular rule fails
// Without a 4th reading the strict + 1. rule cannot confirm.
expect(detectTempShift([...logs], 0.2)).toBeNull();
// 4th reading merely above the line (no 0.2 requirement) → confirmed by rule 1.
logs.push(bbtDay(addDays(start, 9), 36.5));
const shift = detectTempShift(logs, 0.2);
expect(shift?.rule).toBe(1);
expect(shift?.ovulationDate).toBe(addDays(start, 5));
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
});
it("detectTempShift 2. Ausnahmeregel: a single fall-back day confirms on a 4th ≥0.2°C reading", () => {
// 2. Ausnahmeregel: one of the 3 readings falls back to/below the line; it
// is discounted and a 4th reading is required that again clears 0.2°C.
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
// cover line = 36.42. 1st + 2nd above, 3rd falls back below the line.
logs.push(bbtDay(addDays(start, 6), 36.6));
logs.push(bbtDay(addDays(start, 7), 36.62));
logs.push(bbtDay(addDays(start, 8), 36.4)); // falls back below 36.42
// Without a 4th reading nothing confirms.
expect(detectTempShift([...logs], 0.2)).toBeNull();
// 4th reading that does NOT clear 0.2 must not confirm.
const tooLow = [...logs, bbtDay(addDays(start, 9), 36.5)]; // +0.08 only
expect(detectTempShift(tooLow, 0.2)).toBeNull();
// 4th reading ≥0.2°C above the line → confirmed by rule 2.
logs.push(bbtDay(addDays(start, 9), 36.63)); // +0.21 over 36.42
const shift = detectTempShift(logs, 0.2);
expect(shift?.rule).toBe(2);
expect(shift?.ovulationDate).toBe(addDays(start, 5));
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
});
it("detectTempShift: an excluded (disturbed) baseline spike no longer masks a true rise", () => {
const start = "2024-01-01";
// 6 baseline days; the LAST (immediately before the rise) is a fever spike,
// so it sits in the cover-line window. Unexcluded it raises the cover line
// and contaminates detection; excluded it is dropped and the remaining lows
// form the real (lower) cover line.
const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 37.5];
const extraLow = 36.39; // a 6th valid low so exclusion still leaves 6
const rise = [36.55, 36.6, 36.62];
const build = (excludeFever: boolean): DayLogInput[] => {
const logs: DayLogInput[] = [];
logs.push(bbtDay(addDays(start, 0), extraLow));
baseline.forEach((t, i) => {
const day = bbtDay(addDays(start, 1 + i), t);
if (i === 5 && excludeFever) day.temperatureExcluded = true;
logs.push(day);
});
rise.forEach((t, i) => logs.push(bbtDay(addDays(start, 7 + i), t)));
return logs;
};
// Fever NOT excluded: the 37.5 spike contaminates detection — it does NOT
// yield the correct shift (ovulation = day before the real rise).
const corrupted = detectTempShift(build(false), 0.2);
expect(corrupted?.ovulationDate).not.toBe(addDays(start, 6));
// Fever excluded: cover line falls to the real baseline max and the genuine
// rise confirms with ovulation the day before it (2024-01-07).
const shift = detectTempShift(build(true), 0.2);
expect(shift?.rule).toBe(0);
expect(shift?.ovulationDate).toBe(addDays(start, 6));
});
it("detectTempShift: an excluded fever spike inside the rise no longer fabricates a shift", () => {
const start = "2024-01-01";
const logs: DayLogInput[] = [];
[36.4, 36.4, 36.4, 36.4, 36.4, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
const fever = bbtDay(addDays(start, 6), 38.0);
fever.temperatureExcluded = true;
logs.push(fever);
logs.push(bbtDay(addDays(start, 7), 36.41));
logs.push(bbtDay(addDays(start, 8), 36.4));
// The excluded fever is dropped; the remaining readings show no rise.
expect(detectTempShift(logs, 0.2)).toBeNull();
});
it("detectMucusPeak: confirms the last best-quality day followed by 3 drier days", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
const logs = [
m(addDays(start, 0), "STICKY"),
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "EGG_WHITE"), // peak candidate
m(addDays(start, 3), "CREAMY"),
m(addDays(start, 4), "STICKY"),
m(addDays(start, 5), "DRY"),
];
expect(detectMucusPeak(logs)).toBe(addDays(start, 2));
});
it("detectMucusPeak: returns null until 3 drier days follow the best-quality day", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
// egg-white then only 2 drier observed days → not yet confirmable.
const logs = [
m(addDays(start, 0), "EGG_WHITE"),
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "DRY"),
];
expect(detectMucusPeak(logs)).toBeNull();
});
it("detectMucusPeak: a stray late egg-white entry no longer moves a confirmed peak", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
const logs = [
m(addDays(start, 0), "EGG_WHITE"), // true peak
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "STICKY"),
m(addDays(start, 3), "DRY"),
// a stray late egg-white with NO 3 drier days after it (only 1 logged
// day follows) — must not be confirmed as a new peak.
m(addDays(start, 8), "EGG_WHITE"),
m(addDays(start, 9), "DRY"),
];
// The confirmed peak stays on the true (early) egg-white day, not the stray.
expect(detectMucusPeak(logs)).toBe(addDays(start, 0));
});
it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
@@ -323,17 +527,131 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06.
logs.push({
date: "2024-01-06",
// temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06,
// confirmed by 3 drier days after it (Sensiplan post-peak count).
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
cervicalMucus: m,
});
logs.push(mucusDay("2024-01-06", "EGG_WHITE"));
logs.push(mucusDay("2024-01-07", "STICKY"));
logs.push(mucusDay("2024-01-08", "DRY"));
logs.push(mucusDay("2024-01-09", "DRY"));
expect(confirmSymptothermal(logs)).toBe("2024-01-06");
});
it("confirmSymptothermal defaults to the mucus secondary symptom (cervix ignored)", () => {
// A confirmed temp shift + a confirmed CERVIX peak but NO mucus must NOT
// confirm on the default (mucus) path — the default behaviour is unchanged.
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// cervix peak on 2024-01-06, confirmed by 3 closed days — but no mucus.
logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED"));
// Default secondary = mucus → no mucus peak → no confirmation.
expect(confirmSymptothermal(logs)).toBeNull();
});
it("detectCervixPeak: confirms the last fertile-cervix day followed by 3 closed days", () => {
const start = "2024-01-01";
const logs = [
cervixDay(addDays(start, 0), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 1), "HIGH", "SOFT", "OPEN"), // fertile peak
cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 3), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 4), "LOW", "FIRM", "CLOSED"),
];
expect(detectCervixPeak(logs)).toBe(addDays(start, 1));
});
it("detectCervixPeak: returns null until 3 closed days follow the fertile peak", () => {
const start = "2024-01-01";
const logs = [
cervixDay(addDays(start, 0), "HIGH", "SOFT", "OPEN"),
cervixDay(addDays(start, 1), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"),
];
expect(detectCervixPeak(logs)).toBeNull();
});
it("confirmSymptothermal(CERVIX): a cervix peak + temp shift confirms like mucus does", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// temp ovulation = 2024-01-06. Cervix peak on 2024-01-06, confirmed by 3
// closed days after it (cervix-closure rule).
logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED"));
expect(confirmSymptothermal(logs, "CERVIX")).toBe("2024-01-06");
});
it("confirmSymptothermal(CERVIX): a cervix sign alone (no temp shift) never confirms", () => {
const start = "2024-01-01";
// A confirmed cervix peak but a FLAT temperature series (no rise).
const logs: DayLogInput[] = [];
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4, 36.41, 36.4, 36.39].forEach(
(t, i) => logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(cervixDay(addDays(start, 4), "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay(addDays(start, 5), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(start, 6), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(start, 7), "LOW", "FIRM", "CLOSED"));
// Single sign (cervix) without the temperature double-check → no confirmation.
expect(confirmSymptothermal(logs, "CERVIX")).toBeNull();
});
it("predictCycle(CERVIX): a cervix-peak + temp shift confirms the same boundary as mucus", () => {
const cervixProfile: CycleProfileInput = {
...BASE_PROFILE,
secondarySymptom: "CERVIX",
};
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
const logs: DayLogInput[] = [];
const ovDay = addDays(lastStart, 13);
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(baselineStart, i), t)),
);
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
// cervix peak agreeing with the temp ovulation, confirmed by 3 closed days.
logs.push(cervixDay(ovDay, "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay(addDays(ovDay, 1), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(ovDay, 2), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(ovDay, 3), "LOW", "FIRM", "CLOSED"));
const result = predictCycle(
cycles,
logs,
cervixProfile,
addDays(lastStart, 16),
);
expect(result.ovulationConfirmed).toBe(true);
expect(result.method).toBe("BLENDED");
expect(result.predictedOvulation).toBe(ovDay);
});
it("predictCycle: a confirmed symptothermal ovulation overrides next-start and tightens the band", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); // 3 lengths, last start 2024-03-25
const lastStart = cycles[cycles.length - 1].startDate;
@@ -351,14 +669,18 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
// mucus peak agreeing with the temp ovulation.
logs.push({
date: ovDay,
// mucus peak agreeing with the temp ovulation, confirmed by 3 drier days.
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
cervicalMucus: m,
});
logs.push(mucusDay(ovDay, "EGG_WHITE"));
logs.push(mucusDay(addDays(ovDay, 1), "STICKY"));
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
expect(result.ovulationConfirmed).toBe(true);
@@ -369,6 +691,103 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
});
});
describe("cycle/prediction — Marquette LH/OPK ovulation anchor (§4, C2)", () => {
function lhDay(date: string): DayLogInput {
return {
date,
flow: null,
basalBodyTempC: null,
ovulationTest: "POSITIVE_LH_SURGE",
cervicalMucus: null,
};
}
it("detectLhSurgeOvulation: anchors ovulation one day after the LAST positive LH", () => {
const logs = [lhDay("2024-01-10"), lhDay("2024-01-11")];
expect(detectLhSurgeOvulation(logs)).toBe("2024-01-12");
expect(detectLhSurgeOvulation([])).toBeNull();
});
it("predictCycle: a positive LH surge anchors ovulation on the surge, not nextStart−luteal", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25
// The calendar back-calc would place ovulation at nextStart−14. A positive
// LH on lastStart+16 should anchor ovulation to lastStart+17.
const lhDate = addDays(lastStart, 16);
const logs = [lhDay(lhDate)];
const today = addDays(lastStart, 18);
const calendarOnly = predictCycle(cycles, [], BASE_PROFILE, today);
const withLh = predictCycle(cycles, logs, BASE_PROFILE, today);
// The LH anchor moves the ovulation estimate off the calendar back-calc.
expect(withLh.predictedOvulation).toBe(addDays(lhDate, 1));
expect(withLh.predictedOvulation).not.toBe(calendarOnly.predictedOvulation);
// next-start = anchored ovulation + luteal(14).
expect(withLh.nextPeriodStart).toBe(addDays(lhDate, 1 + 14));
expect(withLh.method).toBe("BLENDED");
// A single LH indicator never asserts CONFIRMED ovulation.
expect(withLh.ovulationConfirmed).toBe(false);
});
it("predictCycle: LH sharpens the estimate in the thin-data (1-cycle) case", () => {
const cycles = cyclesFromGaps("2024-01-01", [28]); // one completed length
const lastStart = cycles[cycles.length - 1].startDate; // 2024-01-29
const lhDate = addDays(lastStart, 15);
const today = addDays(lastStart, 17);
const result = predictCycle(cycles, [lhDay(lhDate)], BASE_PROFILE, today);
expect(result.predictedOvulation).toBe(addDays(lhDate, 1));
expect(result.ovulationConfirmed).toBe(false);
// The still-learning gate is preserved (1 cycle < 3).
expect(result.stillLearning).toBe(true);
});
it("predictCycle: a confirmed symptothermal ovulation wins over the LH anchor", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
const ovDay = addDays(lastStart, 13);
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
const logs: DayLogInput[] = [];
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push({
date: addDays(baselineStart, i),
flow: null,
basalBodyTempC: t,
ovulationTest: null,
cervicalMucus: null,
}),
);
const bbt = (date: string, t: number): DayLogInput => ({
date,
flow: null,
basalBodyTempC: t,
ovulationTest: null,
cervicalMucus: null,
});
logs.push(bbt(riseStart, 36.6));
logs.push(bbt(addDays(riseStart, 1), 36.62));
logs.push(bbt(addDays(riseStart, 2), 36.63));
const mucus = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: m,
});
logs.push(mucus(ovDay, "EGG_WHITE"));
logs.push(mucus(addDays(ovDay, 1), "STICKY"));
logs.push(mucus(addDays(ovDay, 2), "DRY"));
logs.push(mucus(addDays(ovDay, 3), "DRY"));
// A contradicting LH surge far from the temp shift must NOT override it.
logs.push(lhDay(addDays(ovDay, 5)));
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 20));
expect(result.ovulationConfirmed).toBe(true);
expect(result.predictedOvulation).toBe(ovDay); // symptothermal wins
});
});
describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)", () => {
it("detectTemperatureTrend: 3-of-4 nights >= +0.15°C over the trailing 6-night mean", () => {
const start = "2024-02-01";
@@ -420,13 +839,18 @@ describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
logs.push({
date: ovDay,
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
cervicalMucus: m,
});
// Peak on ovDay confirmed by 3 drier observed days (Sensiplan post-peak count).
logs.push(mucusDay(ovDay, "EGG_WHITE"));
logs.push(mucusDay(addDays(ovDay, 1), "STICKY"));
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
return logs;
}
@@ -514,3 +938,71 @@ describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", (
expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10));
});
});
describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)", () => {
/** A fully-populated engine result; `stillLearning` is overridden per case. */
function makeResult(
overrides: Partial<CyclePredictionResult> = {},
): CyclePredictionResult {
return {
method: "CALENDAR",
nextPeriodStart: "2024-02-01",
nextPeriodStartLow: "2024-01-30",
nextPeriodStartHigh: "2024-02-03",
predictedPeriodLength: 5,
fertileWindowStart: "2024-01-14",
fertileWindowEnd: "2024-01-20",
predictedOvulation: "2024-01-18",
ovulationConfirmed: true,
confidence: 0.6,
confidenceLabel: "medium",
cyclesObserved: 5,
stillLearning: false,
estimatedCycleLength: 28,
estimatedCycleSd: 1.5,
...overrides,
};
}
it("suppresses the fertile window + predicted ovulation on the wire while still learning (<3 cycles), even when the goal allows it", () => {
const result = makeResult({ stillLearning: true, cyclesObserved: 2 });
const dto = toCyclePredictionDTO(result, /* goalAllowsFertile */ true, "d");
expect(dto.fertileWindowStart).toBeNull();
expect(dto.fertileWindowEnd).toBeNull();
expect(dto.predictedOvulation).toBeNull();
// The honesty flag still reaches the client so it can paint the calm state.
expect(dto.stillLearning).toBe(true);
});
it("populates the fertile window + predicted ovulation once learning completes (>=3 cycles) and the goal allows it", () => {
const result = makeResult({ stillLearning: false, cyclesObserved: 3 });
const dto = toCyclePredictionDTO(result, /* goalAllowsFertile */ true, "d");
expect(dto.fertileWindowStart).toBe("2024-01-14");
expect(dto.fertileWindowEnd).toBe("2024-01-20");
expect(dto.predictedOvulation).toBe("2024-01-18");
expect(dto.stillLearning).toBe(false);
});
it("keeps ovulationConfirmed goal-gated only — unchanged by the still-learning gate", () => {
// Still learning but goal allows: a confirmed shift is observed data, not a
// prior, so it stays surfaced even while the predicted window is suppressed.
const learning = toCyclePredictionDTO(
makeResult({ stillLearning: true, ovulationConfirmed: true }),
true,
"d",
);
expect(learning.ovulationConfirmed).toBe(true);
// Goal forbids the fertile window → confirmation drops regardless of learning.
const goalGated = toCyclePredictionDTO(
makeResult({ stillLearning: false, ovulationConfirmed: true }),
false,
"d",
);
expect(goalGated.ovulationConfirmed).toBe(false);
expect(goalGated.fertileWindowStart).toBeNull();
expect(goalGated.predictedOvulation).toBeNull();
});
});
+26
View File
@@ -21,6 +21,7 @@ export interface CycleBackupSection {
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
secondarySymptom: string;
predictionEnabled: boolean;
rawChartMode: boolean;
discreetNotifications: boolean;
@@ -40,8 +41,12 @@ export interface CycleBackupSection {
flow: string | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
temperatureExcluded: boolean;
ovulationTest: string | null;
cervicalMucus: string | null;
cervixPosition: string | null;
cervixFirmness: string | null;
cervixOpening: string | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
@@ -88,6 +93,7 @@ export async function buildCycleBackupSection(
typicalCycleLength: profile.typicalCycleLength,
typicalPeriodLength: profile.typicalPeriodLength,
lutealPhaseLength: profile.lutealPhaseLength,
secondarySymptom: profile.secondarySymptom,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
discreetNotifications: profile.discreetNotifications,
@@ -108,8 +114,12 @@ export async function buildCycleBackupSection(
flow: d.flow,
intermenstrualBleeding: d.intermenstrualBleeding,
basalBodyTempC: d.basalBodyTempC,
temperatureExcluded: d.temperatureExcluded,
ovulationTest: d.ovulationTest,
cervicalMucus: d.cervicalMucus,
cervixPosition: d.cervixPosition,
cervixFirmness: d.cervixFirmness,
cervixOpening: d.cervixOpening,
sexualActivity: d.sexualActivity,
protectedSex: d.protectedSex,
pregnancyTest: d.pregnancyTest,
@@ -154,6 +164,10 @@ const CERVICAL_MUCUS = new Set([
"WATERY",
"EGG_WHITE",
]);
const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]);
const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]);
const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]);
const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]);
const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]);
const CONTRACEPTIVES = new Set([
"NONE",
@@ -219,6 +233,8 @@ export async function restoreCycleData(
typicalCycleLength: p.typicalCycleLength ?? null,
typicalPeriodLength: p.typicalPeriodLength ?? null,
lutealPhaseLength: p.lutealPhaseLength ?? null,
secondarySymptom: (enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ??
"MUCUS") as never,
predictionEnabled: p.predictionEnabled ?? true,
rawChartMode: p.rawChartMode ?? false,
discreetNotifications: p.discreetNotifications ?? false,
@@ -289,8 +305,18 @@ export async function restoreCycleData(
flow: enumOrNull(d.flow, FLOW_LEVELS) as never,
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
basalBodyTempC: d.basalBodyTempC ?? null,
temperatureExcluded: d.temperatureExcluded ?? false,
ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never,
cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never,
cervixPosition: enumOrNull(
d.cervixPosition,
CERVIX_POSITIONS,
) as never,
cervixFirmness: enumOrNull(
d.cervixFirmness,
CERVIX_FIRMNESSES,
) as never,
cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS) as never,
sexualActivity: d.sexualActivity ?? false,
protectedSex: d.protectedSex ?? null,
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never,
+36
View File
@@ -140,8 +140,12 @@ type ExistingRow = {
flow: string | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
temperatureExcluded: boolean;
ovulationTest: string | null;
cervicalMucus: string | null;
cervixPosition: string | null;
cervixFirmness: string | null;
cervixOpening: string | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
@@ -158,8 +162,12 @@ const EXISTING_SELECT = {
flow: true,
intermenstrualBleeding: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
cervixPosition: true,
cervixFirmness: true,
cervixOpening: true,
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
@@ -309,6 +317,10 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
entry.basalBodyTempC !== undefined
? (entry.basalBodyTempC ?? null)
: (existing?.basalBodyTempC ?? null);
const temperatureExcluded =
entry.temperatureExcluded !== undefined
? entry.temperatureExcluded
: (existing?.temperatureExcluded ?? false);
const ovulationTest =
entry.ovulationTest !== undefined
? (entry.ovulationTest ?? null)
@@ -317,6 +329,18 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
entry.cervicalMucus !== undefined
? (entry.cervicalMucus ?? null)
: (existing?.cervicalMucus ?? null);
const cervixPosition =
entry.cervixPosition !== undefined
? (entry.cervixPosition ?? null)
: (existing?.cervixPosition ?? null);
const cervixFirmness =
entry.cervixFirmness !== undefined
? (entry.cervixFirmness ?? null)
: (existing?.cervixFirmness ?? null);
const cervixOpening =
entry.cervixOpening !== undefined
? (entry.cervixOpening ?? null)
: (existing?.cervixOpening ?? null);
// Split the sensitive fields between plaintext columns and the envelope
// depending on the flag. When encrypting, the plaintext columns are NULL
@@ -338,8 +362,12 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
flow: flow as never,
intermenstrualBleeding,
basalBodyTempC,
temperatureExcluded,
ovulationTest: ovulationTest as never,
cervicalMucus: cervicalMucus as never,
cervixPosition: cervixPosition as never,
cervixFirmness: cervixFirmness as never,
cervixOpening: cervixOpening as never,
sexualActivity: sensitivePlaintext.sexualActivity,
protectedSex: sensitivePlaintext.protectedSex,
pregnancyTest: sensitivePlaintext.pregnancyTest as never,
@@ -357,8 +385,12 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
(existing.flow ?? null) !== flow ||
existing.intermenstrualBleeding !== intermenstrualBleeding ||
(existing.basalBodyTempC ?? null) !== basalBodyTempC ||
existing.temperatureExcluded !== temperatureExcluded ||
(existing.ovulationTest ?? null) !== ovulationTest ||
(existing.cervicalMucus ?? null) !== cervicalMucus ||
(existing.cervixPosition ?? null) !== cervixPosition ||
(existing.cervixFirmness ?? null) !== cervixFirmness ||
(existing.cervixOpening ?? null) !== cervixOpening ||
storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity ||
storedSensitive.protectedSex !== resolvedSensitive.protectedSex ||
storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest ||
@@ -395,8 +427,12 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
flow: baseData.flow as never,
intermenstrualBleeding: baseData.intermenstrualBleeding as never,
basalBodyTempC: baseData.basalBodyTempC as never,
temperatureExcluded: baseData.temperatureExcluded as never,
ovulationTest: baseData.ovulationTest as never,
cervicalMucus: baseData.cervicalMucus as never,
cervixPosition: baseData.cervixPosition as never,
cervixFirmness: baseData.cervixFirmness as never,
cervixOpening: baseData.cervixOpening as never,
sexualActivity: sensitivePlaintext.sexualActivity,
protectedSex: sensitivePlaintext.protectedSex,
pregnancyTest: sensitivePlaintext.pregnancyTest as never,
+27 -3
View File
@@ -29,8 +29,14 @@ export interface CycleDayLogDTO {
flow: string | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
/** Whether the day's BBT is marked disturbed (excluded from the engine). */
temperatureExcluded: boolean;
ovulationTest: string | null;
cervicalMucus: string | null;
/** Cervix observation signs (symptothermal secondary indicator). */
cervixPosition: string | null;
cervixFirmness: string | null;
cervixOpening: string | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
@@ -107,8 +113,12 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
flow: row.flow,
intermenstrualBleeding: row.intermenstrualBleeding,
basalBodyTempC: row.basalBodyTempC,
temperatureExcluded: row.temperatureExcluded,
ovulationTest: row.ovulationTest,
cervicalMucus: row.cervicalMucus,
cervixPosition: row.cervixPosition,
cervixFirmness: row.cervixFirmness,
cervixOpening: row.cervixOpening,
sexualActivity: hasEnvelope
? (enc.sexualActivity ?? false)
: row.sexualActivity,
@@ -188,9 +198,19 @@ export function toCyclePredictionDTO(
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null,
fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null,
predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null,
// Fertile-window + predicted ovulation are suppressed both when the goal
// forbids them AND while still learning (<3 cycles): below that we would be
// emitting a population-prior guess, not a data-grounded estimate. Making
// the gate structural here (not just in the web panels / calendar grid)
// means iOS — and any client reading `prediction.*` directly — cannot paint
// a fertile window the rest of the app refuses to show. `ovulationConfirmed`
// stays goal-gated only: a confirmed shift is observed data, not a prior.
fertileWindowStart:
goalAllowsFertile && !result.stillLearning ? result.fertileWindowStart : null,
fertileWindowEnd:
goalAllowsFertile && !result.stillLearning ? result.fertileWindowEnd : null,
predictedOvulation:
goalAllowsFertile && !result.stillLearning ? result.predictedOvulation : null,
ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
@@ -203,6 +223,8 @@ export function toCyclePredictionDTO(
export interface CycleProfileDTO {
goal: string;
cycleTrackingEnabled: boolean;
/** Symptothermal secondary symptom — MUCUS (default) or CERVIX. */
secondarySymptom: string;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
@@ -220,6 +242,7 @@ export function toCycleProfileDTO(
return {
goal: row.goal,
cycleTrackingEnabled,
secondarySymptom: row.secondarySymptom,
rawChartMode: row.rawChartMode,
predictionEnabled: row.predictionEnabled,
discreetNotifications: row.discreetNotifications,
@@ -233,6 +256,7 @@ export function toCycleProfileDTO(
type MenstrualCycleProfileRow = {
goal: string;
secondarySymptom: string;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
+55 -4
View File
@@ -37,6 +37,8 @@ export interface CalendarDayDTO {
confidence: number;
/** Logged basal body temperature (°C), or null. Feeds the web BBT chart. */
basalBodyTempC: number | null;
/** Whether the day's BBT is marked disturbed (excluded from the engine). */
temperatureExcluded: boolean;
/** Logged ovulation-test result, or null. */
ovulationTest: string | null;
/** Logged cervical-mucus quality, or null. */
@@ -46,7 +48,15 @@ export interface CalendarDayDTO {
/** Rows the calendar needs from each day-log. */
export type CalendarDayLogRow = Pick<
CycleDayLog,
"date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus"
| "date"
| "flow"
| "basalBodyTempC"
| "temperatureExcluded"
| "ovulationTest"
| "cervicalMucus"
| "cervixPosition"
| "cervixFirmness"
| "cervixOpening"
> & { hasSymptoms: boolean };
/** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */
@@ -73,15 +83,27 @@ export function toCycleInputs(
export function toDayLogInputs(
logs: readonly Pick<
CycleDayLog,
"date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus"
| "date"
| "flow"
| "basalBodyTempC"
| "temperatureExcluded"
| "ovulationTest"
| "cervicalMucus"
| "cervixPosition"
| "cervixFirmness"
| "cervixOpening"
>[],
): DayLogInput[] {
return logs.map((l) => ({
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
temperatureExcluded: l.temperatureExcluded,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
cervixPosition: l.cervixPosition,
cervixFirmness: l.cervixFirmness,
cervixOpening: l.cervixOpening,
}));
}
@@ -94,6 +116,7 @@ export function toProfileInput(profile: CycleProfile): CycleProfileInput {
lutealPhaseLength: profile.lutealPhaseLength,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
secondarySymptom: profile.secondarySymptom,
};
}
@@ -214,6 +237,15 @@ export function buildPhaseDayMap(
export interface CalendarBuildResult {
prediction: CyclePredictionResult | null;
/**
* True while the engine is "still learning" the user's cycle (< 3 observed
* cycles, mirrors `prediction.stillLearning`). When set, the calendar grid
* does NOT assert a fertile window, an ovulation dot, or a population-framed
* phase band — those are population guesses the app has not yet earned the
* confidence to show as fact. The client renders a calm "learning your cycle"
* state instead. False when no prediction ran (raw-chart mode / disabled).
*/
stillLearning: boolean;
days: CalendarDayDTO[];
}
@@ -226,6 +258,15 @@ export interface CalendarBuildResult {
* (GENERAL_HEALTH / PERIMENOPAUSE) to suppress `isFertileWindow` +
* `isPredictedOvulation` at the grid level (the prediction's own window
* fields are nulled upstream by the engine for those goals).
*
* Cold-start honesty (C1): while the engine reports `stillLearning` (< 3
* observed cycles), the grid suppresses the fertile window, the predicted-
* ovulation dot, AND the phase band — all of which would otherwise be painted
* from a population 28/14 prior at ~0.20 confidence. The predicted-period bar
* is kept (the predictions panel shows it too while learning). This matches
* the `stillLearning` gate the predictions panel already applies, so the
* calendar grid never asserts "these are your fertile days" off a single
* logged cycle.
*/
export function buildCalendar(
profile: CycleProfile,
@@ -254,6 +295,11 @@ export function buildCalendar(
);
}
// Cold-start gate: until ≥3 cycles are observed the engine's fertile/
// ovulation/phase output rests on a population prior, so the calendar must
// present it as a calm "learning" state rather than asserting it as fact.
const stillLearning = prediction?.stillLearning ?? false;
const phaseCycles = buildPhaseCycles(
cycles,
prediction?.nextPeriodStart ?? null,
@@ -285,6 +331,7 @@ export function buildCalendar(
isWithin(date, predictedPeriodStart, predictedPeriodEnd);
const isFertileWindow =
!stillLearning &&
goalAllowsFertile &&
prediction?.fertileWindowStart != null &&
prediction.fertileWindowEnd != null &&
@@ -295,13 +342,16 @@ export function buildCalendar(
);
const isPredictedOvulation =
!stillLearning &&
goalAllowsFertile &&
prediction?.predictedOvulation != null &&
prediction.predictedOvulation === date;
days.push({
date,
phase: phaseAcross(date, phaseCycles),
// No asserted phase band while learning — a population-28 frame off a
// single cycle is not yet earned (Lower: single-cycle phase band).
phase: stillLearning ? null : phaseAcross(date, phaseCycles),
isPredictedPeriod,
isFertileWindow,
isPredictedOvulation,
@@ -310,10 +360,11 @@ export function buildCalendar(
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
basalBodyTempC: log?.basalBodyTempC ?? null,
temperatureExcluded: log?.temperatureExcluded ?? false,
ovulationTest: log?.ovulationTest ?? null,
cervicalMucus: log?.cervicalMucus ?? null,
});
}
return { prediction, days };
return { prediction, stillLearning, days };
}
+1
View File
@@ -17,6 +17,7 @@ export {
detectMucusPeak,
confirmSymptothermal,
detectTemperatureTrend,
detectLhSurgeOvulation,
median,
mad,
resolveLuteal,
+14
View File
@@ -21,3 +21,17 @@ export const PHASE_WHATS_HAPPENING_KEY: Record<CyclePhase, string> = {
OVULATORY: "cycle.phaseEducation.whatsHappening.OVULATORY",
LUTEAL: "cycle.phaseEducation.whatsHappening.LUTEAL",
};
/**
* The phase → i18n leaf for the second, deeper "context" line — what someone
* might notice (energy, sleep, basal temperature, mood) and a gentle framing of
* normal variation. Same descriptive-only constraint as above: no advice, no
* clinical claim, never prescriptive. Spelled out as literals for the same
* call-site-coverage reason.
*/
export const PHASE_CONTEXT_KEY: Record<CyclePhase, string> = {
MENSTRUAL: "cycle.phaseEducation.context.MENSTRUAL",
FOLLICULAR: "cycle.phaseEducation.context.FOLLICULAR",
OVULATORY: "cycle.phaseEducation.context.OVULATORY",
LUTEAL: "cycle.phaseEducation.context.LUTEAL",
};
+279 -30
View File
@@ -31,6 +31,7 @@ import {
HARD_CYCLE_MAX,
HARD_CYCLE_MIN,
HISTORY_WINDOW_N,
LH_SURGE_TO_OVULATION_DAYS,
LOG_SPARSITY_SCALE,
LUTEAL_DEFAULT,
LUTEAL_MAX,
@@ -56,6 +57,7 @@ import {
type CycleProfileInput,
type DayLogInput,
type NightlyTempInput,
type SecondarySymptom,
type PredictionMethod,
} from "./types";
@@ -250,74 +252,265 @@ export function estimatePeriodLength(
/* §4 — symptothermal + temperature-trend ovulation detection */
/* ------------------------------------------------------------------ */
/** Which sensiplan temperature rule confirmed the shift (provenance). */
export type TempShiftRule = 0 | 1 | 2;
interface TempShiftResult {
/** Confirmed ovulation day = day before the first of the 3 elevated readings. */
ovulationDate: string;
/**
* Which rule confirmed the rise: 0 = the regular 3-over-6 rule (3rd reading
* ≥0.2°C above the cover line), 1 = the 1. Ausnahmeregel (slow/staircase rise,
* 4th reading required), 2 = the 2. Ausnahmeregel (one of the 3 falls back to
* the line, 4th reading required ≥0.2°C above).
*/
rule: TempShiftRule;
/** Day the evaluation completed (evening of the 3rd or 4th high reading). */
evaluationCompleteDate: string;
}
/**
* §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures.
* The first day of 3 consecutive temps each strictly above the highest of the
* immediately preceding 6 temps, AND the 3rd is >= 0.2°C above that 6-day max.
* Ovulation = the last low-temp day before the rise (the day BEFORE the first
* elevated reading).
* §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures, with both
* published Ausnahmeregeln (exception rules).
*
* Cover line (Hüllkurve) = the highest of the 6 measured low values immediately
* preceding the rise. Excluded (disturbed) readings are dropped before the cover
* line and the rise are evaluated, so a fever / late reading neither raises the
* cover line nor masks a true shift (Sensiplan: the line is drawn over the last
* six *unbracketed* values). Ovulation = the last low day before the rise (the
* day BEFORE the first elevated reading).
*
* Rules (Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Temperatur­kurve
* auswerten"; Generation-Pille "Die wichtigsten NFP-Regeln"):
* - **Regular rule (0):** 3 consecutive readings strictly above the cover line,
* the 3rd ≥0.2°C above it. Evaluation completes on the 3rd high day.
* - **1. Ausnahmeregel (1):** if the 3rd reading is not ≥0.2°C above the line,
* await a 4th reading which need only be above the line. Completes on the 4th.
* - **2. Ausnahmeregel (2):** if exactly one of the 3 readings falls back to/below
* the line, it is discounted and a 4th reading is required which must again be
* ≥0.2°C above the line. Completes on the 4th.
* The two exception rules are mutually exclusive by construction.
*/
export function detectTempShift(
dayLogs: readonly DayLogInput[],
thresholdC: number,
): TempShiftResult | null {
const temps = dayLogs
.filter((l) => l.basalBodyTempC != null)
.filter((l) => l.basalBodyTempC != null && l.temperatureExcluded !== true)
.map((l) => ({ date: l.date, t: l.basalBodyTempC as number }))
.sort((a, b) => dayDiff(a.date, b.date));
// Need 6 baseline + 3 elevated = 9 readings minimum. Scan from the END and
// return the LATEST qualifying shift — for a multi-cycle BBT series the most
// recent rise is the one that belongs to the current cycle; the earliest
// match would confirm a months-old ovulation (QA: window scoping).
// match would confirm a months-old ovulation (QA: window scoping). When a 4th
// reading is needed (exception rules) the candidate's start index may run to
// temps.length - 4; the loop starts there and skips candidates without enough
// following readings for the rule that fires.
for (let i = temps.length - 3; i >= 6; i--) {
const baseline = temps.slice(i - 6, i);
const sixMax = Math.max(...baseline.map((x) => x.t));
const rise = [temps[i], temps[i + 1], temps[i + 2]];
const allAbove = rise.every((r) => r.t > sixMax);
// The 3rd elevated reading must clear the 6-day max by the threshold.
const thirdClears = roundHalf(rise[2].t - sixMax, 2) >= thresholdC;
if (allAbove && thirdClears) {
// Ovulation = day before the first elevated reading.
return { ovulationDate: addDays(rise[0].date, -1) };
const r1 = temps[i];
const r2 = temps[i + 1];
const r3 = temps[i + 2];
const r4 = temps[i + 3]; // may be undefined (no 4th reading available)
const above = (t: number) => t > sixMax;
const clears = (t: number) => roundHalf(t - sixMax, 2) >= thresholdC;
// Regular rule (0): 3 above, 3rd clears the threshold.
if (above(r1.t) && above(r2.t) && above(r3.t) && clears(r3.t)) {
return {
ovulationDate: addDays(r1.date, -1),
rule: 0,
evaluationCompleteDate: r3.date,
};
}
// 1. Ausnahmeregel (1): all 3 above the line but the 3rd does not clear
// 0.2°C → require a 4th reading that is merely above the line.
if (
above(r1.t) &&
above(r2.t) &&
above(r3.t) &&
!clears(r3.t) &&
r4 != null &&
above(r4.t)
) {
return {
ovulationDate: addDays(r1.date, -1),
rule: 1,
evaluationCompleteDate: r4.date,
};
}
// 2. Ausnahmeregel (2): the 1st is above the line and EXACTLY ONE of the
// 2nd/3rd falls back to/below the line → that value is discounted and a 4th
// reading is required which must again clear 0.2°C above the line. (The 1st
// high measurement itself must stay above the line — it anchors the rise.)
const oneFellBack = above(r1.t) && above(r2.t) !== above(r3.t);
if (oneFellBack && r4 != null && above(r4.t) && clears(r4.t)) {
return {
ovulationDate: addDays(r1.date, -1),
rule: 2,
evaluationCompleteDate: r4.date,
};
}
}
return null;
}
/**
* §4.2(b) — cervical-mucus peak: the LAST day of egg-white/watery mucus.
* Infertile phase confirmed on peak+3; ovulation is taken at the peak day for
* the agreement check.
* Mucus quality magnitude on the Sensiplan t/f/S/+S ladder (0 = driest,
* 4 = best/peak quality). EGG_WHITE/WATERY are the peak-quality (+S) classes;
* STICKY/CREAMY are lower fertile-mucus, DRY is t. A null reading is not a
* "drier day" — it is an unobserved day and does not count toward the post-peak
* run (the run is over observed mucus days only).
*/
function mucusQuality(m: DayLogInput["cervicalMucus"]): number {
switch (m) {
case "EGG_WHITE":
return 4;
case "WATERY":
return 3;
case "CREAMY":
return 2;
case "STICKY":
return 1;
case "DRY":
return 0;
default:
return -1; // not observed
}
}
/** Peak-quality mucus = the best (+S) classes: egg-white / watery. */
const MUCUS_PEAK_QUALITY = 3;
/**
* §4.2(b) — sensiplan mucus peak (Höhepunkt). The peak day is the LAST day of
* best-quality (egg-white / watery / spinnbar) mucus that is FOLLOWED by at
* least 3 consecutive drier observed days (each strictly lower quality than the
* peak). The peak is only confirmable retrospectively, after those 3 days — so a
* stray late egg-white entry that is NOT yet followed by 3 drier days cannot
* move an already-confirmed peak.
*
* Returns the confirmed peak day, or null when no best-quality day has yet been
* followed by 3 drier observed days.
*
* Citation: "Höhepunkt = the last day of best-quality (S+) mucus; evaluation
* completes on the evening of the 3rd day after the change to poorer quality."
* Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Zervixschleim
* beobachten".
*/
export function detectMucusPeak(
dayLogs: readonly DayLogInput[],
): string | null {
const peakDays = dayLogs
.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;
// Observed mucus days only, oldest→newest. Unobserved days are skipped so a
// gap in logging doesn't break the 3-drier-day post-peak run.
const observed = dayLogs
.filter((l) => mucusQuality(l.cervicalMucus) >= 0)
.map((l) => ({ date: l.date, q: mucusQuality(l.cervicalMucus) }))
.sort((a, b) => dayDiff(a.date, b.date));
// Walk every best-quality day; a candidate peak is confirmed iff the next 3
// observed days are all strictly lower quality. Scan forward and keep the
// LATEST confirmed peak (a true later peak supersedes an earlier one).
let confirmedPeak: string | null = null;
for (let i = 0; i < observed.length; i++) {
if (observed[i].q < MUCUS_PEAK_QUALITY) continue;
const following = observed.slice(i + 1, i + 4);
if (following.length < 3) continue; // not yet evaluable
const allDrier = following.every((d) => d.q < observed[i].q);
if (allDrier) confirmedPeak = observed[i].date;
}
return confirmedPeak;
}
/**
* §4.2 — symptothermal CONFIRMATION: ovulation is confirmed only when the
* temp-shift day and the mucus-peak agree within ±2 days. Returns the
* temp-derived ovulation day when confirmed, else null.
* §4.2(b') — sensiplan cervix peak (Höhepunkt), the CERVIX-secondary-symptom
* analogue of `detectMucusPeak`. The cervix is a fertile sign while it is HIGH,
* SOFT, or OPEN (any of the three Sensiplan cervix signs in its fertile state);
* it is infertile only when fully closed — LOW + FIRM + CLOSED. The peak day is
* the LAST fertile-cervix day FOLLOWED by at least 3 consecutive closed
* (infertile) observed days (the cervix-closure rule). Like the mucus peak it is
* only confirmable retrospectively, so a stray late fertile-cervix entry not yet
* followed by 3 closed days cannot move an already-confirmed peak.
*
* A cervix "day" counts as observed when at least one of the three signs is
* logged (an unlogged sign is treated as its fertile/uncertain state so a
* partial entry never fabricates a closure). Returns the confirmed peak day, or
* null when no fertile-cervix day has yet been followed by 3 closed days.
*
* Citation: cervix observation as a Sensiplan secondary sign — the cervix opens,
* softens and rises around ovulation and closes, firms and lowers afterward;
* evaluation follows the same "last fertile day + 3 confirming days" shape as
* mucus. Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Den
* Gebärmutterhals beobachten".
*/
export function detectCervixPeak(
dayLogs: readonly DayLogInput[],
): string | null {
// Observed cervix days only (at least one sign logged), oldest→newest.
const observed = dayLogs
.filter(
(l) =>
l.cervixPosition != null ||
l.cervixFirmness != null ||
l.cervixOpening != null,
)
.map((l) => ({ date: l.date, closed: isCervixClosed(l) }))
.sort((a, b) => dayDiff(a.date, b.date));
// Walk every fertile-cervix day; a candidate peak is confirmed iff the next 3
// observed cervix days are all closed (infertile). Scan forward and keep the
// LATEST confirmed peak (a true later peak supersedes an earlier one).
let confirmedPeak: string | null = null;
for (let i = 0; i < observed.length; i++) {
if (observed[i].closed) continue; // only a fertile-cervix day can be a peak
const following = observed.slice(i + 1, i + 4);
if (following.length < 3) continue; // not yet evaluable
if (following.every((d) => d.closed)) confirmedPeak = observed[i].date;
}
return confirmedPeak;
}
/**
* A cervix is in its infertile (closed) state only when ALL three signs read
* infertile: LOW + FIRM + CLOSED. Any fertile sign (HIGH / SOFT / OPEN) — or an
* unlogged sign, treated conservatively as not-yet-closed — keeps the day
* fertile, so a partial entry never fabricates a closure.
*/
function isCervixClosed(log: DayLogInput): boolean {
return (
log.cervixPosition === "LOW" &&
log.cervixFirmness === "FIRM" &&
log.cervixOpening === "CLOSED"
);
}
/**
* §4.2 — symptothermal CONFIRMATION (the Sensiplan double-check): ovulation is
* confirmed only when the temperature primary sign AND the chosen SECONDARY sign
* both confirm and agree within ±2 days. The secondary sign is the cervical
* mucus peak by default, or the cervix peak when `secondarySymptom` is `CERVIX`.
* Returns the temp-derived ovulation day when confirmed, else null.
*
* A single sign never confirms — no temperature shift returns null regardless of
* the secondary sign, and no secondary peak returns null regardless of the
* temperature. This is the honesty principle: the double-check requires two
* indicators, never one.
*/
export function confirmSymptothermal(
dayLogs: readonly DayLogInput[],
secondarySymptom: SecondarySymptom = "MUCUS",
): string | null {
const shift = detectTempShift(dayLogs, TEMP_SHIFT_C_MANUAL);
if (!shift) return null;
const peak = detectMucusPeak(dayLogs);
const peak =
secondarySymptom === "CERVIX"
? detectCervixPeak(dayLogs)
: detectMucusPeak(dayLogs);
if (!peak) return null;
if (
Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS
@@ -353,6 +546,33 @@ export function detectTemperatureTrend(
return null;
}
/**
* §4 — Marquette (Fehring) LH/OPK ovulation anchor. A positive urinary-LH surge
* precedes ovulation by ~24–36 h, so the estimated ovulation day is one day
* after the LAST positive LH test in the supplied logs (the surge can read
* positive across consecutive days; the last positive is closest to ovulation).
*
* This ANCHORS the ovulation estimate — it never confirms it. A single LH
* indicator is not the symptothermal double-check, so the caller folds this in
* as a refining signal that sharpens the estimate without flipping
* `ovulationConfirmed`. Returns the estimated ovulation day, or null when no
* positive LH test is present.
*
* Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before
* ovulation.
*/
export function detectLhSurgeOvulation(
dayLogs: readonly DayLogInput[],
): string | null {
const positives = dayLogs
.filter((l) => l.ovulationTest === "POSITIVE_LH_SURGE")
.map((l) => l.date)
.sort((a, b) => dayDiff(a, b));
if (positives.length === 0) return null;
const lastPositive = positives[positives.length - 1];
return addDays(lastPositive, LH_SURGE_TO_OVULATION_DAYS);
}
/* ------------------------------------------------------------------ */
/* §3 — confidence + band */
/* ------------------------------------------------------------------ */
@@ -453,7 +673,7 @@ export function resolveLuteal(
* engine returns the window and the caller hides it).
*
* @param cycles confirmed (non-predicted) cycles; order-independent.
* @param dayLogs all day logs the device holds (for period, symptothermal).
* @param dayLogs all day logs the device holds (period, symptothermal, LH/OPK).
* @param profile user priors + mode flags.
* @param today `YYYY-MM-DD` reference day (for adherence density).
* @param nights optional nightly passive-temperature series (Apple Watch).
@@ -525,7 +745,10 @@ export function predictCycle(
dayDiff(date, windowStart) >= 0 && dayDiff(today, date) >= 0;
const currentDayLogs = dayLogs.filter((l) => isInCurrentWindow(l.date));
const currentNights = nights.filter((n) => isInCurrentWindow(n.date));
const symptoOvulation = confirmSymptothermal(currentDayLogs);
const symptoOvulation = confirmSymptothermal(
currentDayLogs,
profile.secondarySymptom,
);
const trendOvulation = symptoOvulation
? null
: detectTemperatureTrend(currentNights);
@@ -552,6 +775,32 @@ export function predictCycle(
}
}
// -------- §4 Marquette LH/OPK ANCHOR (refines, never confirms) --------
// A positive LH surge predicts ovulation ~24–36 h later (Marquette/Fehring).
// It is a single indicator, NOT the symptothermal double-check, so it only
// RE-ANCHORS the ovulation estimate — it never flips `ovulationConfirmed`.
// When the symptothermal or temperature-trend layer already confirmed, that
// stronger signal wins and the LH anchor is ignored. Otherwise the LH surge
// sharpens the still-estimated ovulation (and the derived next-start) over
// the calendar back-calculation — most valuable in the thin-data (<3 cycle)
// case where the median/luteal back-calc is weak. The still-learning gate and
// the un-confirmed status are both preserved (honesty principle).
if (!ovulationConfirmed) {
const lhOvulation = detectLhSurgeOvulation(currentDayLogs);
if (lhOvulation) {
const lhNextStart = addDays(lhOvulation, lutealLength);
// Only adopt a future-dated anchor (a positive-LH next-start in the past
// is a stale prior-cycle signal that slipped the window).
if (dayDiff(lhNextStart, today) >= 0) {
predictedOvulation = lhOvulation;
nextStart = lhNextStart;
// method records the mix but ovulationConfirmed stays FALSE — a single
// LH indicator never asserts confirmed ovulation.
method = "BLENDED";
}
}
}
// -------- §3 band half-width --------
const { cAdherence, logSparsity } = adherenceFactors(
lastConfirmedStart,
+69 -2
View File
@@ -34,6 +34,22 @@ export type CervicalMucus =
| "WATERY"
| "EGG_WHITE";
/**
* Which symptothermal SECONDARY sign pairs with the temperature primary sign
* for the Sensiplan double-check. `MUCUS` (cervical-mucus quality) is the
* default; `CERVIX` swaps in the cervix observation.
*/
export type SecondarySymptom = "MUCUS" | "CERVIX";
/** Cervix vertical position — fertile = HIGH, infertile = LOW. */
export type CervixPosition = "LOW" | "HIGH";
/** Cervix firmness — fertile = SOFT, infertile = FIRM. */
export type CervixFirmness = "FIRM" | "SOFT";
/** Cervix os / opening — fertile = OPEN, infertile = CLOSED. */
export type CervixOpening = "CLOSED" | "OPEN";
export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
export type PredictionMethod =
@@ -105,9 +121,23 @@ export const FERTILE_POST = 1;
export const OUTLIER_K = 3.0;
export const OUTLIER_K_PERIMENOPAUSE = 4.0;
/** §5 — hard physiological cycle-length bounds (always outlier candidates). */
/**
* §5 — hard physiological cycle-length bounds (always outlier candidates).
*
* The ceiling is a deliberately generous outlier *backstop*, not a clinical
* "normal" cap — the robust MAD fence + missed-log heuristic do the real
* outlier work. ACOG flags cycles > 35 d as oligomenorrhea, but such cycles are
* still genuine, and adolescents / perimenopausal users routinely run to ~50+ d.
* A hard cap of 45 force-excluded those legitimate long cycles, biasing the
* estimate toward the population middle for exactly the irregular users who most
* need an honest estimate. 60 d keeps the rare gross-error guard (a length this
* long is almost always a missed log, which the missed-log rule also catches)
* while letting real long cycles reach the MAD fence. The MAD fence is
* computed pre-exclusion, so raising the ceiling does not move the fence — it
* only stops the hard rule from pre-empting it on long but consistent cycles.
*/
export const HARD_CYCLE_MIN = 21;
export const HARD_CYCLE_MAX = 45;
export const HARD_CYCLE_MAX = 60;
/** §5 — a single length ≥ this × median is treated as a probable missed-log. */
export const MISSED_LOG_FACTOR = 1.75;
@@ -125,6 +155,18 @@ export const HALF_WIDTH_MULT_TEMP_TREND = 0.75;
/** §4 — symptothermal agreement tolerance between temp-shift and mucus peak (days). */
export const SYMPTOTHERMAL_AGREE_DAYS = 2;
/**
* §4 — Marquette (Fehring) LH/OPK ovulation lag. A positive urinary-LH surge
* precedes ovulation by ~24–36 h, so ovulation is estimated one day after the
* (last) positive LH test. Used to ANCHOR — never to confirm — the ovulation
* estimate: a single LH indicator is not the symptothermal double-check, so it
* sharpens the estimate without asserting confirmed ovulation.
*
* Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before
* ovulation. ESHRE/Wilcox fertile-window literature.
*/
export const LH_SURGE_TO_OVULATION_DAYS = 1;
/**
* §4 — trailing lookback (days) for the symptothermal / temperature-trend
* detectors. The 3-over-6 BBT rule needs 6 baseline readings before the rise,
@@ -178,8 +220,26 @@ export interface DayLogInput {
flow: FlowLevel | null;
/** Basal body temperature in °C, already rounded to 2 dp before input. */
basalBodyTempC: number | null;
/**
* Whether this day's BBT is a disturbed/unreliable reading (fever, illness,
* alcohol, a late or short-sleep measurement) the user marked excluded. When
* true the engine drops the reading from the cover-line baseline AND the shift
* evaluation, so a spike neither fabricates nor masks a temperature rise
* (Sensiplan: the cover line is drawn over the last six *unbracketed* values).
* Optional for back-compat; treated as `false` when absent.
*/
temperatureExcluded?: boolean | null;
ovulationTest: OvulationTest | null;
cervicalMucus: CervicalMucus | null;
/**
* Cervix observation — the three Sensiplan cervix signs. Used as the
* symptothermal SECONDARY indicator when the profile's `secondarySymptom`
* is `CERVIX`; ignored on the default (mucus) path. All optional for
* back-compat; an unobserved sign is `null`/absent.
*/
cervixPosition?: CervixPosition | null;
cervixFirmness?: CervixFirmness | null;
cervixOpening?: CervixOpening | null;
}
/**
@@ -205,6 +265,13 @@ export interface CycleProfileInput {
predictionEnabled: boolean;
/** Read-Your-Body mode: suppress all interpretation. */
rawChartMode: boolean;
/**
* Which symptothermal SECONDARY sign the double-check uses — `MUCUS`
* (default) or `CERVIX`. Optional for back-compat; the engine treats an
* absent value as `MUCUS`, so existing callers keep the mucus path
* unchanged.
*/
secondarySymptom?: SecondarySymptom;
}
/* ------------------------------------------------------------------ */
+19
View File
@@ -11,6 +11,10 @@ import {
flowLevelEnum,
ovulationTestEnum,
cervicalMucusEnum,
secondarySymptomEnum,
cervixPositionEnum,
cervixFirmnessEnum,
cervixOpeningEnum,
homeTestResultEnum,
cycleTrackingGoalEnum,
cycleDayLogInputSchema,
@@ -101,8 +105,12 @@ const cycleDayLogDto = z
flow: flowLevelEnum.nullable(),
intermenstrualBleeding: z.boolean(),
basalBodyTempC: z.number().nullable(),
temperatureExcluded: z.boolean(),
ovulationTest: ovulationTestEnum.nullable(),
cervicalMucus: cervicalMucusEnum.nullable(),
cervixPosition: cervixPositionEnum.nullable(),
cervixFirmness: cervixFirmnessEnum.nullable(),
cervixOpening: cervixOpeningEnum.nullable(),
sexualActivity: z.boolean(),
protectedSex: z.boolean().nullable(),
pregnancyTest: homeTestResultEnum.nullable(),
@@ -175,14 +183,19 @@ const cycleCalendarDayDto = z.object({
// so the web BBT chart renders from the calendar read (the values are already
// loaded server-side for the symptothermal layer; no extra query).
basalBodyTempC: z.number().nullable(),
temperatureExcluded: z.boolean(),
ovulationTest: ovulationTestEnum.nullable(),
cervicalMucus: cervicalMucusEnum.nullable(),
cervixPosition: cervixPositionEnum.nullable(),
cervixFirmness: cervixFirmnessEnum.nullable(),
cervixOpening: cervixOpeningEnum.nullable(),
});
const cycleProfileDto = z
.object({
goal: cycleTrackingGoalEnum,
cycleTrackingEnabled: z.boolean(),
secondarySymptom: secondarySymptomEnum,
rawChartMode: z.boolean(),
predictionEnabled: z.boolean(),
discreetNotifications: z.boolean(),
@@ -205,6 +218,12 @@ const cycleCalendarResponse = z.object({
cyclesObserved: z.number().int(),
}),
prediction: cyclePredictionDto.nullable(),
// Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
// are observed. When set, the `days` grid carries no fertile window, no
// predicted-ovulation dot, and no phase band (those would rest on a
// population prior) — the client shows a calm "learning your cycle" state.
// Additive + back-compatible.
stillLearning: z.boolean(),
days: z.array(cycleCalendarDayDto),
meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }),
});
+5
View File
@@ -141,8 +141,12 @@ const cycleDayLogSchema = z
flow: z.string().nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().nullable().optional(),
temperatureExcluded: z.boolean().optional(),
ovulationTest: z.string().nullable().optional(),
cervicalMucus: z.string().nullable().optional(),
cervixPosition: z.string().nullable().optional(),
cervixFirmness: z.string().nullable().optional(),
cervixOpening: z.string().nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: z.string().nullable().optional(),
@@ -165,6 +169,7 @@ const cycleProfileSchema = z
typicalCycleLength: z.number().int().nullable().optional(),
typicalPeriodLength: z.number().int().nullable().optional(),
lutealPhaseLength: z.number().int().nullable().optional(),
secondarySymptom: z.string().nullable().optional(),
predictionEnabled: z.boolean().optional(),
rawChartMode: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
+23
View File
@@ -51,6 +51,14 @@ export const cervicalMucusEnum = z.enum([
"EGG_WHITE",
]);
/** Symptothermal secondary-symptom choice (mirrors the Prisma enum). */
export const secondarySymptomEnum = z.enum(["MUCUS", "CERVIX"]);
/** The three Sensiplan cervix signs (mirror the Prisma enums). */
export const cervixPositionEnum = z.enum(["LOW", "HIGH"]);
export const cervixFirmnessEnum = z.enum(["FIRM", "SOFT"]);
export const cervixOpeningEnum = z.enum(["CLOSED", "OPEN"]);
export const homeTestResultEnum = z.enum([
"NEGATIVE",
"POSITIVE",
@@ -103,8 +111,16 @@ export const cycleDayLogInputSchema = z.object({
flow: flowLevelEnum.optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().min(30).max(45).optional(),
// Marks the day's BBT as disturbed (fever / illness / late reading): the
// symptothermal engine drops it from the cover line + shift evaluation.
temperatureExcluded: z.boolean().optional(),
ovulationTest: ovulationTestEnum.optional(),
cervicalMucus: cervicalMucusEnum.optional(),
// Cervix observation — the symptothermal secondary indicator when the
// profile's secondarySymptom is CERVIX.
cervixPosition: cervixPositionEnum.optional(),
cervixFirmness: cervixFirmnessEnum.optional(),
cervixOpening: cervixOpeningEnum.optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: homeTestResultEnum.optional(),
@@ -132,8 +148,12 @@ export const cycleDayLogPatchSchema = z.object({
flow: flowLevelEnum.nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().min(30).max(45).nullable().optional(),
temperatureExcluded: z.boolean().optional(),
ovulationTest: ovulationTestEnum.nullable().optional(),
cervicalMucus: cervicalMucusEnum.nullable().optional(),
cervixPosition: cervixPositionEnum.nullable().optional(),
cervixFirmness: cervixFirmnessEnum.nullable().optional(),
cervixOpening: cervixOpeningEnum.nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: homeTestResultEnum.nullable().optional(),
@@ -188,6 +208,9 @@ export const cyclePrefsSchema = z
.object({
enabled: z.boolean().optional(),
goal: cycleTrackingGoalEnum.optional(),
// Symptothermal secondary symptom — advanced cycle setting (MUCUS default
// | CERVIX). Drives which second sign the double-check uses.
secondarySymptom: secondarySymptomEnum.optional(),
rawChartMode: z.boolean().optional(),
predictionEnabled: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
+13 -1
View File
@@ -176,6 +176,9 @@ describe("cycle backup — round-trips through build + restore", () => {
date: "2026-03-02",
flow: "MEDIUM",
cycleId: cycle.id,
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
notesEncrypted: "v1:abc123:cipher-envelope",
source: "MANUAL",
},
@@ -187,7 +190,12 @@ describe("cycle backup — round-trips through build + restore", () => {
data: { dayLogId: dayLog.id, symptomId: symptom.id },
});
await prisma.cycleProfile.create({
data: { userId: USER_ID, goal: "TRYING_TO_CONCEIVE", cycleTrackingEnabled: true },
data: {
userId: USER_ID,
goal: "TRYING_TO_CONCEIVE",
cycleTrackingEnabled: true,
secondarySymptom: "CERVIX",
},
});
// Build the backup section, then assert it round-trips through the schema.
@@ -217,6 +225,9 @@ describe("cycle backup — round-trips through build + restore", () => {
});
expect(restored).toHaveLength(1);
expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope");
expect(restored[0].cervixPosition).toBe("HIGH");
expect(restored[0].cervixFirmness).toBe("SOFT");
expect(restored[0].cervixOpening).toBe("OPEN");
expect(restored[0].cycleId).not.toBeNull();
expect(restored[0].symptomLinks.map((l) => l.symptom.key)).toEqual([
"cramps",
@@ -226,5 +237,6 @@ describe("cycle backup — round-trips through build + restore", () => {
where: { userId: USER_ID },
});
expect(profile.goal).toBe("TRYING_TO_CONCEIVE");
expect(profile.secondarySymptom).toBe("CERVIX");
});
});
+42
View File
@@ -202,6 +202,30 @@ describe("cycle day-logs — single read (GET)", () => {
const res = await GET(jsonRequest("/api/cycle/day-logs?date=nope", "GET"));
expect(res.status).toBe(422);
});
it("round-trips the cervix observation signs through POST → GET", async () => {
await loginAs(FEMALE_USER_ID);
const route = await import("@/app/api/cycle/day-logs/route");
await route.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-05-02",
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
loggedAt: "2026-05-02T08:00:00.000Z",
}),
);
const hit = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-05-02", "GET"),
);
expect(hit.status).toBe(200);
const { data } = await hit.json();
expect(data.cervixPosition).toBe("HIGH");
expect(data.cervixFirmness).toBe("SOFT");
expect(data.cervixOpening).toBe("OPEN");
});
});
describe("cycle day-logs — symptom severity", () => {
@@ -488,6 +512,24 @@ describe("cycle-prefs gate flip", () => {
);
expect(res.status).toBe(201);
});
it("defaults secondarySymptom to MUCUS and persists a switch to CERVIX", async () => {
await loginAs(FEMALE_USER_ID);
const { GET, PATCH } = await import(
"@/app/api/auth/me/cycle-prefs/route"
);
const initial = await GET();
expect((await initial.json()).data.secondarySymptom).toBe("MUCUS");
const patched = await PATCH(
jsonRequest("/api/auth/me/cycle-prefs", "PATCH", {
secondarySymptom: "CERVIX",
}),
);
expect(patched.status).toBe(200);
expect((await patched.json()).data.secondarySymptom).toBe("CERVIX");
});
});
describe("sync/changes — cycle domains", () => {