mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #275 from MBombeck/release/v1.15.0
release/v1.15.0 — cycle tracking
This commit is contained in:
142 files changed
+20987
-361
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.0] — 2026-06-06 — cycle tracking
|
||||
|
||||
### Added
|
||||
|
||||
- **Cycle tracking.** A new, private menstrual-cycle vertical. Log your period (flow and intermenstrual spotting), basal body temperature, cervical mucus, ovulation and pregnancy/progesterone tests, contraception, intercourse, and graded symptoms — all from one quick-entry sheet. A month calendar marks logged days, the predicted period window, the fertile window, and ovulation; the cycle wheel shows your current day and phase at a glance, and a logged day can be edited or deleted.
|
||||
- **Honest predictions.** Next-period and fertile-window forecasts are always shown as a range with a confidence level — never a single certain date — and carry a clear, non-medical caveat (with a stronger one when you track to avoid pregnancy). A "read your body" mode suppresses every interpretation if you prefer to see only your raw data. The forecast is computed from your own data so the iOS client produces the identical result offline.
|
||||
- **Basal-body-temperature chart** for the current cycle, with your fertility signs in the tooltip and the estimated — or confirmed — ovulation marked.
|
||||
- **Phase insights.** An insights tab relates your cycle phase to your vitals — resting heart rate, HRV, sleep, steps, weight, blood glucose, mood — showing where each differs between phases with statistical significance, and which of your logged symptoms cluster in which phase. Associations only, never cause. A per-cycle history lists your recent cycle and period lengths.
|
||||
- **Privacy first.** Cycle tracking is off by default except for accounts set to female, and anyone can turn it on or off from settings. The intent-revealing fields are encrypted at rest behind a default-on toggle; a discreet-notifications mode keeps any reminder generic on the lock screen; period reminders are opt-in per channel; and a one-tap purge hard-deletes every cycle row, the cycle audit trail, and reminder records, and clears your tracking intent.
|
||||
- **Apple Health import.** Reproductive-health categories from an Apple Health export fold into your cycle log, and cycle data is included in the health-record export (PDF and FHIR).
|
||||
|
||||
## [1.14.0] — 2026-06-06 — animated health scores, score explanations, mood-factor correlations, log past doses
|
||||
|
||||
### Added
|
||||
|
||||
@@ -595,7 +595,8 @@ The full documentation set lives under [`docs/`](docs/) (also published at [docs
|
||||
|
||||
| Area | Where | What it covers |
|
||||
| ---- | ----- | -------------- |
|
||||
| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling |
|
||||
| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling, [reproductive-health data on your own server](docs/self-hosting/reproductive-health-data.md) |
|
||||
| Threat model | [`docs/security/cycle-data-threat-model.md`](docs/security/cycle-data-threat-model.md) | What self-hosting cycle data does and does not protect against |
|
||||
| Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning |
|
||||
| API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset |
|
||||
| Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Apple Health → cycle mapping
|
||||
|
||||
How HealthLog folds Apple Health reproductive samples into the cycle model.
|
||||
Both the `export.xml` importer and the iOS batch contract share one mapping
|
||||
table: `src/lib/cycle/healthkit-mapping.ts`. This page is the human-readable
|
||||
companion — keep the two in sync.
|
||||
|
||||
Reproductive samples route into **cycle day-logs** (`CycleDayLog`), never into
|
||||
`Measurement`. Each Apple Health day collapses into one canonical day-log per
|
||||
`(user, date)`; the importer writes `source = APPLE_HEALTH` with a synthetic
|
||||
per-day `externalId` (`hkcycle:<YYYY-MM-DD>`) so a re-import upserts the same
|
||||
row. The fold only runs when cycle tracking is enabled for the account.
|
||||
|
||||
## Day-log fields
|
||||
|
||||
| HealthKit category type | CycleDayLog field | Values |
|
||||
|---|---|---|
|
||||
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
|
||||
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
|
||||
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
|
||||
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
|
||||
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
|
||||
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
|
||||
|
||||
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
|
||||
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
|
||||
logged-but-unspecified bleeding day is at least light flow).
|
||||
|
||||
## Symptom category types → seeded symptom keys
|
||||
|
||||
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
|
||||
sample whose severity is *not present* (codepoint 1) does NOT create a link.
|
||||
|
||||
| HealthKit category type | CycleSymptom key |
|
||||
|---|---|
|
||||
| `AbdominalCramps` | `cramps` |
|
||||
| `Headache` | `headache` |
|
||||
| `Bloating` | `bloating` |
|
||||
| `Acne` | `acne` |
|
||||
| `BreastPain` | `breast_tenderness` |
|
||||
| `Fatigue` | `fatigue` |
|
||||
| `LowerBackPain` | `back_pain` |
|
||||
| `SleepChanges` | `insomnia` |
|
||||
| `MoodChanges` | `mood_swings` |
|
||||
| `AppetiteChanges` | `food_cravings` |
|
||||
| `Nausea` | `nausea` |
|
||||
|
||||
## Profile-level signals
|
||||
|
||||
A `Contraceptive` sample asserting an active method also nudges the
|
||||
`CycleProfile.goal` to `AVOID_PREGNANCY` — but only when the goal is still on
|
||||
its `GENERAL_HEALTH` default, so an explicit user choice is never overwritten.
|
||||
|
||||
## Basal body temperature
|
||||
|
||||
`basalBodyTemperature` is **not** routed here. It stays a
|
||||
`Measurement(BODY_TEMPERATURE)` (the system of record for the rollup tier and
|
||||
the cross-source source-priority ladder) and is mirrored onto
|
||||
`CycleDayLog.basalBodyTempC` for the cycle chart. Apple Watch wrist/skin
|
||||
temperature already flows to `WRIST_TEMPERATURE` / `SKIN_TEMPERATURE` and feeds
|
||||
the temperature-trend ovulation method directly from `Measurement`.
|
||||
|
||||
## Deferred
|
||||
|
||||
| HealthKit category type | Status |
|
||||
|---|---|
|
||||
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
|
||||
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
|
||||
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
|
||||
+1883
-171
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
# Cycle-data threat model
|
||||
|
||||
A compact statement of what self-hosting HealthLog's cycle tracking
|
||||
protects against and what it does not. Pair this with the operator
|
||||
guidance in
|
||||
[`self-hosting/reproductive-health-data.md`](../self-hosting/reproductive-health-data.md).
|
||||
|
||||
The scope here is reproductive-health data specifically (menstrual,
|
||||
fertility, pregnancy, contraception, sexual-activity fields), because
|
||||
that is the category where the "who can reach my data" question carries
|
||||
the highest stakes.
|
||||
|
||||
## What self-hosting + encryption-at-rest protects against
|
||||
|
||||
- **A cloud vendor holding your data.** There is no SaaS account and no
|
||||
vendor key escrow. No third party can be served process for data it
|
||||
does not have.
|
||||
- **A stolen disk or a leaked backup, while the key is held separately.**
|
||||
The intent-revealing fields are AES-256-GCM encrypted at rest; a disk
|
||||
image without the `ENCRYPTION_KEYS` value does not yield them.
|
||||
- **Casual lock-screen disclosure.** Discreet notifications collapse
|
||||
every cycle reminder to a generic "HealthLog reminder" so the event is
|
||||
never named on a lock screen.
|
||||
- **Third-party analytics / ad-tech leakage.** HealthLog ships no
|
||||
telemetry and no third-party trackers; cycle data is never sent to an
|
||||
analytics or advertising endpoint.
|
||||
|
||||
## What it does NOT protect against
|
||||
|
||||
- **Seizure or compulsion of your own host.** If the running machine is
|
||||
seized, imaged, or compelled, the data is on it. Encryption at rest
|
||||
does not help against a live host or a key stored beside the data.
|
||||
- **Your hosting provider.** A VPS provider can snapshot the disk and
|
||||
can be served process. Self-hosting on rented infrastructure narrows
|
||||
the set of parties that hold the data; it does not eliminate them.
|
||||
- **Your own backups.** Any `pg_dump` or off-host export carries the
|
||||
same data and must be protected as part of the same surface.
|
||||
- **A compromised or unlocked client device.** A phone or laptop with a
|
||||
live session or cached client holds data locally. Server-side
|
||||
protection does nothing for an unlocked device.
|
||||
- **Re-identification from "plaintext" fields.** Flow, basal body
|
||||
temperature, ovulation tests, cervical mucus, and cycle dates stay in
|
||||
plaintext so the engines can read them. They are health observations,
|
||||
not the disclosure-sensitive intent fields — but they are still
|
||||
health data on your host.
|
||||
|
||||
## The one-line version
|
||||
|
||||
Self-hosting means **no cloud vendor to subpoena**. It does **not** mean
|
||||
the data is beyond reach: your own infrastructure, your provider, your
|
||||
backups, and your devices are all still part of the surface. Size your
|
||||
operator-side hardening to your own threat model.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Reproductive-health data on your own server
|
||||
|
||||
HealthLog's cycle tracking stores menstrual, fertility, and related
|
||||
reproductive-health data the same way it stores every other metric: in
|
||||
the PostgreSQL database that runs on the host you control, encrypted at
|
||||
rest for the intent-revealing fields. This page explains what that
|
||||
arrangement does and does not change about who can reach that data, in
|
||||
plain terms and without overstating the protection.
|
||||
|
||||
## What self-hosting changes
|
||||
|
||||
A consumer cycle-tracking app runs on the vendor's servers. Your
|
||||
reproductive-health data lives in their database, under their keys, in
|
||||
their jurisdiction. If that vendor receives a subpoena, a civil
|
||||
discovery request, or a law-enforcement demand it judges valid, the
|
||||
vendor — not you — decides how to respond, and it holds the data and the
|
||||
keys needed to comply. Several vendors state in their own privacy
|
||||
policies that they comply with valid legal process; that is a factual
|
||||
description of where the data sits, not an accusation.
|
||||
|
||||
Self-hosting removes that vendor from the picture. There is no cloud
|
||||
account holding your cycle data, no vendor key escrow, and no third
|
||||
party that can be served process to hand over data it does not have. The
|
||||
honest framing is narrow and worth stating precisely:
|
||||
|
||||
> Self-hosting means there is no cloud vendor to subpoena for your
|
||||
> reproductive-health data. It does **not** make the data
|
||||
> unreachable.
|
||||
|
||||
The encryption-at-rest layer is real: the intent-revealing day-log
|
||||
fields (free-text notes always; pregnancy, contraception, and
|
||||
sexual-activity fields by default for this category) are encrypted with
|
||||
AES-256-GCM under a key you supply via `ENCRYPTION_KEYS`, and the
|
||||
application fails closed rather than ever writing those fields in
|
||||
plaintext. Flow, basal body temperature, ovulation-test results,
|
||||
cervical-mucus observations, and the cycle dates stay in plaintext
|
||||
because the prediction and correlation engines read them; they are
|
||||
health observations, not the disclosure-sensitive intent fields.
|
||||
|
||||
## The residual threat (read this part)
|
||||
|
||||
Self-hosting moves the data onto a box you own. That box can still be
|
||||
reached:
|
||||
|
||||
- **Physical or legal seizure of your own host.** If the machine
|
||||
running PostgreSQL — your VPS, your home server, your NAS — is seized,
|
||||
imaged, or compelled, the data is on it. Encryption at rest protects
|
||||
against a stolen disk or a leaked backup only while the key is *not*
|
||||
also available; if the host is running, or the key is stored next to
|
||||
the data, that protection is gone.
|
||||
- **Your hosting provider.** A VPS provider can snapshot your disk and
|
||||
can be served process the same way a SaaS vendor can. Self-hosting on
|
||||
rented infrastructure narrows the set of parties who hold your data;
|
||||
it does not reduce it to zero.
|
||||
- **Your own backups.** Off-host backups (the optional S3 export, or any
|
||||
`pg_dump` you take) carry the same data. Encrypt them, control where
|
||||
they land, and treat them as part of the same threat surface.
|
||||
- **The device that talks to the server.** A phone or laptop with a
|
||||
live session, a cached PWA, or the native client holds data locally
|
||||
too. Protecting the server does nothing for an unlocked device.
|
||||
|
||||
The promise is therefore specific: **no cloud vendor to subpoena**, not
|
||||
**data nobody can ever reach**. If your threat model includes seizure of
|
||||
your own infrastructure, encryption at rest plus a key you keep off the
|
||||
host is the floor, and the threat-model note below covers the rest.
|
||||
|
||||
## Public record
|
||||
|
||||
The reason this distinction matters is documented in public legal
|
||||
record, not speculation:
|
||||
|
||||
- In 2021 the U.S. Federal Trade Commission settled with the maker of a
|
||||
period- and fertility-tracking app over allegations that it shared
|
||||
users' health data with third-party analytics and advertising
|
||||
services despite privacy promises. The settlement is public record.
|
||||
- In 2025 a federal jury found that the same app's maker violated a
|
||||
California privacy statute over the sharing of menstrual-tracking
|
||||
data; the verdict and the underlying class action are public record.
|
||||
|
||||
These are cited as factual public record to explain *why* the
|
||||
"who holds the data" question is the one that matters for this category.
|
||||
HealthLog makes no claim about any other product's current practices.
|
||||
|
||||
## Practical hardening
|
||||
|
||||
If reproductive-health privacy is the reason you self-host, the
|
||||
operator-side steps that matter most:
|
||||
|
||||
- Keep `ENCRYPTION_KEYS` off the host that runs PostgreSQL where your
|
||||
threat model allows it (an injected secret at boot, not a file next to
|
||||
the database). See `docs/ops/encryption-key-rotation.md`.
|
||||
- Turn off any integration you do not need. The fewer outbound paths,
|
||||
the smaller the surface.
|
||||
- Encrypt and control your backups. The off-host backup feature uses
|
||||
AES-GCM; the destination bucket and its access are yours to lock down.
|
||||
- Use the in-app **discreet notifications** toggle so cycle reminders
|
||||
never name the event on a lock screen, and the **client-managed
|
||||
reminders** path if you want the device, not the server, to own those
|
||||
notifications.
|
||||
- Lock the devices that hold a live session.
|
||||
|
||||
See also the [threat-model note](../security/cycle-data-threat-model.md)
|
||||
for a compact "protects against / does not protect against" summary.
|
||||
+312
-3
@@ -146,6 +146,7 @@
|
||||
"moodSensitive": "Sensibel — standardmäßig aus.",
|
||||
"compliance": "Medikamenten-Compliance",
|
||||
"sleep": "Schlafdauer",
|
||||
"cycle": "Menstruationszyklus",
|
||||
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
|
||||
"unavailableHint": "Keine Daten in diesem Zeitraum"
|
||||
},
|
||||
@@ -163,7 +164,22 @@
|
||||
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
|
||||
"wellnessRecovery": "Erholung",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Belastung"
|
||||
"wellnessStrain": "Belastung",
|
||||
"cycleTitle": "Menstruationszyklus",
|
||||
"cycleLmp": "Letzte Periode",
|
||||
"cycleAvgLength": "Durchschnittliche Zykluslänge",
|
||||
"cycleAvgPeriod": "Durchschnittliche Periodendauer",
|
||||
"cyclePhase": "Aktuelle Phase",
|
||||
"cycleDays": "Tage",
|
||||
"cycleColStart": "Periodenbeginn",
|
||||
"cycleColLength": "Zykluslänge (Tage)",
|
||||
"cycleColPeriod": "Periodendauer (Tage)",
|
||||
"cyclePhases": {
|
||||
"MENSTRUAL": "Menstruation",
|
||||
"FOLLICULAR": "Follikelphase",
|
||||
"OVULATORY": "Ovulation",
|
||||
"LUTEAL": "Lutealphase"
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -216,7 +232,8 @@
|
||||
"medicationDescription": "Eine Dosis als genommen markieren",
|
||||
"mood": "Stimmung",
|
||||
"moodDescription": "Stimmung und Tags erfassen"
|
||||
}
|
||||
},
|
||||
"cycle": "Zyklus"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -3860,6 +3877,14 @@
|
||||
"statusRevoked": "Widerrufen",
|
||||
"statusExpired": "Abgelaufen",
|
||||
"inactiveTitle": "Widerrufen und abgelaufen ({count})"
|
||||
},
|
||||
"genderOther": "Divers",
|
||||
"cycleTracking": {
|
||||
"title": "Zyklus-Tracking",
|
||||
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.",
|
||||
"enable": "Zyklus-Tracking aktivieren",
|
||||
"manage": "Zyklus-Tracking öffnen",
|
||||
"error": "Zyklus-Tracking konnte nicht aktualisiert werden."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -4673,7 +4698,11 @@
|
||||
"eventPersonalRecord": "Persönlicher Rekord",
|
||||
"eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.",
|
||||
"eventMoodReminder": "Stimmungs-Erinnerung",
|
||||
"eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren."
|
||||
"eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.",
|
||||
"eventCyclePeriodSoon": "Periode steht bevor",
|
||||
"eventCyclePeriodSoonDesc": "Ein paar Tage vor deiner vorhergesagten nächsten Periode.",
|
||||
"eventCyclePeriodConfirm": "Hat deine Periode begonnen?",
|
||||
"eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4867,5 +4896,285 @@
|
||||
"score": "Wellness-Wert"
|
||||
},
|
||||
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
"physical": "Körperlich",
|
||||
"emotional": "Emotional",
|
||||
"digestive": "Verdauung"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Krämpfe",
|
||||
"headache": "Kopfschmerzen",
|
||||
"bloating": "Blähbauch",
|
||||
"acne": "Akne",
|
||||
"breastTenderness": "Brustspannen",
|
||||
"fatigue": "Müdigkeit",
|
||||
"backPain": "Rückenschmerzen",
|
||||
"insomnia": "Schlaflosigkeit",
|
||||
"libidoHigh": "Hohe Libido",
|
||||
"libidoLow": "Niedrige Libido",
|
||||
"moodSwings": "Stimmungsschwankungen",
|
||||
"foodCravings": "Heißhunger",
|
||||
"nausea": "Übelkeit",
|
||||
"diarrhea": "Durchfall",
|
||||
"constipation": "Verstopfung"
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst."
|
||||
},
|
||||
"insights": {
|
||||
"headline": {
|
||||
"title": "Dein Zyklus und dein Körper",
|
||||
"restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.",
|
||||
"heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.",
|
||||
"generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.",
|
||||
"dirHigher": "höher",
|
||||
"dirLower": "niedriger",
|
||||
"empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter — das füllt sich, je länger deine Historie wird."
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht – auf deinen eigenen Daten.",
|
||||
"pairLabel": "{metric} · Luteal vs. Follikel",
|
||||
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).",
|
||||
"footer": "Das sind Zusammenhänge in deinen eigenen Daten, keine Ursachen. Nur Muster, die einen Signifikanztest bestehen, werden gezeigt.",
|
||||
"metricRestingHeartRate": "Ruhepuls",
|
||||
"metricHrv": "Herzfrequenzvariabilität",
|
||||
"metricSleepDuration": "Schlafdauer",
|
||||
"metricSteps": "Schritte",
|
||||
"metricWeight": "Gewicht",
|
||||
"metricBasalBodyTemp": "Basaltemperatur",
|
||||
"metricWristTemperature": "Handgelenkstemperatur nachts",
|
||||
"metricSkinTemperature": "Hauttemperatur",
|
||||
"unitHours": "Std.",
|
||||
"unitSteps": "Schritte",
|
||||
"unitBpm": "S/min",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitCelsius": "°C",
|
||||
"confidenceLow": "Geringe Sicherheit",
|
||||
"confidenceMedium": "Mittlere Sicherheit",
|
||||
"confidenceHigh": "Hohe Sicherheit"
|
||||
},
|
||||
"title": "Zyklus-Erkenntnisse",
|
||||
"empty": "Phasen-Erkenntnisse erscheinen hier, sobald du genug Zyklen erfasst hast. Sie vergleichen deine Werte über die Zyklusphasen hinweg, mit offen gezeigter Statistik.",
|
||||
"sampleSize": "n = {n}",
|
||||
"effectSize": "Effektstärke {value}",
|
||||
"qValue": "q = {value}",
|
||||
"symptomPatternsTitle": "Symptommuster nach Phase",
|
||||
"symptomPatternsEmpty": "Erfasse Symptome über einen Zyklus, um zu sehen, in welcher Phase sie sich häufen.",
|
||||
"symptomMostlyIn": "Meist in {phase} ({count}/{total} Tage)"
|
||||
},
|
||||
"disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.",
|
||||
"title": "Dein Zyklus",
|
||||
"subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.",
|
||||
"tabs": {
|
||||
"calendar": "Kalender",
|
||||
"predictions": "Prognosen",
|
||||
"insights": "Erkenntnisse",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"logToday": "Heute erfassen",
|
||||
"startedPeriod": "Periode hat heute begonnen",
|
||||
"endedPeriod": "Periode heute beendet",
|
||||
"phase": {
|
||||
"MENSTRUAL": "Menstruation",
|
||||
"FOLLICULAR": "Follikelphase",
|
||||
"OVULATORY": "Ovulation",
|
||||
"LUTEAL": "Lutealphase",
|
||||
"none": "Keine Phase",
|
||||
"label": "Phase"
|
||||
},
|
||||
"flow": {
|
||||
"NONE": "Keine",
|
||||
"SPOTTING": "Schmierblutung",
|
||||
"LIGHT": "Leicht",
|
||||
"MEDIUM": "Mittel",
|
||||
"HEAVY": "Stark",
|
||||
"label": "Stärke"
|
||||
},
|
||||
"ovulationTest": {
|
||||
"NEGATIVE": "Negativ",
|
||||
"POSITIVE_LH_SURGE": "LH-Anstieg",
|
||||
"ESTROGEN_SURGE": "Östrogen-Anstieg",
|
||||
"INDETERMINATE": "Unklar",
|
||||
"label": "Ovulationstest"
|
||||
},
|
||||
"mucus": {
|
||||
"DRY": "Trocken",
|
||||
"STICKY": "Klebrig",
|
||||
"CREAMY": "Cremig",
|
||||
"WATERY": "Wässrig",
|
||||
"EGG_WHITE": "Eiweißartig",
|
||||
"label": "Zervixschleim"
|
||||
},
|
||||
"goal": {
|
||||
"GENERAL_HEALTH": "Allgemeine Gesundheit",
|
||||
"AVOID_PREGNANCY": "Verhütung",
|
||||
"TRYING_TO_CONCEIVE": "Kinderwunsch",
|
||||
"PERIMENOPAUSE": "Perimenopause",
|
||||
"OFF": "Tracking aus",
|
||||
"label": "Wofür trackst du?",
|
||||
"description": "Das bestimmt die Sprache und welche Prognosen du siehst. Hinweise zum fruchtbaren Fenster erscheinen nur bei Kinderwunsch."
|
||||
},
|
||||
"ring": {
|
||||
"dayOfCycle": "Tag {day}",
|
||||
"dayUnknown": "—",
|
||||
"ariaPhase": "Zyklustag {day}, {phase}-Phase",
|
||||
"ariaUnknown": "Noch kein aktiver Zyklus",
|
||||
"caption": "deines Zyklus",
|
||||
"firstPeriodCta": "Erste Periode erfassen"
|
||||
},
|
||||
"calendar": {
|
||||
"legendPeriod": "Erfasste Periode",
|
||||
"legendPredicted": "Prognostizierte Periode",
|
||||
"legendFertile": "Fruchtbares Fenster",
|
||||
"legendOvulation": "Ovulation",
|
||||
"legendSymptoms": "Symptome erfasst",
|
||||
"prevMonth": "Vorheriger Monat",
|
||||
"nextMonth": "Nächster Monat",
|
||||
"loading": "Kalender wird geladen…",
|
||||
"empty": "Erfasse Daten, damit sich dein Kalender füllt.",
|
||||
"today": "Heute",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Symptome"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Was als Nächstes kommt",
|
||||
"nextPeriod": "Nächste Periode",
|
||||
"nextPeriodRange": "Etwa am {date}",
|
||||
"nextPeriodWindow": "{low} – {high}",
|
||||
"fertileWindow": "Fruchtbares Fenster",
|
||||
"ovulation": "Geschätzter Eisprung",
|
||||
"confidence": "Sicherheit",
|
||||
"confidenceHigh": "Hoch",
|
||||
"confidenceMedium": "Mittel",
|
||||
"confidenceLow": "Niedrig",
|
||||
"stillLearning": "Noch am Lernen",
|
||||
"stillLearningBody": "Erfasse mindestens drei vollständige Zyklen, dann werden deine Prognosen genauer. Bis dahin ist dies eine grobe Schätzung.",
|
||||
"rawMode": "Prognose ist aus",
|
||||
"rawModeBody": "Du hast Prognosen ausgeschaltet — es werden nur deine erfassten Daten gezeigt, ohne Vorhersage.",
|
||||
"none": "Noch keine Prognose. Erfasse eine Periode, um zu starten.",
|
||||
"ovulationConfirmed": "Eisprung (bestätigt)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Deine Zyklen",
|
||||
"avgLength": "Durchschnittliche Länge",
|
||||
"variability": "Schwankung",
|
||||
"avgPeriodLength": "Durchschnittliche Periode",
|
||||
"regularity": "Regelmäßigkeit",
|
||||
"regularityREGULAR": "Regelmäßig",
|
||||
"regularityIRREGULAR": "Unregelmäßig",
|
||||
"regularityLEARNING": "Lernend",
|
||||
"days": "{count} Tage",
|
||||
"daysPlusMinus": "± {count} Tage",
|
||||
"none": "Noch keine Zyklen erfasst.",
|
||||
"recentCycles": "Letzte Zyklen",
|
||||
"ongoing": "laufend",
|
||||
"periodDays": "Periode {count} T",
|
||||
"ovulationChip": "Eisprung"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Tag erfassen",
|
||||
"description": "Erfasse Stärke, Symptome und Notizen von heute.",
|
||||
"date": "Datum",
|
||||
"flow": "Stärke",
|
||||
"symptoms": "Symptome",
|
||||
"temperature": "Basaltemperatur",
|
||||
"temperaturePlaceholder": "°C",
|
||||
"ovulationTest": "Ovulationstest",
|
||||
"mucus": "Zervixschleim",
|
||||
"intercourse": "Geschlechtsverkehr",
|
||||
"protected": "Geschützt",
|
||||
"note": "Notiz",
|
||||
"notePlaceholder": "Was du dir über heute merken möchtest…",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern…",
|
||||
"cancel": "Abbrechen",
|
||||
"saved": "Tag gespeichert",
|
||||
"saveError": "Konnte nicht gespeichert werden. Versuch es erneut.",
|
||||
"periodStarted": "Periodenbeginn erfasst",
|
||||
"clear": "Keine",
|
||||
"intermenstrualBleeding": "Schmierblutung zwischen Perioden",
|
||||
"pregnancyTest": "Schwangerschaftstest",
|
||||
"progesteroneTest": "Progesterontest",
|
||||
"contraceptive": "Verhütung",
|
||||
"severity": "Schweregrad",
|
||||
"severityLevel": "Schweregrad {level} von 4",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Zyklus-Einstellungen",
|
||||
"enable": "Zyklus-Tracking",
|
||||
"enableDescription": "Schalte den Zyklus-Tab für dein Konto ein oder aus.",
|
||||
"typicalCycleLength": "Typische Zykluslänge (Tage)",
|
||||
"typicalPeriodLength": "Typische Periodenlänge (Tage)",
|
||||
"lutealPhaseLength": "Länge der Lutealphase (Tage)",
|
||||
"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.",
|
||||
"prediction": "Prognosen",
|
||||
"predictionDescription": "Prognostiziere deine nächste Periode und, wenn relevant, dein fruchtbares Fenster.",
|
||||
"encryption": "Sensible Einträge verschlüsseln",
|
||||
"encryptionDescription": "Verschlüssele absichtsverratende Felder (Schwangerschaftstests, Verhütung, Geschlechtsverkehr) ruhend, zusätzlich zu den stets verschlüsselten Notizen.",
|
||||
"discreet": "Diskrete Benachrichtigungen",
|
||||
"discreetDescription": "Verbirg Zyklusdetails im Benachrichtigungstext auf deinem Sperrbildschirm.",
|
||||
"dataTitle": "Deine Daten",
|
||||
"dataDescription": "Deine Zyklusdaten liegen nur auf diesem Server. Exportiere sie jederzeit oder lösche sie endgültig.",
|
||||
"export": "Meine Zyklusdaten exportieren",
|
||||
"deleteData": "Meine Zyklusdaten löschen",
|
||||
"saved": "Einstellungen gespeichert",
|
||||
"saveError": "Konnte nicht gespeichert werden. Versuch es erneut.",
|
||||
"save": "Speichern",
|
||||
"purgeTitle": "Alle Zyklusdaten löschen",
|
||||
"purgeDescription": "Entfernt dauerhaft jeden erfassten Tag, Zyklus, jede Vorhersage und jeden Erinnerungseintrag. Kann nicht rückgängig gemacht werden.",
|
||||
"purgeButton": "Alle Zyklusdaten löschen",
|
||||
"purgeConfirmTitle": "Alle Zyklusdaten löschen?",
|
||||
"purgeConfirmBody": "Dies löscht dauerhaft jede Periode, jeden Tageseintrag, jedes Symptom und jede Vorhersage. Dies kann nicht rückgängig gemacht werden.",
|
||||
"purgeConfirm": "Alle Zyklusdaten dauerhaft löschen?",
|
||||
"purgeCancel": "Abbrechen",
|
||||
"purgeSuccess": "Alle Zyklusdaten gelöscht.",
|
||||
"purgeError": "Zyklusdaten konnten nicht gelöscht werden.",
|
||||
"reminders": "Erinnerungen",
|
||||
"remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.",
|
||||
"remindersLink": "Benachrichtigungseinstellungen",
|
||||
"purgeData": "Zyklusdaten löschen",
|
||||
"purgeConfirmAction": "Ja, alles löschen",
|
||||
"purgeDone": "Zyklusdaten gelöscht."
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positiv",
|
||||
"NEGATIVE": "Negativ",
|
||||
"INDETERMINATE": "Unklar"
|
||||
},
|
||||
"contraceptive": {
|
||||
"NONE": "Keine",
|
||||
"UNSPECIFIED": "Andere",
|
||||
"IMPLANT": "Implantat",
|
||||
"INJECTION": "Spritze",
|
||||
"IUD": "Spirale",
|
||||
"INTRAVAGINAL_RING": "Ring",
|
||||
"ORAL": "Pille",
|
||||
"PATCH": "Pflaster",
|
||||
"EMERGENCY": "Notfall"
|
||||
},
|
||||
"loadError": "Zyklusdaten konnten nicht geladen werden.",
|
||||
"bbt": {
|
||||
"title": "Basaltemperatur",
|
||||
"caption": "Deine Morgentemperatur über diesen Zyklus. Ein anhaltender Anstieg um etwa 0,2 °C folgt auf den Eisprung.",
|
||||
"empty": "Noch keine Temperaturwerte",
|
||||
"emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.",
|
||||
"ovulationEstimate": "Gesch. Eisprung",
|
||||
"tooltipTemp": "{temp} °C",
|
||||
"ovulationConfirmed": "Bestätigter Eisprung"
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
|
||||
"periodSoonBody": "Deine nächste Periode wird in etwa zwei Tagen erwartet.",
|
||||
"periodConfirmTitle": "Hat deine Periode begonnen?",
|
||||
"periodConfirmBody": "Deine Periode wurde für etwa jetzt vorhergesagt. Trage sie ein, damit die Vorhersagen genau bleiben.",
|
||||
"discreetTitle": "HealthLog-Erinnerung",
|
||||
"discreetBody": "Du hast eine Erinnerung in HealthLog."
|
||||
}
|
||||
}
|
||||
+312
-3
@@ -146,6 +146,7 @@
|
||||
"moodSensitive": "Sensitive — off by default.",
|
||||
"compliance": "Medication compliance",
|
||||
"sleep": "Sleep duration",
|
||||
"cycle": "Menstrual cycle",
|
||||
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
|
||||
"unavailableHint": "No data in this period"
|
||||
},
|
||||
@@ -163,7 +164,22 @@
|
||||
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
|
||||
"wellnessRecovery": "Recovery",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Strain"
|
||||
"wellnessStrain": "Strain",
|
||||
"cycleTitle": "Menstrual cycle",
|
||||
"cycleLmp": "Last period",
|
||||
"cycleAvgLength": "Average cycle length",
|
||||
"cycleAvgPeriod": "Average period length",
|
||||
"cyclePhase": "Current phase",
|
||||
"cycleDays": "days",
|
||||
"cycleColStart": "Period start",
|
||||
"cycleColLength": "Cycle length (days)",
|
||||
"cycleColPeriod": "Period length (days)",
|
||||
"cyclePhases": {
|
||||
"MENSTRUAL": "Menstrual",
|
||||
"FOLLICULAR": "Follicular",
|
||||
"OVULATORY": "Ovulatory",
|
||||
"LUTEAL": "Luteal"
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -216,7 +232,8 @@
|
||||
"medicationDescription": "Mark a dose as taken",
|
||||
"mood": "Mood",
|
||||
"moodDescription": "Log your mood and tags"
|
||||
}
|
||||
},
|
||||
"cycle": "Cycle"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -3860,6 +3877,14 @@
|
||||
"statusRevoked": "Revoked",
|
||||
"statusExpired": "Expired",
|
||||
"inactiveTitle": "Revoked and expired ({count})"
|
||||
},
|
||||
"genderOther": "Other",
|
||||
"cycleTracking": {
|
||||
"title": "Cycle tracking",
|
||||
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.",
|
||||
"enable": "Enable cycle tracking",
|
||||
"manage": "Open cycle tracking",
|
||||
"error": "Couldn't update cycle tracking."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -4673,7 +4698,11 @@
|
||||
"eventPersonalRecord": "Personal record",
|
||||
"eventPersonalRecordDesc": "Sent when a reading beats your previous best.",
|
||||
"eventMoodReminder": "Mood reminder",
|
||||
"eventMoodReminderDesc": "Daily nudge to log your current mood."
|
||||
"eventMoodReminderDesc": "Daily nudge to log your current mood.",
|
||||
"eventCyclePeriodSoon": "Period coming up",
|
||||
"eventCyclePeriodSoonDesc": "Sent a couple of days before your predicted next period.",
|
||||
"eventCyclePeriodConfirm": "Did your period start?",
|
||||
"eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4867,5 +4896,285 @@
|
||||
"score": "Wellness score"
|
||||
},
|
||||
"footer": "Shared from HealthLog — a self-hosted personal health tracker."
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
"physical": "Physical",
|
||||
"emotional": "Emotional",
|
||||
"digestive": "Digestive"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Cramps",
|
||||
"headache": "Headache",
|
||||
"bloating": "Bloating",
|
||||
"acne": "Acne",
|
||||
"breastTenderness": "Breast tenderness",
|
||||
"fatigue": "Fatigue",
|
||||
"backPain": "Back pain",
|
||||
"insomnia": "Insomnia",
|
||||
"libidoHigh": "High libido",
|
||||
"libidoLow": "Low libido",
|
||||
"moodSwings": "Mood swings",
|
||||
"foodCravings": "Food cravings",
|
||||
"nausea": "Nausea",
|
||||
"diarrhea": "Diarrhea",
|
||||
"constipation": "Constipation"
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles."
|
||||
},
|
||||
"insights": {
|
||||
"headline": {
|
||||
"title": "Your cycle and your body",
|
||||
"restingHeartRate": "Your resting heart rate runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.",
|
||||
"heartRateVariability": "Your heart-rate variability runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.",
|
||||
"generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.",
|
||||
"dirHigher": "higher",
|
||||
"dirLower": "lower",
|
||||
"empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging — this fills in as your history grows."
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "How each measurement compares between your luteal and follicular phases, on your own data.",
|
||||
"pairLabel": "{metric} · luteal vs follicular",
|
||||
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).",
|
||||
"footer": "These are associations in your own data, not causes. Only patterns that clear a significance check are shown.",
|
||||
"metricRestingHeartRate": "Resting heart rate",
|
||||
"metricHrv": "Heart-rate variability",
|
||||
"metricSleepDuration": "Sleep duration",
|
||||
"metricSteps": "Steps",
|
||||
"metricWeight": "Weight",
|
||||
"metricBasalBodyTemp": "Basal body temperature",
|
||||
"metricWristTemperature": "Overnight wrist temperature",
|
||||
"metricSkinTemperature": "Skin temperature",
|
||||
"unitHours": "h",
|
||||
"unitSteps": "steps",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitCelsius": "°C",
|
||||
"confidenceLow": "Low confidence",
|
||||
"confidenceMedium": "Medium confidence",
|
||||
"confidenceHigh": "High confidence"
|
||||
},
|
||||
"title": "Cycle insights",
|
||||
"empty": "Phase insights appear here once you have enough logged cycles. They compare your metrics across cycle phases, with the statistics shown openly.",
|
||||
"sampleSize": "n = {n}",
|
||||
"effectSize": "Effect size {value}",
|
||||
"qValue": "q = {value}",
|
||||
"symptomPatternsTitle": "Symptom patterns by phase",
|
||||
"symptomPatternsEmpty": "Log symptoms across a cycle to see which phase they cluster in.",
|
||||
"symptomMostlyIn": "Mostly in {phase} ({count}/{total} days)"
|
||||
},
|
||||
"disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.",
|
||||
"title": "Your cycle",
|
||||
"subtitle": "Log your period and symptoms, see what comes next.",
|
||||
"tabs": {
|
||||
"calendar": "Calendar",
|
||||
"predictions": "Predictions",
|
||||
"insights": "Insights",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"logToday": "Log today",
|
||||
"startedPeriod": "Started my period today",
|
||||
"endedPeriod": "Ended my period today",
|
||||
"phase": {
|
||||
"MENSTRUAL": "Menstrual",
|
||||
"FOLLICULAR": "Follicular",
|
||||
"OVULATORY": "Ovulatory",
|
||||
"LUTEAL": "Luteal",
|
||||
"none": "No phase",
|
||||
"label": "Phase"
|
||||
},
|
||||
"flow": {
|
||||
"NONE": "None",
|
||||
"SPOTTING": "Spotting",
|
||||
"LIGHT": "Light",
|
||||
"MEDIUM": "Medium",
|
||||
"HEAVY": "Heavy",
|
||||
"label": "Flow"
|
||||
},
|
||||
"ovulationTest": {
|
||||
"NEGATIVE": "Negative",
|
||||
"POSITIVE_LH_SURGE": "LH surge",
|
||||
"ESTROGEN_SURGE": "Estrogen surge",
|
||||
"INDETERMINATE": "Indeterminate",
|
||||
"label": "Ovulation test"
|
||||
},
|
||||
"mucus": {
|
||||
"DRY": "Dry",
|
||||
"STICKY": "Sticky",
|
||||
"CREAMY": "Creamy",
|
||||
"WATERY": "Watery",
|
||||
"EGG_WHITE": "Egg white",
|
||||
"label": "Cervical mucus"
|
||||
},
|
||||
"goal": {
|
||||
"GENERAL_HEALTH": "General health",
|
||||
"AVOID_PREGNANCY": "Avoiding pregnancy",
|
||||
"TRYING_TO_CONCEIVE": "Trying to conceive",
|
||||
"PERIMENOPAUSE": "Perimenopause",
|
||||
"OFF": "Tracking off",
|
||||
"label": "What are you tracking for?",
|
||||
"description": "This shapes the language and which predictions you see. Fertile-window guidance shows only when you are trying to conceive."
|
||||
},
|
||||
"ring": {
|
||||
"dayOfCycle": "Day {day}",
|
||||
"dayUnknown": "—",
|
||||
"ariaPhase": "Cycle day {day}, {phase} phase",
|
||||
"ariaUnknown": "No active cycle yet",
|
||||
"caption": "of your cycle",
|
||||
"firstPeriodCta": "Log my first period"
|
||||
},
|
||||
"calendar": {
|
||||
"legendPeriod": "Logged period",
|
||||
"legendPredicted": "Predicted period",
|
||||
"legendFertile": "Fertile window",
|
||||
"legendOvulation": "Ovulation",
|
||||
"legendSymptoms": "Symptoms logged",
|
||||
"prevMonth": "Previous month",
|
||||
"nextMonth": "Next month",
|
||||
"loading": "Loading your calendar…",
|
||||
"empty": "Start logging to see your calendar fill in.",
|
||||
"today": "Today",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Symptoms"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "What comes next",
|
||||
"nextPeriod": "Next period",
|
||||
"nextPeriodRange": "Around {date}",
|
||||
"nextPeriodWindow": "{low} – {high}",
|
||||
"fertileWindow": "Fertile window",
|
||||
"ovulation": "Estimated ovulation",
|
||||
"confidence": "Confidence",
|
||||
"confidenceHigh": "High",
|
||||
"confidenceMedium": "Medium",
|
||||
"confidenceLow": "Low",
|
||||
"stillLearning": "Still learning",
|
||||
"stillLearningBody": "Log at least three full cycles and your predictions sharpen. Until then this is a rough estimate.",
|
||||
"rawMode": "Prediction is off",
|
||||
"rawModeBody": "You turned predictions off — only your logged data is shown, with no forecast.",
|
||||
"none": "No prediction yet. Log a period to get started.",
|
||||
"ovulationConfirmed": "Ovulation (confirmed)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Your cycles",
|
||||
"avgLength": "Average length",
|
||||
"variability": "Variability",
|
||||
"avgPeriodLength": "Average period",
|
||||
"regularity": "Regularity",
|
||||
"regularityREGULAR": "Regular",
|
||||
"regularityIRREGULAR": "Irregular",
|
||||
"regularityLEARNING": "Learning",
|
||||
"days": "{count} days",
|
||||
"daysPlusMinus": "± {count} days",
|
||||
"none": "No cycles recorded yet.",
|
||||
"recentCycles": "Recent cycles",
|
||||
"ongoing": "ongoing",
|
||||
"periodDays": "period {count} d",
|
||||
"ovulationChip": "Ovulation"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Log your day",
|
||||
"description": "Capture today's flow, symptoms, and notes.",
|
||||
"date": "Date",
|
||||
"flow": "Flow",
|
||||
"symptoms": "Symptoms",
|
||||
"temperature": "Basal body temperature",
|
||||
"temperaturePlaceholder": "°C",
|
||||
"ovulationTest": "Ovulation test",
|
||||
"mucus": "Cervical mucus",
|
||||
"intercourse": "Intercourse",
|
||||
"protected": "Protected",
|
||||
"intermenstrualBleeding": "Spotting between periods",
|
||||
"pregnancyTest": "Pregnancy test",
|
||||
"progesteroneTest": "Progesterone test",
|
||||
"contraceptive": "Contraceptive",
|
||||
"severity": "Severity",
|
||||
"severityLevel": "Severity {level} of 4",
|
||||
"delete": "Delete",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Anything you want to remember about today…",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Day saved",
|
||||
"saveError": "Could not save. Try again.",
|
||||
"periodStarted": "Period start logged",
|
||||
"clear": "None"
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positive",
|
||||
"NEGATIVE": "Negative",
|
||||
"INDETERMINATE": "Unclear"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Cycle settings",
|
||||
"enable": "Cycle tracking",
|
||||
"enableDescription": "Turn the cycle tab on or off for your account.",
|
||||
"typicalCycleLength": "Typical cycle length (days)",
|
||||
"typicalPeriodLength": "Typical period length (days)",
|
||||
"lutealPhaseLength": "Luteal phase length (days)",
|
||||
"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.",
|
||||
"prediction": "Predictions",
|
||||
"predictionDescription": "Forecast your next period and, when relevant, your fertile window.",
|
||||
"encryption": "Encrypt sensitive entries",
|
||||
"encryptionDescription": "Encrypt intent-revealing fields (pregnancy tests, contraception, intercourse) at rest, on top of the always-encrypted notes.",
|
||||
"discreet": "Discreet notifications",
|
||||
"discreetDescription": "Hide cycle details from notification text on your lock screen.",
|
||||
"dataTitle": "Your data",
|
||||
"dataDescription": "Your cycle data lives only on this server. Export it any time, or delete it for good.",
|
||||
"export": "Export my cycle data",
|
||||
"deleteData": "Delete my cycle data",
|
||||
"saved": "Settings saved",
|
||||
"saveError": "Could not save. Try again.",
|
||||
"save": "Save",
|
||||
"purgeTitle": "Delete all cycle data",
|
||||
"purgeDescription": "Permanently removes every logged day, cycle, prediction, and reminder record. This cannot be undone.",
|
||||
"purgeButton": "Delete all cycle data",
|
||||
"purgeConfirmTitle": "Delete all cycle data?",
|
||||
"purgeConfirmBody": "This permanently deletes every period, day-log, symptom, and prediction. This cannot be undone.",
|
||||
"purgeConfirm": "Delete all cycle data permanently?",
|
||||
"purgeCancel": "Cancel",
|
||||
"purgeSuccess": "All cycle data deleted.",
|
||||
"purgeError": "Couldn't delete cycle data.",
|
||||
"reminders": "Reminders",
|
||||
"remindersDescription": "Turn period reminders on per channel in notification settings.",
|
||||
"remindersLink": "Notification settings",
|
||||
"purgeData": "Delete cycle data",
|
||||
"purgeConfirmAction": "Yes, delete everything",
|
||||
"purgeDone": "Cycle data deleted."
|
||||
},
|
||||
"contraceptive": {
|
||||
"NONE": "None",
|
||||
"UNSPECIFIED": "Other",
|
||||
"IMPLANT": "Implant",
|
||||
"INJECTION": "Injection",
|
||||
"IUD": "IUD",
|
||||
"INTRAVAGINAL_RING": "Ring",
|
||||
"ORAL": "Pill",
|
||||
"PATCH": "Patch",
|
||||
"EMERGENCY": "Emergency"
|
||||
},
|
||||
"loadError": "Couldn't load your cycle data.",
|
||||
"bbt": {
|
||||
"title": "Basal body temperature",
|
||||
"caption": "Your morning temperature across this cycle. A sustained rise of about 0.2 °C follows ovulation.",
|
||||
"empty": "No temperature readings yet",
|
||||
"emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.",
|
||||
"ovulationEstimate": "Est. ovulation",
|
||||
"tooltipTemp": "{temp} °C",
|
||||
"ovulationConfirmed": "Confirmed ovulation"
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Period likely soon",
|
||||
"periodSoonBody": "Your next period is predicted in about two days.",
|
||||
"periodConfirmTitle": "Did your period start?",
|
||||
"periodConfirmBody": "Your period was predicted around now. Log it to keep predictions accurate.",
|
||||
"discreetTitle": "HealthLog reminder",
|
||||
"discreetBody": "You have a reminder in HealthLog."
|
||||
}
|
||||
}
|
||||
+312
-3
@@ -146,6 +146,7 @@
|
||||
"moodSensitive": "Sensible — desactivado por defecto.",
|
||||
"compliance": "Cumplimiento de medicación",
|
||||
"sleep": "Duración del sueño",
|
||||
"cycle": "Ciclo menstrual",
|
||||
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
|
||||
"unavailableHint": "Sin datos en este periodo"
|
||||
},
|
||||
@@ -163,7 +164,22 @@
|
||||
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
|
||||
"wellnessRecovery": "Recuperación",
|
||||
"wellnessStress": "Estrés",
|
||||
"wellnessStrain": "Esfuerzo"
|
||||
"wellnessStrain": "Esfuerzo",
|
||||
"cycleTitle": "Ciclo menstrual",
|
||||
"cycleLmp": "Última menstruación",
|
||||
"cycleAvgLength": "Duración media del ciclo",
|
||||
"cycleAvgPeriod": "Duración media del periodo",
|
||||
"cyclePhase": "Fase actual",
|
||||
"cycleDays": "días",
|
||||
"cycleColStart": "Inicio del periodo",
|
||||
"cycleColLength": "Duración del ciclo (días)",
|
||||
"cycleColPeriod": "Duración del periodo (días)",
|
||||
"cyclePhases": {
|
||||
"MENSTRUAL": "Menstrual",
|
||||
"FOLLICULAR": "Folicular",
|
||||
"OVULATORY": "Ovulatoria",
|
||||
"LUTEAL": "Lútea"
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -216,7 +232,8 @@
|
||||
"medicationDescription": "Marcar una dosis como tomada",
|
||||
"mood": "Estado de ánimo",
|
||||
"moodDescription": "Registrar estado de ánimo y etiquetas"
|
||||
}
|
||||
},
|
||||
"cycle": "Ciclo"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -3860,6 +3877,14 @@
|
||||
"statusRevoked": "Revocado",
|
||||
"statusExpired": "Caducado",
|
||||
"inactiveTitle": "Revocados y caducados ({count})"
|
||||
},
|
||||
"genderOther": "Otro",
|
||||
"cycleTracking": {
|
||||
"title": "Seguimiento del ciclo",
|
||||
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.",
|
||||
"enable": "Activar el seguimiento del ciclo",
|
||||
"manage": "Abrir seguimiento del ciclo",
|
||||
"error": "No se pudo actualizar el seguimiento del ciclo."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -4673,7 +4698,11 @@
|
||||
"eventPersonalRecord": "Récord personal",
|
||||
"eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.",
|
||||
"eventMoodReminder": "Recordatorio de ánimo",
|
||||
"eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual."
|
||||
"eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.",
|
||||
"eventCyclePeriodSoon": "Período próximo",
|
||||
"eventCyclePeriodSoonDesc": "Se envía un par de días antes de tu próximo período previsto.",
|
||||
"eventCyclePeriodConfirm": "¿Empezó tu período?",
|
||||
"eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4867,5 +4896,285 @@
|
||||
"score": "Puntuación de bienestar"
|
||||
},
|
||||
"footer": "Compartido desde HealthLog — un registro de salud autoalojado."
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
"physical": "Físico",
|
||||
"emotional": "Emocional",
|
||||
"digestive": "Digestivo"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Calambres",
|
||||
"headache": "Dolor de cabeza",
|
||||
"bloating": "Hinchazón",
|
||||
"acne": "Acné",
|
||||
"breastTenderness": "Sensibilidad en los senos",
|
||||
"fatigue": "Fatiga",
|
||||
"backPain": "Dolor de espalda",
|
||||
"insomnia": "Insomnio",
|
||||
"libidoHigh": "Libido alta",
|
||||
"libidoLow": "Libido baja",
|
||||
"moodSwings": "Cambios de humor",
|
||||
"foodCravings": "Antojos",
|
||||
"nausea": "Náuseas",
|
||||
"diarrhea": "Diarrea",
|
||||
"constipation": "Estreñimiento"
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos."
|
||||
},
|
||||
"insights": {
|
||||
"headline": {
|
||||
"title": "Tu ciclo y tu cuerpo",
|
||||
"restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.",
|
||||
"heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.",
|
||||
"generic": "Tu {metric} es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.",
|
||||
"dirHigher": "más alta",
|
||||
"dirLower": "más baja",
|
||||
"empty": "Aún no hay suficientes ciclos ni registros para mostrar un patrón de fase. Sigue registrando: se completará a medida que crezca tu historial."
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.",
|
||||
"pairLabel": "{metric} · lútea vs. folicular",
|
||||
"detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).",
|
||||
"footer": "Son asociaciones en tus propios datos, no causas. Solo se muestran los patrones que superan una prueba de significación.",
|
||||
"metricRestingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
"metricHrv": "Variabilidad de la frecuencia cardíaca",
|
||||
"metricSleepDuration": "Duración del sueño",
|
||||
"metricSteps": "Pasos",
|
||||
"metricWeight": "Peso",
|
||||
"metricBasalBodyTemp": "Temperatura basal",
|
||||
"metricWristTemperature": "Temperatura de la muñeca nocturna",
|
||||
"metricSkinTemperature": "Temperatura de la piel",
|
||||
"unitHours": "h",
|
||||
"unitSteps": "pasos",
|
||||
"unitBpm": "lpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitCelsius": "°C",
|
||||
"confidenceLow": "Confianza baja",
|
||||
"confidenceMedium": "Confianza media",
|
||||
"confidenceHigh": "Confianza alta"
|
||||
},
|
||||
"title": "Análisis del ciclo",
|
||||
"empty": "Los análisis por fase aparecen aquí cuando tienes suficientes ciclos registrados. Comparan tus métricas entre fases del ciclo, mostrando la estadística abiertamente.",
|
||||
"sampleSize": "n = {n}",
|
||||
"effectSize": "Tamaño del efecto {value}",
|
||||
"qValue": "q = {value}",
|
||||
"symptomPatternsTitle": "Patrones de síntomas por fase",
|
||||
"symptomPatternsEmpty": "Registra síntomas a lo largo de un ciclo para ver en qué fase se concentran.",
|
||||
"symptomMostlyIn": "Sobre todo en {phase} ({count}/{total} días)"
|
||||
},
|
||||
"disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.",
|
||||
"title": "Tu ciclo",
|
||||
"subtitle": "Registra tu periodo y síntomas, y mira qué viene después.",
|
||||
"tabs": {
|
||||
"calendar": "Calendario",
|
||||
"predictions": "Predicciones",
|
||||
"insights": "Análisis",
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"logToday": "Registrar hoy",
|
||||
"startedPeriod": "Mi periodo empezó hoy",
|
||||
"endedPeriod": "Mi periodo terminó hoy",
|
||||
"phase": {
|
||||
"MENSTRUAL": "Menstrual",
|
||||
"FOLLICULAR": "Folicular",
|
||||
"OVULATORY": "Ovulatoria",
|
||||
"LUTEAL": "Lútea",
|
||||
"none": "Sin fase",
|
||||
"label": "Fase"
|
||||
},
|
||||
"flow": {
|
||||
"NONE": "Ninguno",
|
||||
"SPOTTING": "Manchado",
|
||||
"LIGHT": "Ligero",
|
||||
"MEDIUM": "Medio",
|
||||
"HEAVY": "Abundante",
|
||||
"label": "Flujo"
|
||||
},
|
||||
"ovulationTest": {
|
||||
"NEGATIVE": "Negativo",
|
||||
"POSITIVE_LH_SURGE": "Pico de LH",
|
||||
"ESTROGEN_SURGE": "Pico de estrógeno",
|
||||
"INDETERMINATE": "Indeterminado",
|
||||
"label": "Test de ovulación"
|
||||
},
|
||||
"mucus": {
|
||||
"DRY": "Seco",
|
||||
"STICKY": "Pegajoso",
|
||||
"CREAMY": "Cremoso",
|
||||
"WATERY": "Acuoso",
|
||||
"EGG_WHITE": "Clara de huevo",
|
||||
"label": "Moco cervical"
|
||||
},
|
||||
"goal": {
|
||||
"GENERAL_HEALTH": "Salud general",
|
||||
"AVOID_PREGNANCY": "Evitar el embarazo",
|
||||
"TRYING_TO_CONCEIVE": "Buscando embarazo",
|
||||
"PERIMENOPAUSE": "Perimenopausia",
|
||||
"OFF": "Seguimiento desactivado",
|
||||
"label": "¿Para qué haces seguimiento?",
|
||||
"description": "Esto define el lenguaje y qué predicciones ves. La guía de ventana fértil solo aparece si buscas embarazo."
|
||||
},
|
||||
"ring": {
|
||||
"dayOfCycle": "Día {day}",
|
||||
"dayUnknown": "—",
|
||||
"ariaPhase": "Día {day} del ciclo, fase {phase}",
|
||||
"ariaUnknown": "Aún no hay ciclo activo",
|
||||
"caption": "de tu ciclo",
|
||||
"firstPeriodCta": "Registrar mi primer periodo"
|
||||
},
|
||||
"calendar": {
|
||||
"legendPeriod": "Periodo registrado",
|
||||
"legendPredicted": "Periodo previsto",
|
||||
"legendFertile": "Ventana fértil",
|
||||
"legendOvulation": "Ovulación",
|
||||
"legendSymptoms": "Síntomas registrados",
|
||||
"prevMonth": "Mes anterior",
|
||||
"nextMonth": "Mes siguiente",
|
||||
"loading": "Cargando tu calendario…",
|
||||
"empty": "Empieza a registrar para ver tu calendario.",
|
||||
"today": "Hoy",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Síntomas"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Qué viene después",
|
||||
"nextPeriod": "Próximo periodo",
|
||||
"nextPeriodRange": "Alrededor del {date}",
|
||||
"nextPeriodWindow": "{low} – {high}",
|
||||
"fertileWindow": "Ventana fértil",
|
||||
"ovulation": "Ovulación estimada",
|
||||
"confidence": "Confianza",
|
||||
"confidenceHigh": "Alta",
|
||||
"confidenceMedium": "Media",
|
||||
"confidenceLow": "Baja",
|
||||
"stillLearning": "Aún aprendiendo",
|
||||
"stillLearningBody": "Registra al menos tres ciclos completos y tus predicciones mejorarán. Hasta entonces es una estimación aproximada.",
|
||||
"rawMode": "Predicción desactivada",
|
||||
"rawModeBody": "Has desactivado las predicciones — solo se muestran tus datos registrados, sin pronóstico.",
|
||||
"none": "Aún no hay predicción. Registra un periodo para empezar.",
|
||||
"ovulationConfirmed": "Ovulación (confirmada)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Tus ciclos",
|
||||
"avgLength": "Duración media",
|
||||
"variability": "Variabilidad",
|
||||
"avgPeriodLength": "Periodo medio",
|
||||
"regularity": "Regularidad",
|
||||
"regularityREGULAR": "Regular",
|
||||
"regularityIRREGULAR": "Irregular",
|
||||
"regularityLEARNING": "Aprendiendo",
|
||||
"days": "{count} días",
|
||||
"daysPlusMinus": "± {count} días",
|
||||
"none": "Aún no hay ciclos registrados.",
|
||||
"recentCycles": "Ciclos recientes",
|
||||
"ongoing": "en curso",
|
||||
"periodDays": "período {count} d",
|
||||
"ovulationChip": "Ovulación"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registrar tu día",
|
||||
"description": "Captura el flujo, los síntomas y las notas de hoy.",
|
||||
"date": "Fecha",
|
||||
"flow": "Flujo",
|
||||
"symptoms": "Síntomas",
|
||||
"temperature": "Temperatura basal",
|
||||
"temperaturePlaceholder": "°C",
|
||||
"ovulationTest": "Test de ovulación",
|
||||
"mucus": "Moco cervical",
|
||||
"intercourse": "Relaciones",
|
||||
"protected": "Protegidas",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Algo que quieras recordar de hoy…",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"cancel": "Cancelar",
|
||||
"saved": "Día guardado",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"periodStarted": "Inicio del periodo registrado",
|
||||
"clear": "Ninguno",
|
||||
"intermenstrualBleeding": "Manchado entre periodos",
|
||||
"pregnancyTest": "Prueba de embarazo",
|
||||
"progesteroneTest": "Prueba de progesterona",
|
||||
"contraceptive": "Anticonceptivo",
|
||||
"severity": "Intensidad",
|
||||
"severityLevel": "Intensidad {level} de 4",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del ciclo",
|
||||
"enable": "Seguimiento del ciclo",
|
||||
"enableDescription": "Activa o desactiva la pestaña del ciclo en tu cuenta.",
|
||||
"typicalCycleLength": "Duración típica del ciclo (días)",
|
||||
"typicalPeriodLength": "Duración típica del periodo (días)",
|
||||
"lutealPhaseLength": "Duración de la fase lútea (días)",
|
||||
"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.",
|
||||
"prediction": "Predicciones",
|
||||
"predictionDescription": "Pronostica tu próximo periodo y, cuando corresponda, tu ventana fértil.",
|
||||
"encryption": "Cifrar entradas sensibles",
|
||||
"encryptionDescription": "Cifra en reposo los campos que revelan intención (test de embarazo, anticoncepción, relaciones), además de las notas siempre cifradas.",
|
||||
"discreet": "Notificaciones discretas",
|
||||
"discreetDescription": "Oculta los detalles del ciclo en el texto de las notificaciones de tu pantalla de bloqueo.",
|
||||
"dataTitle": "Tus datos",
|
||||
"dataDescription": "Tus datos del ciclo viven solo en este servidor. Expórtalos cuando quieras o elimínalos para siempre.",
|
||||
"export": "Exportar mis datos del ciclo",
|
||||
"deleteData": "Eliminar mis datos del ciclo",
|
||||
"saved": "Ajustes guardados",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"save": "Guardar",
|
||||
"purgeTitle": "Eliminar todos los datos del ciclo",
|
||||
"purgeDescription": "Elimina de forma permanente cada día registrado, ciclo, predicción y registro de recordatorio. No se puede deshacer.",
|
||||
"purgeButton": "Eliminar todos los datos del ciclo",
|
||||
"purgeConfirmTitle": "¿Eliminar todos los datos del ciclo?",
|
||||
"purgeConfirmBody": "Esto elimina de forma permanente todos los periodos, registros diarios, síntomas y predicciones. No se puede deshacer.",
|
||||
"purgeConfirm": "¿Eliminar permanentemente todos los datos del ciclo?",
|
||||
"purgeCancel": "Cancelar",
|
||||
"purgeSuccess": "Todos los datos del ciclo eliminados.",
|
||||
"purgeError": "No se pudieron eliminar los datos del ciclo.",
|
||||
"reminders": "Recordatorios",
|
||||
"remindersDescription": "Activa los recordatorios de período por canal en los ajustes de notificaciones.",
|
||||
"remindersLink": "Ajustes de notificaciones",
|
||||
"purgeData": "Eliminar datos del ciclo",
|
||||
"purgeConfirmAction": "Sí, eliminar todo",
|
||||
"purgeDone": "Datos del ciclo eliminados."
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positivo",
|
||||
"NEGATIVE": "Negativo",
|
||||
"INDETERMINATE": "No claro"
|
||||
},
|
||||
"contraceptive": {
|
||||
"NONE": "Ninguno",
|
||||
"UNSPECIFIED": "Otro",
|
||||
"IMPLANT": "Implante",
|
||||
"INJECTION": "Inyección",
|
||||
"IUD": "DIU",
|
||||
"INTRAVAGINAL_RING": "Anillo",
|
||||
"ORAL": "Píldora",
|
||||
"PATCH": "Parche",
|
||||
"EMERGENCY": "Emergencia"
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus datos del ciclo.",
|
||||
"bbt": {
|
||||
"title": "Temperatura basal",
|
||||
"caption": "Tu temperatura matutina a lo largo de este ciclo. Una subida sostenida de unos 0,2 °C sigue a la ovulación.",
|
||||
"empty": "Aún no hay mediciones de temperatura",
|
||||
"emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.",
|
||||
"ovulationEstimate": "Ovulación est.",
|
||||
"tooltipTemp": "{temp} °C",
|
||||
"ovulationConfirmed": "Ovulación confirmada"
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "El periodo llegará pronto",
|
||||
"periodSoonBody": "Se prevé tu próximo periodo en unos dos días.",
|
||||
"periodConfirmTitle": "¿Ha comenzado tu periodo?",
|
||||
"periodConfirmBody": "Tu periodo estaba previsto para ahora. Regístralo para mantener las predicciones precisas.",
|
||||
"discreetTitle": "Recordatorio de HealthLog",
|
||||
"discreetBody": "Tienes un recordatorio en HealthLog."
|
||||
}
|
||||
}
|
||||
+312
-3
@@ -146,6 +146,7 @@
|
||||
"moodSensitive": "Sensible — désactivé par défaut.",
|
||||
"compliance": "Observance des médicaments",
|
||||
"sleep": "Durée du sommeil",
|
||||
"cycle": "Cycle menstruel",
|
||||
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
|
||||
"unavailableHint": "Pas de données sur cette période"
|
||||
},
|
||||
@@ -163,7 +164,22 @@
|
||||
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
|
||||
"wellnessRecovery": "Récupération",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Effort"
|
||||
"wellnessStrain": "Effort",
|
||||
"cycleTitle": "Cycle menstruel",
|
||||
"cycleLmp": "Dernières règles",
|
||||
"cycleAvgLength": "Durée moyenne du cycle",
|
||||
"cycleAvgPeriod": "Durée moyenne des règles",
|
||||
"cyclePhase": "Phase actuelle",
|
||||
"cycleDays": "jours",
|
||||
"cycleColStart": "Début des règles",
|
||||
"cycleColLength": "Durée du cycle (jours)",
|
||||
"cycleColPeriod": "Durée des règles (jours)",
|
||||
"cyclePhases": {
|
||||
"MENSTRUAL": "Menstruelle",
|
||||
"FOLLICULAR": "Folliculaire",
|
||||
"OVULATORY": "Ovulatoire",
|
||||
"LUTEAL": "Lutéale"
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -216,7 +232,8 @@
|
||||
"medicationDescription": "Marquer une dose comme prise",
|
||||
"mood": "Humeur",
|
||||
"moodDescription": "Enregistrer l'humeur et les tags"
|
||||
}
|
||||
},
|
||||
"cycle": "Cycle"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Se connecter",
|
||||
@@ -3860,6 +3877,14 @@
|
||||
"statusRevoked": "Révoqué",
|
||||
"statusExpired": "Expiré",
|
||||
"inactiveTitle": "Révoqués et expirés ({count})"
|
||||
},
|
||||
"genderOther": "Autre",
|
||||
"cycleTracking": {
|
||||
"title": "Suivi du cycle",
|
||||
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.",
|
||||
"enable": "Activer le suivi du cycle",
|
||||
"manage": "Ouvrir le suivi du cycle",
|
||||
"error": "Impossible de mettre à jour le suivi du cycle."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -4673,7 +4698,11 @@
|
||||
"eventPersonalRecord": "Record personnel",
|
||||
"eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.",
|
||||
"eventMoodReminder": "Rappel d'humeur",
|
||||
"eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment."
|
||||
"eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.",
|
||||
"eventCyclePeriodSoon": "Règles à venir",
|
||||
"eventCyclePeriodSoonDesc": "Envoyé quelques jours avant vos prochaines règles prévues.",
|
||||
"eventCyclePeriodConfirm": "Vos règles ont-elles commencé ?",
|
||||
"eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4867,5 +4896,285 @@
|
||||
"score": "Score de bien-être"
|
||||
},
|
||||
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
"physical": "Physique",
|
||||
"emotional": "Émotionnel",
|
||||
"digestive": "Digestif"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Crampes",
|
||||
"headache": "Mal de tête",
|
||||
"bloating": "Ballonnements",
|
||||
"acne": "Acné",
|
||||
"breastTenderness": "Sensibilité des seins",
|
||||
"fatigue": "Fatigue",
|
||||
"backPain": "Mal de dos",
|
||||
"insomnia": "Insomnie",
|
||||
"libidoHigh": "Libido élevée",
|
||||
"libidoLow": "Libido faible",
|
||||
"moodSwings": "Sautes d'humeur",
|
||||
"foodCravings": "Fringales",
|
||||
"nausea": "Nausées",
|
||||
"diarrhea": "Diarrhée",
|
||||
"constipation": "Constipation"
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles."
|
||||
},
|
||||
"insights": {
|
||||
"headline": {
|
||||
"title": "Ton cycle et ton corps",
|
||||
"restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.",
|
||||
"heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.",
|
||||
"generic": "Ton/ta {metric} est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.",
|
||||
"dirHigher": "plus élevée",
|
||||
"dirLower": "plus basse",
|
||||
"empty": "Pas encore assez de cycles et de relevés pour faire ressortir un schéma de phase. Continue à enregistrer : cela se complétera à mesure que ton historique grandit."
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.",
|
||||
"pairLabel": "{metric} · lutéale vs folliculaire",
|
||||
"detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).",
|
||||
"footer": "Ce sont des associations dans tes propres données, pas des causes. Seuls les schémas qui passent un test de significativité sont affichés.",
|
||||
"metricRestingHeartRate": "Fréquence cardiaque au repos",
|
||||
"metricHrv": "Variabilité de la fréquence cardiaque",
|
||||
"metricSleepDuration": "Durée du sommeil",
|
||||
"metricSteps": "Pas",
|
||||
"metricWeight": "Poids",
|
||||
"metricBasalBodyTemp": "Température basale",
|
||||
"metricWristTemperature": "Température du poignet la nuit",
|
||||
"metricSkinTemperature": "Température de la peau",
|
||||
"unitHours": "h",
|
||||
"unitSteps": "pas",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitCelsius": "°C",
|
||||
"confidenceLow": "Confiance faible",
|
||||
"confidenceMedium": "Confiance moyenne",
|
||||
"confidenceHigh": "Confiance élevée"
|
||||
},
|
||||
"title": "Analyses du cycle",
|
||||
"empty": "Les analyses par phase apparaissent ici une fois assez de cycles enregistrés. Elles comparent tes mesures entre les phases du cycle, avec les statistiques affichées ouvertement.",
|
||||
"sampleSize": "n = {n}",
|
||||
"effectSize": "Taille d'effet {value}",
|
||||
"qValue": "q = {value}",
|
||||
"symptomPatternsTitle": "Tendances des symptômes par phase",
|
||||
"symptomPatternsEmpty": "Notez vos symptômes sur un cycle pour voir dans quelle phase ils se regroupent.",
|
||||
"symptomMostlyIn": "Surtout en {phase} ({count}/{total} jours)"
|
||||
},
|
||||
"disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.",
|
||||
"title": "Ton cycle",
|
||||
"subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.",
|
||||
"tabs": {
|
||||
"calendar": "Calendrier",
|
||||
"predictions": "Prévisions",
|
||||
"insights": "Analyses",
|
||||
"settings": "Réglages"
|
||||
},
|
||||
"logToday": "Enregistrer aujourd'hui",
|
||||
"startedPeriod": "Mes règles ont commencé aujourd'hui",
|
||||
"endedPeriod": "Mes règles se sont terminées aujourd'hui",
|
||||
"phase": {
|
||||
"MENSTRUAL": "Menstruelle",
|
||||
"FOLLICULAR": "Folliculaire",
|
||||
"OVULATORY": "Ovulatoire",
|
||||
"LUTEAL": "Lutéale",
|
||||
"none": "Aucune phase",
|
||||
"label": "Phase"
|
||||
},
|
||||
"flow": {
|
||||
"NONE": "Aucun",
|
||||
"SPOTTING": "Spotting",
|
||||
"LIGHT": "Léger",
|
||||
"MEDIUM": "Moyen",
|
||||
"HEAVY": "Abondant",
|
||||
"label": "Flux"
|
||||
},
|
||||
"ovulationTest": {
|
||||
"NEGATIVE": "Négatif",
|
||||
"POSITIVE_LH_SURGE": "Pic de LH",
|
||||
"ESTROGEN_SURGE": "Pic d'œstrogène",
|
||||
"INDETERMINATE": "Indéterminé",
|
||||
"label": "Test d'ovulation"
|
||||
},
|
||||
"mucus": {
|
||||
"DRY": "Sec",
|
||||
"STICKY": "Collant",
|
||||
"CREAMY": "Crémeux",
|
||||
"WATERY": "Aqueux",
|
||||
"EGG_WHITE": "Blanc d'œuf",
|
||||
"label": "Glaire cervicale"
|
||||
},
|
||||
"goal": {
|
||||
"GENERAL_HEALTH": "Santé générale",
|
||||
"AVOID_PREGNANCY": "Éviter une grossesse",
|
||||
"TRYING_TO_CONCEIVE": "Désir d'enfant",
|
||||
"PERIMENOPAUSE": "Périménopause",
|
||||
"OFF": "Suivi désactivé",
|
||||
"label": "Pourquoi fais-tu ce suivi ?",
|
||||
"description": "Cela adapte le langage et les prévisions affichées. Les conseils sur la fenêtre fertile n'apparaissent qu'en cas de désir d'enfant."
|
||||
},
|
||||
"ring": {
|
||||
"dayOfCycle": "Jour {day}",
|
||||
"dayUnknown": "—",
|
||||
"ariaPhase": "Jour {day} du cycle, phase {phase}",
|
||||
"ariaUnknown": "Pas encore de cycle actif",
|
||||
"caption": "de ton cycle",
|
||||
"firstPeriodCta": "Enregistrer mes premières règles"
|
||||
},
|
||||
"calendar": {
|
||||
"legendPeriod": "Règles enregistrées",
|
||||
"legendPredicted": "Règles prévues",
|
||||
"legendFertile": "Fenêtre fertile",
|
||||
"legendOvulation": "Ovulation",
|
||||
"legendSymptoms": "Symptômes enregistrés",
|
||||
"prevMonth": "Mois précédent",
|
||||
"nextMonth": "Mois suivant",
|
||||
"loading": "Chargement de ton calendrier…",
|
||||
"empty": "Commence à enregistrer pour remplir ton calendrier.",
|
||||
"today": "Aujourd'hui",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Symptômes"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Ce qui arrive ensuite",
|
||||
"nextPeriod": "Prochaines règles",
|
||||
"nextPeriodRange": "Vers le {date}",
|
||||
"nextPeriodWindow": "{low} – {high}",
|
||||
"fertileWindow": "Fenêtre fertile",
|
||||
"ovulation": "Ovulation estimée",
|
||||
"confidence": "Confiance",
|
||||
"confidenceHigh": "Élevée",
|
||||
"confidenceMedium": "Moyenne",
|
||||
"confidenceLow": "Faible",
|
||||
"stillLearning": "Encore en apprentissage",
|
||||
"stillLearningBody": "Enregistre au moins trois cycles complets et tes prévisions s'affineront. D'ici là, ceci reste une estimation approximative.",
|
||||
"rawMode": "Prévision désactivée",
|
||||
"rawModeBody": "Tu as désactivé les prévisions — seules tes données enregistrées sont affichées, sans prévision.",
|
||||
"none": "Pas encore de prévision. Enregistre des règles pour commencer.",
|
||||
"ovulationConfirmed": "Ovulation (confirmée)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Tes cycles",
|
||||
"avgLength": "Durée moyenne",
|
||||
"variability": "Variabilité",
|
||||
"avgPeriodLength": "Règles moyennes",
|
||||
"regularity": "Régularité",
|
||||
"regularityREGULAR": "Régulier",
|
||||
"regularityIRREGULAR": "Irrégulier",
|
||||
"regularityLEARNING": "En apprentissage",
|
||||
"days": "{count} jours",
|
||||
"daysPlusMinus": "± {count} jours",
|
||||
"none": "Aucun cycle enregistré pour l'instant.",
|
||||
"recentCycles": "Cycles récents",
|
||||
"ongoing": "en cours",
|
||||
"periodDays": "règles {count} j",
|
||||
"ovulationChip": "Ovulation"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Enregistrer ta journée",
|
||||
"description": "Saisis le flux, les symptômes et les notes du jour.",
|
||||
"date": "Date",
|
||||
"flow": "Flux",
|
||||
"symptoms": "Symptômes",
|
||||
"temperature": "Température basale",
|
||||
"temperaturePlaceholder": "°C",
|
||||
"ovulationTest": "Test d'ovulation",
|
||||
"mucus": "Glaire cervicale",
|
||||
"intercourse": "Rapports",
|
||||
"protected": "Protégés",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Ce que tu veux retenir d'aujourd'hui…",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"cancel": "Annuler",
|
||||
"saved": "Journée enregistrée",
|
||||
"saveError": "Enregistrement impossible. Réessaie.",
|
||||
"periodStarted": "Début des règles enregistré",
|
||||
"clear": "Aucun",
|
||||
"intermenstrualBleeding": "Saignements entre les règles",
|
||||
"pregnancyTest": "Test de grossesse",
|
||||
"progesteroneTest": "Test de progestérone",
|
||||
"contraceptive": "Contraception",
|
||||
"severity": "Intensité",
|
||||
"severityLevel": "Intensité {level} sur 4",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Réglages du cycle",
|
||||
"enable": "Suivi du cycle",
|
||||
"enableDescription": "Active ou désactive l'onglet cycle pour ton compte.",
|
||||
"typicalCycleLength": "Durée typique du cycle (jours)",
|
||||
"typicalPeriodLength": "Durée typique des règles (jours)",
|
||||
"lutealPhaseLength": "Durée de la phase lutéale (jours)",
|
||||
"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.",
|
||||
"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",
|
||||
"encryptionDescription": "Chiffre au repos les champs révélant l'intention (tests de grossesse, contraception, rapports), en plus des notes toujours chiffrées.",
|
||||
"discreet": "Notifications discrètes",
|
||||
"discreetDescription": "Masque les détails du cycle dans le texte des notifications sur ton écran verrouillé.",
|
||||
"dataTitle": "Tes données",
|
||||
"dataDescription": "Tes données de cycle ne vivent que sur ce serveur. Exporte-les quand tu veux, ou supprime-les définitivement.",
|
||||
"export": "Exporter mes données de cycle",
|
||||
"deleteData": "Supprimer mes données de cycle",
|
||||
"saved": "Réglages enregistrés",
|
||||
"saveError": "Enregistrement impossible. Réessaie.",
|
||||
"save": "Enregistrer",
|
||||
"purgeTitle": "Supprimer toutes les données de cycle",
|
||||
"purgeDescription": "Supprime définitivement chaque jour enregistré, cycle, prédiction et rappel. Action irréversible.",
|
||||
"purgeButton": "Supprimer toutes les données de cycle",
|
||||
"purgeConfirmTitle": "Supprimer toutes les données de cycle ?",
|
||||
"purgeConfirmBody": "Cela supprime définitivement chaque règle, journal quotidien, symptôme et prédiction. Cette action est irréversible.",
|
||||
"purgeConfirm": "Supprimer définitivement toutes les données de cycle ?",
|
||||
"purgeCancel": "Annuler",
|
||||
"purgeSuccess": "Toutes les données de cycle ont été supprimées.",
|
||||
"purgeError": "Impossible de supprimer les données de cycle.",
|
||||
"reminders": "Rappels",
|
||||
"remindersDescription": "Activez les rappels de règles par canal dans les paramètres de notification.",
|
||||
"remindersLink": "Paramètres de notification",
|
||||
"purgeData": "Supprimer les données de cycle",
|
||||
"purgeConfirmAction": "Oui, tout supprimer",
|
||||
"purgeDone": "Données de cycle supprimées."
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positif",
|
||||
"NEGATIVE": "Négatif",
|
||||
"INDETERMINATE": "Incertain"
|
||||
},
|
||||
"contraceptive": {
|
||||
"NONE": "Aucune",
|
||||
"UNSPECIFIED": "Autre",
|
||||
"IMPLANT": "Implant",
|
||||
"INJECTION": "Injection",
|
||||
"IUD": "DIU",
|
||||
"INTRAVAGINAL_RING": "Anneau",
|
||||
"ORAL": "Pilule",
|
||||
"PATCH": "Patch",
|
||||
"EMERGENCY": "Urgence"
|
||||
},
|
||||
"loadError": "Impossible de charger vos données de cycle.",
|
||||
"bbt": {
|
||||
"title": "Température basale",
|
||||
"caption": "Votre température matinale sur ce cycle. Une hausse durable d'environ 0,2 °C suit l'ovulation.",
|
||||
"empty": "Pas encore de relevés de température",
|
||||
"emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.",
|
||||
"ovulationEstimate": "Ovulation est.",
|
||||
"tooltipTemp": "{temp} °C",
|
||||
"ovulationConfirmed": "Ovulation confirmée"
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Règles probablement bientôt",
|
||||
"periodSoonBody": "Vos prochaines règles sont prévues dans environ deux jours.",
|
||||
"periodConfirmTitle": "Vos règles ont-elles commencé ?",
|
||||
"periodConfirmBody": "Vos règles étaient prévues pour maintenant. Enregistrez-les pour garder des prévisions précises.",
|
||||
"discreetTitle": "Rappel HealthLog",
|
||||
"discreetBody": "Vous avez un rappel dans HealthLog."
|
||||
}
|
||||
}
|
||||
+312
-3
@@ -146,6 +146,7 @@
|
||||
"moodSensitive": "Sensibile — disattivato per default.",
|
||||
"compliance": "Aderenza ai farmaci",
|
||||
"sleep": "Durata del sonno",
|
||||
"cycle": "Ciclo mestruale",
|
||||
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
|
||||
"unavailableHint": "Nessun dato in questo periodo"
|
||||
},
|
||||
@@ -163,7 +164,22 @@
|
||||
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
|
||||
"wellnessRecovery": "Recupero",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Sforzo"
|
||||
"wellnessStrain": "Sforzo",
|
||||
"cycleTitle": "Ciclo mestruale",
|
||||
"cycleLmp": "Ultime mestruazioni",
|
||||
"cycleAvgLength": "Durata media del ciclo",
|
||||
"cycleAvgPeriod": "Durata media delle mestruazioni",
|
||||
"cyclePhase": "Fase attuale",
|
||||
"cycleDays": "giorni",
|
||||
"cycleColStart": "Inizio mestruazioni",
|
||||
"cycleColLength": "Durata del ciclo (giorni)",
|
||||
"cycleColPeriod": "Durata mestruazioni (giorni)",
|
||||
"cyclePhases": {
|
||||
"MENSTRUAL": "Mestruale",
|
||||
"FOLLICULAR": "Follicolare",
|
||||
"OVULATORY": "Ovulatoria",
|
||||
"LUTEAL": "Luteale"
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -216,7 +232,8 @@
|
||||
"medicationDescription": "Segna una dose come assunta",
|
||||
"mood": "Umore",
|
||||
"moodDescription": "Registra umore e tag"
|
||||
}
|
||||
},
|
||||
"cycle": "Ciclo"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -3860,6 +3877,14 @@
|
||||
"statusRevoked": "Revocato",
|
||||
"statusExpired": "Scaduto",
|
||||
"inactiveTitle": "Revocati e scaduti ({count})"
|
||||
},
|
||||
"genderOther": "Altro",
|
||||
"cycleTracking": {
|
||||
"title": "Monitoraggio del ciclo",
|
||||
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.",
|
||||
"enable": "Attiva il monitoraggio del ciclo",
|
||||
"manage": "Apri il monitoraggio del ciclo",
|
||||
"error": "Impossibile aggiornare il monitoraggio del ciclo."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -4673,7 +4698,11 @@
|
||||
"eventPersonalRecord": "Record personale",
|
||||
"eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.",
|
||||
"eventMoodReminder": "Promemoria umore",
|
||||
"eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale."
|
||||
"eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.",
|
||||
"eventCyclePeriodSoon": "Ciclo in arrivo",
|
||||
"eventCyclePeriodSoonDesc": "Inviato un paio di giorni prima del ciclo previsto.",
|
||||
"eventCyclePeriodConfirm": "Il ciclo è iniziato?",
|
||||
"eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4867,5 +4896,285 @@
|
||||
"score": "Punteggio di benessere"
|
||||
},
|
||||
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
"physical": "Fisico",
|
||||
"emotional": "Emotivo",
|
||||
"digestive": "Digestivo"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Crampi",
|
||||
"headache": "Mal di testa",
|
||||
"bloating": "Gonfiore",
|
||||
"acne": "Acne",
|
||||
"breastTenderness": "Tensione al seno",
|
||||
"fatigue": "Affaticamento",
|
||||
"backPain": "Mal di schiena",
|
||||
"insomnia": "Insonnia",
|
||||
"libidoHigh": "Libido alta",
|
||||
"libidoLow": "Libido bassa",
|
||||
"moodSwings": "Sbalzi d'umore",
|
||||
"foodCravings": "Voglie alimentari",
|
||||
"nausea": "Nausea",
|
||||
"diarrhea": "Diarrea",
|
||||
"constipation": "Stitichezza"
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli."
|
||||
},
|
||||
"insights": {
|
||||
"headline": {
|
||||
"title": "Il tuo ciclo e il tuo corpo",
|
||||
"restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.",
|
||||
"heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.",
|
||||
"generic": "Il/la tuo/a {metric} è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.",
|
||||
"dirHigher": "più alta",
|
||||
"dirLower": "più bassa",
|
||||
"empty": "Non ci sono ancora abbastanza cicli e rilevazioni per mostrare uno schema di fase. Continua a registrare: si completerà man mano che cresce il tuo storico."
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.",
|
||||
"pairLabel": "{metric} · luteale vs follicolare",
|
||||
"detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).",
|
||||
"footer": "Sono associazioni nei tuoi dati, non cause. Vengono mostrati solo gli schemi che superano un test di significatività.",
|
||||
"metricRestingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"metricHrv": "Variabilità della frequenza cardiaca",
|
||||
"metricSleepDuration": "Durata del sonno",
|
||||
"metricSteps": "Passi",
|
||||
"metricWeight": "Peso",
|
||||
"metricBasalBodyTemp": "Temperatura basale",
|
||||
"metricWristTemperature": "Temperatura del polso notturna",
|
||||
"metricSkinTemperature": "Temperatura della pelle",
|
||||
"unitHours": "h",
|
||||
"unitSteps": "passi",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitCelsius": "°C",
|
||||
"confidenceLow": "Affidabilità bassa",
|
||||
"confidenceMedium": "Affidabilità media",
|
||||
"confidenceHigh": "Affidabilità alta"
|
||||
},
|
||||
"title": "Analisi del ciclo",
|
||||
"empty": "Le analisi per fase compaiono qui quando hai registrato abbastanza cicli. Confrontano le tue misure tra le fasi del ciclo, con la statistica mostrata apertamente.",
|
||||
"sampleSize": "n = {n}",
|
||||
"effectSize": "Dimensione dell'effetto {value}",
|
||||
"qValue": "q = {value}",
|
||||
"symptomPatternsTitle": "Pattern dei sintomi per fase",
|
||||
"symptomPatternsEmpty": "Registra i sintomi durante un ciclo per vedere in quale fase si concentrano.",
|
||||
"symptomMostlyIn": "Soprattutto in {phase} ({count}/{total} giorni)"
|
||||
},
|
||||
"disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.",
|
||||
"title": "Il tuo ciclo",
|
||||
"subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.",
|
||||
"tabs": {
|
||||
"calendar": "Calendario",
|
||||
"predictions": "Previsioni",
|
||||
"insights": "Analisi",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"logToday": "Registra oggi",
|
||||
"startedPeriod": "Le mestruazioni sono iniziate oggi",
|
||||
"endedPeriod": "Le mestruazioni sono finite oggi",
|
||||
"phase": {
|
||||
"MENSTRUAL": "Mestruale",
|
||||
"FOLLICULAR": "Follicolare",
|
||||
"OVULATORY": "Ovulatoria",
|
||||
"LUTEAL": "Luteale",
|
||||
"none": "Nessuna fase",
|
||||
"label": "Fase"
|
||||
},
|
||||
"flow": {
|
||||
"NONE": "Nessuno",
|
||||
"SPOTTING": "Spotting",
|
||||
"LIGHT": "Leggero",
|
||||
"MEDIUM": "Medio",
|
||||
"HEAVY": "Abbondante",
|
||||
"label": "Flusso"
|
||||
},
|
||||
"ovulationTest": {
|
||||
"NEGATIVE": "Negativo",
|
||||
"POSITIVE_LH_SURGE": "Picco di LH",
|
||||
"ESTROGEN_SURGE": "Picco di estrogeni",
|
||||
"INDETERMINATE": "Indeterminato",
|
||||
"label": "Test di ovulazione"
|
||||
},
|
||||
"mucus": {
|
||||
"DRY": "Secco",
|
||||
"STICKY": "Appiccicoso",
|
||||
"CREAMY": "Cremoso",
|
||||
"WATERY": "Acquoso",
|
||||
"EGG_WHITE": "Albume",
|
||||
"label": "Muco cervicale"
|
||||
},
|
||||
"goal": {
|
||||
"GENERAL_HEALTH": "Salute generale",
|
||||
"AVOID_PREGNANCY": "Evitare la gravidanza",
|
||||
"TRYING_TO_CONCEIVE": "Cerco una gravidanza",
|
||||
"PERIMENOPAUSE": "Perimenopausa",
|
||||
"OFF": "Monitoraggio disattivato",
|
||||
"label": "Per cosa stai monitorando?",
|
||||
"description": "Questo definisce il linguaggio e quali previsioni vedi. Le indicazioni sulla finestra fertile compaiono solo se cerchi una gravidanza."
|
||||
},
|
||||
"ring": {
|
||||
"dayOfCycle": "Giorno {day}",
|
||||
"dayUnknown": "—",
|
||||
"ariaPhase": "Giorno {day} del ciclo, fase {phase}",
|
||||
"ariaUnknown": "Nessun ciclo attivo ancora",
|
||||
"caption": "del tuo ciclo",
|
||||
"firstPeriodCta": "Registra il mio primo ciclo"
|
||||
},
|
||||
"calendar": {
|
||||
"legendPeriod": "Ciclo registrato",
|
||||
"legendPredicted": "Ciclo previsto",
|
||||
"legendFertile": "Finestra fertile",
|
||||
"legendOvulation": "Ovulazione",
|
||||
"legendSymptoms": "Sintomi registrati",
|
||||
"prevMonth": "Mese precedente",
|
||||
"nextMonth": "Mese successivo",
|
||||
"loading": "Caricamento del calendario…",
|
||||
"empty": "Inizia a registrare per riempire il calendario.",
|
||||
"today": "Oggi",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Sintomi"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Cosa arriva dopo",
|
||||
"nextPeriod": "Prossimo ciclo",
|
||||
"nextPeriodRange": "Intorno al {date}",
|
||||
"nextPeriodWindow": "{low} – {high}",
|
||||
"fertileWindow": "Finestra fertile",
|
||||
"ovulation": "Ovulazione stimata",
|
||||
"confidence": "Affidabilità",
|
||||
"confidenceHigh": "Alta",
|
||||
"confidenceMedium": "Media",
|
||||
"confidenceLow": "Bassa",
|
||||
"stillLearning": "Sto ancora imparando",
|
||||
"stillLearningBody": "Registra almeno tre cicli completi e le previsioni miglioreranno. Fino ad allora è una stima approssimativa.",
|
||||
"rawMode": "Previsione disattivata",
|
||||
"rawModeBody": "Hai disattivato le previsioni — vengono mostrati solo i tuoi dati registrati, senza previsione.",
|
||||
"none": "Nessuna previsione ancora. Registra un ciclo per iniziare.",
|
||||
"ovulationConfirmed": "Ovulazione (confermata)"
|
||||
},
|
||||
"history": {
|
||||
"title": "I tuoi cicli",
|
||||
"avgLength": "Durata media",
|
||||
"variability": "Variabilità",
|
||||
"avgPeriodLength": "Mestruazioni medie",
|
||||
"regularity": "Regolarità",
|
||||
"regularityREGULAR": "Regolare",
|
||||
"regularityIRREGULAR": "Irregolare",
|
||||
"regularityLEARNING": "In apprendimento",
|
||||
"days": "{count} giorni",
|
||||
"daysPlusMinus": "± {count} giorni",
|
||||
"none": "Nessun ciclo registrato ancora.",
|
||||
"recentCycles": "Cicli recenti",
|
||||
"ongoing": "in corso",
|
||||
"periodDays": "ciclo {count} g",
|
||||
"ovulationChip": "Ovulazione"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registra la tua giornata",
|
||||
"description": "Annota flusso, sintomi e note di oggi.",
|
||||
"date": "Data",
|
||||
"flow": "Flusso",
|
||||
"symptoms": "Sintomi",
|
||||
"temperature": "Temperatura basale",
|
||||
"temperaturePlaceholder": "°C",
|
||||
"ovulationTest": "Test di ovulazione",
|
||||
"mucus": "Muco cervicale",
|
||||
"intercourse": "Rapporti",
|
||||
"protected": "Protetti",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Qualcosa che vuoi ricordare di oggi…",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio…",
|
||||
"cancel": "Annulla",
|
||||
"saved": "Giornata salvata",
|
||||
"saveError": "Impossibile salvare. Riprova.",
|
||||
"periodStarted": "Inizio del ciclo registrato",
|
||||
"clear": "Nessuno",
|
||||
"intermenstrualBleeding": "Spotting tra i cicli",
|
||||
"pregnancyTest": "Test di gravidanza",
|
||||
"progesteroneTest": "Test del progesterone",
|
||||
"contraceptive": "Contraccettivo",
|
||||
"severity": "Intensità",
|
||||
"severityLevel": "Intensità {level} su 4",
|
||||
"delete": "Elimina"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni del ciclo",
|
||||
"enable": "Monitoraggio del ciclo",
|
||||
"enableDescription": "Attiva o disattiva la scheda ciclo per il tuo account.",
|
||||
"typicalCycleLength": "Durata tipica del ciclo (giorni)",
|
||||
"typicalPeriodLength": "Durata tipica delle mestruazioni (giorni)",
|
||||
"lutealPhaseLength": "Durata della fase luteale (giorni)",
|
||||
"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.",
|
||||
"prediction": "Previsioni",
|
||||
"predictionDescription": "Prevedi il prossimo ciclo e, quando rilevante, la tua finestra fertile.",
|
||||
"encryption": "Cifra le voci sensibili",
|
||||
"encryptionDescription": "Cifra a riposo i campi che rivelano l'intenzione (test di gravidanza, contraccezione, rapporti), oltre alle note sempre cifrate.",
|
||||
"discreet": "Notifiche discrete",
|
||||
"discreetDescription": "Nascondi i dettagli del ciclo nel testo delle notifiche sulla schermata di blocco.",
|
||||
"dataTitle": "I tuoi dati",
|
||||
"dataDescription": "I tuoi dati del ciclo vivono solo su questo server. Esportali quando vuoi, o eliminali per sempre.",
|
||||
"export": "Esporta i miei dati del ciclo",
|
||||
"deleteData": "Elimina i miei dati del ciclo",
|
||||
"saved": "Impostazioni salvate",
|
||||
"saveError": "Impossibile salvare. Riprova.",
|
||||
"save": "Salva",
|
||||
"purgeTitle": "Elimina tutti i dati del ciclo",
|
||||
"purgeDescription": "Rimuove definitivamente ogni giorno registrato, ciclo, previsione e promemoria. Operazione irreversibile.",
|
||||
"purgeButton": "Elimina tutti i dati del ciclo",
|
||||
"purgeConfirmTitle": "Eliminare tutti i dati del ciclo?",
|
||||
"purgeConfirmBody": "Questo elimina definitivamente ogni mestruazione, registro giornaliero, sintomo e previsione. L'operazione è irreversibile.",
|
||||
"purgeConfirm": "Eliminare definitivamente tutti i dati del ciclo?",
|
||||
"purgeCancel": "Annulla",
|
||||
"purgeSuccess": "Tutti i dati del ciclo eliminati.",
|
||||
"purgeError": "Impossibile eliminare i dati del ciclo.",
|
||||
"reminders": "Promemoria",
|
||||
"remindersDescription": "Attiva i promemoria del ciclo per canale nelle impostazioni di notifica.",
|
||||
"remindersLink": "Impostazioni di notifica",
|
||||
"purgeData": "Elimina i dati del ciclo",
|
||||
"purgeConfirmAction": "Sì, elimina tutto",
|
||||
"purgeDone": "Dati del ciclo eliminati."
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positivo",
|
||||
"NEGATIVE": "Negativo",
|
||||
"INDETERMINATE": "Non chiaro"
|
||||
},
|
||||
"contraceptive": {
|
||||
"NONE": "Nessuno",
|
||||
"UNSPECIFIED": "Altro",
|
||||
"IMPLANT": "Impianto",
|
||||
"INJECTION": "Iniezione",
|
||||
"IUD": "IUD",
|
||||
"INTRAVAGINAL_RING": "Anello",
|
||||
"ORAL": "Pillola",
|
||||
"PATCH": "Cerotto",
|
||||
"EMERGENCY": "Emergenza"
|
||||
},
|
||||
"loadError": "Impossibile caricare i dati del ciclo.",
|
||||
"bbt": {
|
||||
"title": "Temperatura basale",
|
||||
"caption": "La tua temperatura mattutina in questo ciclo. Un rialzo prolungato di circa 0,2 °C segue l'ovulazione.",
|
||||
"empty": "Ancora nessuna misurazione della temperatura",
|
||||
"emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.",
|
||||
"ovulationEstimate": "Ovulazione stim.",
|
||||
"tooltipTemp": "{temp} °C",
|
||||
"ovulationConfirmed": "Ovulazione confermata"
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Ciclo probabilmente in arrivo",
|
||||
"periodSoonBody": "Il tuo prossimo ciclo è previsto tra circa due giorni.",
|
||||
"periodConfirmTitle": "Il ciclo è iniziato?",
|
||||
"periodConfirmBody": "Il tuo ciclo era previsto per adesso. Registralo per mantenere accurate le previsioni.",
|
||||
"discreetTitle": "Promemoria HealthLog",
|
||||
"discreetBody": "Hai un promemoria in HealthLog."
|
||||
}
|
||||
}
|
||||
+312
-3
@@ -146,6 +146,7 @@
|
||||
"moodSensitive": "Wrażliwe — domyślnie wyłączone.",
|
||||
"compliance": "Przestrzeganie leków",
|
||||
"sleep": "Czas snu",
|
||||
"cycle": "Cykl menstruacyjny",
|
||||
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
|
||||
"unavailableHint": "Brak danych w tym okresie"
|
||||
},
|
||||
@@ -163,7 +164,22 @@
|
||||
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
|
||||
"wellnessRecovery": "Regeneracja",
|
||||
"wellnessStress": "Stres",
|
||||
"wellnessStrain": "Obciążenie"
|
||||
"wellnessStrain": "Obciążenie",
|
||||
"cycleTitle": "Cykl menstruacyjny",
|
||||
"cycleLmp": "Ostatnia miesiączka",
|
||||
"cycleAvgLength": "Średnia długość cyklu",
|
||||
"cycleAvgPeriod": "Średnia długość miesiączki",
|
||||
"cyclePhase": "Bieżąca faza",
|
||||
"cycleDays": "dni",
|
||||
"cycleColStart": "Początek miesiączki",
|
||||
"cycleColLength": "Długość cyklu (dni)",
|
||||
"cycleColPeriod": "Długość miesiączki (dni)",
|
||||
"cyclePhases": {
|
||||
"MENSTRUAL": "Menstruacyjna",
|
||||
"FOLLICULAR": "Folikularna",
|
||||
"OVULATORY": "Owulacyjna",
|
||||
"LUTEAL": "Lutealna"
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -216,7 +232,8 @@
|
||||
"medicationDescription": "Oznacz dawkę jako przyjętą",
|
||||
"mood": "Nastrój",
|
||||
"moodDescription": "Zapisz nastrój i tagi"
|
||||
}
|
||||
},
|
||||
"cycle": "Cykl"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj się",
|
||||
@@ -3860,6 +3877,14 @@
|
||||
"statusRevoked": "Unieważniony",
|
||||
"statusExpired": "Wygasły",
|
||||
"inactiveTitle": "Unieważnione i wygasłe ({count})"
|
||||
},
|
||||
"genderOther": "Inna",
|
||||
"cycleTracking": {
|
||||
"title": "Śledzenie cyklu",
|
||||
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.",
|
||||
"enable": "Włącz śledzenie cyklu",
|
||||
"manage": "Otwórz śledzenie cyklu",
|
||||
"error": "Nie udało się zaktualizować śledzenia cyklu."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -4673,7 +4698,11 @@
|
||||
"eventPersonalRecord": "Rekord osobisty",
|
||||
"eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.",
|
||||
"eventMoodReminder": "Przypomnienie o nastroju",
|
||||
"eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój."
|
||||
"eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.",
|
||||
"eventCyclePeriodSoon": "Zbliża się okres",
|
||||
"eventCyclePeriodSoonDesc": "Wysyłane kilka dni przed przewidywanym okresem.",
|
||||
"eventCyclePeriodConfirm": "Czy okres się zaczął?",
|
||||
"eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu."
|
||||
},
|
||||
"i18n": {
|
||||
"maintainershipBanner": {
|
||||
@@ -4867,5 +4896,285 @@
|
||||
"score": "Wynik dobrostanu"
|
||||
},
|
||||
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
"physical": "Fizyczne",
|
||||
"emotional": "Emocjonalne",
|
||||
"digestive": "Trawienne"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Skurcze",
|
||||
"headache": "Ból głowy",
|
||||
"bloating": "Wzdęcia",
|
||||
"acne": "Trądzik",
|
||||
"breastTenderness": "Tkliwość piersi",
|
||||
"fatigue": "Zmęczenie",
|
||||
"backPain": "Ból pleców",
|
||||
"insomnia": "Bezsenność",
|
||||
"libidoHigh": "Wysokie libido",
|
||||
"libidoLow": "Niskie libido",
|
||||
"moodSwings": "Wahania nastroju",
|
||||
"foodCravings": "Zachcianki",
|
||||
"nausea": "Nudności",
|
||||
"diarrhea": "Biegunka",
|
||||
"constipation": "Zaparcia"
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli."
|
||||
},
|
||||
"insights": {
|
||||
"headline": {
|
||||
"title": "Twój cykl i Twoje ciało",
|
||||
"restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.",
|
||||
"heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.",
|
||||
"generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.",
|
||||
"dirHigher": "wyższe",
|
||||
"dirLower": "niższe",
|
||||
"empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej — uzupełni się to wraz z historią."
|
||||
},
|
||||
"crosstab": {
|
||||
"description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.",
|
||||
"pairLabel": "{metric} · lutealna vs folikularna",
|
||||
"detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).",
|
||||
"footer": "To powiązania w Twoich danych, nie przyczyny. Pokazywane są tylko wzorce, które przechodzą test istotności.",
|
||||
"metricRestingHeartRate": "Tętno spoczynkowe",
|
||||
"metricHrv": "Zmienność rytmu serca",
|
||||
"metricSleepDuration": "Czas snu",
|
||||
"metricSteps": "Kroki",
|
||||
"metricWeight": "Waga",
|
||||
"metricBasalBodyTemp": "Temperatura podstawowa",
|
||||
"metricWristTemperature": "Nocna temperatura nadgarstka",
|
||||
"metricSkinTemperature": "Temperatura skóry",
|
||||
"unitHours": "godz.",
|
||||
"unitSteps": "kroki",
|
||||
"unitBpm": "ud./min",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitCelsius": "°C",
|
||||
"confidenceLow": "Niska pewność",
|
||||
"confidenceMedium": "Średnia pewność",
|
||||
"confidenceHigh": "Wysoka pewność"
|
||||
},
|
||||
"title": "Analizy cyklu",
|
||||
"empty": "Analizy faz pojawią się tutaj, gdy zapiszesz wystarczająco wiele cykli. Porównują twoje wskaźniki między fazami cyklu, z otwarcie pokazaną statystyką.",
|
||||
"sampleSize": "n = {n}",
|
||||
"effectSize": "Wielkość efektu {value}",
|
||||
"qValue": "q = {value}",
|
||||
"symptomPatternsTitle": "Wzorce objawów wg fazy",
|
||||
"symptomPatternsEmpty": "Zapisuj objawy przez cały cykl, aby zobaczyć, w której fazie się grupują.",
|
||||
"symptomMostlyIn": "Głównie w fazie {phase} ({count}/{total} dni)"
|
||||
},
|
||||
"disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.",
|
||||
"title": "Twój cykl",
|
||||
"subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.",
|
||||
"tabs": {
|
||||
"calendar": "Kalendarz",
|
||||
"predictions": "Prognozy",
|
||||
"insights": "Analizy",
|
||||
"settings": "Ustawienia"
|
||||
},
|
||||
"logToday": "Zapisz dziś",
|
||||
"startedPeriod": "Miesiączka zaczęła się dzisiaj",
|
||||
"endedPeriod": "Miesiączka skończyła się dzisiaj",
|
||||
"phase": {
|
||||
"MENSTRUAL": "Miesiączkowa",
|
||||
"FOLLICULAR": "Folikularna",
|
||||
"OVULATORY": "Owulacyjna",
|
||||
"LUTEAL": "Lutealna",
|
||||
"none": "Brak fazy",
|
||||
"label": "Faza"
|
||||
},
|
||||
"flow": {
|
||||
"NONE": "Brak",
|
||||
"SPOTTING": "Plamienie",
|
||||
"LIGHT": "Słaby",
|
||||
"MEDIUM": "Średni",
|
||||
"HEAVY": "Obfity",
|
||||
"label": "Krwawienie"
|
||||
},
|
||||
"ovulationTest": {
|
||||
"NEGATIVE": "Negatywny",
|
||||
"POSITIVE_LH_SURGE": "Skok LH",
|
||||
"ESTROGEN_SURGE": "Skok estrogenu",
|
||||
"INDETERMINATE": "Nieokreślony",
|
||||
"label": "Test owulacyjny"
|
||||
},
|
||||
"mucus": {
|
||||
"DRY": "Suchy",
|
||||
"STICKY": "Lepki",
|
||||
"CREAMY": "Kremowy",
|
||||
"WATERY": "Wodnisty",
|
||||
"EGG_WHITE": "Jak białko jaja",
|
||||
"label": "Śluz szyjkowy"
|
||||
},
|
||||
"goal": {
|
||||
"GENERAL_HEALTH": "Ogólne zdrowie",
|
||||
"AVOID_PREGNANCY": "Unikanie ciąży",
|
||||
"TRYING_TO_CONCEIVE": "Staranie o dziecko",
|
||||
"PERIMENOPAUSE": "Perimenopauza",
|
||||
"OFF": "Śledzenie wyłączone",
|
||||
"label": "W jakim celu śledzisz?",
|
||||
"description": "To dostosowuje język i widoczne prognozy. Wskazówki dotyczące okna płodnego pojawiają się tylko przy staraniu o dziecko."
|
||||
},
|
||||
"ring": {
|
||||
"dayOfCycle": "Dzień {day}",
|
||||
"dayUnknown": "—",
|
||||
"ariaPhase": "Dzień {day} cyklu, faza {phase}",
|
||||
"ariaUnknown": "Brak aktywnego cyklu",
|
||||
"caption": "twojego cyklu",
|
||||
"firstPeriodCta": "Zapisz pierwszą miesiączkę"
|
||||
},
|
||||
"calendar": {
|
||||
"legendPeriod": "Zapisana miesiączka",
|
||||
"legendPredicted": "Prognozowana miesiączka",
|
||||
"legendFertile": "Okno płodne",
|
||||
"legendOvulation": "Owulacja",
|
||||
"legendSymptoms": "Zapisane objawy",
|
||||
"prevMonth": "Poprzedni miesiąc",
|
||||
"nextMonth": "Następny miesiąc",
|
||||
"loading": "Ładowanie kalendarza…",
|
||||
"empty": "Zacznij zapisywać, aby wypełnić kalendarz.",
|
||||
"today": "Dziś",
|
||||
"dayAria": "{date}",
|
||||
"legendSymptom": "Objawy"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Co dalej",
|
||||
"nextPeriod": "Następna miesiączka",
|
||||
"nextPeriodRange": "Około {date}",
|
||||
"nextPeriodWindow": "{low} – {high}",
|
||||
"fertileWindow": "Okno płodne",
|
||||
"ovulation": "Szacowana owulacja",
|
||||
"confidence": "Pewność",
|
||||
"confidenceHigh": "Wysoka",
|
||||
"confidenceMedium": "Średnia",
|
||||
"confidenceLow": "Niska",
|
||||
"stillLearning": "Wciąż się uczę",
|
||||
"stillLearningBody": "Zapisz co najmniej trzy pełne cykle, a prognozy się wyostrzą. Do tego czasu to zgrubne oszacowanie.",
|
||||
"rawMode": "Prognoza wyłączona",
|
||||
"rawModeBody": "Wyłączyłaś prognozy — pokazywane są tylko zapisane dane, bez przewidywań.",
|
||||
"none": "Brak prognozy. Zapisz miesiączkę, aby zacząć.",
|
||||
"ovulationConfirmed": "Owulacja (potwierdzona)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Twoje cykle",
|
||||
"avgLength": "Średnia długość",
|
||||
"variability": "Zmienność",
|
||||
"avgPeriodLength": "Średnia miesiączka",
|
||||
"regularity": "Regularność",
|
||||
"regularityREGULAR": "Regularny",
|
||||
"regularityIRREGULAR": "Nieregularny",
|
||||
"regularityLEARNING": "Uczenie",
|
||||
"days": "{count} dni",
|
||||
"daysPlusMinus": "± {count} dni",
|
||||
"none": "Brak zapisanych cykli.",
|
||||
"recentCycles": "Ostatnie cykle",
|
||||
"ongoing": "w toku",
|
||||
"periodDays": "okres {count} d",
|
||||
"ovulationChip": "Owulacja"
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Zapisz swój dzień",
|
||||
"description": "Zapisz dzisiejsze krwawienie, objawy i notatki.",
|
||||
"date": "Data",
|
||||
"flow": "Krwawienie",
|
||||
"symptoms": "Objawy",
|
||||
"temperature": "Podstawowa temperatura ciała",
|
||||
"temperaturePlaceholder": "°C",
|
||||
"ovulationTest": "Test owulacyjny",
|
||||
"mucus": "Śluz szyjkowy",
|
||||
"intercourse": "Współżycie",
|
||||
"protected": "Zabezpieczone",
|
||||
"note": "Notatka",
|
||||
"notePlaceholder": "Cokolwiek chcesz zapamiętać z dzisiaj…",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"cancel": "Anuluj",
|
||||
"saved": "Dzień zapisany",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"periodStarted": "Zapisano początek miesiączki",
|
||||
"clear": "Brak",
|
||||
"intermenstrualBleeding": "Plamienie między okresami",
|
||||
"pregnancyTest": "Test ciążowy",
|
||||
"progesteroneTest": "Test progesteronu",
|
||||
"contraceptive": "Antykoncepcja",
|
||||
"severity": "Nasilenie",
|
||||
"severityLevel": "Nasilenie {level} z 4",
|
||||
"delete": "Usuń"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia cyklu",
|
||||
"enable": "Śledzenie cyklu",
|
||||
"enableDescription": "Włącz lub wyłącz zakładkę cyklu dla swojego konta.",
|
||||
"typicalCycleLength": "Typowa długość cyklu (dni)",
|
||||
"typicalPeriodLength": "Typowa długość miesiączki (dni)",
|
||||
"lutealPhaseLength": "Długość fazy lutealnej (dni)",
|
||||
"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.",
|
||||
"prediction": "Prognozy",
|
||||
"predictionDescription": "Prognozuj następną miesiączkę oraz, gdy to istotne, okno płodne.",
|
||||
"encryption": "Szyfruj wrażliwe wpisy",
|
||||
"encryptionDescription": "Szyfruj w spoczynku pola ujawniające intencje (testy ciążowe, antykoncepcja, współżycie), obok zawsze szyfrowanych notatek.",
|
||||
"discreet": "Dyskretne powiadomienia",
|
||||
"discreetDescription": "Ukryj szczegóły cyklu w treści powiadomień na ekranie blokady.",
|
||||
"dataTitle": "Twoje dane",
|
||||
"dataDescription": "Twoje dane cyklu istnieją tylko na tym serwerze. Eksportuj je w każdej chwili lub usuń na zawsze.",
|
||||
"export": "Eksportuj moje dane cyklu",
|
||||
"deleteData": "Usuń moje dane cyklu",
|
||||
"saved": "Ustawienia zapisane",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"save": "Zapisz",
|
||||
"purgeTitle": "Usuń wszystkie dane cyklu",
|
||||
"purgeDescription": "Trwale usuwa każdy zapisany dzień, cykl, prognozę i przypomnienie. Tej operacji nie można cofnąć.",
|
||||
"purgeButton": "Usuń wszystkie dane cyklu",
|
||||
"purgeConfirmTitle": "Usunąć wszystkie dane cyklu?",
|
||||
"purgeConfirmBody": "Spowoduje to trwałe usunięcie każdej miesiączki, wpisu dziennego, objawu i prognozy. Tej operacji nie można cofnąć.",
|
||||
"purgeConfirm": "Trwale usunąć wszystkie dane cyklu?",
|
||||
"purgeCancel": "Anuluj",
|
||||
"purgeSuccess": "Usunięto wszystkie dane cyklu.",
|
||||
"purgeError": "Nie udało się usunąć danych cyklu.",
|
||||
"reminders": "Przypomnienia",
|
||||
"remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.",
|
||||
"remindersLink": "Ustawienia powiadomień",
|
||||
"purgeData": "Usuń dane cyklu",
|
||||
"purgeConfirmAction": "Tak, usuń wszystko",
|
||||
"purgeDone": "Dane cyklu usunięte."
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Pozytywny",
|
||||
"NEGATIVE": "Negatywny",
|
||||
"INDETERMINATE": "Niejasny"
|
||||
},
|
||||
"contraceptive": {
|
||||
"NONE": "Brak",
|
||||
"UNSPECIFIED": "Inne",
|
||||
"IMPLANT": "Implant",
|
||||
"INJECTION": "Zastrzyk",
|
||||
"IUD": "Wkładka",
|
||||
"INTRAVAGINAL_RING": "Krążek",
|
||||
"ORAL": "Tabletka",
|
||||
"PATCH": "Plaster",
|
||||
"EMERGENCY": "Awaryjna"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać danych cyklu.",
|
||||
"bbt": {
|
||||
"title": "Podstawowa temperatura ciała",
|
||||
"caption": "Twoja poranna temperatura w tym cyklu. Trwały wzrost o około 0,2 °C następuje po owulacji.",
|
||||
"empty": "Brak pomiarów temperatury",
|
||||
"emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.",
|
||||
"ovulationEstimate": "Szac. owulacja",
|
||||
"tooltipTemp": "{temp} °C",
|
||||
"ovulationConfirmed": "Potwierdzona owulacja"
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
|
||||
"periodSoonBody": "Twoja następna miesiączka jest przewidywana za około dwa dni.",
|
||||
"periodConfirmTitle": "Czy miesiączka się zaczęła?",
|
||||
"periodConfirmBody": "Twoja miesiączka była przewidywana mniej więcej teraz. Zapisz ją, aby prognozy pozostały dokładne.",
|
||||
"discreetTitle": "Przypomnienie HealthLog",
|
||||
"discreetBody": "Masz przypomnienie w HealthLog."
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
-- v1.15.0 — cycle tracking core.
|
||||
--
|
||||
-- Adds the eight cycle enums plus the three capture tables: the 1:1
|
||||
-- CycleProfile settings row, the soft-deleted MenstrualCycle + CycleDayLog
|
||||
-- capture rows (same syncVersion + deletedAt shape as MoodEntry so they
|
||||
-- ride the `/api/sync/changes` delta feed), and their indexes / uniques.
|
||||
-- Purely additive — no existing table is touched.
|
||||
--
|
||||
-- CycleDayLog carries the MoodEntry dual-unique: the canonical-day key
|
||||
-- `(user_id, date)` plus the NULL-distinct external-dedup key
|
||||
-- `(user_id, source, external_id)` for HealthKit re-ingest.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "cycle_day_logs";
|
||||
-- DROP TABLE IF EXISTS "menstrual_cycles";
|
||||
-- DROP TABLE IF EXISTS "cycle_profiles";
|
||||
-- DROP TYPE IF EXISTS "prediction_method";
|
||||
-- DROP TYPE IF EXISTS "cycle_phase";
|
||||
-- DROP TYPE IF EXISTS "contraceptive_kind";
|
||||
-- DROP TYPE IF EXISTS "home_test_result";
|
||||
-- DROP TYPE IF EXISTS "cervical_mucus";
|
||||
-- DROP TYPE IF EXISTS "ovulation_test";
|
||||
-- DROP TYPE IF EXISTS "flow_level";
|
||||
-- DROP TYPE IF EXISTS "cycle_tracking_goal";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "cycle_tracking_goal" AS ENUM ('GENERAL_HEALTH', 'AVOID_PREGNANCY', 'TRYING_TO_CONCEIVE', 'PERIMENOPAUSE', 'OFF');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "flow_level" AS ENUM ('NONE', 'SPOTTING', 'LIGHT', 'MEDIUM', 'HEAVY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ovulation_test" AS ENUM ('NEGATIVE', 'POSITIVE_LH_SURGE', 'ESTROGEN_SURGE', 'INDETERMINATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "cervical_mucus" AS ENUM ('DRY', 'STICKY', 'CREAMY', 'WATERY', 'EGG_WHITE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "home_test_result" AS ENUM ('NEGATIVE', 'POSITIVE', 'INDETERMINATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "contraceptive_kind" AS ENUM ('NONE', 'UNSPECIFIED', 'IMPLANT', 'INJECTION', 'IUD', 'INTRAVAGINAL_RING', 'ORAL', 'PATCH', 'EMERGENCY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "cycle_phase" AS ENUM ('MENSTRUAL', 'FOLLICULAR', 'OVULATORY', 'LUTEAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "prediction_method" AS ENUM ('CALENDAR', 'SYMPTOTHERMAL', 'TEMPERATURE_TREND', 'BLENDED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"goal" "cycle_tracking_goal" NOT NULL DEFAULT 'GENERAL_HEALTH',
|
||||
"typical_cycle_length" INTEGER,
|
||||
"typical_period_length" INTEGER,
|
||||
"luteal_phase_length" INTEGER,
|
||||
"prediction_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"raw_chart_mode" BOOLEAN NOT NULL DEFAULT false,
|
||||
"discreet_notifications" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sensitive_category_encryption" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cycle_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "menstrual_cycles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"start_date" TEXT NOT NULL,
|
||||
"end_date" TEXT,
|
||||
"period_end_date" TEXT,
|
||||
"length_days" INTEGER,
|
||||
"ovulation_date" TEXT,
|
||||
"ovulation_confirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_predicted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"tz" TEXT,
|
||||
"sync_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "menstrual_cycles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_day_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"cycle_id" TEXT,
|
||||
"flow" "flow_level",
|
||||
"intermenstrual_bleeding" BOOLEAN NOT NULL DEFAULT false,
|
||||
"basal_body_temp_c" DOUBLE PRECISION,
|
||||
"ovulation_test" "ovulation_test",
|
||||
"cervical_mucus" "cervical_mucus",
|
||||
"sexual_activity" BOOLEAN NOT NULL DEFAULT false,
|
||||
"protected_sex" BOOLEAN,
|
||||
"pregnancy_test" "home_test_result",
|
||||
"progesterone_test" "home_test_result",
|
||||
"contraceptive" "contraceptive_kind",
|
||||
"notes_encrypted" TEXT,
|
||||
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
|
||||
"external_id" TEXT,
|
||||
"tz" TEXT,
|
||||
"sync_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cycle_day_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_profiles_user_id_key" ON "cycle_profiles"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menstrual_cycles_user_id_start_date_idx" ON "menstrual_cycles"("user_id", "start_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menstrual_cycles_user_id_updated_at_id_idx" ON "menstrual_cycles"("user_id", "updated_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "menstrual_cycles_user_id_start_date_key" ON "menstrual_cycles"("user_id", "start_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_day_logs_user_id_date_idx" ON "cycle_day_logs"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_day_logs_user_id_updated_at_id_idx" ON "cycle_day_logs"("user_id", "updated_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_day_logs_user_id_date_key" ON "cycle_day_logs"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_day_logs_user_id_source_external_id_key" ON "cycle_day_logs"("user_id", "source", "external_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_profiles" ADD CONSTRAINT "cycle_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "menstrual_cycles" ADD CONSTRAINT "menstrual_cycles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "menstrual_cycles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,112 @@
|
||||
-- v1.15.0 — cycle symptom taxonomy.
|
||||
--
|
||||
-- Clones the mood-tag trio (0101): a global Category -> Symptom catalog
|
||||
-- plus a per-day-log join. `CycleSymptom` additionally carries
|
||||
-- `user_id` + `label_encrypted` so a future release can mint per-user
|
||||
-- custom symptoms; v1.15.0 ships seeded-catalogue-only.
|
||||
--
|
||||
-- `label_key` columns carry an i18n message key (resolved client-side
|
||||
-- against all six locales) rather than a hard-coded label, so the seeded
|
||||
-- catalog renders localised everywhere. The seed at the bottom uses
|
||||
-- deterministic ids + ON CONFLICT DO NOTHING on `key` so re-running is a
|
||||
-- no-op (idempotent).
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "cycle_symptom_links";
|
||||
-- DROP TABLE IF EXISTS "cycle_symptoms";
|
||||
-- DROP TABLE IF EXISTS "cycle_symptom_categories";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_symptom_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label_key" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "cycle_symptom_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_symptoms" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label_key" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"user_id" TEXT,
|
||||
"label_encrypted" TEXT,
|
||||
|
||||
CONSTRAINT "cycle_symptoms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_symptom_links" (
|
||||
"day_log_id" TEXT NOT NULL,
|
||||
"symptom_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "cycle_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_symptom_categories_key_key" ON "cycle_symptom_categories"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_symptoms_key_key" ON "cycle_symptoms"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_symptoms_category_id_sort_order_idx" ON "cycle_symptoms"("category_id", "sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_symptoms_user_id_idx" ON "cycle_symptoms"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_symptom_links_symptom_id_idx" ON "cycle_symptom_links"("symptom_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "cycle_symptom_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "cycle_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "cycle_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ─── Seed the default symptom taxonomy ───────────────────────────────
|
||||
-- Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING (the
|
||||
-- mood-tag seed precedent 0101 / 0118 / 0126). Three categories
|
||||
-- (physical, emotional, digestive) with ~15 high-signal symptoms. Keep
|
||||
-- in sync with `src/lib/cycle/symptom-taxonomy.ts` if either changes.
|
||||
|
||||
-- Categories
|
||||
INSERT INTO "cycle_symptom_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
|
||||
('csc_physical', 'physical', 'cycle.symptomCategory.physical', 'Activity', 0, true),
|
||||
('csc_emotional', 'emotional', 'cycle.symptomCategory.emotional', 'Heart', 1, true),
|
||||
('csc_digestive', 'digestive', 'cycle.symptomCategory.digestive', 'Soup', 2, true)
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
|
||||
-- Symptoms
|
||||
INSERT INTO "cycle_symptoms" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
|
||||
('cs_cramps', 'csc_physical', 'cramps', 'cycle.symptom.cramps', 'Zap', 0, true),
|
||||
('cs_headache', 'csc_physical', 'headache', 'cycle.symptom.headache', 'Brain', 1, true),
|
||||
('cs_bloating', 'csc_physical', 'bloating', 'cycle.symptom.bloating', 'CircleDot', 2, true),
|
||||
('cs_acne', 'csc_physical', 'acne', 'cycle.symptom.acne', 'Droplet', 3, true),
|
||||
('cs_breast_tenderness', 'csc_physical', 'breast_tenderness', 'cycle.symptom.breastTenderness', 'HeartPulse', 4, true),
|
||||
('cs_fatigue', 'csc_physical', 'fatigue', 'cycle.symptom.fatigue', 'BatteryLow', 5, true),
|
||||
('cs_back_pain', 'csc_physical', 'back_pain', 'cycle.symptom.backPain', 'PersonStanding', 6, true),
|
||||
('cs_insomnia', 'csc_physical', 'insomnia', 'cycle.symptom.insomnia', 'MoonStar', 7, true),
|
||||
('cs_libido_high', 'csc_emotional', 'libido_high', 'cycle.symptom.libidoHigh', 'Flame', 0, true),
|
||||
('cs_libido_low', 'csc_emotional', 'libido_low', 'cycle.symptom.libidoLow', 'Snowflake', 1, true),
|
||||
('cs_mood_swings', 'csc_emotional', 'mood_swings', 'cycle.symptom.moodSwings', 'Drama', 2, true),
|
||||
('cs_food_cravings', 'csc_digestive', 'food_cravings', 'cycle.symptom.foodCravings', 'Cookie', 0, true),
|
||||
('cs_nausea', 'csc_digestive', 'nausea', 'cycle.symptom.nausea', 'Frown', 1, true),
|
||||
('cs_diarrhea', 'csc_digestive', 'diarrhea', 'cycle.symptom.diarrhea', 'Toilet', 2, true),
|
||||
('cs_constipation', 'csc_digestive', 'constipation', 'cycle.symptom.constipation', 'CircleSlash', 3, true)
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- v1.15.0 — cycle prediction cache.
|
||||
--
|
||||
-- One cached forecast per user, regenerated debounced /
|
||||
-- stale-while-revalidate (the InsightNarrative + v1.8.7 assessment cache
|
||||
-- pattern), NOT per-read. Derived data — not soft-deleted. The ranged
|
||||
-- Low / High band is rendered as a band, never a single date.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "cycle_predictions";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_predictions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"method" "prediction_method" NOT NULL,
|
||||
"next_period_start" TEXT NOT NULL,
|
||||
"next_period_start_low" TEXT NOT NULL,
|
||||
"next_period_start_high" TEXT NOT NULL,
|
||||
"fertile_window_start" TEXT,
|
||||
"fertile_window_end" TEXT,
|
||||
"predicted_ovulation" TEXT,
|
||||
"confidence" DOUBLE PRECISION NOT NULL,
|
||||
"cycles_observed" INTEGER NOT NULL,
|
||||
"generated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cycle_predictions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_predictions_user_id_key" ON "cycle_predictions"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_predictions" ADD CONSTRAINT "cycle_predictions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Additive: at-rest envelope column for the intent-revealing cycle day-log
|
||||
-- fields. Populated INSTEAD of the plaintext columns when
|
||||
-- CycleProfile.sensitive_category_encryption is ON; the plaintext columns
|
||||
-- are then NULL. AES-256-GCM ciphertext, decrypted fail-soft on read.
|
||||
ALTER TABLE "cycle_day_logs" ADD COLUMN "sensitive_encrypted" TEXT;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.15.0 — cycle-tracking feature enable flag.
|
||||
--
|
||||
-- Adds the per-user `cycle_tracking_enabled` toggle to `cycle_profiles`.
|
||||
-- The column is NULLABLE on purpose: NULL means "derive from gender"
|
||||
-- (`FEMALE` → enabled, everything else → disabled), so no row backfill
|
||||
-- is required — existing profiles read NULL and resolve through the
|
||||
-- `isCycleEnabled()` gate helper. An explicit `true` opts a non-FEMALE
|
||||
-- account in (Settings toggle); an explicit `false` opts a FEMALE
|
||||
-- account out. Every `/api/cycle/*` route returns
|
||||
-- `403 { error, meta:{ errorCode:"cycle.disabled" } }` when the resolved
|
||||
-- value is false.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- ALTER TABLE "cycle_profiles" DROP COLUMN IF EXISTS "cycle_tracking_enabled";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "cycle_profiles" ADD COLUMN "cycle_tracking_enabled" BOOLEAN;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Optional 1-4 Likert intensity per symptom link. NULL preserves the
|
||||
-- pre-severity presence-link contract for every existing row.
|
||||
ALTER TABLE "cycle_symptom_links" ADD COLUMN "severity" INTEGER;
|
||||
+432
-1
@@ -48,7 +48,7 @@ model User {
|
||||
// Profile
|
||||
heightCm Float? @map("height_cm")
|
||||
dateOfBirth DateTime? @map("date_of_birth")
|
||||
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
|
||||
gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe
|
||||
timezone String @default("Europe/Berlin")
|
||||
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
@@ -495,6 +495,18 @@ model User {
|
||||
// unset `insurerName` collapses its name.
|
||||
insurerIkNumber String? @map("insurer_ik_number")
|
||||
|
||||
// v1.15.0 — cycle tracking. The CycleProfile is a 1:1 settings row
|
||||
// (lazily upserted on first cycle-surface access); MenstrualCycle +
|
||||
// CycleDayLog are the soft-deleted, sync-feed-bearing capture rows;
|
||||
// CyclePrediction is a 1:1 cached forecast (debounced regenerate).
|
||||
cycleProfile CycleProfile?
|
||||
menstrualCycles MenstrualCycle[]
|
||||
cycleDayLogs CycleDayLog[]
|
||||
cyclePrediction CyclePrediction?
|
||||
// v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global
|
||||
// seeded catalogue).
|
||||
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -3161,3 +3173,422 @@ model InsightNarrative {
|
||||
@@index([userId, updatedAt(sort: Desc)])
|
||||
@@map("insight_narratives")
|
||||
}
|
||||
|
||||
// ─── Cycle Tracking (v1.15.0) ─────────────────────────────────
|
||||
//
|
||||
// Self-hosted reproductive-health tracking: period + flow, symptom
|
||||
// taxonomy, BBT / OPK / cervical-mucus capture, ranged-confidence
|
||||
// prediction. The capture rows (MenstrualCycle, CycleDayLog) carry the
|
||||
// same soft-delete + syncVersion shape as MoodEntry so they ride the
|
||||
// `/api/sync/changes` delta feed. CycleProfile + CyclePrediction are
|
||||
// settings / derived rows and do NOT tombstone.
|
||||
//
|
||||
// Sensitivity: free-text notes are always AES-256-GCM at rest
|
||||
// (`notesEncrypted`, the `*Encrypted` convention). The default-ON
|
||||
// `sensitiveCategoryEncryption` flag drives whether the intent-revealing
|
||||
// fields (pregnancy / contraception / sexual activity) also encrypt at
|
||||
// the application layer — those stay queryable-plaintext-physiology
|
||||
// (flow, BBT, OPK, mucus, dates, phase) untouched so the rollup +
|
||||
// correlation tier keeps working.
|
||||
|
||||
/// Per-user goal that gates every cycle copy + the fertile-window
|
||||
/// surfacing. `OFF` disables the category entirely; `GENERAL_HEALTH` /
|
||||
/// `PERIMENOPAUSE` hide conception framing; only `TRYING_TO_CONCEIVE`
|
||||
/// (and, with a stronger caveat, `AVOID_PREGNANCY`) surface fertile-window
|
||||
/// language.
|
||||
enum CycleTrackingGoal {
|
||||
GENERAL_HEALTH
|
||||
AVOID_PREGNANCY
|
||||
TRYING_TO_CONCEIVE
|
||||
PERIMENOPAUSE
|
||||
OFF
|
||||
|
||||
@@map("cycle_tracking_goal")
|
||||
}
|
||||
|
||||
/// Menstrual-flow intensity for a logged day. Mirrors the HealthKit
|
||||
/// `HKCategoryValueMenstrualFlow` ladder (unspecified collapses to the
|
||||
/// presence of a row; `NONE` is an explicit no-flow marker).
|
||||
enum FlowLevel {
|
||||
NONE
|
||||
SPOTTING
|
||||
LIGHT
|
||||
MEDIUM
|
||||
HEAVY
|
||||
|
||||
@@map("flow_level")
|
||||
}
|
||||
|
||||
/// Ovulation predictor-kit (OPK) reading. `POSITIVE_LH_SURGE` is the
|
||||
/// LH-surge positive; `ESTROGEN_SURGE` is the estrogen-rise pre-peak
|
||||
/// some dual-hormone kits report. Mirrors HealthKit
|
||||
/// `HKCategoryValueOvulationTestResult`.
|
||||
enum OvulationTest {
|
||||
NEGATIVE
|
||||
POSITIVE_LH_SURGE
|
||||
ESTROGEN_SURGE
|
||||
INDETERMINATE
|
||||
|
||||
@@map("ovulation_test")
|
||||
}
|
||||
|
||||
/// Cervical-mucus quality, fertility-awareness ladder (dry → egg-white
|
||||
/// peak). Mirrors HealthKit `HKCategoryValueCervicalMucusQuality`.
|
||||
enum CervicalMucus {
|
||||
DRY
|
||||
STICKY
|
||||
CREAMY
|
||||
WATERY
|
||||
EGG_WHITE
|
||||
|
||||
@@map("cervical_mucus")
|
||||
}
|
||||
|
||||
/// Generic home-test result for the pregnancy / progesterone test
|
||||
/// fields. Mirrors HealthKit `HKCategoryValuePregnancyTestResult` /
|
||||
/// `HKCategoryValueProgesteroneTestResult`.
|
||||
enum HomeTestResult {
|
||||
NEGATIVE
|
||||
POSITIVE
|
||||
INDETERMINATE
|
||||
|
||||
@@map("home_test_result")
|
||||
}
|
||||
|
||||
/// Contraceptive method logged for a day. `UNSPECIFIED` mirrors the
|
||||
/// HealthKit `unspecified` member distinct from `NONE` (an explicit
|
||||
/// no-method marker). Mirrors HealthKit `HKCategoryValueContraceptive`.
|
||||
enum ContraceptiveKind {
|
||||
NONE
|
||||
UNSPECIFIED
|
||||
IMPLANT
|
||||
INJECTION
|
||||
IUD
|
||||
INTRAVAGINAL_RING
|
||||
ORAL
|
||||
PATCH
|
||||
EMERGENCY
|
||||
|
||||
@@map("contraceptive_kind")
|
||||
}
|
||||
|
||||
/// Derived cycle phase used by the prediction engine + the CYCLE_PHASE
|
||||
/// correlation channel. Computed from day-of-cycle + the back-calculated
|
||||
/// luteal length, never stored on the capture rows.
|
||||
enum CyclePhase {
|
||||
MENSTRUAL
|
||||
FOLLICULAR
|
||||
OVULATORY
|
||||
LUTEAL
|
||||
|
||||
@@map("cycle_phase")
|
||||
}
|
||||
|
||||
/// Which method produced a cached CyclePrediction. `CALENDAR` is the
|
||||
/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the
|
||||
/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the
|
||||
/// retrospective wrist/skin-temperature ovulation; `BLENDED` combines
|
||||
/// them.
|
||||
enum PredictionMethod {
|
||||
CALENDAR
|
||||
SYMPTOTHERMAL
|
||||
TEMPERATURE_TREND
|
||||
BLENDED
|
||||
|
||||
@@map("prediction_method")
|
||||
}
|
||||
|
||||
/// One settings row per user, lazily upserted on first cycle-surface
|
||||
/// access (goal defaults to GENERAL_HEALTH). Not soft-deleted — it is a
|
||||
/// profile row, like the other per-user settings columns on User.
|
||||
model CycleProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
/// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal).
|
||||
goal CycleTrackingGoal @default(GENERAL_HEALTH)
|
||||
|
||||
/// Per-user feature enable. NULL = derive from gender (`FEMALE` → on,
|
||||
/// everything else → off) so the column never needs a backfill; an
|
||||
/// explicit `true` opts a non-FEMALE account in, an explicit `false`
|
||||
/// opts a FEMALE account out. Resolved through `isCycleEnabled()` —
|
||||
/// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"`
|
||||
/// when the resolved value is false.
|
||||
cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled")
|
||||
|
||||
/// Optional user-supplied priors. The engine applies a luteal default
|
||||
/// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is
|
||||
/// NULL, so a future constant change never strands historical rows.
|
||||
typicalCycleLength Int? @map("typical_cycle_length")
|
||||
typicalPeriodLength Int? @map("typical_period_length")
|
||||
lutealPhaseLength Int? @map("luteal_phase_length")
|
||||
|
||||
/// When false, the app computes / stores NO predictions — pure logging
|
||||
/// (the Read-Your-Body posture for users who distrust algorithmic
|
||||
/// interpretation).
|
||||
predictionEnabled Boolean @default(true) @map("prediction_enabled")
|
||||
/// Raw-chart mode — surfaces logged values without the interpreted
|
||||
/// prediction overlays.
|
||||
rawChartMode Boolean @default(false) @map("raw_chart_mode")
|
||||
|
||||
/// When true, every cycle push body is generic ("HealthLog reminder")
|
||||
/// so no cycle-event naming reaches the lock screen.
|
||||
discreetNotifications Boolean @default(false) @map("discreet_notifications")
|
||||
/// Default-ON for this category (post-Dobbs threat model): drives
|
||||
/// whether the intent-revealing day-log fields encrypt at the
|
||||
/// application layer in addition to the always-encrypted notes.
|
||||
sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId])
|
||||
@@map("cycle_profiles")
|
||||
}
|
||||
|
||||
/// One row per menstrual cycle (a period-start anchor + derived span).
|
||||
/// `isPredicted` rows are forward-minted forecasts; observed rows are
|
||||
/// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the
|
||||
/// row rides the `/api/sync/changes` feed as a tombstone.
|
||||
model MenstrualCycle {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
/// YYYY-MM-DD — period start, anchored to `tz` when non-null, else the
|
||||
/// legacy Europe/Berlin assumption (the MoodEntry.date convention).
|
||||
startDate String @map("start_date")
|
||||
/// YYYY-MM-DD — cycle end (= day before the next cycle's start).
|
||||
endDate String? @map("end_date")
|
||||
/// YYYY-MM-DD — last bleeding day of this cycle's period.
|
||||
periodEndDate String? @map("period_end_date")
|
||||
|
||||
/// Observed cycle length in days (start→next start). NULL until the
|
||||
/// next cycle anchors it.
|
||||
lengthDays Int? @map("length_days")
|
||||
/// YYYY-MM-DD — confirmed or estimated ovulation day.
|
||||
ovulationDate String? @map("ovulation_date")
|
||||
/// True once ovulation is confirmed (symptothermal / temperature-trend),
|
||||
/// as opposed to a calendar estimate.
|
||||
ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed")
|
||||
/// Forward-minted forecast row vs an observed cycle.
|
||||
isPredicted Boolean @default(false) @map("is_predicted")
|
||||
|
||||
/// Per-row IANA timezone the date strings are anchored to (NULL =
|
||||
/// legacy Europe/Berlin), mirroring MoodEntry.tz.
|
||||
tz String?
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
dayLogs CycleDayLog[]
|
||||
|
||||
@@unique([userId, startDate])
|
||||
@@index([userId, startDate])
|
||||
// Keyset support for the `/api/sync/changes` cycle feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
@@map("menstrual_cycles")
|
||||
}
|
||||
|
||||
/// One canonical row per (user, day): the HealthKit sync unit + the
|
||||
/// manual log-day surface. Dual-unique exactly like MoodEntry — the
|
||||
/// canonical-day key plus the NULL-distinct external-dedup key for
|
||||
/// HealthKit re-ingest. Soft-deleted + syncVersion-bumped.
|
||||
model CycleDayLog {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// YYYY-MM-DD — anchored to `tz` when non-null, else legacy
|
||||
/// Europe/Berlin (the MoodEntry.date convention).
|
||||
date String
|
||||
/// Optional link to the owning MenstrualCycle (set once the day is
|
||||
/// attributed to a cycle span).
|
||||
cycleId String? @map("cycle_id")
|
||||
|
||||
/// Menstrual-flow intensity (NULL = not logged this day).
|
||||
flow FlowLevel?
|
||||
/// Spotting / bleeding outside the period window.
|
||||
intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding")
|
||||
|
||||
/// Basal body temperature in Celsius. Queryable-plaintext — feeds the
|
||||
/// 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")
|
||||
/// Ovulation predictor-kit (OPK) reading.
|
||||
ovulationTest OvulationTest? @map("ovulation_test")
|
||||
/// Cervical-mucus quality.
|
||||
cervicalMucus CervicalMucus? @map("cervical_mucus")
|
||||
|
||||
/// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption
|
||||
/// is ON these are encrypted at the application layer (the dispatch
|
||||
/// envelope), default-ON for this category.
|
||||
sexualActivity Boolean @default(false) @map("sexual_activity")
|
||||
protectedSex Boolean? @map("protected_sex")
|
||||
pregnancyTest HomeTestResult? @map("pregnancy_test")
|
||||
progesteroneTest HomeTestResult? @map("progesterone_test")
|
||||
contraceptive ContraceptiveKind? @map("contraceptive")
|
||||
|
||||
/// AES-256-GCM ciphertext envelope of the intent-revealing fields
|
||||
/// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest /
|
||||
/// contraceptive). Populated INSTEAD of the plaintext columns above when
|
||||
/// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns
|
||||
/// are NULLed and this carries the JSON envelope, so the most sensitive
|
||||
/// reproductive data is encrypted at rest. Those five fields then drop
|
||||
/// out of the rollup / correlation tier (queryable plaintext is the
|
||||
/// trade for at-rest secrecy). Decrypted fail-soft on read.
|
||||
sensitiveEncrypted String? @map("sensitive_encrypted")
|
||||
|
||||
/// AES-256-GCM ciphertext envelope of the day's free-text note (always
|
||||
/// encrypted — the `*Encrypted` String? convention, matching
|
||||
/// MoodTag.labelEncrypted + InsightNarrative).
|
||||
notesEncrypted String? @map("notes_encrypted")
|
||||
|
||||
/// Provenance. MANUAL (web / single POST) or APPLE_HEALTH (the batch
|
||||
/// ingest); mirrors MoodEntry.source. The wire spelling is APPLE_HEALTH.
|
||||
source MeasurementSource @default(MANUAL)
|
||||
/// Stable per-source dedup id. NULL for native / MANUAL entries (those
|
||||
/// dedup on the canonical `(userId, date)` key); a HealthKit re-ingest
|
||||
/// upserts on `(userId, source, externalId)`. Postgres treats NULLs as
|
||||
/// distinct so MANUAL rows never collide on it.
|
||||
externalId String? @map("external_id")
|
||||
|
||||
tz String?
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
cycle MenstrualCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull)
|
||||
symptomLinks CycleSymptomLink[]
|
||||
|
||||
@@unique([userId, date])
|
||||
// NULL-distinct external dedup key for HealthKit re-ingest (the
|
||||
// MoodEntry dual-unique precedent).
|
||||
@@unique([userId, source, externalId])
|
||||
@@index([userId, date])
|
||||
// Keyset support for the `/api/sync/changes` cycle-day feed.
|
||||
@@index([userId, updatedAt, id])
|
||||
@@map("cycle_day_logs")
|
||||
}
|
||||
|
||||
// ─── Cycle Symptom Taxonomy (v1.15.0) ─────────────────────────
|
||||
//
|
||||
// Clones the MoodTagCategory → MoodTag → MoodEntryTagLink trio: a global,
|
||||
// icon-bearing, labelKey-i18n, isActive/sortOrder catalog plus a per-day
|
||||
// join. `CycleSymptom` additionally carries `userId?` + `labelEncrypted?`
|
||||
// so a future release can mint per-user custom symptoms (the MoodTag
|
||||
// custom-tag precedent); v1.15.0 ships seeded-catalogue-only.
|
||||
|
||||
model CycleSymptomCategory {
|
||||
id String @id @default(cuid())
|
||||
/// Stable machine key (e.g. `physical`). Unique so the seed migration
|
||||
/// can upsert idempotently.
|
||||
key String @unique
|
||||
/// i18n message key for the category label (`cycle.symptomCategory.<key>`).
|
||||
labelKey String @map("label_key")
|
||||
/// Lucide icon name for the category header.
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
/// Soft-hide a category without deleting its historical links.
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
symptoms CycleSymptom[]
|
||||
|
||||
@@map("cycle_symptom_categories")
|
||||
}
|
||||
|
||||
model CycleSymptom {
|
||||
id String @id @default(cuid())
|
||||
categoryId String @map("category_id")
|
||||
/// Stable machine key (e.g. `cramps`). Unique across the catalog.
|
||||
key String @unique
|
||||
/// i18n message key for the symptom label (`cycle.symptom.<key>`).
|
||||
labelKey String @map("label_key")
|
||||
/// Lucide icon name for the symptom chip.
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
/// Owner of a per-user custom symptom. NULL = the global seeded
|
||||
/// catalogue (resolves `labelKey` against the locale). Set = a user's
|
||||
/// own custom symptom carrying a `custom:<cuid>` key and a free-text
|
||||
/// label held in `labelEncrypted` (the MoodTag custom-tag precedent).
|
||||
userId String? @map("user_id")
|
||||
/// AES-256-GCM ciphertext of a custom symptom's free-text label. NULL
|
||||
/// for catalogue rows. Decrypted server-side for the effective read.
|
||||
labelEncrypted String? @map("label_encrypted")
|
||||
|
||||
category CycleSymptomCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("UserCycleSymptoms", fields: [userId], references: [id], onDelete: Cascade)
|
||||
links CycleSymptomLink[]
|
||||
|
||||
@@index([categoryId, sortOrder])
|
||||
@@index([userId])
|
||||
@@map("cycle_symptoms")
|
||||
}
|
||||
|
||||
/// Join: which symptoms a cycle day-log carries. Composite PK keeps the
|
||||
/// pairing unique; cascade on both sides so deleting a day-log (or a
|
||||
/// catalog symptom) cleans up its links.
|
||||
model CycleSymptomLink {
|
||||
dayLogId String @map("day_log_id")
|
||||
symptomId String @map("symptom_id")
|
||||
/// Optional 1-4 Likert intensity for this symptom on this day. NULL = a
|
||||
/// plain presence link (the pre-severity contract). Surfaced in
|
||||
/// CycleDayLogDTO so the log-day sheet can pre-fill the severity chips.
|
||||
severity Int? @map("severity")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
dayLog CycleDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade)
|
||||
symptom CycleSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([dayLogId, symptomId])
|
||||
@@index([symptomId])
|
||||
@@map("cycle_symptom_links")
|
||||
}
|
||||
|
||||
/// One cached forecast per user, regenerated debounced /
|
||||
/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment
|
||||
/// cache pattern), NOT per-read. Derived — not soft-deleted.
|
||||
model CyclePrediction {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
/// Which method produced this forecast.
|
||||
method PredictionMethod
|
||||
|
||||
/// YYYY-MM-DD — predicted next period start (point estimate) plus the
|
||||
/// ranged Low / High band (rendered as a band, never a single date).
|
||||
nextPeriodStart String @map("next_period_start")
|
||||
nextPeriodStartLow String @map("next_period_start_low")
|
||||
nextPeriodStartHigh String @map("next_period_start_high")
|
||||
|
||||
/// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface).
|
||||
fertileWindowStart String? @map("fertile_window_start")
|
||||
fertileWindowEnd String? @map("fertile_window_end")
|
||||
/// YYYY-MM-DD — predicted ovulation day.
|
||||
predictedOvulation String? @map("predicted_ovulation")
|
||||
|
||||
/// 0..1 confidence, shrinking with cycle-length variance + sparse
|
||||
/// logging + < MIN_CYCLES observed.
|
||||
confidence Float
|
||||
/// How many observed cycles the forecast was computed from.
|
||||
cyclesObserved Int @map("cycles_observed")
|
||||
/// When the forecast was generated (drives the stale-while-revalidate
|
||||
/// debounce).
|
||||
generatedAt DateTime @map("generated_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId])
|
||||
@@map("cycle_predictions")
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -63,6 +64,10 @@ interface RestoreResponse {
|
||||
notificationChannels: number;
|
||||
pushSubscriptions: number;
|
||||
telegramScheduledDeletions: number;
|
||||
// v1.15.0 — cycle tables wiped before recreate.
|
||||
cycles: number;
|
||||
cycleDayLogs: number;
|
||||
cycleProfile: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,6 +402,11 @@ const handler = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// v1.15.0 — cycle tables (profile + observed spans + day-logs +
|
||||
// symptom links). Delete-then-recreate, mirroring the contract
|
||||
// above. `notesEncrypted` is restored as ciphertext verbatim.
|
||||
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
|
||||
|
||||
return {
|
||||
measurements: measurements.count,
|
||||
medications: meds.count,
|
||||
@@ -405,6 +415,9 @@ const handler = apiHandler(
|
||||
notificationChannels: channels.count,
|
||||
pushSubscriptions: subs.count,
|
||||
telegramScheduledDeletions: tgDel.count,
|
||||
cycles: cycleCleared.cycles,
|
||||
cycleDayLogs: cycleCleared.cycleDayLogs,
|
||||
cycleProfile: cycleCleared.cycleProfile,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -513,6 +526,8 @@ const handler = apiHandler(
|
||||
medications: summary.medications,
|
||||
intakeEvents: summary.intakeEvents,
|
||||
moodEntries: summary.moodEntries,
|
||||
cycles: summary.cycles,
|
||||
cycleDayLogs: summary.cycleDayLogs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +216,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
medications: summary.medications,
|
||||
intakeEvents: summary.intakeEvents,
|
||||
moodEntries: summary.moodEntries,
|
||||
cycles: summary.cycles,
|
||||
cycleDayLogs: summary.cycleDayLogs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.15.0 — per-user cycle-tracking preferences.
|
||||
*
|
||||
* GET /api/auth/me/cycle-prefs — current resolved CycleProfileDTO.
|
||||
* PATCH /api/auth/me/cycle-prefs — body `{ enabled?, goal?, rawChartMode?,
|
||||
* predictionEnabled?, discreetNotifications?,
|
||||
* sensitiveCategoryEncryption?,
|
||||
* typicalCycleLength?, typicalPeriodLength?,
|
||||
* lutealPhaseLength? }`. Deep-merges the
|
||||
* supplied fields over the persisted row
|
||||
* (the notification-prefs precedent) and
|
||||
* returns the merged CycleProfileDTO.
|
||||
*
|
||||
* This route does NOT enforce the cycle gate — it is the surface that
|
||||
* FLIPS the gate (`enabled` → `cycleTrackingEnabled`). `enabled` is
|
||||
* idempotent last-writer-wins; the route is otherwise idempotent.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { toCycleProfileDTO } from "@/lib/cycle/dto";
|
||||
import { cyclePrefsSchema } from "@/lib/validations/cycle";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.cycle-prefs.get" } });
|
||||
|
||||
const profile = await getOrCreateCycleProfile(user.id);
|
||||
const resolved = isCycleEnabled(user.gender, profile);
|
||||
return apiSuccess(toCycleProfileDTO(profile, resolved));
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`cycle-prefs:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return apiError("Invalid JSON body", 422, {
|
||||
errorCode: "cycle-prefs.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = cyclePrefsSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.cycle-prefs.patch.invalid_shape" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle-prefs.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure the row exists, then deep-merge: only the supplied fields are
|
||||
// touched; siblings are left untouched (field-by-field, no mass
|
||||
// assignment). `enabled` maps onto the `cycleTrackingEnabled` column.
|
||||
await getOrCreateCycleProfile(user.id);
|
||||
const p = parsed.data;
|
||||
|
||||
const updated = await prisma.cycleProfile.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }),
|
||||
...(p.goal !== undefined && { goal: p.goal }),
|
||||
...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }),
|
||||
...(p.predictionEnabled !== undefined && {
|
||||
predictionEnabled: p.predictionEnabled,
|
||||
}),
|
||||
...(p.discreetNotifications !== undefined && {
|
||||
discreetNotifications: p.discreetNotifications,
|
||||
}),
|
||||
...(p.sensitiveCategoryEncryption !== undefined && {
|
||||
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption,
|
||||
}),
|
||||
...(p.typicalCycleLength !== undefined && {
|
||||
typicalCycleLength: p.typicalCycleLength,
|
||||
}),
|
||||
...(p.typicalPeriodLength !== undefined && {
|
||||
typicalPeriodLength: p.typicalPeriodLength,
|
||||
}),
|
||||
...(p.lutealPhaseLength !== undefined && {
|
||||
lutealPhaseLength: p.lutealPhaseLength,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = isCycleEnabled(user.gender, updated);
|
||||
|
||||
await auditLog("user.cycle-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { changed: Object.keys(p) },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "user.cycle_prefs.update" },
|
||||
meta: { changed: Object.keys(p), enabled: resolved },
|
||||
});
|
||||
|
||||
return apiSuccess(toCycleProfileDTO(updated, resolved));
|
||||
});
|
||||
@@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
mood: true,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -112,6 +113,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
expect(env.data).toEqual({
|
||||
medication: { clientManaged: false, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +110,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
expect(env.data).toEqual({
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +132,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
expect(env.data).toEqual({
|
||||
medication: { clientManaged: false, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -162,6 +165,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
expect(env.data).toEqual({
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
@@ -170,6 +174,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
notificationPrefs: {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -182,10 +187,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
previous: {
|
||||
medication: { clientManaged: false, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
},
|
||||
next: {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
},
|
||||
changed: ["medication"],
|
||||
}),
|
||||
@@ -246,6 +253,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
notificationPrefs: {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -270,6 +278,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
expect(env.data).toEqual({
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
@@ -278,6 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
notificationPrefs: {
|
||||
medication: { clientManaged: true, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -305,6 +315,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
notificationPrefs: {
|
||||
medication: { clientManaged: false, deliveryDefault: "server" },
|
||||
mood: { reminderHour: 9 },
|
||||
cycle: { clientManaged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,6 +21,15 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
annotate({ action: { name: "auth.me" } });
|
||||
|
||||
// v1.15.0 — resolved cycle-tracking gate. Read the profile without
|
||||
// forcing a row (a NULL toggle derives from gender); the resolver
|
||||
// collapses both gates into the single boolean iOS hides the tab on.
|
||||
const cycleProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
});
|
||||
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
// one row never 500s the whole profile fetch (the field just reads
|
||||
@@ -68,5 +79,8 @@ export const GET = apiHandler(async () => {
|
||||
insurerName: user.insurerName ?? null,
|
||||
insurerIkNumber: user.insurerIkNumber ?? null,
|
||||
insuranceNumber,
|
||||
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
|
||||
// hides the whole cycle tab when this is false.
|
||||
cycleTrackingEnabled,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `DELETE /api/cycle/all` — one-click cycle hard-delete purge.
|
||||
*
|
||||
* Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`,
|
||||
* which leaves a tombstone for the sync feed): this HARD-deletes every
|
||||
* cycle row the user owns — day-logs (and their symptom links, by cascade),
|
||||
* menstrual cycles, predictions, the cycle audit trail, and the cycle
|
||||
* reminder-delivery rows in the push-attempts ledger — so no dated
|
||||
* reproductive trace survives in Postgres (the post-Dobbs threat model). The
|
||||
* `CycleProfile` row is left in place (gate + settings keep working) but its
|
||||
* intent-revealing fields — goal + cycle/period/luteal priors — are reset to
|
||||
* neutral defaults, so no reproductive intent survives either.
|
||||
*
|
||||
* Gated (`cycle.disabled` 403) and owner-scoped. The purge action itself IS
|
||||
* audited (written after the transaction, so it survives) — its `details`
|
||||
* carry only counts, no reproductive content.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Hard delete in a transaction. Symptom links cascade off the day-log
|
||||
// delete (the schema's onDelete: Cascade). Order child-before-parent for
|
||||
// the rows that don't cascade.
|
||||
const cleared = await prisma.$transaction(async (tx) => {
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const predictions = await tx.cyclePrediction.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const cycles = await tx.menstrualCycle.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
// The cycle audit trail carries dated reproductive-adjacent details
|
||||
// (period boundaries, the AVOID_PREGNANCY goal-nudge, goal changes). A
|
||||
// purge that promises "nothing reproductive persists" must clear it too.
|
||||
// The purge's own audit row is written AFTER this transaction, so it
|
||||
// survives (counts only, no reproductive content).
|
||||
const auditRows = await tx.auditLog.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
OR: [
|
||||
{ action: { startsWith: "cycle." } },
|
||||
{ action: "user.cycle-prefs.update" },
|
||||
],
|
||||
},
|
||||
});
|
||||
// The shared push-attempts ledger holds dated cycle reminder-delivery
|
||||
// rows (eventType + createdAt) for up to 90 days — drop the cycle ones.
|
||||
const pushRows = await tx.pushAttempt.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] },
|
||||
},
|
||||
});
|
||||
// Reset the reproductive INTENT carried on the profile (goal + cycle/period/
|
||||
// luteal priors) to neutral defaults so a purge leaves no intent-revealing
|
||||
// state, while keeping the row so the gate + settings keep working.
|
||||
await tx.cycleProfile.updateMany({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
goal: "GENERAL_HEALTH",
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
},
|
||||
});
|
||||
return {
|
||||
dayLogs: dayLogs.count,
|
||||
predictions: predictions.count,
|
||||
cycles: cycles.count,
|
||||
auditRows: auditRows.count,
|
||||
pushRows: pushRows.count,
|
||||
};
|
||||
});
|
||||
|
||||
await auditLog("cycle.purge", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: cleared,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.purge" },
|
||||
meta: cleared,
|
||||
});
|
||||
|
||||
return apiSuccess({ purged: true, ...cleared });
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* `GET /api/cycle/calendar?from&to` — predicted calendar read
|
||||
* (ios-contract §2.D).
|
||||
*
|
||||
* Calls the pure engine (`predictCycle` + per-day phase) to build
|
||||
* `{ profile, prediction, days }`. The synchronous engine call is cheap
|
||||
* (pure stats); the heavy part is the cache persist, which is debounced
|
||||
* stale-while-revalidate and fire-and-forget so it never blocks the GET.
|
||||
* Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and
|
||||
* AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method"
|
||||
* disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF),
|
||||
* resolved server-side.
|
||||
*
|
||||
* Default range: current cycle − 90d … +180d forward.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
|
||||
import {
|
||||
buildCalendar,
|
||||
type CalendarDayLogRow,
|
||||
} from "@/lib/cycle/engine-adapter";
|
||||
import {
|
||||
toCyclePredictionDTO,
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { BBT_WINDOW } from "@/lib/cycle/types";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
const DEFAULT_PAST_DAYS = 90;
|
||||
const DEFAULT_FORWARD_DAYS = 180;
|
||||
/** Hard cap on the rendered span (days) to bound the day-grid build. */
|
||||
const MAX_SPAN_DAYS = 400;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
const profile = gate.profile;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = cycleCalendarQuerySchema.safeParse({
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid calendar query", 422, {
|
||||
errorCode: "cycle.calendar.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS);
|
||||
const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS);
|
||||
|
||||
// Reject an inverted or absurdly wide range up front.
|
||||
const fromMs = Date.parse(`${from}T12:00:00Z`);
|
||||
const toMs = Date.parse(`${to}T12:00:00Z`);
|
||||
if (toMs < fromMs || (toMs - fromMs) / 86_400_000 > MAX_SPAN_DAYS) {
|
||||
return apiError("Calendar range too wide or inverted", 422, {
|
||||
errorCode: "cycle.calendar.range",
|
||||
});
|
||||
}
|
||||
|
||||
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
// Bound the day-log read to the rendered span plus the symptothermal
|
||||
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
|
||||
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
|
||||
// — unbounded full-history read on a hot, per-navigation route).
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte:
|
||||
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
|
||||
? from
|
||||
: addDays(today, -BBT_WINDOW),
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
flow: true,
|
||||
basalBodyTempC: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
_count: { select: { symptomLinks: true } },
|
||||
},
|
||||
}),
|
||||
// Apple Watch wrist/skin temperature feeds the temperature-trend
|
||||
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
|
||||
// values; the engine derives the trailing-mean deviation itself.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
hasSymptoms: l._count.symptomLinks > 0,
|
||||
}));
|
||||
|
||||
const nights = nightlyTemps.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, tz),
|
||||
valueC: m.value,
|
||||
}));
|
||||
|
||||
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
|
||||
|
||||
const { prediction, days } = buildCalendar(
|
||||
profile,
|
||||
cycles,
|
||||
dayLogs,
|
||||
nights,
|
||||
from,
|
||||
to,
|
||||
today,
|
||||
goalAllowsFertile,
|
||||
);
|
||||
|
||||
// Persist the cache fire-and-forget (debounced) — never block the read.
|
||||
let generatedAt = new Date().toISOString();
|
||||
if (prediction) {
|
||||
const now = new Date();
|
||||
generatedAt = now.toISOString();
|
||||
void persistPredictionCache(user.id, prediction, now);
|
||||
}
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? undefined,
|
||||
});
|
||||
const t = getServerTranslator(locale);
|
||||
// AVOID_PREGNANCY surfaces the fertile window, so it must carry the stronger
|
||||
// "not a contraceptive method" caveat (QA H-1).
|
||||
const disclaimer = t.t(cycleDisclaimerKey(profile.goal));
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.calendar.read" },
|
||||
meta: {
|
||||
days: days.length,
|
||||
cycles_observed: prediction?.cyclesObserved ?? 0,
|
||||
has_prediction: prediction !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
profile: {
|
||||
goal: profile.goal,
|
||||
rawChartMode: profile.rawChartMode,
|
||||
predictionEnabled: profile.predictionEnabled,
|
||||
cyclesObserved: prediction?.cyclesObserved ?? cycles.length,
|
||||
},
|
||||
prediction: prediction
|
||||
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
|
||||
: null,
|
||||
days,
|
||||
meta: { generatedAt },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
|
||||
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
|
||||
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.menstrualCycle.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Cycle not found", 404);
|
||||
}
|
||||
|
||||
await prisma.menstrualCycle.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("cycle.cycle.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { cycleId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.cycle.delete",
|
||||
entity_type: "menstrual_cycle",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `GET /api/cycle/cycles?limit=24` — cycle history + summary stats
|
||||
* (ios-contract §2.E).
|
||||
*
|
||||
* Returns the most recent observed cycles (newest first) plus
|
||||
* `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays,
|
||||
* regularity }`. The median / MAD reuse the engine helpers so the stats
|
||||
* match the prediction's own variability math.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleHistoryQuerySchema } from "@/lib/validations/cycle";
|
||||
import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
|
||||
import { median, mad } from "@/lib/cycle";
|
||||
import { dayDiff } from "@/lib/cycle/day-math";
|
||||
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
|
||||
|
||||
const DEFAULT_LIMIT = 24;
|
||||
|
||||
/**
|
||||
* Regularity gate. < MIN_CYCLES → LEARNING; a length MAD at/under 2 days
|
||||
* (the clinical "regular cycle" rule of thumb) → REGULAR, else IRREGULAR.
|
||||
*/
|
||||
const REGULAR_MAD_THRESHOLD = 2;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = cycleHistoryQuerySchema.safeParse({
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid history query", 422, {
|
||||
errorCode: "cycle.cycles.invalid",
|
||||
});
|
||||
}
|
||||
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const rows = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// Completed lengths (non-null lengthDays) drive the length stats.
|
||||
const lengths = rows
|
||||
.map((r) => r.lengthDays)
|
||||
.filter((l): l is number => l != null && l > 0);
|
||||
|
||||
// Period lengths from periodEndDate − startDate + 1.
|
||||
const periodLengths = rows
|
||||
.filter((r) => r.periodEndDate != null)
|
||||
.map((r) => dayDiff(r.periodEndDate as string, r.startDate) + 1)
|
||||
.filter((l) => l > 0);
|
||||
|
||||
let avgLengthDays: number | null = null;
|
||||
let lengthVariabilityDays: number | null = null;
|
||||
let avgPeriodLengthDays: number | null = null;
|
||||
let regularity: "REGULAR" | "IRREGULAR" | "LEARNING" = "LEARNING";
|
||||
|
||||
if (lengths.length > 0) {
|
||||
const med = median(lengths);
|
||||
avgLengthDays = Math.round(med);
|
||||
lengthVariabilityDays = Math.round(mad(lengths, med) * 10) / 10;
|
||||
if (lengths.length < MIN_CYCLES_TO_PREDICT) {
|
||||
regularity = "LEARNING";
|
||||
} else {
|
||||
regularity =
|
||||
lengthVariabilityDays <= REGULAR_MAD_THRESHOLD
|
||||
? "REGULAR"
|
||||
: "IRREGULAR";
|
||||
}
|
||||
}
|
||||
if (periodLengths.length > 0) {
|
||||
avgPeriodLengthDays = Math.round(median(periodLengths));
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.cycles.read" },
|
||||
meta: { returned: rows.length, regularity },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
cycles: rows.map(toMenstrualCycleDTO),
|
||||
stats: {
|
||||
avgLengthDays,
|
||||
lengthVariabilityDays,
|
||||
avgPeriodLengthDays,
|
||||
regularity,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* `PATCH /api/cycle/day-logs/{id}` — edit a single day-log
|
||||
* (ios-contract §2.A).
|
||||
* `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F):
|
||||
* set `deletedAt` + bump `syncVersion`, emit a tombstone on the next
|
||||
* sync page. 204. Idempotent.
|
||||
*
|
||||
* Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by
|
||||
* another user 404s).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
|
||||
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
|
||||
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.cycleDayLog.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Day-log not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = cycleDayLogPatchSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "cycle.day-log.patch.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
|
||||
// Resolve the stored plaintext sensitive fields (from the envelope when
|
||||
// the row was encrypted, else the columns) so a partial patch merges
|
||||
// against the true current value.
|
||||
const encryptSensitive = (await getOrCreateCycleProfile(user.id))
|
||||
.sensitiveCategoryEncryption;
|
||||
const stored = readStoredSensitive(existing);
|
||||
const merged = {
|
||||
sexualActivity:
|
||||
body.sexualActivity !== undefined
|
||||
? body.sexualActivity
|
||||
: stored.sexualActivity,
|
||||
protectedSex:
|
||||
body.protectedSex !== undefined
|
||||
? (body.protectedSex ?? null)
|
||||
: stored.protectedSex,
|
||||
pregnancyTest:
|
||||
body.pregnancyTest !== undefined
|
||||
? (body.pregnancyTest ?? null)
|
||||
: stored.pregnancyTest,
|
||||
progesteroneTest:
|
||||
body.progesteroneTest !== undefined
|
||||
? (body.progesteroneTest ?? null)
|
||||
: stored.progesteroneTest,
|
||||
contraceptive:
|
||||
body.contraceptive !== undefined
|
||||
? (body.contraceptive ?? null)
|
||||
: stored.contraceptive,
|
||||
};
|
||||
|
||||
// Field-by-field update (no mass assignment). Non-sensitive fields are
|
||||
// written only when present; the sensitive set is re-resolved and split
|
||||
// between plaintext columns and the envelope per the flag.
|
||||
await prisma.cycleDayLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.flow !== undefined && { flow: body.flow }),
|
||||
...(body.intermenstrualBleeding !== undefined && {
|
||||
intermenstrualBleeding: body.intermenstrualBleeding,
|
||||
}),
|
||||
...(body.basalBodyTempC !== undefined && {
|
||||
basalBodyTempC: body.basalBodyTempC,
|
||||
}),
|
||||
...(body.ovulationTest !== undefined && {
|
||||
ovulationTest: body.ovulationTest,
|
||||
}),
|
||||
...(body.cervicalMucus !== undefined && {
|
||||
cervicalMucus: body.cervicalMucus,
|
||||
}),
|
||||
sexualActivity: encryptSensitive ? false : merged.sexualActivity,
|
||||
protectedSex: encryptSensitive ? null : merged.protectedSex,
|
||||
pregnancyTest: (encryptSensitive
|
||||
? null
|
||||
: merged.pregnancyTest) as never,
|
||||
progesteroneTest: (encryptSensitive
|
||||
? null
|
||||
: merged.progesteroneTest) as never,
|
||||
contraceptive: (encryptSensitive
|
||||
? null
|
||||
: merged.contraceptive) as never,
|
||||
sensitiveEncrypted: encryptSensitive
|
||||
? encrypt(JSON.stringify(merged))
|
||||
: null,
|
||||
...(body.note !== undefined && {
|
||||
notesEncrypted: body.note ? encrypt(body.note) : null,
|
||||
}),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Replace symptom links only when `symptoms` was supplied.
|
||||
if (body.symptoms !== undefined) {
|
||||
await replaceSymptomLinks(user.id, id, body.symptoms);
|
||||
}
|
||||
|
||||
const row = await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
await auditLog("cycle.day-log.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.day-log.update",
|
||||
entity_type: "cycle_day_log",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toCycleDayLogDTO(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.cycleDayLog.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Day-log not found", 404);
|
||||
}
|
||||
|
||||
// Soft-delete: leave the row in place so the `/api/sync/changes`
|
||||
// delta feed surfaces it as a tombstone. A re-delete re-bumps
|
||||
// `syncVersion` harmlessly (idempotent).
|
||||
await prisma.cycleDayLog.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("cycle.day-log.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.day-log.delete",
|
||||
entity_type: "cycle_day_log",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
);
|
||||
|
||||
/** The stored plaintext sensitive fields (envelope when encrypted, else columns). */
|
||||
function readStoredSensitive(row: {
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
}): {
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
} {
|
||||
if (row.sensitiveEncrypted) {
|
||||
try {
|
||||
const dec = JSON.parse(decrypt(row.sensitiveEncrypted)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
sexualActivity: (dec.sexualActivity as boolean) ?? false,
|
||||
protectedSex: (dec.protectedSex as boolean | null) ?? null,
|
||||
pregnancyTest: (dec.pregnancyTest as string | null) ?? null,
|
||||
progesteroneTest: (dec.progesteroneTest as string | null) ?? null,
|
||||
contraceptive: (dec.contraceptive as string | null) ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Fail-soft: an undecryptable envelope reads as cleared.
|
||||
return {
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
sexualActivity: row.sexualActivity,
|
||||
protectedSex: row.protectedSex,
|
||||
pregnancyTest: row.pregnancyTest,
|
||||
progesteroneTest: row.progesteroneTest,
|
||||
contraceptive: row.contraceptive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* `POST /api/cycle/day-logs/bulk` — Outbox / HealthKit drain
|
||||
* (ios-contract §2.B / §3).
|
||||
*
|
||||
* The route iOS's Outbox flushes to. Per-entry UPSERT keyed on the
|
||||
* NULL-distinct `(userId, source, externalId)` (or `(userId, date)`
|
||||
* when no externalId). Capped at 500, wrapped in `withIdempotency`,
|
||||
* rate-limited `cycle:day-logs:bulk:{userId}` 60/min. Always 200; each
|
||||
* entry carries `{ index, status, id?, externalId?, reason? }` where
|
||||
* status is `inserted` | `duplicate` | `updated` | `skipped`. `updated`
|
||||
* = an externalId re-post that changed a field; `duplicate` = an
|
||||
* unchanged re-post.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
cycleBulkSchema,
|
||||
MAX_CYCLE_BULK_ENTRIES,
|
||||
} from "@/lib/validations/cycle";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
type EntryStatus = "inserted" | "duplicate" | "updated" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
id?: string;
|
||||
externalId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`cycle:day-logs:bulk:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
if (
|
||||
typeof rawBody === "object" &&
|
||||
rawBody !== null &&
|
||||
"entries" in rawBody &&
|
||||
Array.isArray((rawBody as { entries: unknown }).entries) &&
|
||||
(rawBody as { entries: unknown[] }).entries.length > MAX_CYCLE_BULK_ENTRIES
|
||||
) {
|
||||
return apiError(
|
||||
`Batch exceeds the ${MAX_CYCLE_BULK_ENTRIES}-entry limit`,
|
||||
422,
|
||||
{ errorCode: "cycle.bulk.too_large" },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = cycleBulkSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "cycle.bulk.validation-failed" },
|
||||
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.bulk.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// Hoist cycle attribution + the invariant encryption flag out of the loop:
|
||||
// load the user's cycle start dates once and resolve the owning span in
|
||||
// memory (latest start <= date), instead of a findOwningCycleId query per
|
||||
// entry (up to 500) + a profile read per entry (QA M-3 / round-1 N+1).
|
||||
const cycleRows = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { id: true, startDate: true },
|
||||
});
|
||||
const owningCycleId = (date: string): string | null => {
|
||||
let id: string | null = null;
|
||||
for (const c of cycleRows) {
|
||||
if (c.startDate <= date) id = c.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
const encryptSensitive = gate.profile.sensitiveCategoryEncryption;
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let duplicates = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const cycleId = owningCycleId(entry.date);
|
||||
const r = await upsertCycleDayLog(
|
||||
user.id,
|
||||
entry,
|
||||
tz,
|
||||
cycleId,
|
||||
encryptSensitive,
|
||||
);
|
||||
|
||||
let status: EntryStatus;
|
||||
if (!r.existed) {
|
||||
status = "inserted";
|
||||
inserted += 1;
|
||||
} else if (r.changed) {
|
||||
status = "updated";
|
||||
updated += 1;
|
||||
} else {
|
||||
status = "duplicate";
|
||||
duplicates += 1;
|
||||
}
|
||||
|
||||
results.push({
|
||||
index: i,
|
||||
status,
|
||||
id: r.id,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Map to a stable closed set of reason codes — never echo the raw
|
||||
// driver message (it can carry column / constraint / value fragments).
|
||||
// `annotate` meta is NOT run through the central redactor (only
|
||||
// setError/setHttp are), so the raw message must not be attached here:
|
||||
// record the stable code + the Prisma error code only (QA M-sec1).
|
||||
const code =
|
||||
typeof err === "object" && err !== null && "code" in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
const reason =
|
||||
code === "P2002"
|
||||
? "constraint"
|
||||
: code === "P2003"
|
||||
? "constraint"
|
||||
: "upsert_failed";
|
||||
annotate({
|
||||
action: { name: "cycle.bulk.entry-failed" },
|
||||
meta: {
|
||||
index: i,
|
||||
reason,
|
||||
error_code: typeof code === "string" ? code : "unknown",
|
||||
},
|
||||
});
|
||||
skipped += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("cycle.bulk.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.bulk.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* `POST /api/cycle/day-logs` — single cycle day-log capture
|
||||
* (ios-contract §2.A).
|
||||
*
|
||||
* Upserts on `(userId, source, externalId)` when an `externalId` is
|
||||
* present (the cross-device dedup key), else the canonical `(userId,
|
||||
* date)` key. `note` is encrypted at rest (`notesEncrypted`); the intent
|
||||
* fields stay queryable plaintext (they feed the rollup / correlation
|
||||
* tier). Returns the full `CycleDayLogDTO`: 201 on insert, 200 on update.
|
||||
*
|
||||
* Gated: a disabled / non-FEMALE-without-opt-in account 403s with
|
||||
* `errorCode:"cycle.disabled"` even with a valid Bearer token.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
cycleDayLogInputSchema,
|
||||
cycleDayLogQuerySchema,
|
||||
} from "@/lib/validations/cycle";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
|
||||
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
|
||||
|
||||
/**
|
||||
* `GET /api/cycle/day-logs?date=YYYY-MM-DD` — the full `CycleDayLogDTO`
|
||||
* for one tz-anchored day, or `null` when nothing is logged. Lets the
|
||||
* log-day sheet pre-fill (no blank-sheet data loss) and Delete resolve the
|
||||
* row id. Gated + owner-scoped + `deletedAt:null`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = cycleDayLogQuerySchema.safeParse({
|
||||
date: new URL(request.url).searchParams.get("date"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const row = await prisma.cycleDayLog.findFirst({
|
||||
where: { userId: user.id, date: parsed.data.date, deletedAt: null },
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.day-log.read", entity_type: "cycle_day_log" },
|
||||
meta: { found: row !== null },
|
||||
});
|
||||
|
||||
return apiSuccess(row ? toCycleDayLogDTO(row) : null);
|
||||
});
|
||||
|
||||
async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "cycle.day-log.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.day-log.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const cycleId = await findOwningCycleId(user.id, entry.date);
|
||||
let result;
|
||||
try {
|
||||
result = await upsertCycleDayLog(user.id, entry, tz, cycleId);
|
||||
} catch (err: unknown) {
|
||||
// A residual unique-constraint collision (the helper adopts the
|
||||
// canonical row on the common case) surfaces as a clean 409, never a
|
||||
// 500 (the MoodEntry conflict precedent).
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "cycle.day-log.conflict" },
|
||||
meta: { date: entry.date },
|
||||
});
|
||||
return apiError("Day-log already exists for this date", 409, {
|
||||
errorCode: "cycle.day-log.conflict",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const row = await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
where: { id: result.id },
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
await auditLog("cycle.day-log.upsert", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: result.id, existed: result.existed },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.day-log.upsert",
|
||||
entity_type: "cycle_day_log",
|
||||
entity_id: result.id,
|
||||
},
|
||||
meta: { existed: result.existed, source: entry.source },
|
||||
});
|
||||
|
||||
return apiSuccess(toCycleDayLogDTO(row), result.existed ? 200 : 201);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* `GET /api/cycle/insights` — cycle-phase × vital correlation insights
|
||||
* (integration-audit §1 + §3 "INSIGHTS").
|
||||
*
|
||||
* The gender-gated home for the CYCLE_PHASE correlation. The general
|
||||
* `/api/insights/correlations` route is NOT gender-gated, so phase — and the
|
||||
* temperature channels a phase relation rides on — must never surface there;
|
||||
* this route is the deliberate, gated place they do.
|
||||
*
|
||||
* Surfaces the FDR-guarded LUTEAL-vs-FOLLICULAR phase contrast per outcome
|
||||
* metric (the same Welch + Benjamini-Hochberg machinery the mood-factor
|
||||
* crosstab runs) plus the ONE headline insight (resting-heart-rate-by-phase,
|
||||
* falling back to HRV) for the v1.15.0 card. Honest: n / effect size / q are
|
||||
* shown, every row is FDR-gated, nothing is fabricated on thin data, nothing
|
||||
* is causal.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
|
||||
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
|
||||
import {
|
||||
buildPhaseDayMap,
|
||||
toCycleInputs,
|
||||
toDayLogInputs,
|
||||
toProfileInput,
|
||||
} from "@/lib/cycle/engine-adapter";
|
||||
import {
|
||||
computePhaseMetricCrosstab,
|
||||
discoverPhaseCorrelations,
|
||||
selectHeadlinePhaseRow,
|
||||
PHASE_CROSSTAB_METRIC_TYPES,
|
||||
MOOD_CHANNEL_KEY,
|
||||
} from "@/lib/cycle/phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
import {
|
||||
computeSymptomPhasePatterns,
|
||||
type SymptomDay,
|
||||
} from "@/lib/cycle/symptom-phase";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
|
||||
/** Trailing window the phase-contrast walks (days). */
|
||||
const WINDOW_DAYS = 365;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
const profile = gate.profile;
|
||||
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
const [
|
||||
cycles,
|
||||
dayLogRows,
|
||||
nightlyTempRows,
|
||||
measurementRows,
|
||||
prefsRow,
|
||||
moodRows,
|
||||
] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
// Bound the day-log read to the rendered window (`from` = today − 365d),
|
||||
// which is already earlier than the symptothermal lookback. Cycle-length
|
||||
// stats run off MenstrualCycle rows so the day-logs can be windowed (QA:
|
||||
// perf — unbounded full-history read).
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, deletedAt: null, date: { gte: from } },
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
flow: true,
|
||||
basalBodyTempC: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
symptomLinks: { select: { symptom: { select: { key: true } } } },
|
||||
},
|
||||
}),
|
||||
// Passive wrist temperature feeds the temperature-trend ovulation layer
|
||||
// (used only to sharpen the prediction's next-start the phase-day map
|
||||
// extends to). Not a crosstab input.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
// The outcome metrics the phase contrast compares — soft-delete-scoped,
|
||||
// canonical-source deduped per day inside `metricDayMap`.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
}),
|
||||
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
|
||||
// Measurement row, so read it here and inject it into the crosstab as a
|
||||
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// The latest open cycle runs to the predicted next-period start so the
|
||||
// trailing window is phase-labelled even before the next period is logged.
|
||||
const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, tz),
|
||||
valueC: m.value,
|
||||
}));
|
||||
const prediction =
|
||||
profile.predictionEnabled && !profile.rawChartMode
|
||||
? predictCycle(
|
||||
toCycleInputs(cycles),
|
||||
toDayLogInputs(dayLogRows),
|
||||
toProfileInput(profile),
|
||||
today,
|
||||
nights,
|
||||
)
|
||||
: null;
|
||||
|
||||
const phaseByDay = buildPhaseDayMap(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
profile.lutealPhaseLength ?? LUTEAL_DEFAULT,
|
||||
from,
|
||||
today,
|
||||
);
|
||||
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
source: m.source,
|
||||
deviceType: m.deviceType,
|
||||
}));
|
||||
// Inject mood as a synthetic MOOD_CHANNEL_KEY series (no source ladder — the
|
||||
// metricDayMap pass-through keeps every row, averaging multiple same-day
|
||||
// entries like every other channel).
|
||||
for (const m of moodRows) {
|
||||
measurements.push({
|
||||
type: MOOD_CHANNEL_KEY,
|
||||
value: m.score,
|
||||
measuredAt: m.moodLoggedAt,
|
||||
source: null,
|
||||
deviceType: null,
|
||||
});
|
||||
}
|
||||
|
||||
const userPriorityJson = prefsRow?.sourcePriorityJson ?? null;
|
||||
|
||||
// Mechanism A — the categorical luteal-vs-follicular contrast (the headline
|
||||
// surface). Mechanism B — the continuous CYCLE_PHASE ordinal folded into the
|
||||
// lagged-Pearson FDR matrix. Both are FDR-gated; both live only here.
|
||||
const rows = computePhaseMetricCrosstab({
|
||||
phaseByDay,
|
||||
measurements,
|
||||
userPriorityJson,
|
||||
});
|
||||
const headline = selectHeadlinePhaseRow(rows);
|
||||
const lagged = discoverPhaseCorrelations({
|
||||
phaseByDay,
|
||||
measurements,
|
||||
userPriorityJson,
|
||||
});
|
||||
|
||||
// Cycle-NATIVE insight: where each logged symptom clusters across the phases.
|
||||
const symptomDays: SymptomDay[] = dayLogRows.map((d) => ({
|
||||
date: d.date,
|
||||
keys: d.symptomLinks.map((l) => l.symptom.key),
|
||||
}));
|
||||
const symptomPatterns = computeSymptomPhasePatterns(symptomDays, phaseByDay);
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.insights.read" },
|
||||
meta: {
|
||||
labelled_days: phaseByDay.size,
|
||||
cycles_observed: cycles.length,
|
||||
rows: rows.length,
|
||||
has_headline: headline !== null,
|
||||
lagged_discovered: lagged.discovered.length,
|
||||
lagged_pairs_tested: lagged.pairsTested,
|
||||
symptom_patterns: symptomPatterns.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
rows,
|
||||
headline,
|
||||
lagged: {
|
||||
discovered: lagged.discovered,
|
||||
pairsTested: lagged.pairsTested,
|
||||
fdrQ: lagged.fdrQ,
|
||||
minPairs: lagged.minPairs,
|
||||
},
|
||||
symptomPatterns,
|
||||
contrast: { high: "LUTEAL", low: "FOLLICULAR" },
|
||||
windowDays: WINDOW_DAYS,
|
||||
cyclesObserved: cycles.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* `POST /api/cycle/period` — one-tap period-boundary shortcut
|
||||
* (ios-contract §2.C).
|
||||
*
|
||||
* `action:"start"` opens a new `MenstrualCycle` anchored at `date`,
|
||||
* closes the prior open cycle (endDate = day before, lengthDays set),
|
||||
* and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"`
|
||||
* stamps the current cycle's `periodEndDate` and writes the boundary
|
||||
* day-log. Convenience over §2.A; returns
|
||||
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { cyclePeriodSchema } from "@/lib/validations/cycle";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import type { FlowLevel } from "@/lib/cycle/types";
|
||||
import {
|
||||
toCycleDayLogDTO,
|
||||
toMenstrualCycleDTO,
|
||||
dayLogSymptomInclude,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
|
||||
|
||||
async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = cyclePeriodSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "cycle.period.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.period.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { action, date, externalId, loggedAt } = parsed.data;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// The close-prior + open-new (start) or stamp-current (end) mutation set
|
||||
// is run inside a single transaction so two concurrent taps can never
|
||||
// double-close a prior cycle or compute the new cycle's length against a
|
||||
// stale read. `withIdempotency` on the route additionally collapses an
|
||||
// exact-key replay; the transaction guards the interleaving case.
|
||||
const txResult = await prisma.$transaction(async (db) => {
|
||||
if (action === "start") {
|
||||
// Close the prior open cycle (its end is the day before this start)
|
||||
// and record its observed length.
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true, startDate: true },
|
||||
});
|
||||
if (prior) {
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: {
|
||||
endDate: addDays(date, -1),
|
||||
lengthDays: dayDiff(date, prior.startDate),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Upsert the new cycle on the `(userId, startDate)` unique so a
|
||||
// re-tap on the same day is idempotent.
|
||||
const cycle = await db.menstrualCycle.upsert({
|
||||
where: { userId_startDate: { userId: user.id, startDate: date } },
|
||||
create: { userId: user.id, startDate: date, tz, isPredicted: false },
|
||||
update: {
|
||||
deletedAt: null,
|
||||
isPredicted: false,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-anchor the FOLLOWING neighbour: when this start is back-filled
|
||||
// between two existing cycles, the new cycle's end + length are already
|
||||
// known from its successor's start. Without this the inserted cycle keeps
|
||||
// endDate/lengthDays = null even though the next start exists, and the
|
||||
// iOS mirror + history surfaces read a stale open cycle (QA M-1).
|
||||
const next = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
endDate: addDays(next.startDate, -1),
|
||||
lengthDays: dayDiff(next.startDate, date),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { cycleId: cycle.id as string | null };
|
||||
}
|
||||
|
||||
// `end`: stamp the current cycle's periodEndDate.
|
||||
const current = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!current) {
|
||||
return { cycleId: null };
|
||||
}
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: current.id },
|
||||
data: { periodEndDate: date, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return { cycleId: current.id as string | null };
|
||||
});
|
||||
|
||||
if (txResult.cycleId === null) {
|
||||
return apiSuccessNoCycle();
|
||||
}
|
||||
const cycleId: string = txResult.cycleId;
|
||||
|
||||
// Boundary flow. Start → MEDIUM opens the bleed; end → SPOTTING tail.
|
||||
// Never downgrade a richer flow already logged for the same day (e.g. a
|
||||
// manual HEAVY entry must survive a later one-tap "start") — only set the
|
||||
// boundary flow when it ranks at or above what's stored.
|
||||
const boundaryFlow: FlowLevel = action === "start" ? "MEDIUM" : "SPOTTING";
|
||||
const existingDay = await prisma.cycleDayLog.findFirst({
|
||||
where: { userId: user.id, date, deletedAt: null },
|
||||
select: { flow: true },
|
||||
});
|
||||
const flow =
|
||||
flowRank(boundaryFlow) >= flowRank(existingDay?.flow ?? null)
|
||||
? boundaryFlow
|
||||
: undefined;
|
||||
|
||||
await upsertCycleDayLog(
|
||||
user.id,
|
||||
{
|
||||
date,
|
||||
...(flow !== undefined ? { flow } : {}),
|
||||
loggedAt,
|
||||
source: "MANUAL",
|
||||
...(externalId ? { externalId } : {}),
|
||||
},
|
||||
tz,
|
||||
cycleId,
|
||||
);
|
||||
|
||||
const [cycleRow, dayLogRow] = await Promise.all([
|
||||
prisma.menstrualCycle.findUniqueOrThrow({ where: { id: cycleId } }),
|
||||
prisma.cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: user.id, date, deletedAt: null },
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
]);
|
||||
|
||||
await auditLog("cycle.period.boundary", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { action, date, cycleId },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.period.boundary",
|
||||
entity_type: "menstrual_cycle",
|
||||
entity_id: cycleId,
|
||||
},
|
||||
meta: { boundary: action },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
cycle: toMenstrualCycleDTO(cycleRow),
|
||||
dayLog: toCycleDayLogDTO(dayLogRow),
|
||||
});
|
||||
}
|
||||
|
||||
/** `end` with no preceding cycle is a no-op the client should not hit. */
|
||||
function apiSuccessNoCycle(): Response {
|
||||
return apiSuccess({ cycle: null, dayLog: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordinal rank of a flow level (NONE = 0 … HEAVY = 5; a missing flow ranks
|
||||
* below NONE). Used to keep the one-tap boundary flow from downgrading a
|
||||
* richer same-day entry.
|
||||
*/
|
||||
function flowRank(flow: FlowLevel | string | null): number {
|
||||
switch (flow) {
|
||||
case "NONE":
|
||||
return 1;
|
||||
case "SPOTTING":
|
||||
return 2;
|
||||
case "LIGHT":
|
||||
return 3;
|
||||
case "MEDIUM":
|
||||
return 4;
|
||||
case "HEAVY":
|
||||
return 5;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* `GET /api/cycle/profile` — full CycleProfileDTO (ios-contract §2.G).
|
||||
*
|
||||
* Gated like every cycle route; returns the lazily-upserted profile with
|
||||
* the resolved `cycleTrackingEnabled`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { toCycleProfileDTO } from "@/lib/cycle/dto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const resolved = isCycleEnabled(user.gender, gate.profile);
|
||||
|
||||
annotate({ action: { name: "cycle.profile.read" } });
|
||||
|
||||
return apiSuccess(toCycleProfileDTO(gate.profile, resolved));
|
||||
});
|
||||
@@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
@@ -318,6 +322,9 @@ describe("GET /api/export/full-backup", () => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
|
||||
@@ -22,6 +22,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -33,7 +34,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries] =
|
||||
const [measurements, medications, intakeEvents, moodEntries, cycle] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
// v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the
|
||||
@@ -74,6 +75,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
// v1.15.0 — cycle tables (profile + observed spans + day-logs). The
|
||||
// shared helper carries `notesEncrypted` verbatim (never decrypted).
|
||||
buildCycleBackupSection(prisma, user.id),
|
||||
]);
|
||||
|
||||
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
|
||||
@@ -118,6 +122,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
cycleDayLogs: cycle.cycleDayLogs,
|
||||
};
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
@@ -129,6 +137,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
moodEntries: moodEntries.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -139,6 +149,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_medications_count: medications.length,
|
||||
export_intake_count: intakeEvents.length,
|
||||
export_mood_count: moodEntries.length,
|
||||
export_cycle_count: cycle.cycles.length,
|
||||
export_cycle_day_log_count: cycle.cycleDayLogs.length,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* domain now soft-delete (set `deletedAt` + bump `syncVersion`) rather
|
||||
* than hard-delete.
|
||||
*
|
||||
* Domains served: `measurements`, `mood`, `intakes`. The iOS consumer
|
||||
* Domains served: `measurements`, `mood`, `intakes`, `cycleDays`,
|
||||
* `cycles` (the last two added v1.15.0; `cycleDays` tombstones key on
|
||||
* `externalId` when present else `id`, `cycles` on `id`). The iOS consumer
|
||||
* is measurements-only this cycle (iOS-coord
|
||||
* `v1.7.0-ios-offline-sync-answers.md` §7.1); mood + intakes are
|
||||
* forward-prep so the contract is complete + tested before the
|
||||
@@ -57,6 +59,13 @@ import {
|
||||
type SyncCursor,
|
||||
type SyncDomain,
|
||||
} from "@/lib/sync/cursor";
|
||||
import {
|
||||
toCycleDayLogDTO,
|
||||
toMenstrualCycleDTO,
|
||||
dayLogSymptomInclude,
|
||||
type CycleDayLogDTO,
|
||||
type MenstrualCycleDTO,
|
||||
} from "@/lib/cycle/dto";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 500;
|
||||
@@ -126,6 +135,25 @@ interface IntakeTombstone {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// v1.15.0 — cycle domains. cycleDays key tombstones on `externalId` when
|
||||
// present (HealthKit-origin rows are externalId-keyed cross-device, like
|
||||
// measurements) else the server `id`; cycles key on the server `id`
|
||||
// (computed rows carry no externalId).
|
||||
interface CycleDayTombstone {
|
||||
id: string;
|
||||
externalId: string | null;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CycleTombstone {
|
||||
id: string;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
|
||||
* Empty object (full scan) when the client has no watermark for it yet.
|
||||
@@ -196,6 +224,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measurements: { upserts: [], tombstones: [] },
|
||||
mood: { upserts: [], tombstones: [] },
|
||||
intakes: { upserts: [], tombstones: [] },
|
||||
cycleDays: { upserts: [], tombstones: [] },
|
||||
cycles: { upserts: [], tombstones: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -204,7 +234,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a
|
||||
// row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore`
|
||||
// without a count query.
|
||||
const [measurementRows, moodRows, intakeRows] = await Promise.all([
|
||||
const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.measurements) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
@@ -257,6 +288,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const nextCursor: SyncCursor = { ...cursor };
|
||||
@@ -354,6 +396,49 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
advanceCursor(nextCursor, "intakes", intakePage);
|
||||
hasMore = hasMore || intakeHasMore;
|
||||
|
||||
// ── cycleDays ─────────────────────────────────────────────
|
||||
const cycleDayHasMore = cycleDayRows.length > limit;
|
||||
const cycleDayPage = cycleDayHasMore
|
||||
? cycleDayRows.slice(0, limit)
|
||||
: cycleDayRows;
|
||||
const cycleDayUpserts: CycleDayLogDTO[] = [];
|
||||
const cycleDayTombstones: CycleDayTombstone[] = [];
|
||||
for (const row of cycleDayPage) {
|
||||
if (row.deletedAt) {
|
||||
cycleDayTombstones.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
cycleDayUpserts.push(toCycleDayLogDTO(row));
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "cycleDays", cycleDayPage);
|
||||
hasMore = hasMore || cycleDayHasMore;
|
||||
|
||||
// ── cycles ────────────────────────────────────────────────
|
||||
const cycleHasMore = cycleRows.length > limit;
|
||||
const cyclePage = cycleHasMore ? cycleRows.slice(0, limit) : cycleRows;
|
||||
const cycleUpserts: MenstrualCycleDTO[] = [];
|
||||
const cycleTombstones: CycleTombstone[] = [];
|
||||
for (const row of cyclePage) {
|
||||
if (row.deletedAt) {
|
||||
cycleTombstones.push({
|
||||
id: row.id,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
cycleUpserts.push(toMenstrualCycleDTO(row));
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "cycles", cyclePage);
|
||||
hasMore = hasMore || cycleHasMore;
|
||||
|
||||
annotate({
|
||||
action: { name: "sync.changes.pull" },
|
||||
meta: {
|
||||
@@ -363,6 +448,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
mood_tombstones: moodTombstones.length,
|
||||
intake_upserts: intakeUpserts.length,
|
||||
intake_tombstones: intakeTombstones.length,
|
||||
cycle_day_upserts: cycleDayUpserts.length,
|
||||
cycle_day_tombstones: cycleDayTombstones.length,
|
||||
cycle_upserts: cycleUpserts.length,
|
||||
cycle_tombstones: cycleTombstones.length,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(parsed.data.cursor),
|
||||
},
|
||||
@@ -380,6 +469,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
mood: { upserts: moodUpserts, tombstones: moodTombstones },
|
||||
intakes: { upserts: intakeUpserts, tombstones: intakeTombstones },
|
||||
cycleDays: {
|
||||
upserts: cycleDayUpserts,
|
||||
tombstones: cycleDayTombstones,
|
||||
},
|
||||
cycles: { upserts: cycleUpserts, tombstones: cycleTombstones },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { CycleView } from "@/components/cycle/cycle-view";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the cycle vertical entry.
|
||||
*
|
||||
* Gated on `cycleTrackingEnabled` from `GET /api/auth/me` (resolved
|
||||
* server-side from gender + the per-user opt-in). An unauthenticated visitor
|
||||
* is bounced to login; an authenticated account without the feature enabled
|
||||
* is bounced home (the nav entry is already hidden for them, so this only
|
||||
* catches a direct URL hit). Every `/api/cycle/*` route also enforces the
|
||||
* gate server-side, so this is a UX redirect, not the security boundary.
|
||||
*/
|
||||
export default function CyclePage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = user?.cycleTrackingEnabled === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CycleView />;
|
||||
}
|
||||
+123
-6
@@ -238,6 +238,17 @@
|
||||
--ring-red-from: #c93a2a;
|
||||
--ring-red-to: #ff7a6b;
|
||||
|
||||
/*
|
||||
* v1.15.0 — cycle-phase ring hues (Dracula dark). Four calm, distinct
|
||||
* tones for the cycle wheel: menstrual rose · follicular green · ovulatory
|
||||
* amber-gold · luteal violet. Tuned to read as a soft instrument, never a
|
||||
* gaudy rainbow — the same restraint as the wellness rings above.
|
||||
*/
|
||||
--cycle-phase-menstrual: #ff6e9c;
|
||||
--cycle-phase-follicular: #5cf08f;
|
||||
--cycle-phase-ovulatory: #ffce6b;
|
||||
--cycle-phase-luteal: #bd93f9;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -312,6 +323,17 @@
|
||||
--ring-red-from: #a3271a;
|
||||
--ring-red-to: #cb3a2a;
|
||||
|
||||
/*
|
||||
* v1.15.0 — cycle-phase ring hues (Alucard light). Deeper + more
|
||||
* saturated than the dark set so each phase holds AA-grade presence on the
|
||||
* white card. Same four distinct hues: menstrual rose · follicular green ·
|
||||
* ovulatory amber · luteal violet.
|
||||
*/
|
||||
--cycle-phase-menstrual: #c8295e;
|
||||
--cycle-phase-follicular: #0f7a3a;
|
||||
--cycle-phase-ovulatory: #a34d14;
|
||||
--cycle-phase-luteal: #644ac9;
|
||||
|
||||
/*
|
||||
* Status-pill text in light mode (Alucard). The bright Dracula
|
||||
* green / orange / red read 1.2–2.7:1 on a near-white tint; these
|
||||
@@ -466,7 +488,8 @@
|
||||
color-mix(in srgb, var(--tile-hue) 7%, var(--card)) 100%
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent);
|
||||
box-shadow: 0 10px 30px -26px color-mix(in srgb, var(--tile-hue) 40%, transparent);
|
||||
box-shadow: 0 10px 30px -26px
|
||||
color-mix(in srgb, var(--tile-hue) 40%, transparent);
|
||||
transition:
|
||||
transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
box-shadow 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
@@ -494,7 +517,8 @@
|
||||
.wellness-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent);
|
||||
box-shadow: 0 16px 40px -24px color-mix(in srgb, var(--tile-hue) 52%, transparent);
|
||||
box-shadow: 0 16px 40px -24px
|
||||
color-mix(in srgb, var(--tile-hue) 52%, transparent);
|
||||
}
|
||||
.wellness-tile:hover [data-slot="score-ring"] {
|
||||
transform: scale(1.015);
|
||||
@@ -530,7 +554,18 @@
|
||||
— the Oura/WHOOP fill feel. Falls back to the browser default on very old
|
||||
engines that ignore linear() (instant fill, not broken). */
|
||||
--ring-spring: linear(
|
||||
0, 0.06, 0.23, 0.46, 0.7, 0.89, 1.02, 1.06, 1.03, 1, 0.99, 1
|
||||
0,
|
||||
0.06,
|
||||
0.23,
|
||||
0.46,
|
||||
0.7,
|
||||
0.89,
|
||||
1.02,
|
||||
1.06,
|
||||
1.03,
|
||||
1,
|
||||
0.99,
|
||||
1
|
||||
);
|
||||
--ring-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
@@ -610,7 +645,9 @@
|
||||
on the strongest (green-band) ring so the strip celebrates a good score
|
||||
rather than jittering all five. `--ring-glow` is set inline per ring. */
|
||||
.wellness-ring-arc {
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent));
|
||||
filter: drop-shadow(
|
||||
0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)
|
||||
);
|
||||
}
|
||||
.wellness-ring-arc--pulse {
|
||||
/* Breathe a few times to celebrate a strong score, then settle — an
|
||||
@@ -621,10 +658,14 @@
|
||||
@keyframes ringGlow {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent));
|
||||
filter: drop-shadow(
|
||||
0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)
|
||||
);
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent));
|
||||
filter: drop-shadow(
|
||||
0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,3 +722,79 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* v1.15.0 — the cycle-ring "signature reveal".
|
||||
*
|
||||
* The phase dial earns the same premium signature as the wellness rings
|
||||
* (sweep + glow + sheen + count-up) without losing its own identity as a
|
||||
* four-phase dial:
|
||||
* 1. a clockwise CLOCK-WIPE draws the phase arcs on from the 12-o'clock
|
||||
* day-0 origin (the <svg> already carries `-rotate-90`, so the conic
|
||||
* origin lines up with both the arcs and the current-day marker);
|
||||
* 2. a phase-tinted bloom on the active arc (shared `.wellness-ring-arc`,
|
||||
* `--ring-glow` set inline to the active phase hue);
|
||||
* 3. the shared `.wellness-ring-sheen` specular pass + `.wellness-tile`
|
||||
* surface the strip already uses;
|
||||
* 4. a soft halo pulse on the current-day marker so "today" reads as alive.
|
||||
* All gated on the host's `data-revealed` (set once per session) so a
|
||||
* background calendar refetch never re-triggers the moment, and all zeroed
|
||||
* under prefers-reduced-motion.
|
||||
*/
|
||||
@property --cycle-wipe {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 360deg;
|
||||
}
|
||||
|
||||
.cycle-ring-reveal {
|
||||
-webkit-mask: conic-gradient(#000 var(--cycle-wipe), transparent 0);
|
||||
mask: conic-gradient(#000 var(--cycle-wipe), transparent 0);
|
||||
}
|
||||
[data-revealed="true"] .cycle-ring-reveal {
|
||||
animation: cycleRingWipe 1100ms var(--ring-ease) forwards;
|
||||
}
|
||||
@keyframes cycleRingWipe {
|
||||
from {
|
||||
--cycle-wipe: 0deg;
|
||||
}
|
||||
to {
|
||||
--cycle-wipe: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
/* The current-day marker's halo — a few gentle pulses on reveal, then calm
|
||||
(a finite count, never an infinite repaint of the non-compositable ring). */
|
||||
.cycle-ring-marker-halo {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
opacity: 0;
|
||||
}
|
||||
[data-revealed="true"] .cycle-ring-marker-halo {
|
||||
animation: cycleMarkerHalo 2.4s ease-in-out 950ms 3;
|
||||
}
|
||||
@keyframes cycleMarkerHalo {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(1.9);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cycle-ring-reveal,
|
||||
[data-revealed="true"] .cycle-ring-reveal {
|
||||
-webkit-mask: none;
|
||||
mask: none;
|
||||
animation: none;
|
||||
}
|
||||
.cycle-ring-marker-halo,
|
||||
[data-revealed="true"] .cycle-ring-marker-halo {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,8 @@ export function BackupsSection() {
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
cycles?: number;
|
||||
cycleDayLogs?: number;
|
||||
};
|
||||
};
|
||||
},
|
||||
@@ -242,7 +244,9 @@ export function BackupsSection() {
|
||||
data.summary.measurements +
|
||||
data.summary.medications +
|
||||
data.summary.intakeEvents +
|
||||
data.summary.moodEntries;
|
||||
data.summary.moodEntries +
|
||||
(data.summary.cycles ?? 0) +
|
||||
(data.summary.cycleDayLogs ?? 0);
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { BbtChart } from "../bbt-chart";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function day(
|
||||
date: string,
|
||||
overrides: Partial<CalendarDay> = {},
|
||||
): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase: "FOLLICULAR",
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<BbtChart>", () => {
|
||||
const today = "2026-06-10";
|
||||
|
||||
it("shows the empty hint when fewer than two readings exist", () => {
|
||||
const days = [
|
||||
day("2026-06-01", { phase: "MENSTRUAL" }),
|
||||
day("2026-06-09", { basalBodyTempC: 36.5 }),
|
||||
];
|
||||
const html = render(
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
predictedOvulation={null}
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-bbt-empty"');
|
||||
expect(html).not.toContain('data-slot="cycle-bbt-area"');
|
||||
});
|
||||
|
||||
it("draws the curve once two or more readings are present", () => {
|
||||
const days = [
|
||||
day("2026-06-01", { phase: "MENSTRUAL", basalBodyTempC: 36.4 }),
|
||||
day("2026-06-05", { phase: "FOLLICULAR", basalBodyTempC: 36.5 }),
|
||||
day("2026-06-08", { phase: "OVULATORY", basalBodyTempC: 36.7 }),
|
||||
day("2026-06-10", { phase: "LUTEAL", basalBodyTempC: 36.9 }),
|
||||
];
|
||||
const html = render(
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
predictedOvulation="2026-06-08"
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-bbt-chart"');
|
||||
expect(html).toContain('data-slot="cycle-bbt-caption"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CycleCalendar } from "../cycle-calendar";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function dayBase(date: string): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase: null,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<CycleCalendar>", () => {
|
||||
const today = "2026-06-15";
|
||||
|
||||
it("renders the month grid for today's month", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-calendar"');
|
||||
expect(html).toContain('role="grid"');
|
||||
// The current day cell is marked.
|
||||
expect(html).toContain('aria-current="date"');
|
||||
});
|
||||
|
||||
it("labels a logged-period day in its aria-label", () => {
|
||||
const days = [{ ...dayBase("2026-06-10"), isPeriodLogged: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Logged period");
|
||||
});
|
||||
|
||||
it("renders the predicted period as a band, not a logged pip", () => {
|
||||
const days = [{ ...dayBase("2026-06-20"), isPredictedPeriod: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
// The predicted band uses a dashed gradient underline + the aria marker.
|
||||
expect(html).toContain("Predicted period");
|
||||
expect(html).toContain("repeating-linear-gradient");
|
||||
});
|
||||
|
||||
it("labels a fertile-window day (only present when goal-gated server allows it)", () => {
|
||||
const days = [{ ...dayBase("2026-06-12"), isFertileWindow: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("renders a localized Monday-first weekday header row", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('role="columnheader"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CycleRing } from "../cycle-ring";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CycleRing>", () => {
|
||||
it("renders the root data-slot tagged with the active phase", () => {
|
||||
const html = render(
|
||||
<CycleRing
|
||||
dayOfCycle={8}
|
||||
cycleLength={28}
|
||||
phase="FOLLICULAR"
|
||||
spans={[
|
||||
{ phase: "MENSTRUAL", fraction: 0.18 },
|
||||
{ phase: "FOLLICULAR", fraction: 0.32 },
|
||||
{ phase: "OVULATORY", fraction: 0.1 },
|
||||
{ phase: "LUTEAL", fraction: 0.4 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-ring"');
|
||||
expect(html).toContain('data-phase="FOLLICULAR"');
|
||||
});
|
||||
|
||||
it("shows the day-of-cycle number as centred text", () => {
|
||||
const html = render(
|
||||
<CycleRing dayOfCycle={12} cycleLength={28} phase="OVULATORY" />,
|
||||
);
|
||||
expect(html).toContain(">12<");
|
||||
});
|
||||
|
||||
it("carries a phase-aware aria-label (never colour-only)", () => {
|
||||
const html = render(
|
||||
<CycleRing dayOfCycle={3} cycleLength={28} phase="MENSTRUAL" />,
|
||||
);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain("aria-label");
|
||||
// The label restates the day + phase.
|
||||
expect(html).toContain("3");
|
||||
});
|
||||
|
||||
it("parks the day-1 marker at the 12-o'clock origin (no double-rotate)", () => {
|
||||
// The <svg> carries `-rotate-90`, so day 1 (progress 0, angle 0) must
|
||||
// land at the un-rotated 3-o'clock SVG point (cx ≈ CX + R, cy ≈ CX) which
|
||||
// renders at the top. The prior `-π/2` bug parked it at cx ≈ CX (9 o'clock
|
||||
// after rotation). R = (100 - 8) / 2 = 46, CX = 50.
|
||||
const html = render(
|
||||
<CycleRing dayOfCycle={1} cycleLength={28} phase="MENSTRUAL" />,
|
||||
);
|
||||
// The marker circle's cx is the last <circle> with a stroke-width of 2.
|
||||
expect(html).toMatch(/cx="9[0-9](\.\d+)?"/);
|
||||
});
|
||||
|
||||
it("renders the no-cycle state for a null day with a fallback aria-label", () => {
|
||||
const html = render(
|
||||
<CycleRing dayOfCycle={null} cycleLength={null} phase={null} />,
|
||||
);
|
||||
expect(html).toContain('data-phase="none"');
|
||||
expect(html).toContain('role="img"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
buildDayLogPatch,
|
||||
buildDayLogInput,
|
||||
type DayLogFormState,
|
||||
} from "../log-day-sheet";
|
||||
|
||||
function blank(): DayLogFormState {
|
||||
return {
|
||||
flow: null,
|
||||
intermenstrual: false,
|
||||
bbt: "",
|
||||
opk: null,
|
||||
mucus: null,
|
||||
intercourse: false,
|
||||
protectedSex: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
note: "",
|
||||
symptoms: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDayLogPatch (edit → clear semantics, QA W-2)", () => {
|
||||
it("sends explicit null for every deselected enum so an edit clears it", () => {
|
||||
const patch = buildDayLogPatch(blank());
|
||||
// A blank form on an EXISTING row must null every field, not omit it —
|
||||
// omission would leave the stored value behind (the bug this fixes).
|
||||
expect(patch.flow).toBeNull();
|
||||
expect(patch.ovulationTest).toBeNull();
|
||||
expect(patch.cervicalMucus).toBeNull();
|
||||
expect(patch.pregnancyTest).toBeNull();
|
||||
expect(patch.progesteroneTest).toBeNull();
|
||||
expect(patch.contraceptive).toBeNull();
|
||||
expect(patch.basalBodyTempC).toBeNull();
|
||||
expect(patch.note).toBeNull();
|
||||
// Keys are PRESENT (not omitted) so the server actually applies the clear.
|
||||
expect("flow" in patch).toBe(true);
|
||||
expect("cervicalMucus" in patch).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the set values through and gates protection on intercourse", () => {
|
||||
const patch = buildDayLogPatch({
|
||||
...blank(),
|
||||
flow: "MEDIUM",
|
||||
opk: "POSITIVE_LH_SURGE",
|
||||
bbt: "36.62",
|
||||
intercourse: true,
|
||||
protectedSex: true,
|
||||
note: " hello ",
|
||||
symptoms: new Map([["cramps", 3]]),
|
||||
});
|
||||
expect(patch.flow).toBe("MEDIUM");
|
||||
expect(patch.ovulationTest).toBe("POSITIVE_LH_SURGE");
|
||||
expect(patch.basalBodyTempC).toBe(36.62);
|
||||
expect(patch.protectedSex).toBe(true);
|
||||
expect(patch.note).toBe("hello");
|
||||
expect(patch.symptoms).toEqual([{ key: "cramps", severity: 3 }]);
|
||||
});
|
||||
|
||||
it("nulls protection when no intercourse is logged", () => {
|
||||
const patch = buildDayLogPatch({
|
||||
...blank(),
|
||||
intercourse: false,
|
||||
protectedSex: true,
|
||||
});
|
||||
expect(patch.sexualActivity).toBe(false);
|
||||
expect(patch.protectedSex).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDayLogInput (new row → omit empties)", () => {
|
||||
it("omits unset enums but posts an explicit empty note", () => {
|
||||
const input = buildDayLogInput(blank(), "2026-06-06");
|
||||
expect("flow" in input).toBe(false);
|
||||
expect("ovulationTest" in input).toBe(false);
|
||||
expect("basalBodyTempC" in input).toBe(false);
|
||||
expect(input.note).toBe("");
|
||||
expect(input.date).toBe("2026-06-06");
|
||||
expect(input.source).toBe("MANUAL");
|
||||
});
|
||||
|
||||
it("includes set values for a new row", () => {
|
||||
const input = buildDayLogInput(
|
||||
{ ...blank(), flow: "LIGHT", bbt: "36.5" },
|
||||
"2026-06-06",
|
||||
);
|
||||
expect(input.flow).toBe("LIGHT");
|
||||
expect(input.basalBodyTempC).toBe(36.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { PredictionsPanel } from "../predictions-panel";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "../types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const basePrediction: CyclePrediction = {
|
||||
method: "CALENDAR",
|
||||
nextPeriodStart: "2026-07-01",
|
||||
nextPeriodStartLow: "2026-06-29",
|
||||
nextPeriodStartHigh: "2026-07-03",
|
||||
fertileWindowStart: null,
|
||||
fertileWindowEnd: null,
|
||||
predictedOvulation: null,
|
||||
ovulationConfirmed: false,
|
||||
confidence: 0.8,
|
||||
cyclesObserved: 6,
|
||||
stillLearning: false,
|
||||
disclaimer: "Estimates only, not medical advice.",
|
||||
};
|
||||
|
||||
const emptyHistory: CycleHistoryResponse = {
|
||||
cycles: [],
|
||||
stats: {
|
||||
avgLengthDays: null,
|
||||
lengthVariabilityDays: null,
|
||||
avgPeriodLengthDays: null,
|
||||
regularity: "LEARNING",
|
||||
},
|
||||
};
|
||||
|
||||
describe("<PredictionsPanel>", () => {
|
||||
it("renders the next-period range + the fixed disclaimer", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-disclaimer"');
|
||||
expect(html).toContain("Estimates only, not medical advice.");
|
||||
// The range (not a single date) renders.
|
||||
expect(html).toContain("–");
|
||||
});
|
||||
|
||||
it("renders the fertile window only when the prediction carries one", () => {
|
||||
const withFertile: CyclePrediction = {
|
||||
...basePrediction,
|
||||
fertileWindowStart: "2026-06-14",
|
||||
fertileWindowEnd: "2026-06-19",
|
||||
predictedOvulation: "2026-06-17",
|
||||
};
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={withFertile}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Fertile window");
|
||||
expect(html).toContain("Estimated ovulation");
|
||||
});
|
||||
|
||||
it("suppresses fertile-window language when the prediction nulls it (goal-gated server-side)", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("shows the still-learning state for an under-3-cycle prediction", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={{ ...basePrediction, stillLearning: true }}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Still learning");
|
||||
});
|
||||
|
||||
it("shows the raw-chart-mode state when predictions are off", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={null}
|
||||
rawChartMode={true}
|
||||
history={emptyHistory}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Prediction is off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { deriveWheelState, currentCycleStartDate } from "../wheel-state";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
function day(date: string, phase: CalendarDay["phase"]): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: phase === "MENSTRUAL",
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveWheelState", () => {
|
||||
it("returns a null state when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
const w = deriveWheelState(days, "2026-06-02");
|
||||
expect(w.dayOfCycle).toBeNull();
|
||||
expect(w.phase).toBeNull();
|
||||
expect(w.spans).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts the day-of-cycle from the most recent menstrual start", () => {
|
||||
// A run starting 2026-06-01 (menstrual) → today 2026-06-05 is cycle day 5.
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-05");
|
||||
expect(w.dayOfCycle).toBe(5);
|
||||
expect(w.phase).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("builds proportional phase spans summing to ~1", () => {
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "OVULATORY"),
|
||||
day("2026-06-05", "LUTEAL"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-03");
|
||||
const total = w.spans.reduce((s, x) => s + x.fraction, 0);
|
||||
expect(total).toBeCloseTo(1, 5);
|
||||
// Only phases present in the run are emitted.
|
||||
expect(w.spans.map((s) => s.phase)).toContain("MENSTRUAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentCycleStartDate", () => {
|
||||
it("returns the most recent menstrual run-start at/before today", () => {
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
];
|
||||
expect(currentCycleStartDate(days, "2026-06-04")).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("returns null when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
expect(currentCycleStartDate(days, "2026-06-02")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when today is absent from the calendar", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
expect(currentCycleStartDate(days, "2026-06-09")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the basal-body-temperature (BBT) chart.
|
||||
*
|
||||
* Plots the user's logged BBT across the CURRENT cycle (the same window the
|
||||
* wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots,
|
||||
* the symptothermal signal a fertility-aware reader expects. Fertility signs
|
||||
* already logged for a day — egg-white/watery mucus, a positive LH test —
|
||||
* surface in the tooltip so the temperature shift reads in context. When the
|
||||
* goal surfaces a fertile window, the estimated ovulation day is marked with a
|
||||
* reference line.
|
||||
*
|
||||
* Honest data contract: BBT is a LOGGED fact (no estimate), so the curve needs
|
||||
* no disclaimer of its own; the estimated-ovulation marker is the prediction
|
||||
* panel's, which already carries the fixed non-medical disclaimer beside it.
|
||||
* In raw-chart ("Read Your Body") mode the phase tint + ovulation marker are
|
||||
* suppressed — only the neutral temperatures are drawn, no interpretation.
|
||||
*
|
||||
* Recharts (the project's charting lib), Dracula/Alucard tokens, reduced-motion
|
||||
* gated via `isAnimationActive`. Needs ≥2 logged readings to draw; below that
|
||||
* it surfaces a "log your morning temperature" hint rather than a flat dot.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Thermometer } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
|
||||
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import { currentCycleStartDate } from "./wheel-state";
|
||||
|
||||
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
|
||||
const FALLBACK_WINDOW_DAYS = 35;
|
||||
|
||||
const LINE_COLOR = "var(--cycle-phase-luteal)";
|
||||
|
||||
interface BbtPoint {
|
||||
t: number;
|
||||
temp: number;
|
||||
phaseHue: string;
|
||||
mucus: CervicalMucus | null;
|
||||
ovulationTest: OvulationTest | null;
|
||||
}
|
||||
|
||||
export interface BbtChartProps {
|
||||
days: CalendarDay[];
|
||||
today: string;
|
||||
/** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */
|
||||
predictedOvulation: string | null;
|
||||
/** Whether the ovulation day was confirmed by a signal layer (solid marker). */
|
||||
ovulationConfirmed?: boolean;
|
||||
/** Read-Your-Body mode: suppress phase tint + ovulation marker. */
|
||||
rawChartMode: boolean;
|
||||
}
|
||||
|
||||
function ymdToMs(d: string): number {
|
||||
// Noon UTC so a YYYY-MM-DD never rolls a day across the viewer's tz.
|
||||
return Date.parse(`${d}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function BbtChart({
|
||||
days,
|
||||
today,
|
||||
predictedOvulation,
|
||||
ovulationConfirmed = false,
|
||||
rawChartMode,
|
||||
}: BbtChartProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// Scope to the current cycle (wheel window); fall back to a trailing window
|
||||
// when no cycle is active so a user still sees their recent temperatures.
|
||||
const fromDate = useMemo(() => {
|
||||
const start = currentCycleStartDate(days, today);
|
||||
if (start) return start;
|
||||
const fallback = new Date(ymdToMs(today));
|
||||
fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS);
|
||||
return fallback.toISOString().slice(0, 10);
|
||||
}, [days, today]);
|
||||
|
||||
const points = useMemo<BbtPoint[]>(() => {
|
||||
const fromMs = ymdToMs(fromDate);
|
||||
const todayMs = ymdToMs(today);
|
||||
return days
|
||||
.filter((d) => {
|
||||
if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) {
|
||||
return false;
|
||||
}
|
||||
const ms = ymdToMs(d.date);
|
||||
return ms >= fromMs && ms <= todayMs;
|
||||
})
|
||||
.map((d) => ({
|
||||
t: ymdToMs(d.date),
|
||||
temp: d.basalBodyTempC as number,
|
||||
phaseHue:
|
||||
rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase],
|
||||
mucus: d.cervicalMucus,
|
||||
ovulationTest: d.ovulationTest,
|
||||
}))
|
||||
.sort((a, b) => a.t - b.t);
|
||||
}, [days, fromDate, today, rawChartMode]);
|
||||
|
||||
const ovulationMs = useMemo(() => {
|
||||
if (rawChartMode || !predictedOvulation) return null;
|
||||
const ms = ymdToMs(predictedOvulation);
|
||||
if (points.length < 2) return null;
|
||||
const first = points[0].t;
|
||||
const last = points[points.length - 1].t;
|
||||
return ms >= first && ms <= last ? ms : null;
|
||||
}, [predictedOvulation, points, rawChartMode]);
|
||||
|
||||
const yDomain = useMemo<[number, number]>(() => {
|
||||
if (points.length === 0) return [36, 37.5];
|
||||
const temps = points.map((p) => p.temp);
|
||||
const min = Math.min(...temps);
|
||||
const max = Math.max(...temps);
|
||||
// Pad ±0.15 °C so the shift is legible and the line never hugs an edge.
|
||||
return [
|
||||
Math.floor((min - 0.15) * 10) / 10,
|
||||
Math.ceil((max + 0.15) * 10) / 10,
|
||||
];
|
||||
}, [points]);
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
if (points.length < 2) return [];
|
||||
const first = points[0].t;
|
||||
const last = points[points.length - 1].t;
|
||||
const span = last - first;
|
||||
if (span <= 0) return [first];
|
||||
return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f));
|
||||
}, [points]);
|
||||
|
||||
const hasCurve = points.length >= 2;
|
||||
|
||||
return (
|
||||
<Card data-slot="cycle-bbt-chart">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Thermometer className="text-primary h-4 w-4" aria-hidden="true" />
|
||||
{t("cycle.bbt.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasCurve ? (
|
||||
<div
|
||||
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
|
||||
data-slot="cycle-bbt-empty"
|
||||
>
|
||||
<p className="font-medium">{t("cycle.bbt.empty")}</p>
|
||||
<p className="text-xs">{t("cycle.bbt.emptyHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
className="text-muted-foreground mb-2 text-xs"
|
||||
data-slot="cycle-bbt-caption"
|
||||
>
|
||||
{t("cycle.bbt.caption")}
|
||||
</p>
|
||||
<div
|
||||
className="touch-pan-y"
|
||||
style={{ height: "210px" }}
|
||||
data-slot="cycle-bbt-area"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={points}
|
||||
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
ticks={ticks}
|
||||
tickFormatter={(v) => fmt.dateShort(new Date(v as number))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
width={40}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${fmt.number(v as number)}°`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={<BbtTooltip />}
|
||||
/>
|
||||
{ovulationMs != null ? (
|
||||
<ReferenceLine
|
||||
x={ovulationMs}
|
||||
stroke={OVULATION_HUE}
|
||||
// Solid line for a confirmed ovulation (signal-detected),
|
||||
// dashed for the calendar estimate (QA M1).
|
||||
strokeDasharray={ovulationConfirmed ? undefined : "4 3"}
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: ovulationConfirmed
|
||||
? t("cycle.bbt.ovulationConfirmed")
|
||||
: t("cycle.bbt.ovulationEstimate"),
|
||||
position: "insideTopRight",
|
||||
fontSize: 10,
|
||||
// Muted text (not the amber hue) so the tiny label keeps
|
||||
// AA contrast on the card; the line carries the colour (L1).
|
||||
fill: "var(--muted-foreground)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="temp"
|
||||
stroke={LINE_COLOR}
|
||||
strokeWidth={2}
|
||||
dot={<PhaseDot />}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** A line dot tinted to its day's cycle phase (neutral in raw-chart mode). */
|
||||
function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
|
||||
const { cx, cy, payload } = props;
|
||||
if (cx == null || cy == null || !payload) return null;
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={3.5}
|
||||
fill={payload.phaseHue}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tooltip restating the temperature + any fertility signs logged that day. */
|
||||
function BbtTooltip(props: {
|
||||
active?: boolean;
|
||||
payload?: { payload: BbtPoint }[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (!props.active || !props.payload || props.payload.length === 0)
|
||||
return null;
|
||||
const p = props.payload[0].payload;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-popover border-border rounded-md border px-2.5 py-2 text-xs shadow-md",
|
||||
)}
|
||||
>
|
||||
<p className="text-muted-foreground">{fmt.dateShort(new Date(p.t))}</p>
|
||||
<p className="text-foreground font-medium tabular-nums">
|
||||
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
|
||||
</p>
|
||||
{p.mucus ? (
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{t("cycle.mucus.label")}: {t(`cycle.mucus.${p.mucus}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{p.ovulationTest && p.ovulationTest !== "NEGATIVE" ? (
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{t("cycle.ovulationTest.label")}:{" "}
|
||||
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CalendarDay } from "./types";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the month calendar.
|
||||
*
|
||||
* Renders one month at a time over the calendar read's day grid. Logged
|
||||
* period days carry a filled rose pip; the predicted next period renders as
|
||||
* a ranged BAND (a soft rose underline spanning the predicted days, never a
|
||||
* single dated dot); the fertile window (already goal-gated server-side — the
|
||||
* API nulls it unless TRYING_TO_CONCEIVE) renders as a green ring; the
|
||||
* predicted-ovulation day gets an amber marker; per-day symptom presence
|
||||
* shows a small dot. Selecting a day calls `onSelectDay` (the log-day sheet).
|
||||
*
|
||||
* a11y: each day is a real `<button>` with an aria-label restating the date
|
||||
* + its markers; the colour markers are paired with the aria text so the
|
||||
* grid is never colour-only. WCAG 2.5.5 — each cell is ≥ 40 px.
|
||||
*/
|
||||
|
||||
/** Monday-first short weekday labels, localized via Intl (no i18n key). */
|
||||
function shortWeekdays(): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
// 2024-01-01 is a Monday — walk 7 days for a Monday-first header row.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
fmt.format(new Date(2024, 0, 1 + i)),
|
||||
);
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 0=Mon … 6=Sun (Monday-first grid). */
|
||||
function mondayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export interface CycleCalendarProps {
|
||||
days: CalendarDay[];
|
||||
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
|
||||
today: string;
|
||||
onSelectDay: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CycleCalendar({
|
||||
days,
|
||||
today,
|
||||
onSelectDay,
|
||||
className,
|
||||
}: CycleCalendarProps) {
|
||||
const { t } = useTranslations();
|
||||
const weekdays = useMemo(() => shortWeekdays(), []);
|
||||
const byDate = useMemo(() => new Map(days.map((d) => [d.date, d])), [days]);
|
||||
|
||||
// Anchor the visible month on today's month.
|
||||
const [monthAnchor, setMonthAnchor] = useState(() => {
|
||||
const [y, m] = today.split("-").map(Number);
|
||||
return new Date(y, m - 1, 1);
|
||||
});
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const first = new Date(
|
||||
monthAnchor.getFullYear(),
|
||||
monthAnchor.getMonth(),
|
||||
1,
|
||||
);
|
||||
const lead = mondayIndex(first);
|
||||
const daysInMonth = new Date(
|
||||
monthAnchor.getFullYear(),
|
||||
monthAnchor.getMonth() + 1,
|
||||
0,
|
||||
).getDate();
|
||||
const cells: (Date | null)[] = [];
|
||||
for (let i = 0; i < lead; i++) cells.push(null);
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
cells.push(
|
||||
new Date(monthAnchor.getFullYear(), monthAnchor.getMonth(), day),
|
||||
);
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
return cells;
|
||||
}, [monthAnchor]);
|
||||
|
||||
const monthLabel = monthAnchor.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
setMonthAnchor((a) => new Date(a.getFullYear(), a.getMonth() + delta, 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="cycle-calendar" className={cn("space-y-3", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("cycle.calendar.prevMonth")}
|
||||
onClick={() => shiftMonth(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-semibold capitalize">{monthLabel}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("cycle.calendar.nextMonth")}
|
||||
onClick={() => shiftMonth(1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="grid"
|
||||
aria-label={monthLabel}
|
||||
className="grid grid-cols-7 gap-1"
|
||||
>
|
||||
{weekdays.map((wk, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="columnheader"
|
||||
className="text-muted-foreground pb-1 text-center text-[11px] font-medium uppercase"
|
||||
>
|
||||
{wk}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{grid.map((cell, i) => {
|
||||
if (!cell) return <div key={`empty-${i}`} aria-hidden="true" />;
|
||||
const date = ymd(cell);
|
||||
const info = byDate.get(date);
|
||||
const isToday = date === today;
|
||||
const markers: string[] = [];
|
||||
if (info?.isPeriodLogged)
|
||||
markers.push(t("cycle.calendar.legendPeriod"));
|
||||
if (info?.isPredictedPeriod)
|
||||
markers.push(t("cycle.calendar.legendPredicted"));
|
||||
if (info?.isFertileWindow)
|
||||
markers.push(t("cycle.calendar.legendFertile"));
|
||||
if (info?.isPredictedOvulation)
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-label={aria}
|
||||
aria-current={isToday ? "date" : undefined}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
"relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
info?.isFertileWindow && "ring-1 ring-inset",
|
||||
isToday && "font-semibold",
|
||||
)}
|
||||
style={
|
||||
info?.isFertileWindow
|
||||
? ({ "--tw-ring-color": FERTILE_HUE } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Logged-period filled pip behind the number. */}
|
||||
{info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md opacity-20"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
<span className={cn("relative z-10", isToday && "text-primary")}>
|
||||
{cell.getDate()}
|
||||
</span>
|
||||
|
||||
{/* Predicted-period band: a soft underline, never a dot. */}
|
||||
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-1.5 bottom-1 h-0.5 rounded-full opacity-70"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
backgroundImage: `repeating-linear-gradient(90deg, ${FLOW_HUE} 0 3px, transparent 3px 5px)`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Marker row: ovulation + symptom dots. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
|
||||
>
|
||||
{info?.isPredictedOvulation ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: OVULATION_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{info?.hasSymptoms ? (
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<CalendarLegend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarLegend() {
|
||||
const { t } = useTranslations();
|
||||
// Each swatch mirrors its grid affordance: period = filled, predicted =
|
||||
// dashed, fertile = a ring (not a filled dot), ovulation = filled, symptom =
|
||||
// the small grey marker dot the grid draws (QA M4).
|
||||
const items: {
|
||||
hue: string;
|
||||
labelKey: string;
|
||||
variant: "fill" | "dashed" | "ring" | "dot";
|
||||
}[] = [
|
||||
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
|
||||
{
|
||||
hue: FLOW_HUE,
|
||||
labelKey: "cycle.calendar.legendPredicted",
|
||||
variant: "dashed",
|
||||
},
|
||||
{
|
||||
hue: FERTILE_HUE,
|
||||
labelKey: "cycle.calendar.legendFertile",
|
||||
variant: "ring",
|
||||
},
|
||||
{
|
||||
hue: OVULATION_HUE,
|
||||
labelKey: "cycle.calendar.legendOvulation",
|
||||
variant: "fill",
|
||||
},
|
||||
{
|
||||
hue: "var(--muted-foreground)",
|
||||
labelKey: "cycle.calendar.legendSymptom",
|
||||
variant: "dot",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
|
||||
{items.map((it) => (
|
||||
<li key={it.labelKey} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
it.variant === "dot" ? "h-1 w-1" : "h-2.5 w-2.5",
|
||||
it.variant === "dashed" && "opacity-60",
|
||||
)}
|
||||
style={
|
||||
it.variant === "ring"
|
||||
? { border: `1.5px solid ${it.hue}` }
|
||||
: { backgroundColor: it.hue }
|
||||
}
|
||||
/>
|
||||
{t(it.labelKey)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the fixed non-medical disclaimer for every prediction / fertility
|
||||
* surface. Mirrors the GLP-1 drug-level-estimate disclaimer pattern
|
||||
* (`DrugLevelChart`): a muted, border-left note that stays attached wherever
|
||||
* an estimate renders. The copy is descriptive-not-a-contraceptive,
|
||||
* not-medical-advice — never a green/safe claim. The text comes from the
|
||||
* server (`cycle.prediction.disclaimer`, surfaced on the prediction DTO) so
|
||||
* the web + iOS + the engine speak the same words.
|
||||
*/
|
||||
export function CycleDisclaimer({
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
data-slot="cycle-disclaimer"
|
||||
className={cn(
|
||||
"text-foreground/80 border-border bg-muted/40 rounded-md border-l-2 px-3 py-2 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.15 — CYCLE_PHASE × vital crosstab card.
|
||||
*
|
||||
* Mirrors the mood-factor × vital crosstab (`mood-factor-metric-crosstab.tsx`):
|
||||
* for each outcome metric, the vital's mean on LUTEAL-phase days vs
|
||||
* FOLLICULAR-phase days, the signed delta, n per group, q, and a confidence
|
||||
* chip. The math is pre-computed in `src/lib/cycle/phase-crosstab.ts`
|
||||
* (`computePhaseMetricCrosstab`) — the same Welch t-test + day floors +
|
||||
* Benjamini-Hochberg FDR the rest of the relations surface uses; only
|
||||
* FDR-surviving rows reach here. Observational only — the page-level
|
||||
* "associations, not causes" footer carries the caveat once. This surface is
|
||||
* gender-gated by its `/api/cycle/insights` route; phase never renders on the
|
||||
* general correlations surface.
|
||||
*/
|
||||
|
||||
export type CyclePhaseCrosstabDisplay =
|
||||
| "hours"
|
||||
| "steps"
|
||||
| "bpm"
|
||||
| "ms"
|
||||
| "kg"
|
||||
| "celsius";
|
||||
|
||||
export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high";
|
||||
|
||||
export interface CyclePhaseCrosstabRow {
|
||||
metricKey: string;
|
||||
display: CyclePhaseCrosstabDisplay;
|
||||
lutealDays: number;
|
||||
follicularDays: number;
|
||||
lutealAvg: number;
|
||||
follicularAvg: number;
|
||||
delta: number;
|
||||
pValue: number;
|
||||
qValue: number;
|
||||
confidence: CyclePhaseCrosstabConfidence;
|
||||
}
|
||||
|
||||
const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.crosstab.metricRestingHeartRate",
|
||||
heartRateVariability: "cycle.insights.crosstab.metricHrv",
|
||||
sleepDuration: "cycle.insights.crosstab.metricSleepDuration",
|
||||
steps: "cycle.insights.crosstab.metricSteps",
|
||||
weight: "cycle.insights.crosstab.metricWeight",
|
||||
basalBodyTemp: "cycle.insights.crosstab.metricBasalBodyTemp",
|
||||
wristTemperature: "cycle.insights.crosstab.metricWristTemperature",
|
||||
skinTemperature: "cycle.insights.crosstab.metricSkinTemperature",
|
||||
};
|
||||
|
||||
const UNIT_KEY: Record<CyclePhaseCrosstabDisplay, string> = {
|
||||
hours: "cycle.insights.crosstab.unitHours",
|
||||
steps: "cycle.insights.crosstab.unitSteps",
|
||||
bpm: "cycle.insights.crosstab.unitBpm",
|
||||
ms: "cycle.insights.crosstab.unitMs",
|
||||
kg: "cycle.insights.crosstab.unitKg",
|
||||
celsius: "cycle.insights.crosstab.unitCelsius",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<CyclePhaseCrosstabConfidence, string> = {
|
||||
low: "cycle.insights.crosstab.confidenceLow",
|
||||
medium: "cycle.insights.crosstab.confidenceMedium",
|
||||
high: "cycle.insights.crosstab.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<CyclePhaseCrosstabConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-info/15 text-info",
|
||||
high: "bg-success/15 text-success",
|
||||
};
|
||||
|
||||
/** Whole numbers for steps; one decimal otherwise. */
|
||||
function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
|
||||
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
|
||||
}
|
||||
|
||||
const HEADLINE_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.headline.restingHeartRate",
|
||||
heartRateVariability: "cycle.insights.headline.heartRateVariability",
|
||||
};
|
||||
|
||||
/**
|
||||
* The single headline finding rendered prominently above the grid. Resting
|
||||
* heart rate / HRV get bespoke phrasing; every other metric uses the generic
|
||||
* line. Returns null when nothing cleared the FDR gate — an honest empty state.
|
||||
*/
|
||||
export function CyclePhaseHeadline({
|
||||
headline,
|
||||
}: {
|
||||
headline: CyclePhaseCrosstabRow | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (!headline) {
|
||||
return (
|
||||
<p
|
||||
data-slot="cycle-phase-headline-empty"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("cycle.insights.headline.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
|
||||
);
|
||||
const unit = t(UNIT_KEY[headline.display]);
|
||||
const up = headline.delta >= 0;
|
||||
const dir = t(
|
||||
up
|
||||
? "cycle.insights.headline.dirHigher"
|
||||
: "cycle.insights.headline.dirLower",
|
||||
);
|
||||
const params = {
|
||||
metric: metricLabel,
|
||||
delta: fmt(Math.abs(headline.delta), headline.display),
|
||||
unit,
|
||||
dir,
|
||||
};
|
||||
const key =
|
||||
HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic";
|
||||
return (
|
||||
<p
|
||||
data-slot="cycle-phase-headline"
|
||||
data-metric={headline.metricKey}
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t(key, params)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CyclePhaseCrosstab({
|
||||
rows,
|
||||
}: {
|
||||
rows: CyclePhaseCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="cycle-phase-crosstab">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("cycle.insights.crosstab.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{rows.map((row) => {
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
|
||||
// higher in the luteal phase. The number stays NEUTRAL — higher is
|
||||
// good for steps but bad for resting HR, and this board is
|
||||
// observational, so colouring it would imply a verdict the data
|
||||
// doesn't support. The sign prefix + `data-direction` carry it.
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
return (
|
||||
<li
|
||||
key={row.metricKey}
|
||||
className="flex flex-col gap-1.5 py-2"
|
||||
data-slot="cycle-phase-crosstab-row"
|
||||
data-metric={row.metricKey}
|
||||
data-direction={up ? "up" : "down"}
|
||||
data-confidence={row.confidence}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={metricLabel}
|
||||
>
|
||||
{t("cycle.insights.crosstab.pairLabel", {
|
||||
metric: metricLabel,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.insights.crosstab.detail", {
|
||||
metric: metricLabel,
|
||||
lutealAvg: fmt(row.lutealAvg, row.display),
|
||||
follicularAvg: fmt(row.follicularAvg, row.display),
|
||||
unit,
|
||||
lutealDays: row.lutealDays,
|
||||
follicularDays: row.follicularDays,
|
||||
})}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("cycle.insights.crosstab.footer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCountUp } from "@/hooks/use-count-up";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import type { CyclePhase } from "./types";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the cycle wheel: a hand-rolled SVG phase ring (0 KB runtime, no
|
||||
* chart lib) that carries the same premium "signature reveal" as the wellness
|
||||
* rings while keeping its own identity as a FULL-circle four-phase dial. The
|
||||
* four cycle phases render as proportional arc segments around the ring; a
|
||||
* marker dot sits at the current day-of-cycle; the day number is real centred
|
||||
* DOM text.
|
||||
*
|
||||
* Motion (all CSS-first, all gated on `prefers-reduced-motion` AND on the
|
||||
* host's `data-revealed`, which the view sets ONCE per session so a background
|
||||
* calendar refetch never re-triggers the moment):
|
||||
* • a clockwise CLOCK-WIPE (`.cycle-ring-reveal`) draws the arcs on from the
|
||||
* 12-o'clock day-0 origin — the <svg> already carries `-rotate-90`, so the
|
||||
* conic mask origin lines up with both the arcs and the marker;
|
||||
* • the active phase arc carries the shared `.wellness-ring-arc` bloom,
|
||||
* `--ring-glow` set inline to the active phase hue;
|
||||
* • the shared `.wellness-ring-sheen` sweeps a single specular pass;
|
||||
* • the day number eases up with `useCountUp`;
|
||||
* • the marker gets a soft `.cycle-ring-marker-halo` pulse.
|
||||
*
|
||||
* a11y: `role="img"` + a phase-aware aria-label so the ring is never
|
||||
* colour-only. The arcs/marker are real SVG JSX (`<linearGradient>` would not
|
||||
* be `dangerouslySetInnerHTML`); the centred number is real DOM text.
|
||||
*/
|
||||
|
||||
const VIEW = 100;
|
||||
const STROKE = 8;
|
||||
const R = (VIEW - STROKE) / 2;
|
||||
const C = 2 * Math.PI * R;
|
||||
const CX = VIEW / 2;
|
||||
const GAP = 0.5; // unit gap between segments so phases read as distinct arcs.
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface PhaseSpan {
|
||||
phase: CyclePhase;
|
||||
/** Proportion of the cycle this phase covers (0..1). Spans sum to ~1. */
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export interface CycleRingProps {
|
||||
/** 1-based day of the current cycle, or null when no cycle is active. */
|
||||
dayOfCycle: number | null;
|
||||
/** Total length the ring represents (estimated cycle length, days). */
|
||||
cycleLength: number | null;
|
||||
/** The current phase (drives the centre label + aria). */
|
||||
phase: CyclePhase | null;
|
||||
/** Proportional phase spans around the ring. Omit for an even quarter split. */
|
||||
spans?: PhaseSpan[];
|
||||
/** Whether the once-per-session reveal plays (false = paint final). */
|
||||
animate?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function evenSpans(): PhaseSpan[] {
|
||||
return PHASE_ORDER.map((phase) => ({ phase, fraction: 0.25 }));
|
||||
}
|
||||
|
||||
export function CycleRing({
|
||||
dayOfCycle,
|
||||
cycleLength,
|
||||
phase,
|
||||
spans,
|
||||
animate = false,
|
||||
size = 220,
|
||||
className,
|
||||
}: CycleRingProps) {
|
||||
const { t } = useTranslations();
|
||||
const reduced = prefersReducedMotion();
|
||||
const shouldAnimate = animate && !reduced;
|
||||
|
||||
const resolvedSpans = useMemo(() => {
|
||||
const raw = spans && spans.length > 0 ? spans : evenSpans();
|
||||
const total = raw.reduce((s, x) => s + x.fraction, 0) || 1;
|
||||
return raw.map((x) => ({ ...x, fraction: x.fraction / total }));
|
||||
}, [spans]);
|
||||
|
||||
// Build each phase arc as a dash segment around the ring. The running
|
||||
// start-offset is carried through `reduce` rather than mutating a closure
|
||||
// variable inside `map` (the latter trips the render-immutability rule).
|
||||
const segments = useMemo(
|
||||
() =>
|
||||
resolvedSpans.reduce<{
|
||||
offset: number;
|
||||
segs: {
|
||||
phase: CyclePhase;
|
||||
dashArray: string;
|
||||
dashOffset: number;
|
||||
active: boolean;
|
||||
}[];
|
||||
}>(
|
||||
(acc, span) => {
|
||||
const len = Math.max(span.fraction * C - GAP, 0);
|
||||
acc.segs.push({
|
||||
phase: span.phase,
|
||||
dashArray: `${len} ${C - len}`,
|
||||
dashOffset: -acc.offset,
|
||||
active: span.phase === phase,
|
||||
});
|
||||
return {
|
||||
offset: acc.offset + span.fraction * C,
|
||||
segs: acc.segs,
|
||||
};
|
||||
},
|
||||
{ offset: 0, segs: [] },
|
||||
).segs,
|
||||
[resolvedSpans, phase],
|
||||
);
|
||||
|
||||
// The marker sits at the cycle-day proportion around the circle. The
|
||||
// surrounding <svg> already carries `-rotate-90`, so 0 rad here maps to
|
||||
// 12 o'clock — day 0 must NOT subtract another π/2 (the prior double-rotate
|
||||
// bug parked the marker at 9 o'clock). Day 1 is treated as the 12-o'clock
|
||||
// origin (1-based dayOfCycle → 0-based progress).
|
||||
const markerProgress =
|
||||
dayOfCycle != null && cycleLength != null && cycleLength > 0
|
||||
? Math.min((dayOfCycle - 1) / cycleLength, 1)
|
||||
: null;
|
||||
const markerAngle =
|
||||
markerProgress != null ? markerProgress * 2 * Math.PI : null;
|
||||
const markerX = markerAngle != null ? CX + R * Math.cos(markerAngle) : 0;
|
||||
const markerY = markerAngle != null ? CX + R * Math.sin(markerAngle) : 0;
|
||||
|
||||
// The day number eases up on the reveal, mirroring the score-ring count-up.
|
||||
const displayedDay = useCountUp(dayOfCycle ?? 0, {
|
||||
enabled: shouldAnimate && dayOfCycle != null,
|
||||
durationMs: 900,
|
||||
startDelayMs: 220,
|
||||
});
|
||||
|
||||
const phaseLabel = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none");
|
||||
|
||||
const ariaLabel =
|
||||
dayOfCycle != null
|
||||
? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseLabel })
|
||||
: t("cycle.ring.ariaUnknown");
|
||||
|
||||
const markerHue = phase ? PHASE_HUE[phase] : "var(--foreground)";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cycle-ring"
|
||||
data-phase={phase ?? "none"}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
// The bloom on the active arc is tinted to the live phase hue.
|
||||
"--ring-glow": markerHue,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
||||
className="cycle-ring-reveal h-full w-full -rotate-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Faint segmented tick track — reads as an instrument. */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeOpacity={0.12}
|
||||
strokeWidth={STROKE}
|
||||
strokeDasharray="0.5 4.2"
|
||||
/>
|
||||
{/* Phase arcs — one calm hue per phase, the active phase fully opaque
|
||||
and carrying the shared wellness-ring bloom. */}
|
||||
{segments.map((seg) => (
|
||||
<circle
|
||||
key={seg.phase}
|
||||
className={cn(seg.active && "wellness-ring-arc")}
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke={PHASE_HUE[seg.phase]}
|
||||
strokeOpacity={seg.active ? 1 : 0.32}
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={seg.dashArray}
|
||||
strokeDashoffset={seg.dashOffset}
|
||||
style={{
|
||||
transition: reduced ? undefined : "stroke-opacity 400ms ease",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Current-day marker: a soft halo pulse behind a solid dot. */}
|
||||
{markerAngle != null ? (
|
||||
<>
|
||||
<circle
|
||||
className="cycle-ring-marker-halo"
|
||||
cx={markerX}
|
||||
cy={markerY}
|
||||
r={STROKE / 1.4}
|
||||
fill="none"
|
||||
stroke={markerHue}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
<circle
|
||||
cx={markerX}
|
||||
cy={markerY}
|
||||
r={STROKE / 1.7}
|
||||
fill="var(--background)"
|
||||
stroke={markerHue}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
{/* Conic specular sheen — sweeps the dial once on the reveal. */}
|
||||
<div className="wellness-ring-sheen" aria-hidden="true" />
|
||||
{/* Centred day number + phase caption as real DOM text. */}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">
|
||||
{dayOfCycle != null
|
||||
? Math.round(displayedDay)
|
||||
: t("cycle.ring.dayUnknown")}
|
||||
</span>
|
||||
<span className="text-muted-foreground mt-0.5 text-xs">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { useUpdateCyclePrefs, useDeleteAllCycleData } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the single-page cycle settings form.
|
||||
*
|
||||
* One dynamic form, no top/bottom split (the project's settings-UX rule):
|
||||
* the inclusive goal selector drives all copy — fertile-window language only
|
||||
* appears for TRYING_TO_CONCEIVE; GENERAL_HEALTH / PERIMENOPAUSE / OFF hide
|
||||
* conception framing. Priors, raw-chart mode, sensitive-category encryption,
|
||||
* discreet notifications, and the data export/delete affordances all live on
|
||||
* the one page. PATCHes `/api/auth/me/cycle-prefs`.
|
||||
*
|
||||
* The form seeds its local state from `profile` once on mount; the parent
|
||||
* passes `key={profile.updatedAt}` so a profile refetch remounts the form
|
||||
* with the fresh values rather than syncing through an effect.
|
||||
*/
|
||||
|
||||
const GOALS: CycleGoal[] = [
|
||||
"GENERAL_HEALTH",
|
||||
"AVOID_PREGNANCY",
|
||||
"TRYING_TO_CONCEIVE",
|
||||
"PERIMENOPAUSE",
|
||||
"OFF",
|
||||
];
|
||||
|
||||
function numOrEmpty(v: number | null): string {
|
||||
return v == null ? "" : String(v);
|
||||
}
|
||||
|
||||
export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
|
||||
const { t } = useTranslations();
|
||||
const update = useUpdateCyclePrefs();
|
||||
const purge = useDeleteAllCycleData();
|
||||
const [confirmingPurge, setConfirmingPurge] = useState(false);
|
||||
|
||||
const [goal, setGoal] = useState<CycleGoal>(profile.goal);
|
||||
const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode);
|
||||
const [predictionEnabled, setPredictionEnabled] = useState(
|
||||
profile.predictionEnabled,
|
||||
);
|
||||
const [encryption, setEncryption] = useState(
|
||||
profile.sensitiveCategoryEncryption,
|
||||
);
|
||||
const [discreet, setDiscreet] = useState(profile.discreetNotifications);
|
||||
const [cycleLen, setCycleLen] = useState(
|
||||
numOrEmpty(profile.typicalCycleLength),
|
||||
);
|
||||
const [periodLen, setPeriodLen] = useState(
|
||||
numOrEmpty(profile.typicalPeriodLength),
|
||||
);
|
||||
const [lutealLen, setLutealLen] = useState(
|
||||
numOrEmpty(profile.lutealPhaseLength),
|
||||
);
|
||||
|
||||
function parsePrior(v: string): number | null {
|
||||
const n = Number(v);
|
||||
return v.trim() === "" || !Number.isFinite(n) ? null : Math.round(n);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await update.mutateAsync({
|
||||
goal,
|
||||
rawChartMode,
|
||||
predictionEnabled,
|
||||
sensitiveCategoryEncryption: encryption,
|
||||
discreetNotifications: discreet,
|
||||
typicalCycleLength: parsePrior(cycleLen),
|
||||
typicalPeriodLength: parsePrior(periodLen),
|
||||
lutealPhaseLength: parsePrior(lutealLen),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("cycle.settings.title")}</CardTitle>
|
||||
<CardDescription>{t("cycle.goal.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Inclusive goal selector — drives the rest of the copy. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cycle-goal">{t("cycle.goal.label")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-goal"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value as CycleGoal)}
|
||||
>
|
||||
{GOALS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{t(`cycle.goal.${g}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Predictions — when the goal hides conception framing, the
|
||||
description never names a fertile window. */}
|
||||
<ToggleRow
|
||||
id="cycle-prediction"
|
||||
label={t("cycle.settings.prediction")}
|
||||
description={t("cycle.settings.predictionDescription")}
|
||||
checked={predictionEnabled}
|
||||
onChange={setPredictionEnabled}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="cycle-rawchart"
|
||||
label={t("cycle.settings.rawChartMode")}
|
||||
description={t("cycle.settings.rawChartModeDescription")}
|
||||
checked={rawChartMode}
|
||||
onChange={setRawChartMode}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Priors */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("cycle.settings.priorsDescription")}
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<PriorField
|
||||
id="cycle-typical-length"
|
||||
label={t("cycle.settings.typicalCycleLength")}
|
||||
value={cycleLen}
|
||||
onChange={setCycleLen}
|
||||
min={15}
|
||||
max={60}
|
||||
/>
|
||||
<PriorField
|
||||
id="cycle-period-length"
|
||||
label={t("cycle.settings.typicalPeriodLength")}
|
||||
value={periodLen}
|
||||
onChange={setPeriodLen}
|
||||
min={1}
|
||||
max={15}
|
||||
/>
|
||||
<PriorField
|
||||
id="cycle-luteal-length"
|
||||
label={t("cycle.settings.lutealPhaseLength")}
|
||||
value={lutealLen}
|
||||
onChange={setLutealLen}
|
||||
min={10}
|
||||
max={16}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Privacy toggles */}
|
||||
<ToggleRow
|
||||
id="cycle-encryption"
|
||||
label={t("cycle.settings.encryption")}
|
||||
description={t("cycle.settings.encryptionDescription")}
|
||||
checked={encryption}
|
||||
onChange={setEncryption}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="cycle-discreet"
|
||||
label={t("cycle.settings.discreet")}
|
||||
description={t("cycle.settings.discreetDescription")}
|
||||
checked={discreet}
|
||||
onChange={setDiscreet}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reminders — the per-channel toggles live on the notifications page;
|
||||
point users there rather than duplicating the toggle state here. */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("cycle.settings.reminders")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.settings.remindersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||
<Link href="/notifications">
|
||||
{t("cycle.settings.remindersLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Data export / delete */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t("cycle.settings.dataTitle")}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.settings.dataDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/api/export/health-record" download>
|
||||
{t("cycle.settings.export")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/settings/account">
|
||||
{t("cycle.settings.deleteData")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cycle-only hard purge (post-Dobbs threat model): removes every
|
||||
cycle row + the cycle audit trail + reminder ledger, distinct
|
||||
from the account-level delete. Two-step confirm — no accidental
|
||||
destructive tap. */}
|
||||
<div className="border-destructive/30 mt-3 space-y-2 rounded-md border p-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.settings.purgeDescription")}
|
||||
</p>
|
||||
{confirmingPurge ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium" role="alert">
|
||||
{t("cycle.settings.purgeConfirm")}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={purge.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await purge.mutateAsync();
|
||||
} finally {
|
||||
setConfirmingPurge(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{purge.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.settings.purgeConfirmAction")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingPurge(false)}
|
||||
>
|
||||
{t("cycle.settings.purgeCancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/40 hover:bg-destructive/10"
|
||||
onClick={() => setConfirmingPurge(true)}
|
||||
>
|
||||
{t("cycle.settings.purgeData")}
|
||||
</Button>
|
||||
)}
|
||||
{purge.isSuccess ? (
|
||||
<span className="text-muted-foreground text-sm" role="status">
|
||||
{t("cycle.settings.purgeDone")}
|
||||
</span>
|
||||
) : null}
|
||||
{purge.isError ? (
|
||||
<span className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.settings.purgeError")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
{update.isSuccess ? (
|
||||
<span className="text-muted-foreground text-sm" role="status">
|
||||
{t("cycle.settings.saved")}
|
||||
</span>
|
||||
) : null}
|
||||
{update.isError ? (
|
||||
<span className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.settings.saveError")}
|
||||
</span>
|
||||
) : null}
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={id} className="text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import type { CyclePhase } from "./types";
|
||||
import type { SymptomPhaseRow } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.0 — symptom patterns by phase (the cycle-NATIVE insight).
|
||||
*
|
||||
* The phase×vitals crosstab correlates the cycle against your measurements;
|
||||
* this card answers the complementary, cycle-own question: which of your logged
|
||||
* symptoms cluster in which phase. Each row shows the symptom's per-phase
|
||||
* distribution as a proportional 4-segment bar (phase-hued, never colour-only —
|
||||
* the dominant phase is named in words) plus "mostly in your luteal phase
|
||||
* (7 of 9 days)". Observational, never causal; only symptoms logged on ≥3
|
||||
* phase-labelled days surface (the server floor).
|
||||
*/
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface CycleSymptomPatternsProps {
|
||||
rows: SymptomPhaseRow[];
|
||||
}
|
||||
|
||||
export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Flatten the catalog once: symptom key → { labelKey, icon }.
|
||||
const byKey = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{
|
||||
labelKey: string;
|
||||
icon: (typeof CYCLE_SYMPTOM_CATALOG)[number]["symptoms"][number]["icon"];
|
||||
}
|
||||
>();
|
||||
for (const cat of CYCLE_SYMPTOM_CATALOG) {
|
||||
for (const s of cat.symptoms) {
|
||||
map.set(s.key, { labelKey: s.labelKey, icon: s.icon });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card data-slot="cycle-symptom-patterns">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity className="text-primary h-4 w-4" aria-hidden="true" />
|
||||
{t("cycle.insights.symptomPatternsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.insights.symptomPatternsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3" data-slot="cycle-symptom-pattern-list">
|
||||
{rows.map((row) => {
|
||||
const meta = byKey.get(row.symptomKey);
|
||||
const Icon = meta?.icon;
|
||||
const name = meta ? t(meta.labelKey) : row.symptomKey;
|
||||
const phaseName = t(`cycle.phase.${row.topPhase}`);
|
||||
return (
|
||||
<li key={row.symptomKey} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-foreground flex items-center gap-1.5 text-sm font-medium">
|
||||
{Icon ? (
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : null}
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("cycle.insights.symptomMostlyIn", {
|
||||
phase: phaseName,
|
||||
count: row.counts[row.topPhase],
|
||||
total: row.total,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/* Proportional phase-distribution bar — phase-hued, with the
|
||||
dominant phase named above so it is never colour-only. */}
|
||||
<div
|
||||
className="bg-muted flex h-2 overflow-hidden rounded-full"
|
||||
role="img"
|
||||
aria-label={t("cycle.insights.symptomMostlyIn", {
|
||||
phase: phaseName,
|
||||
count: row.counts[row.topPhase],
|
||||
total: row.total,
|
||||
})}
|
||||
>
|
||||
{PHASE_ORDER.map((phase) => {
|
||||
const share =
|
||||
row.total > 0 ? row.counts[phase] / row.total : 0;
|
||||
if (share <= 0) return null;
|
||||
return (
|
||||
<span
|
||||
key={phase}
|
||||
style={{
|
||||
width: `${share * 100}%`,
|
||||
backgroundColor: PHASE_HUE[phase],
|
||||
opacity: phase === row.topPhase ? 1 : 0.45,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { BbtChart } from "./bbt-chart";
|
||||
import { CycleCalendar } from "./cycle-calendar";
|
||||
import { CycleDisclaimer } from "./cycle-disclaimer";
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import {
|
||||
localYmd,
|
||||
useCycleCalendar,
|
||||
useCycleHistory,
|
||||
useCycleInsights,
|
||||
useCycleProfile,
|
||||
} from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the cycle vertical's client orchestrator.
|
||||
*
|
||||
* Holds the four tabs (Calendar · Predictions · Insights · Settings), the
|
||||
* wheel above the tab strip, and the log-day sheet. The calendar read drives
|
||||
* the ring (day-of-cycle + phase), the grid, and the BBT chart; the
|
||||
* predictions read powers the panel; history powers the stats. The page-level
|
||||
* RSC has already gated on `cycleTrackingEnabled`.
|
||||
*
|
||||
* The wheel earns the once-per-session "signature reveal" (sweep + glow +
|
||||
* sheen + count-up): a `data-revealed` flag set on first visit drives the CSS
|
||||
* keyframes, gated by sessionStorage so a background calendar refetch never
|
||||
* re-triggers the moment.
|
||||
*/
|
||||
|
||||
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
|
||||
function shiftToday(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
export function CycleView() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
const history = useCycleHistory();
|
||||
const profileQuery = useCycleProfile();
|
||||
const insights = useCycleInsights();
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
|
||||
// The signature reveal plays ONCE per browser session (mirrors the wellness
|
||||
// strip): a background calendar refetch after a quick-log would otherwise
|
||||
// replay the sweep/sheen and read as janky.
|
||||
const [play] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
if (sessionStorage.getItem("cycle-wheel-revealed")) return false;
|
||||
sessionStorage.setItem("cycle-wheel-revealed", "1");
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const wheel = useMemo(
|
||||
() => deriveWheelState(calendar.data?.days ?? [], today),
|
||||
[calendar.data, today],
|
||||
);
|
||||
|
||||
function openSheet(date: string) {
|
||||
setSelectedDate(date);
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
const loading = calendar.isLoading && !calendar.data;
|
||||
const calendarError = calendar.isError && !calendar.data;
|
||||
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
|
||||
// AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger
|
||||
// "not a contraceptive method" caveat. Prefer the server-resolved
|
||||
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
|
||||
// key for the no-prediction calendar tab (QA H-1).
|
||||
const goal = calendar.data?.profile.goal;
|
||||
const disclaimerText =
|
||||
calendar.data?.prediction?.disclaimer ??
|
||||
t(
|
||||
goal === "AVOID_PREGNANCY"
|
||||
? "cycle.disclaimer"
|
||||
: "cycle.prediction.disclaimer",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("cycle.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("cycle.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openSheet(today)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("cycle.logToday")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
|
||||
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
|
||||
<div
|
||||
data-slot="cycle-wheel-tile"
|
||||
data-revealed={play ? "true" : undefined}
|
||||
style={
|
||||
tileHue
|
||||
? ({ "--tile-hue": tileHue } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6 lg:sticky lg:top-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : calendarError ? (
|
||||
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.loadError")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => void calendar.refetch()}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
animate={play}
|
||||
/>
|
||||
)}
|
||||
{!calendarError ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* First-period CTA — only when no cycle is active yet. */}
|
||||
{!loading && !calendarError && wheel.dayOfCycle == null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => openSheet(today)}
|
||||
>
|
||||
{t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="calendar">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="calendar" className="flex-1">
|
||||
{t("cycle.tabs.calendar")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="predictions" className="flex-1">
|
||||
{t("cycle.tabs.predictions")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="insights" className="flex-1">
|
||||
{t("cycle.tabs.insights")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("cycle.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calendar" className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.calendar.loading")}
|
||||
</p>
|
||||
) : calendarError ? (
|
||||
<TabError onRetry={() => void calendar.refetch()} />
|
||||
) : (
|
||||
<CycleCalendar
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
onSelectDay={openSheet}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!loading && !calendarError ? (
|
||||
<CycleDisclaimer text={disclaimerText} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="predictions" className="mt-4 space-y-4">
|
||||
{calendarError ? (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<TabError onRetry={() => void calendar.refetch()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<PredictionsPanel
|
||||
prediction={calendar.data?.prediction ?? null}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
history={history.data}
|
||||
fallbackDisclaimer={disclaimerText}
|
||||
/>
|
||||
<BbtChart
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
predictedOvulation={
|
||||
calendar.data?.prediction?.predictedOvulation ?? null
|
||||
}
|
||||
ovulationConfirmed={
|
||||
calendar.data?.prediction?.ovulationConfirmed ?? false
|
||||
}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="insights" className="mt-4 space-y-4">
|
||||
{insights.isError ? (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<TabError onRetry={() => void insights.refetch()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<CyclePhaseHeadline
|
||||
headline={insights.data?.headline ?? null}
|
||||
/>
|
||||
<CyclePhaseCrosstab rows={insights.data?.rows ?? []} />
|
||||
<CycleSymptomPatterns
|
||||
rows={insights.data?.symptomPatterns ?? []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
{profileQuery.isError ? (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<TabError onRetry={() => void profileQuery.refetch()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : profileQuery.isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<LogDaySheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
date={selectedDate}
|
||||
today={today}
|
||||
activePeriod={wheel.phase === "MENSTRUAL"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A compact in-place error + Retry for a failed tab read. */
|
||||
function TabError({ onRetry }: { onRetry: () => void }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="cycle-tab-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("cycle.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Droplets, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import {
|
||||
useCycleDayLog,
|
||||
useDeleteDayLog,
|
||||
useEndPeriod,
|
||||
useLogDay,
|
||||
usePatchDayLog,
|
||||
useStartPeriod,
|
||||
type CycleDayLogPatch,
|
||||
} from "./use-cycle";
|
||||
import type {
|
||||
CervicalMucus,
|
||||
ContraceptiveKind,
|
||||
CycleDayLogInput,
|
||||
CycleSymptomSelection,
|
||||
FlowLevel,
|
||||
HomeTestResult,
|
||||
OvulationTest,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the fast-entry log-day sheet.
|
||||
*
|
||||
* One-tap "started my period today" posts the period-boundary shortcut; the
|
||||
* detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus,
|
||||
* intercourse + protection, free-text note). The write is optimistic-ish
|
||||
* (the mutation invalidates the cycle keys on success so the calendar +
|
||||
* wheel repaint); the symptom chips reuse the mood-tag chip styling.
|
||||
*
|
||||
* a11y: every chip is a real `<button>` with `aria-pressed`; the sheet
|
||||
* footer sticky-pins Save/Cancel.
|
||||
*/
|
||||
|
||||
const FLOW_LEVELS: FlowLevel[] = ["SPOTTING", "LIGHT", "MEDIUM", "HEAVY"];
|
||||
const OPK_VALUES: OvulationTest[] = [
|
||||
"NEGATIVE",
|
||||
"POSITIVE_LH_SURGE",
|
||||
"ESTROGEN_SURGE",
|
||||
"INDETERMINATE",
|
||||
];
|
||||
const MUCUS_VALUES: CervicalMucus[] = [
|
||||
"DRY",
|
||||
"STICKY",
|
||||
"CREAMY",
|
||||
"WATERY",
|
||||
"EGG_WHITE",
|
||||
];
|
||||
const TEST_RESULTS: HomeTestResult[] = [
|
||||
"POSITIVE",
|
||||
"NEGATIVE",
|
||||
"INDETERMINATE",
|
||||
];
|
||||
const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [
|
||||
"ORAL",
|
||||
"IUD",
|
||||
"IMPLANT",
|
||||
"INJECTION",
|
||||
"INTRAVAGINAL_RING",
|
||||
"PATCH",
|
||||
"EMERGENCY",
|
||||
"NONE",
|
||||
];
|
||||
const SEVERITY_LEVELS = [1, 2, 3, 4] as const;
|
||||
|
||||
/** The sheet's editable form state, lifted out so the two save-payload builders
|
||||
* are pure + unit-testable (the clear-on-edit semantics are the QA W-2 fix). */
|
||||
export interface DayLogFormState {
|
||||
flow: FlowLevel | null;
|
||||
intermenstrual: boolean;
|
||||
/** Raw text from the BBT input; "" / non-finite resolves to null. */
|
||||
bbt: string;
|
||||
opk: OvulationTest | null;
|
||||
mucus: CervicalMucus | null;
|
||||
intercourse: boolean;
|
||||
protectedSex: boolean;
|
||||
pregnancyTest: HomeTestResult | null;
|
||||
progesteroneTest: HomeTestResult | null;
|
||||
contraceptive: ContraceptiveKind | null;
|
||||
note: string;
|
||||
symptoms: Map<string, number | null>;
|
||||
}
|
||||
|
||||
function resolveBbt(bbt: string): number | null {
|
||||
const n = bbt.trim() === "" ? null : Number(bbt);
|
||||
return n != null && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function symptomList(
|
||||
symptoms: Map<string, number | null>,
|
||||
): CycleSymptomSelection[] {
|
||||
return Array.from(symptoms.entries()).map(([key, severity]) => ({
|
||||
key,
|
||||
severity,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The PATCH payload for an EDIT: every enum carries an explicit `null` when
|
||||
* deselected so the server clears it (the POST merge can only add/keep — QA
|
||||
* W-2). Pure + exported for the unit test.
|
||||
*/
|
||||
export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
|
||||
return {
|
||||
flow: s.flow ?? null,
|
||||
intermenstrualBleeding: s.intermenstrual,
|
||||
basalBodyTempC: resolveBbt(s.bbt),
|
||||
ovulationTest: s.opk ?? null,
|
||||
cervicalMucus: s.mucus ?? null,
|
||||
sexualActivity: s.intercourse,
|
||||
protectedSex: s.intercourse ? s.protectedSex : null,
|
||||
pregnancyTest: s.pregnancyTest ?? null,
|
||||
progesteroneTest: s.progesteroneTest ?? null,
|
||||
contraceptive: s.contraceptive ?? null,
|
||||
symptoms: symptomList(s.symptoms),
|
||||
note: s.note.trim() ? s.note.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The POST payload for a NEW row: omit empty enums (a fresh row has nothing to
|
||||
* clear); `note` posts an explicit "" so an emptied note never persists. */
|
||||
export function buildDayLogInput(
|
||||
s: DayLogFormState,
|
||||
date: string,
|
||||
): CycleDayLogInput {
|
||||
const bbtVal = resolveBbt(s.bbt);
|
||||
return {
|
||||
date,
|
||||
loggedAt: new Date().toISOString(),
|
||||
source: "MANUAL",
|
||||
...(s.flow ? { flow: s.flow } : {}),
|
||||
intermenstrualBleeding: s.intermenstrual,
|
||||
...(bbtVal != null ? { basalBodyTempC: bbtVal } : {}),
|
||||
...(s.opk ? { ovulationTest: s.opk } : {}),
|
||||
...(s.mucus ? { cervicalMucus: s.mucus } : {}),
|
||||
sexualActivity: s.intercourse,
|
||||
protectedSex: s.intercourse ? s.protectedSex : null,
|
||||
...(s.pregnancyTest ? { pregnancyTest: s.pregnancyTest } : {}),
|
||||
...(s.progesteroneTest ? { progesteroneTest: s.progesteroneTest } : {}),
|
||||
...(s.contraceptive ? { contraceptive: s.contraceptive } : {}),
|
||||
symptoms: symptomList(s.symptoms),
|
||||
...(s.note.trim() ? { note: s.note.trim() } : { note: "" }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface LogDaySheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The date being logged (YYYY-MM-DD). */
|
||||
date: string;
|
||||
/** Today (YYYY-MM-DD). */
|
||||
today: string;
|
||||
/**
|
||||
* Whether a period is currently open (today is in the MENSTRUAL phase) — gates
|
||||
* the one-tap "end period" affordance so it never shows when no period is in
|
||||
* progress (QA M2).
|
||||
*/
|
||||
activePeriod?: boolean;
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogDaySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
date,
|
||||
activePeriod = false,
|
||||
}: LogDaySheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const logDay = useLogDay();
|
||||
const patchDay = usePatchDayLog();
|
||||
const startPeriod = useStartPeriod();
|
||||
const endPeriod = useEndPeriod();
|
||||
const deleteDay = useDeleteDayLog();
|
||||
// Pre-fill from the persisted day-log so a re-log never nulls untouched
|
||||
// (or HealthKit-sourced) fields — the v1.15 web data-loss fix.
|
||||
const dayLog = useCycleDayLog(open ? date : null);
|
||||
|
||||
const [flow, setFlow] = useState<FlowLevel | null>(null);
|
||||
const [intermenstrual, setIntermenstrual] = useState(false);
|
||||
const [symptoms, setSymptoms] = useState<Map<string, number | null>>(
|
||||
new Map(),
|
||||
);
|
||||
const [bbt, setBbt] = useState<string>("");
|
||||
const [opk, setOpk] = useState<OvulationTest | null>(null);
|
||||
const [mucus, setMucus] = useState<CervicalMucus | null>(null);
|
||||
const [intercourse, setIntercourse] = useState(false);
|
||||
const [protectedSex, setProtectedSex] = useState(false);
|
||||
const [pregnancyTest, setPregnancyTest] = useState<HomeTestResult | null>(
|
||||
null,
|
||||
);
|
||||
const [progesteroneTest, setProgesteroneTest] =
|
||||
useState<HomeTestResult | null>(null);
|
||||
const [contraceptive, setContraceptive] = useState<ContraceptiveKind | null>(
|
||||
null,
|
||||
);
|
||||
const [note, setNote] = useState("");
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
|
||||
// Hydrate the form from the fetched DTO once per (open, date, fetched-row)
|
||||
// tuple. Tracked as an "adjust state when a prop changes" during render
|
||||
// (React's recommended alternative to a setState-in-effect) keyed on the
|
||||
// open+date+row-identity, so a re-open or a late-arriving fetch repaints the
|
||||
// controls exactly once. `dto === null` (nothing logged) resets to blank.
|
||||
const dto = open ? (dayLog.data ?? null) : undefined;
|
||||
const formKey = open
|
||||
? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
|
||||
: null;
|
||||
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
|
||||
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
|
||||
setLastFormKey(formKey);
|
||||
setRowId(dto?.id ?? null);
|
||||
setFlow((dto?.flow ?? null) as FlowLevel | null);
|
||||
setIntermenstrual(dto?.intermenstrualBleeding ?? false);
|
||||
setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity])));
|
||||
setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : "");
|
||||
setOpk((dto?.ovulationTest ?? null) as OvulationTest | null);
|
||||
setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null);
|
||||
setIntercourse(dto?.sexualActivity ?? false);
|
||||
setProtectedSex(dto?.protectedSex ?? false);
|
||||
setPregnancyTest(dto?.pregnancyTest ?? null);
|
||||
setProgesteroneTest(dto?.progesteroneTest ?? null);
|
||||
setContraceptive(dto?.contraceptive ?? null);
|
||||
setNote(dto?.note ?? "");
|
||||
} else if (!open && lastFormKey !== null) {
|
||||
setLastFormKey(null);
|
||||
}
|
||||
|
||||
function toggleSymptom(key: string) {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.set(key, null);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setSymptomSeverity(key: string, severity: number) {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Map(prev);
|
||||
// Tapping the active level clears it back to a plain presence link.
|
||||
next.set(key, next.get(key) === severity ? null : severity);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const state: DayLogFormState = {
|
||||
flow,
|
||||
intermenstrual,
|
||||
bbt,
|
||||
opk,
|
||||
mucus,
|
||||
intercourse,
|
||||
protectedSex,
|
||||
pregnancyTest,
|
||||
progesteroneTest,
|
||||
contraceptive,
|
||||
note,
|
||||
symptoms,
|
||||
};
|
||||
if (rowId) {
|
||||
// Editing an existing row → PATCH with explicit nulls so a deselected
|
||||
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
|
||||
await patchDay.mutateAsync({ id: rowId, patch: buildDayLogPatch(state) });
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
await logDay.mutateAsync(buildDayLogInput(state, date));
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
// Period boundaries operate on the SELECTED date, so a forgotten day-1 can be
|
||||
// corrected from the calendar retroactively (QA M3) — not today-only.
|
||||
async function handleStartPeriod() {
|
||||
await startPeriod.mutateAsync(date);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
async function handleEndPeriod() {
|
||||
await endPeriod.mutateAsync(date);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!rowId) return;
|
||||
await deleteDay.mutateAsync(rowId);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const busy =
|
||||
logDay.isPending ||
|
||||
patchDay.isPending ||
|
||||
startPeriod.isPending ||
|
||||
endPeriod.isPending ||
|
||||
deleteDay.isPending;
|
||||
const saving = logDay.isPending || patchDay.isPending;
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("cycle.sheet.title")}
|
||||
description={t("cycle.sheet.description")}
|
||||
footer={
|
||||
<>
|
||||
{rowId ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive mr-auto"
|
||||
aria-label={t("cycle.sheet.delete")}
|
||||
>
|
||||
{deleteDay.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("cycle.sheet.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("cycle.sheet.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={busy}>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{saving ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* One-tap period boundaries — available on any selected date so a
|
||||
forgotten day-1 can be corrected retroactively (M3). "End period"
|
||||
shows only while a period is actually open (M2). */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 justify-start gap-2"
|
||||
onClick={handleStartPeriod}
|
||||
disabled={busy}
|
||||
style={{ borderColor: FLOW_HUE }}
|
||||
>
|
||||
{startPeriod.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
|
||||
)}
|
||||
{t("cycle.startedPeriod")}
|
||||
</Button>
|
||||
{activePeriod ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 justify-start gap-2"
|
||||
onClick={handleEndPeriod}
|
||||
disabled={busy}
|
||||
>
|
||||
{endPeriod.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.endedPeriod")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
<Field label={t("cycle.sheet.flow")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FLOW_LEVELS.map((f) => (
|
||||
<Chip
|
||||
key={f}
|
||||
active={flow === f}
|
||||
onClick={() => setFlow((cur) => (cur === f ? null : f))}
|
||||
>
|
||||
{t(`cycle.flow.${f}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<Label htmlFor="cycle-intermenstrual" className="text-sm font-normal">
|
||||
{t("cycle.sheet.intermenstrualBleeding")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-intermenstrual"
|
||||
checked={intermenstrual}
|
||||
onCheckedChange={setIntermenstrual}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Symptoms */}
|
||||
<Field label={t("cycle.sheet.symptoms")}>
|
||||
<div className="space-y-3">
|
||||
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
|
||||
<div key={cat.key} className="space-y-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<cat.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(cat.labelKey)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cat.symptoms.map((s) => {
|
||||
const active = symptoms.has(s.key);
|
||||
const sev = symptoms.get(s.key) ?? null;
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-1">
|
||||
<Chip
|
||||
active={active}
|
||||
onClick={() => toggleSymptom(s.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
</Chip>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={sev === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", {
|
||||
level: lvl,
|
||||
})}
|
||||
onClick={() => setSymptomSeverity(s.key, lvl)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
sev === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* BBT */}
|
||||
<Field label={t("cycle.sheet.temperature")}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={30}
|
||||
max={45}
|
||||
value={bbt}
|
||||
onChange={(e) => setBbt(e.target.value)}
|
||||
placeholder={t("cycle.sheet.temperaturePlaceholder")}
|
||||
className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
aria-label={t("cycle.sheet.temperature")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Ovulation test */}
|
||||
<Field label={t("cycle.sheet.ovulationTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{OPK_VALUES.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={opk === v}
|
||||
onClick={() => setOpk((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.ovulationTest.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Intercourse + protection */}
|
||||
<Field label={t("cycle.sheet.intercourse")}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
|
||||
{t("cycle.sheet.intercourse")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-intercourse"
|
||||
checked={intercourse}
|
||||
onCheckedChange={setIntercourse}
|
||||
/>
|
||||
</div>
|
||||
{intercourse ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="cycle-protected"
|
||||
className="text-muted-foreground text-sm font-normal"
|
||||
>
|
||||
{t("cycle.sheet.protected")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-protected"
|
||||
checked={protectedSex}
|
||||
onCheckedChange={setProtectedSex}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Pregnancy test */}
|
||||
<Field label={t("cycle.sheet.pregnancyTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={pregnancyTest === v}
|
||||
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Progesterone test */}
|
||||
<Field label={t("cycle.sheet.progesteroneTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={progesteroneTest === v}
|
||||
onClick={() =>
|
||||
setProgesteroneTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Contraceptive */}
|
||||
<Field label={t("cycle.sheet.contraceptive")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONTRACEPTIVE_KINDS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={contraceptive === v}
|
||||
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.contraceptive.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Note */}
|
||||
<Field label={t("cycle.sheet.note")}>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder={t("cycle.sheet.notePlaceholder")}
|
||||
aria-label={t("cycle.sheet.note")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{logDay.isError || patchDay.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.sheet.saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.15.0 — cycle-phase hue tokens.
|
||||
*
|
||||
* Each phase maps to a dedicated, per-theme CSS custom property defined in
|
||||
* `globals.css` (Dracula dark + Alucard light), so the hue is hand-tuned for
|
||||
* contrast in both themes — exactly the wellness-ring pattern. The phase
|
||||
* semantic is never colour-only: every surface that uses a hue also renders
|
||||
* the phase word + an aria-label.
|
||||
*/
|
||||
import type { CyclePhase } from "./types";
|
||||
|
||||
export const PHASE_HUE: Record<CyclePhase, string> = {
|
||||
MENSTRUAL: "var(--cycle-phase-menstrual)",
|
||||
FOLLICULAR: "var(--cycle-phase-follicular)",
|
||||
OVULATORY: "var(--cycle-phase-ovulatory)",
|
||||
LUTEAL: "var(--cycle-phase-luteal)",
|
||||
};
|
||||
|
||||
/** Flow-intensity tint for the calendar period dots (rose family). */
|
||||
export const FLOW_HUE = "var(--cycle-phase-menstrual)";
|
||||
/** Fertile-window tint (follicular green family). */
|
||||
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
|
||||
/** Ovulation marker tint (amber-gold family). */
|
||||
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Sparkles } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleDisclaimer } from "./cycle-disclaimer";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the predictions panel.
|
||||
*
|
||||
* Shows the next-period RANGE (a window, with a confidence pill), the fertile
|
||||
* window (already goal-gated server-side — the API nulls it unless TTC, so we
|
||||
* just render what is present), the "still learning" state for < 3 cycles or
|
||||
* raw-chart mode, the cycle-history stats, and the fixed non-medical
|
||||
* disclaimer. Never a single dated next-period claim — always the range.
|
||||
*/
|
||||
|
||||
function formatDate(d: string): string {
|
||||
// Render at noon UTC so the YYYY-MM-DD never rolls a day across tz.
|
||||
return new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function confidenceTone(c: number): {
|
||||
labelKey: string;
|
||||
variant: "default" | "secondary" | "outline";
|
||||
} {
|
||||
if (c >= 0.66)
|
||||
return { labelKey: "cycle.predictions.confidenceHigh", variant: "default" };
|
||||
if (c >= 0.33)
|
||||
return {
|
||||
labelKey: "cycle.predictions.confidenceMedium",
|
||||
variant: "secondary",
|
||||
};
|
||||
return { labelKey: "cycle.predictions.confidenceLow", variant: "outline" };
|
||||
}
|
||||
|
||||
export interface PredictionsPanelProps {
|
||||
prediction: CyclePrediction | null;
|
||||
rawChartMode: boolean;
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** Fallback disclaimer when no prediction carries one. */
|
||||
fallbackDisclaimer?: string;
|
||||
}
|
||||
|
||||
export function PredictionsPanel({
|
||||
prediction,
|
||||
rawChartMode,
|
||||
history,
|
||||
fallbackDisclaimer,
|
||||
}: PredictionsPanelProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const disclaimer = prediction?.disclaimer ?? fallbackDisclaimer ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="text-primary h-4 w-4" aria-hidden="true" />
|
||||
{t("cycle.predictions.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rawChartMode ? (
|
||||
<Learning
|
||||
title={t("cycle.predictions.rawMode")}
|
||||
body={t("cycle.predictions.rawModeBody")}
|
||||
/>
|
||||
) : prediction == null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.predictions.none")}
|
||||
</p>
|
||||
) : prediction.stillLearning ? (
|
||||
<>
|
||||
<Learning
|
||||
title={t("cycle.predictions.stillLearning")}
|
||||
body={t("cycle.predictions.stillLearningBody")}
|
||||
/>
|
||||
<NextPeriod prediction={prediction} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NextPeriod prediction={prediction} />
|
||||
{prediction.fertileWindowStart && prediction.fertileWindowEnd ? (
|
||||
<Row
|
||||
hue={FERTILE_HUE}
|
||||
label={t("cycle.predictions.fertileWindow")}
|
||||
value={`${formatDate(prediction.fertileWindowStart)} – ${formatDate(
|
||||
prediction.fertileWindowEnd,
|
||||
)}`}
|
||||
/>
|
||||
) : null}
|
||||
{prediction.predictedOvulation ? (
|
||||
<Row
|
||||
hue={OVULATION_HUE}
|
||||
label={
|
||||
prediction.ovulationConfirmed
|
||||
? t("cycle.predictions.ovulationConfirmed")
|
||||
: t("cycle.predictions.ovulation")
|
||||
}
|
||||
value={formatDate(prediction.predictedOvulation)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{disclaimer ? <CycleDisclaimer text={disclaimer} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<HistoryCard history={history} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextPeriod({ prediction }: { prediction: CyclePrediction }) {
|
||||
const { t } = useTranslations();
|
||||
const tone = confidenceTone(prediction.confidence);
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Row
|
||||
hue={FLOW_HUE}
|
||||
label={t("cycle.predictions.nextPeriod")}
|
||||
value={t("cycle.predictions.nextPeriodWindow", {
|
||||
low: formatDate(prediction.nextPeriodStartLow),
|
||||
high: formatDate(prediction.nextPeriodStartHigh),
|
||||
})}
|
||||
/>
|
||||
<Badge variant={tone.variant} className="shrink-0">
|
||||
{t(tone.labelKey)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
hue,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
hue: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-foreground ml-auto text-sm font-medium tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Learning({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="bg-muted/40 rounded-md p-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inclusive day span between two `YYYY-MM-DD` dates (noon-UTC anchored). */
|
||||
function daySpan(from: string, to: string): number {
|
||||
const a = Date.parse(`${from}T12:00:00Z`);
|
||||
const b = Date.parse(`${to}T12:00:00Z`);
|
||||
return Math.round((b - a) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
function HistoryCard({
|
||||
history,
|
||||
}: {
|
||||
history: CycleHistoryResponse | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const stats = history?.stats;
|
||||
// Most-recent observed cycles (the spread the regularity number summarises —
|
||||
// seeing it builds trust; the data was already on the wire). QA H2.
|
||||
const observed = (history?.cycles ?? [])
|
||||
.filter((c) => !c.isPredicted)
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("cycle.history.title")}</CardTitle>
|
||||
{stats ? (
|
||||
<CardDescription>
|
||||
{t(`cycle.history.regularity${stats.regularity}`)}
|
||||
</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!stats || stats.avgLengthDays == null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.history.none")}
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Stat
|
||||
label={t("cycle.history.avgLength")}
|
||||
value={t("cycle.history.days", { count: stats.avgLengthDays })}
|
||||
/>
|
||||
{stats.lengthVariabilityDays != null ? (
|
||||
<Stat
|
||||
label={t("cycle.history.variability")}
|
||||
value={t("cycle.history.daysPlusMinus", {
|
||||
count: stats.lengthVariabilityDays,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{stats.avgPeriodLengthDays != null ? (
|
||||
<Stat
|
||||
label={t("cycle.history.avgPeriodLength")}
|
||||
value={t("cycle.history.days", {
|
||||
count: stats.avgPeriodLengthDays,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{observed.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t("cycle.history.recentCycles")}
|
||||
</p>
|
||||
<ul
|
||||
className="divide-border divide-y"
|
||||
data-slot="cycle-history-list"
|
||||
>
|
||||
{observed.map((c) => {
|
||||
const periodLen =
|
||||
c.periodEndDate != null
|
||||
? daySpan(c.startDate, c.periodEndDate)
|
||||
: null;
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{formatDate(c.startDate)}
|
||||
{" – "}
|
||||
{c.endDate
|
||||
? formatDate(c.endDate)
|
||||
: t("cycle.history.ongoing")}
|
||||
</span>
|
||||
<span className="text-muted-foreground flex items-center gap-2 tabular-nums">
|
||||
{c.lengthDays != null
|
||||
? t("cycle.history.days", { count: c.lengthDays })
|
||||
: "—"}
|
||||
{periodLen != null ? (
|
||||
<span className="text-xs">
|
||||
{t("cycle.history.periodDays", { count: periodLen })}
|
||||
</span>
|
||||
) : null}
|
||||
{c.ovulationConfirmed ? (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Check className="h-3 w-3" aria-hidden="true" />
|
||||
{t("cycle.history.ovulationChip")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground mt-0.5 text-lg font-semibold tabular-nums">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* v1.15.0 — the seeded cycle-symptom catalog, mirrored client-side.
|
||||
*
|
||||
* The catalog ships seeded-only in v1.15.0 (no per-user custom CRUD yet),
|
||||
* so the keys are fixed at the migration seed (0129). The `key` is the
|
||||
* snake_case DB key the day-log write resolves against; the `labelKey` is
|
||||
* the camelCase i18n leaf under `cycle.symptom.*`; the `icon` is the
|
||||
* Lucide name from the seed. Keep this in lockstep with the seed in
|
||||
* `prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql` and
|
||||
* the `cycle.symptom.*` / `cycle.symptomCategory.*` i18n leaves.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
BatteryLow,
|
||||
Brain,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
Cookie,
|
||||
Drama,
|
||||
Droplet,
|
||||
Flame,
|
||||
Frown,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
MoonStar,
|
||||
PersonStanding,
|
||||
Snowflake,
|
||||
Soup,
|
||||
Toilet,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface CycleSymptom {
|
||||
/** snake_case DB key — the value sent in `symptoms[].key`. */
|
||||
key: string;
|
||||
/** i18n leaf under `cycle.symptom.*`. */
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface CycleSymptomCategory {
|
||||
key: string;
|
||||
/** i18n leaf under `cycle.symptomCategory.*`. */
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
symptoms: CycleSymptom[];
|
||||
}
|
||||
|
||||
export const CYCLE_SYMPTOM_CATALOG: CycleSymptomCategory[] = [
|
||||
{
|
||||
key: "physical",
|
||||
labelKey: "cycle.symptomCategory.physical",
|
||||
icon: Activity,
|
||||
symptoms: [
|
||||
{ key: "cramps", labelKey: "cycle.symptom.cramps", icon: Zap },
|
||||
{ key: "headache", labelKey: "cycle.symptom.headache", icon: Brain },
|
||||
{ key: "bloating", labelKey: "cycle.symptom.bloating", icon: CircleDot },
|
||||
{ key: "acne", labelKey: "cycle.symptom.acne", icon: Droplet },
|
||||
{
|
||||
key: "breast_tenderness",
|
||||
labelKey: "cycle.symptom.breastTenderness",
|
||||
icon: HeartPulse,
|
||||
},
|
||||
{ key: "fatigue", labelKey: "cycle.symptom.fatigue", icon: BatteryLow },
|
||||
{
|
||||
key: "back_pain",
|
||||
labelKey: "cycle.symptom.backPain",
|
||||
icon: PersonStanding,
|
||||
},
|
||||
{ key: "insomnia", labelKey: "cycle.symptom.insomnia", icon: MoonStar },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "emotional",
|
||||
labelKey: "cycle.symptomCategory.emotional",
|
||||
icon: Heart,
|
||||
symptoms: [
|
||||
{ key: "libido_high", labelKey: "cycle.symptom.libidoHigh", icon: Flame },
|
||||
{
|
||||
key: "libido_low",
|
||||
labelKey: "cycle.symptom.libidoLow",
|
||||
icon: Snowflake,
|
||||
},
|
||||
{ key: "mood_swings", labelKey: "cycle.symptom.moodSwings", icon: Drama },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "digestive",
|
||||
labelKey: "cycle.symptomCategory.digestive",
|
||||
icon: Soup,
|
||||
symptoms: [
|
||||
{
|
||||
key: "food_cravings",
|
||||
labelKey: "cycle.symptom.foodCravings",
|
||||
icon: Cookie,
|
||||
},
|
||||
{ key: "nausea", labelKey: "cycle.symptom.nausea", icon: Frown },
|
||||
{ key: "diarrhea", labelKey: "cycle.symptom.diarrhea", icon: Toilet },
|
||||
{
|
||||
key: "constipation",
|
||||
labelKey: "cycle.symptom.constipation",
|
||||
icon: CircleSlash,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* v1.15.0 — client-side mirrors of the `/api/cycle/*` response DTOs
|
||||
* (ios-contract §2). These match `src/lib/cycle/dto.ts` +
|
||||
* `src/lib/cycle/engine-adapter.ts` field-for-field so the read hooks can
|
||||
* type the unwrapped `(await res.json()).data`.
|
||||
*/
|
||||
|
||||
export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
|
||||
|
||||
export type FlowLevel = "NONE" | "SPOTTING" | "LIGHT" | "MEDIUM" | "HEAVY";
|
||||
|
||||
export type OvulationTest =
|
||||
| "NEGATIVE"
|
||||
| "POSITIVE_LH_SURGE"
|
||||
| "ESTROGEN_SURGE"
|
||||
| "INDETERMINATE";
|
||||
|
||||
export type CervicalMucus =
|
||||
| "DRY"
|
||||
| "STICKY"
|
||||
| "CREAMY"
|
||||
| "WATERY"
|
||||
| "EGG_WHITE";
|
||||
|
||||
export type CycleGoal =
|
||||
| "GENERAL_HEALTH"
|
||||
| "AVOID_PREGNANCY"
|
||||
| "TRYING_TO_CONCEIVE"
|
||||
| "PERIMENOPAUSE"
|
||||
| "OFF";
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string;
|
||||
phase: CyclePhase | null;
|
||||
isPredictedPeriod: boolean;
|
||||
isFertileWindow: boolean;
|
||||
isPredictedOvulation: boolean;
|
||||
isPeriodLogged: boolean;
|
||||
flow: string | null;
|
||||
hasSymptoms: boolean;
|
||||
confidence: number;
|
||||
/** Logged basal body temperature (°C), or null. Feeds the BBT chart. */
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: OvulationTest | null;
|
||||
cervicalMucus: CervicalMucus | null;
|
||||
}
|
||||
|
||||
export interface CyclePrediction {
|
||||
method: string;
|
||||
nextPeriodStart: string;
|
||||
nextPeriodStartLow: string;
|
||||
nextPeriodStartHigh: string;
|
||||
fertileWindowStart: string | null;
|
||||
fertileWindowEnd: string | null;
|
||||
predictedOvulation: string | null;
|
||||
ovulationConfirmed: boolean;
|
||||
confidence: number;
|
||||
cyclesObserved: number;
|
||||
stillLearning: boolean;
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
export interface CalendarResponse {
|
||||
profile: {
|
||||
goal: CycleGoal;
|
||||
rawChartMode: boolean;
|
||||
predictionEnabled: boolean;
|
||||
cyclesObserved: number;
|
||||
};
|
||||
prediction: CyclePrediction | null;
|
||||
days: CalendarDay[];
|
||||
meta: { generatedAt: string };
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
periodEndDate: string | null;
|
||||
lengthDays: number | null;
|
||||
ovulationDate: string | null;
|
||||
ovulationConfirmed: boolean;
|
||||
isPredicted: boolean;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CycleHistoryResponse {
|
||||
cycles: MenstrualCycleDTO[];
|
||||
stats: {
|
||||
avgLengthDays: number | null;
|
||||
lengthVariabilityDays: number | null;
|
||||
avgPeriodLengthDays: number | null;
|
||||
regularity: "REGULAR" | "IRREGULAR" | "LEARNING";
|
||||
};
|
||||
}
|
||||
|
||||
export interface CycleProfileDTO {
|
||||
goal: CycleGoal;
|
||||
cycleTrackingEnabled: boolean;
|
||||
rawChartMode: boolean;
|
||||
predictionEnabled: boolean;
|
||||
discreetNotifications: boolean;
|
||||
sensitiveCategoryEncryption: boolean;
|
||||
typicalCycleLength: number | null;
|
||||
typicalPeriodLength: number | null;
|
||||
lutealPhaseLength: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** The day-log capture payload (subset of the API input we send from web). */
|
||||
export type HomeTestResult = "NEGATIVE" | "POSITIVE" | "INDETERMINATE";
|
||||
|
||||
export type ContraceptiveKind =
|
||||
| "NONE"
|
||||
| "UNSPECIFIED"
|
||||
| "IMPLANT"
|
||||
| "INJECTION"
|
||||
| "IUD"
|
||||
| "INTRAVAGINAL_RING"
|
||||
| "ORAL"
|
||||
| "PATCH"
|
||||
| "EMERGENCY";
|
||||
|
||||
export interface CycleSymptomSelection {
|
||||
key: string;
|
||||
severity?: number | null;
|
||||
}
|
||||
|
||||
export interface CycleDayLogInput {
|
||||
date: string;
|
||||
flow?: FlowLevel;
|
||||
intermenstrualBleeding?: boolean;
|
||||
basalBodyTempC?: number;
|
||||
ovulationTest?: OvulationTest;
|
||||
cervicalMucus?: CervicalMucus;
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: HomeTestResult;
|
||||
progesteroneTest?: HomeTestResult;
|
||||
contraceptive?: ContraceptiveKind;
|
||||
symptoms?: CycleSymptomSelection[];
|
||||
note?: string;
|
||||
loggedAt: string;
|
||||
source?: "MANUAL";
|
||||
}
|
||||
|
||||
/** The full day-log row read back from `GET /api/cycle/day-logs?date=`. */
|
||||
export interface CycleDayLogDTO {
|
||||
id: string;
|
||||
date: string;
|
||||
cycleId: string | null;
|
||||
flow: FlowLevel | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: OvulationTest | null;
|
||||
cervicalMucus: CervicalMucus | null;
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: HomeTestResult | null;
|
||||
progesteroneTest: HomeTestResult | null;
|
||||
contraceptive: ContraceptiveKind | null;
|
||||
symptoms: { key: string; severity: number | null }[];
|
||||
note: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.0 — cycle read + write hooks.
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data` per the project envelope rule;
|
||||
* every key is factory-routed through `queryKeys.cycle*`. Writes invalidate
|
||||
* the whole `["cycle"]` prefix via `cycleDependentKeys` so the calendar,
|
||||
* wheel, predictions panel, and history repaint in lockstep after a quick
|
||||
* log.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
CalendarResponse,
|
||||
CervicalMucus,
|
||||
ContraceptiveKind,
|
||||
CycleDayLogDTO,
|
||||
CycleDayLogInput,
|
||||
CycleHistoryResponse,
|
||||
CycleProfileDTO,
|
||||
CycleSymptomSelection,
|
||||
FlowLevel,
|
||||
HomeTestResult,
|
||||
OvulationTest,
|
||||
} from "./types";
|
||||
import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab";
|
||||
|
||||
/** One symptom's per-phase clustering (mirrors `SymptomPhasePatternRow`). */
|
||||
export interface SymptomPhaseRow {
|
||||
symptomKey: string;
|
||||
counts: {
|
||||
MENSTRUAL: number;
|
||||
FOLLICULAR: number;
|
||||
OVULATORY: number;
|
||||
LUTEAL: number;
|
||||
};
|
||||
total: number;
|
||||
topPhase: "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
|
||||
topShare: number;
|
||||
}
|
||||
|
||||
/** The `/api/cycle/insights` read: the phase-contrast rows + the headline. */
|
||||
export interface CycleInsightsResponse {
|
||||
rows: CyclePhaseCrosstabRow[];
|
||||
headline: CyclePhaseCrosstabRow | null;
|
||||
symptomPatterns: SymptomPhaseRow[];
|
||||
}
|
||||
|
||||
async function unwrap<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
const json = await res.json();
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
|
||||
* are tz-anchored on the server; the client must derive the key from the
|
||||
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
|
||||
* the date over near midnight).
|
||||
*/
|
||||
export function localYmd(d: Date = new Date()): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the full day-log for one date so the log-day sheet pre-fills (no
|
||||
* blank-sheet data loss) and Delete can resolve the row id. `null` when
|
||||
* nothing is logged that day. Enabled only when a date is supplied.
|
||||
*/
|
||||
export function useCycleDayLog(date: string | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleDayLog(date ?? ""),
|
||||
enabled: date !== null,
|
||||
queryFn: () =>
|
||||
fetch(`/api/cycle/day-logs?date=${date}`).then((r) =>
|
||||
unwrap<CycleDayLogDTO | null>(r),
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleCalendar(from: string, to: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleCalendar(from, to),
|
||||
queryFn: () =>
|
||||
fetch(`/api/cycle/calendar?from=${from}&to=${to}`).then((r) =>
|
||||
unwrap<CalendarResponse>(r),
|
||||
),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleHistory(limit = 24) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleHistory(limit),
|
||||
queryFn: () =>
|
||||
fetch(`/api/cycle/cycles?limit=${limit}`).then((r) =>
|
||||
unwrap<CycleHistoryResponse>(r),
|
||||
),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleProfile() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleProfile(),
|
||||
queryFn: () =>
|
||||
fetch("/api/cycle/profile").then((r) => unwrap<CycleProfileDTO>(r)),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The FDR-guarded phase-correlation surface: the luteal-vs-follicular contrast
|
||||
* rows + the one headline finding. Gated server-side; reads only on the cycle
|
||||
* insights tab.
|
||||
*/
|
||||
export function useCycleInsights() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleInsights(),
|
||||
queryFn: () =>
|
||||
fetch("/api/cycle/insights").then((r) =>
|
||||
unwrap<CycleInsightsResponse>(r),
|
||||
),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** A fresh idempotency key per write attempt (matches the iOS Outbox shape). */
|
||||
function idempotencyKey(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function useLogDay() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: CycleDayLogInput) => {
|
||||
const res = await fetch("/api/cycle/day-logs", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey(),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return unwrap<unknown>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The PATCH body for editing an existing day-log. Every field nullable so the
|
||||
* web sheet can CLEAR a previously-set value (the POST merge can only add or
|
||||
* keep — it never nulls an omitted field, so an edit that deselects a chip must
|
||||
* route through PATCH with an explicit null).
|
||||
*/
|
||||
export interface CycleDayLogPatch {
|
||||
flow?: FlowLevel | null;
|
||||
intermenstrualBleeding?: boolean;
|
||||
basalBodyTempC?: number | null;
|
||||
ovulationTest?: OvulationTest | null;
|
||||
cervicalMucus?: CervicalMucus | null;
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: HomeTestResult | null;
|
||||
progesteroneTest?: HomeTestResult | null;
|
||||
contraceptive?: ContraceptiveKind | null;
|
||||
symptoms?: CycleSymptomSelection[];
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
|
||||
export function usePatchDayLog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
patch,
|
||||
}: {
|
||||
id: string;
|
||||
patch: CycleDayLogPatch;
|
||||
}) => {
|
||||
const res = await fetch(`/api/cycle/day-logs/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return unwrap<CycleDayLogDTO>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartPeriod() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (date: string) => {
|
||||
const res = await fetch("/api/cycle/period", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "start",
|
||||
date,
|
||||
loggedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return unwrap<unknown>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
/** The one-tap "end period" boundary (`action:"end"`). */
|
||||
export function useEndPeriod() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (date: string) => {
|
||||
const res = await fetch("/api/cycle/period", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "end",
|
||||
date,
|
||||
loggedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return unwrap<unknown>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete a logged day by its row id (the GET supplies the id). */
|
||||
export function useDeleteDayLog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/cycle/day-logs/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error(`Request failed: ${res.status}`);
|
||||
}
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete EVERY cycle row the user owns (day-logs, cycles, predictions,
|
||||
* the cycle audit trail, and the cycle reminder-delivery ledger rows). The
|
||||
* privacy "purge" the post-Dobbs threat model promises — distinct from the
|
||||
* per-row soft-delete. Invalidates the whole cycle prefix + the nav gate.
|
||||
*/
|
||||
export function useDeleteAllCycleData() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/cycle/all", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
return unwrap<{ purged: boolean }>(res);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface CyclePrefsPatch {
|
||||
enabled?: boolean;
|
||||
goal?: CycleProfileDTO["goal"];
|
||||
rawChartMode?: boolean;
|
||||
predictionEnabled?: boolean;
|
||||
discreetNotifications?: boolean;
|
||||
sensitiveCategoryEncryption?: boolean;
|
||||
typicalCycleLength?: number | null;
|
||||
typicalPeriodLength?: number | null;
|
||||
lutealPhaseLength?: number | null;
|
||||
}
|
||||
|
||||
export function useUpdateCyclePrefs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (patch: CyclePrefsPatch) => {
|
||||
const res = await fetch("/api/auth/me/cycle-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return unwrap<CycleProfileDTO>(res);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
// The enable flag + goal live on /api/auth/me too — repaint the nav gate.
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.15.0 — derive the cycle-wheel state (day-of-cycle, current phase,
|
||||
* proportional phase spans) from the calendar read.
|
||||
*
|
||||
* The calendar read already labels each day with its `phase`; we walk
|
||||
* backward from today to the start of the current run of consecutive
|
||||
* non-null phase days to find the cycle's start, count the day-of-cycle, and
|
||||
* tally each phase's share of that run for the proportional ring segments.
|
||||
* Pure + deterministic so it matches the server engine's labels 1:1.
|
||||
*/
|
||||
import type { CalendarDay, CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface WheelState {
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
|
||||
* the first day of the most recent MENSTRUAL block at/before today with no phase
|
||||
* gap. The ONE source of truth both `currentCycleStartDate` and
|
||||
* `deriveWheelState` use, so the BBT chart, the day-of-cycle count, and the ring
|
||||
* spans can never disagree on where the current cycle begins. Assumes
|
||||
* `sorted[todayIdx].phase != null` (the callers check first).
|
||||
*/
|
||||
function findCycleStartIndex(sorted: CalendarDay[], todayIdx: number): number {
|
||||
let startIdx = todayIdx;
|
||||
for (let i = todayIdx; i >= 0; i--) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
startIdx = i;
|
||||
if (
|
||||
d.phase === "MENSTRUAL" &&
|
||||
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return startIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first day (`YYYY-MM-DD`) of the current cycle, or null when today has no
|
||||
* phase (no active cycle). Scopes the BBT chart to the same cycle the wheel
|
||||
* shows.
|
||||
*/
|
||||
export function currentCycleStartDate(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
): string | null {
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
|
||||
return sorted[findCycleStartIndex(sorted, todayIdx)].date;
|
||||
}
|
||||
|
||||
export function deriveWheelState(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
): WheelState {
|
||||
const byDate = new Map(days.map((d) => [d.date, d]));
|
||||
const todayDay = byDate.get(today);
|
||||
if (!todayDay || todayDay.phase == null) {
|
||||
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
|
||||
}
|
||||
|
||||
// Sort dates ascending and find today's index.
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0) {
|
||||
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
|
||||
}
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// Tally each phase's day-count across the run + the forward predicted run
|
||||
// (so the ring shows the whole cycle, not just elapsed days).
|
||||
const counts: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 0,
|
||||
OVULATORY: 0,
|
||||
LUTEAL: 0,
|
||||
};
|
||||
// Include forward days until the next MENSTRUAL block to bound a full cycle.
|
||||
let end = todayIdx;
|
||||
for (let i = todayIdx + 1; i < sorted.length; i++) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
if (d.phase === "MENSTRUAL") break;
|
||||
end = i;
|
||||
}
|
||||
const fullRun = sorted.slice(startIdx, end + 1);
|
||||
for (const d of fullRun) {
|
||||
if (d.phase) counts[d.phase] += 1;
|
||||
}
|
||||
const total = fullRun.length || 1;
|
||||
const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({
|
||||
phase,
|
||||
fraction: counts[phase] / total,
|
||||
}));
|
||||
|
||||
return {
|
||||
dayOfCycle,
|
||||
cycleLength: fullRun.length,
|
||||
phase: todayDay.phase,
|
||||
spans,
|
||||
};
|
||||
}
|
||||
@@ -80,6 +80,11 @@ interface SectionAvailability {
|
||||
mood: boolean;
|
||||
compliance: boolean;
|
||||
sleep: boolean;
|
||||
// v1.15.0 — cycle section. Not rendered by this legacy dialog
|
||||
// (SECTION_ORDER omits it); the flagship health-record export surfaces
|
||||
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
|
||||
// typechecks. Optional — the availability endpoint may not provide it.
|
||||
cycle?: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReportSubmitPayload {
|
||||
@@ -541,6 +546,9 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
|
||||
mood: "doctorReport.sections.mood",
|
||||
compliance: "doctorReport.sections.compliance",
|
||||
sleep: "doctorReport.sections.sleep",
|
||||
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
|
||||
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
|
||||
cycle: "doctorReport.sections.cycle",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,23 @@ vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
// `<BottomNav>` reads `cycleTrackingEnabled` from `useAuth()` to gate the
|
||||
// cycle hub entry. Each test mutates `mockUserRef.value` before rendering.
|
||||
const mockUserRef = {
|
||||
value: { id: "u1", cycleTrackingEnabled: false } as {
|
||||
id: string;
|
||||
cycleTrackingEnabled: boolean;
|
||||
} | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUserRef.value,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BottomNav } from "../bottom-nav";
|
||||
|
||||
@@ -108,4 +125,20 @@ describe("<BottomNav> iOS-parity layout", () => {
|
||||
/data-testid="bottom-nav-more"[^>]*aria-label="More"/.test(html);
|
||||
expect(hasLabelFirst || hasTestidFirst).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the cycle hub entry out of the closed-hub strip when the gate is off", () => {
|
||||
mockUserRef.value = { id: "u1", cycleTrackingEnabled: false };
|
||||
const html = render();
|
||||
// Like Measurements / Mood, the cycle entry lives in the More hub
|
||||
// (rendered on open — exercised in e2e), never an always-visible strip.
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
|
||||
it("renders without error when the cycle gate is on (entry rides the More hub)", () => {
|
||||
mockUserRef.value = { id: "u1", cycleTrackingEnabled: true };
|
||||
// The closed-hub SSR markup carries no hub items either way; this guards
|
||||
// that gating on the flag never throws.
|
||||
expect(() => render()).not.toThrow();
|
||||
mockUserRef.value = { id: "u1", cycleTrackingEnabled: false };
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const mockUserRef = {
|
||||
email: "marc@example.com",
|
||||
role: "USER" as "USER" | "ADMIN",
|
||||
avatarUrl: null,
|
||||
cycleTrackingEnabled: false,
|
||||
},
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -54,14 +55,16 @@ function render({
|
||||
pathname = "/",
|
||||
bugReportEnabled = true,
|
||||
role = "USER" as "USER" | "ADMIN",
|
||||
cycleTrackingEnabled = false,
|
||||
}: {
|
||||
pathname?: string;
|
||||
bugReportEnabled?: boolean;
|
||||
role?: "USER" | "ADMIN";
|
||||
cycleTrackingEnabled?: boolean;
|
||||
} = {}) {
|
||||
mockPathnameRef.value = pathname;
|
||||
mockSettingsRef.value = { bugReportEnabled };
|
||||
mockUserRef.value = { ...mockUserRef.value, role };
|
||||
mockUserRef.value = { ...mockUserRef.value, role, cycleTrackingEnabled };
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SidebarNav />
|
||||
@@ -84,6 +87,19 @@ describe("<SidebarNav> bug-report toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> cycle entry gate (v1.15.0)", () => {
|
||||
it("renders the Cycle entry when cycle tracking is enabled", () => {
|
||||
const html = render({ cycleTrackingEnabled: true });
|
||||
expect(html).toContain('href="/cycle"');
|
||||
expect(html).toContain("Cycle");
|
||||
});
|
||||
|
||||
it("hides the Cycle entry when cycle tracking is disabled", () => {
|
||||
const html = render({ cycleTrackingEnabled: false });
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
|
||||
it("no longer renders the deprecated /targets entry", () => {
|
||||
// The Targets (Zielwerte) page is deprecated; target editing moved
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
Home,
|
||||
Lightbulb,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -70,12 +72,27 @@ const MORE_HUB: ReadonlyArray<NavLink> = [
|
||||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||||
];
|
||||
|
||||
// v1.15.0 — the cycle entry, spliced into the More hub only when the
|
||||
// account's `cycleTrackingEnabled` gate is true. Hidden for everyone else.
|
||||
const CYCLE_HUB_ITEM: NavLink = {
|
||||
href: "/cycle",
|
||||
tKey: "nav.cycle",
|
||||
icon: Droplets,
|
||||
};
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// v1.15.0 — splice the cycle entry into the More hub after Mood when the
|
||||
// account's gate is on, so it never appears for accounts without it.
|
||||
const moreHub: ReadonlyArray<NavLink> = user?.cycleTrackingEnabled
|
||||
? [MORE_HUB[0], MORE_HUB[1], CYCLE_HUB_ITEM, ...MORE_HUB.slice(2)]
|
||||
: MORE_HUB;
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
return href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
}
|
||||
@@ -83,7 +100,7 @@ export function BottomNav() {
|
||||
// The "More" entry is treated as active when any of its children
|
||||
// is the current page — gives the user a clue that they've drilled
|
||||
// into a sub-route via the hub.
|
||||
const moreActive = MORE_HUB.some((item) => isActiveLink(item.href));
|
||||
const moreActive = moreHub.some((item) => isActiveLink(item.href));
|
||||
|
||||
function renderPrimary(item: NavLink) {
|
||||
const isActive = isActiveLink(item.href);
|
||||
@@ -190,7 +207,7 @@ export function BottomNav() {
|
||||
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid grid-cols-2 gap-2 px-4 pb-4">
|
||||
{MORE_HUB.map((item) => {
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Bug,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Droplets,
|
||||
Home,
|
||||
Lightbulb,
|
||||
LogOut,
|
||||
@@ -80,6 +81,16 @@ const navItems = [
|
||||
},
|
||||
];
|
||||
|
||||
// v1.15.0 — the cycle nav entry, appended to the main list only when the
|
||||
// account's `cycleTrackingEnabled` gate is true (resolved on `/api/auth/me`).
|
||||
// Hidden by construction for accounts without the feature.
|
||||
const cycleNavItem = {
|
||||
href: "/cycle",
|
||||
tKey: "nav.cycle",
|
||||
icon: Droplets,
|
||||
tourId: "nav-cycle",
|
||||
} as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
@@ -249,6 +260,10 @@ export function SidebarNav() {
|
||||
// with no sub-item expansion in the global sidebar — `<AdminShell>`
|
||||
// renders its own per-section nav inside the page itself.
|
||||
const onAdminPage = pathname === "/admin" || pathname.startsWith("/admin/");
|
||||
// v1.15.0 — append the cycle entry only when the gate resolves true.
|
||||
const visibleNavItems = user?.cycleTrackingEnabled
|
||||
? [...navItems, cycleNavItem]
|
||||
: navItems;
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
@@ -350,7 +365,7 @@ export function SidebarNav() {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
|
||||
@@ -61,6 +61,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -56,6 +52,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
|
||||
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
|
||||
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -448,6 +445,7 @@ export function AccountSection() {
|
||||
<option value="">{t("settings.genderNone")}</option>
|
||||
<option value="MALE">{t("settings.genderMale")}</option>
|
||||
<option value="FEMALE">{t("settings.genderFemale")}</option>
|
||||
<option value="OTHER">{t("settings.genderOther")}</option>
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.genderHint")}
|
||||
@@ -525,7 +523,9 @@ export function AccountSection() {
|
||||
{t("settings.identity.description")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="full-name">{t("settings.identity.fullName")}</Label>
|
||||
<Label htmlFor="full-name">
|
||||
{t("settings.identity.fullName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="full-name"
|
||||
value={fullName}
|
||||
@@ -537,7 +537,9 @@ export function AccountSection() {
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="insurer">{t("settings.identity.insurer")}</Label>
|
||||
<Label htmlFor="insurer">
|
||||
{t("settings.identity.insurer")}
|
||||
</Label>
|
||||
<Input
|
||||
id="insurer"
|
||||
value={insurerName}
|
||||
@@ -572,9 +574,7 @@ export function AccountSection() {
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
saveMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
saveMsgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{saveMsg}
|
||||
@@ -600,6 +600,11 @@ export function AccountSection() {
|
||||
timezone dropdown. */}
|
||||
<InjectionSitesCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
|
||||
lets any account opt in (or opt out) before the gated /cycle page is
|
||||
reachable. */}
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Passkeys card */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<SettingsCardHeader
|
||||
@@ -626,9 +631,7 @@ export function AccountSection() {
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-right text-sm ${
|
||||
passkeyMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
passkeyMsgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{passkeyMsg}
|
||||
@@ -1073,7 +1076,8 @@ function AvatarSection() {
|
||||
const res = await fetch("/api/user/avatar", { method: "POST", body });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 415) throw new Error(t("settings.avatar.invalidType"));
|
||||
if (res.status === 415)
|
||||
throw new Error(t("settings.avatar.invalidType"));
|
||||
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
|
||||
throw new Error(json.error || t("settings.avatar.error"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { CalendarHeart } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.15.0 — the cycle-tracking enable on-ramp.
|
||||
*
|
||||
* The cycle vertical auto-enables for accounts set to female (gender-derived
|
||||
* default), but everyone else needs a way IN: the gated `/cycle` page bounces
|
||||
* them home and the in-page settings (where the goal/prefs live) sit behind
|
||||
* that same gate. This card reads the UNGATED `/api/auth/me/cycle-prefs` so any
|
||||
* account can flip `cycleTrackingEnabled` on (or a female account can opt out)
|
||||
* before the gated page is reachable. PATCHes the same prefs route the iOS app
|
||||
* uses; invalidates the nav gate so the sidebar/bottom-nav entry appears.
|
||||
*/
|
||||
|
||||
interface CyclePrefsShape {
|
||||
cycleTrackingEnabled: boolean;
|
||||
}
|
||||
|
||||
export function CycleTrackingCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/cycle-prefs");
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
return (await res.json()).data as CyclePrefsShape;
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const res = await fetch("/api/auth/me/cycle-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
|
||||
return (await res.json()).data as CyclePrefsShape;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
||||
// The nav gate reads the resolved enable state off /auth/me.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = data?.cycleTrackingEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.cycleTracking.title")}
|
||||
description={t("settings.cycleTracking.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label htmlFor="cycle-tracking-enable" className="text-sm">
|
||||
{t("settings.cycleTracking.enable")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="cycle-tracking-enable"
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending || data == null}
|
||||
onCheckedChange={(v) => toggle.mutate(v)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/cycle">{t("settings.cycleTracking.manage")}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{toggle.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("settings.cycleTracking.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,13 @@ export interface AuthUser {
|
||||
*/
|
||||
insurerIkNumber: string | null;
|
||||
insuranceNumber: string | null;
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
|
||||
* + the per-user opt-in. The cycle nav entry + page hide when false; every
|
||||
* `/api/cycle/*` route also enforces the gate. Coerced to `false` against a
|
||||
* stale /me payload (older server image without the field) in `fetchMe`.
|
||||
*/
|
||||
cycleTrackingEnabled: boolean;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
@@ -80,6 +87,9 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
// v1.7.0 — coerce against a stale /me payload (older server image
|
||||
// without the field) so the display defaults to metric.
|
||||
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
|
||||
// v1.15.0 — coerce against a stale /me payload so the cycle nav entry
|
||||
// stays hidden by default when the field is absent.
|
||||
cycleTrackingEnabled: data.cycleTrackingEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { buildCycleSnapshotBlock } from "../cycle-snapshot";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const prismaMock = prisma as unknown as {
|
||||
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
|
||||
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
user: { findUnique: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-03-22T12:00:00.000Z");
|
||||
|
||||
function profile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
goal: "GENERAL_HEALTH",
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
lutealPhaseLength: 14,
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Three completed 28-day cycles ending just before NOW. */
|
||||
function cycles() {
|
||||
return [
|
||||
{
|
||||
startDate: "2026-01-05",
|
||||
endDate: "2026-02-02",
|
||||
periodEndDate: "2026-01-09",
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
},
|
||||
{
|
||||
startDate: "2026-02-02",
|
||||
endDate: "2026-03-02",
|
||||
periodEndDate: "2026-02-06",
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
},
|
||||
{
|
||||
startDate: "2026-03-02",
|
||||
endDate: null,
|
||||
periodEndDate: "2026-03-06",
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* RHR rows: luteal days clearly higher than follicular days. Both windows land
|
||||
* within `[cycle start (2026-03-02), today (2026-03-22)]` so the phase-day map
|
||||
* (which only runs up to `today`) labels every one.
|
||||
*/
|
||||
function rhrMeasurements() {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
const day = (i: number, v: number) =>
|
||||
out.push({
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: v + (i % 3) * 0.5,
|
||||
measuredAt: new Date(`2026-03-${String(i).padStart(2, "0")}T12:00:00.000Z`),
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
});
|
||||
// Follicular window ~ cycle days 6..12 (2026-03-07..03-13).
|
||||
for (let i = 7; i <= 13; i++) day(i, 57);
|
||||
// Luteal window ~ cycle days 16..21 (2026-03-17..03-22).
|
||||
for (let i = 17; i <= 22; i++) day(i, 63);
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.cycleDayLog.findMany.mockResolvedValue([]);
|
||||
prismaMock.user.findUnique.mockResolvedValue({ sourcePriorityJson: null });
|
||||
// The wrist-temperature read + the outcome-metric read share the same mock;
|
||||
// default to empty, override per-test for the metric read.
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("buildCycleSnapshotBlock", () => {
|
||||
it("returns null when the account has no cycle profile", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(null);
|
||||
expect(await buildCycleSnapshotBlock("u1", "FEMALE", NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the account has no observed cycles", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue([]);
|
||||
expect(await buildCycleSnapshotBlock("u1", "FEMALE", NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the current phase, day-of-cycle and next predicted event", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
|
||||
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
|
||||
expect(block).not.toBeNull();
|
||||
// 2026-03-22 is day 21 of a cycle that started 2026-03-02 → luteal.
|
||||
expect(block!.phase).toBe("LUTEAL");
|
||||
expect(block!.dayOfCycle).toBe(21);
|
||||
expect(block!.cyclesObserved).toBe(3);
|
||||
expect(block!.nextEvent).not.toBeNull();
|
||||
expect(block!.nextEvent!.nextPeriodStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(block!.nextEvent!.method).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses the fertile window unless the goal surfaces it", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(
|
||||
profile({ goal: "GENERAL_HEALTH" }),
|
||||
);
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
|
||||
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
|
||||
expect(block!.nextEvent!.fertileWindowStart).toBeUndefined();
|
||||
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(
|
||||
profile({ goal: "TRYING_TO_CONCEIVE" }),
|
||||
);
|
||||
const ttc = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
|
||||
// The conception goal may surface the window (present when the engine
|
||||
// computes one) — the key invariant is the GENERAL_HEALTH path never does.
|
||||
expect(
|
||||
ttc!.nextEvent!.fertileWindowStart === undefined ||
|
||||
typeof ttc!.nextEvent!.fertileWindowStart === "string",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("derives 'today' from the threaded user timezone, not wall-clock", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
|
||||
|
||||
// NOW is 2026-03-22T12:00Z. In Berlin (UTC+1) the local day is the
|
||||
// 22nd → day-of-cycle 21 (cycle started 2026-03-02). In Kiritimati
|
||||
// (UTC+14) the local day has already rolled to the 23rd → day 22.
|
||||
const berlin = await buildCycleSnapshotBlock(
|
||||
"u1",
|
||||
"FEMALE",
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
|
||||
const kiritimati = await buildCycleSnapshotBlock(
|
||||
"u1",
|
||||
"FEMALE",
|
||||
NOW,
|
||||
"Pacific/Kiritimati",
|
||||
);
|
||||
|
||||
expect(berlin!.dayOfCycle).toBe(21);
|
||||
expect(kiritimati!.dayOfCycle).toBe(22);
|
||||
});
|
||||
|
||||
it("includes the headline phase insight when a contrast clears FDR", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
|
||||
// First measurement read = WRIST_TEMPERATURE (empty), second = outcomes.
|
||||
prismaMock.measurement.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(rhrMeasurements());
|
||||
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
|
||||
expect(block!.phaseInsight).not.toBeNull();
|
||||
expect(block!.phaseInsight!.metric).toBe("resting heart rate");
|
||||
expect(block!.phaseInsight!.delta).toBeGreaterThan(0);
|
||||
expect(block!.phaseInsight!.interpretation).toMatch(/descriptive pattern, not a cause/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Cycle/phase snapshot block builder for the Coach prompt (v1.15).
|
||||
*
|
||||
* Adds a cycle dimension to the Coach snapshot so the model can say "you are on
|
||||
* day 22, luteal phase" and reference the user's own phase-correlation finding,
|
||||
* grounded in the same engine the calendar + insights surface use — never
|
||||
* re-derived. The block carries three things, each present only when actually
|
||||
* computed (shown == sent):
|
||||
*
|
||||
* 1. current phase + 1-based day-of-cycle (from the deterministic phase
|
||||
* engine),
|
||||
* 2. the next predicted event — period range + confidence + method, plus the
|
||||
* fertile window ONLY when the goal surfaces it (TRYING_TO_CONCEIVE),
|
||||
* 3. the ONE headline phase insight — the FDR-surviving luteal-vs-follicular
|
||||
* contrast (the comparison already finished, the metric-signal posture:
|
||||
* hand the model the numbers, do not make it re-derive them).
|
||||
*
|
||||
* Gated entirely on `isCycleEnabled` by the caller, so a non-cycle account's
|
||||
* snapshot is byte-for-byte unchanged. The Coach system prompt's cycle ground
|
||||
* rule keeps this descriptive only — never contraception-grade, never a "safe
|
||||
* day" claim.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
predictCycle,
|
||||
type NightlyTempInput,
|
||||
} from "@/lib/cycle";
|
||||
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
|
||||
import {
|
||||
phaseForDate,
|
||||
buildPhaseDayMap,
|
||||
toCycleInputs,
|
||||
toDayLogInputs,
|
||||
} from "@/lib/cycle/engine-adapter";
|
||||
import type { CycleProfileInput } from "@/lib/cycle/types";
|
||||
import { goalAllowsFertileWindow } from "@/lib/cycle/dto";
|
||||
import {
|
||||
computePhaseMetricCrosstab,
|
||||
selectHeadlinePhaseRow,
|
||||
PHASE_CROSSTAB_METRIC_TYPES,
|
||||
PHASE_CROSSTAB_METRICS,
|
||||
type PhaseMetricCrosstabRow,
|
||||
} from "@/lib/cycle/phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
|
||||
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
|
||||
const WINDOW_DAYS = 365;
|
||||
|
||||
/** Natural-language metric labels (no enum leak into the prompt). */
|
||||
const METRIC_LABEL: Record<string, string> = {
|
||||
restingHeartRate: "resting heart rate",
|
||||
heartRateVariability: "heart-rate variability",
|
||||
sleepDuration: "sleep duration",
|
||||
steps: "steps",
|
||||
weight: "weight",
|
||||
basalBodyTemp: "basal body temperature",
|
||||
wristTemperature: "overnight wrist temperature",
|
||||
skinTemperature: "skin temperature",
|
||||
};
|
||||
|
||||
const UNIT_LABEL: Record<string, string> = {
|
||||
hours: "h",
|
||||
steps: "steps",
|
||||
bpm: "bpm",
|
||||
ms: "ms",
|
||||
kg: "kg",
|
||||
celsius: "°C",
|
||||
};
|
||||
|
||||
interface PhaseInsightBlock {
|
||||
metric: string;
|
||||
unit: string;
|
||||
lutealAvg: number;
|
||||
follicularAvg: number;
|
||||
/** lutealAvg − follicularAvg; positive = higher in the luteal phase. */
|
||||
delta: number;
|
||||
/** The finding's confidence band (p + per-group day count). */
|
||||
confidence: PhaseMetricCrosstabRow["confidence"];
|
||||
/** Benjamini-Hochberg q-value across the tested family. */
|
||||
qValue: number;
|
||||
lutealDays: number;
|
||||
follicularDays: number;
|
||||
/** One-line descriptive interpretation — never causal. */
|
||||
interpretation: string;
|
||||
}
|
||||
|
||||
interface NextEventBlock {
|
||||
nextPeriodStart: string;
|
||||
nextPeriodStartLow: string;
|
||||
nextPeriodStartHigh: string;
|
||||
daysUntilPeriod: number;
|
||||
method: string;
|
||||
confidence: number;
|
||||
stillLearning: boolean;
|
||||
/** Goal-gated — present only when the goal surfaces the fertile window. */
|
||||
fertileWindowStart?: string;
|
||||
fertileWindowEnd?: string;
|
||||
}
|
||||
|
||||
export interface CycleSnapshotBlock {
|
||||
phase: CyclePhase | null;
|
||||
dayOfCycle: number | null;
|
||||
goal: string;
|
||||
cyclesObserved: number;
|
||||
nextEvent: NextEventBlock | null;
|
||||
phaseInsight: PhaseInsightBlock | null;
|
||||
}
|
||||
|
||||
/** Descriptive, never-causal interpretation of the headline contrast. */
|
||||
function interpretHeadline(row: PhaseMetricCrosstabRow): string {
|
||||
const metric = METRIC_LABEL[row.metricKey] ?? row.metricKey;
|
||||
const dir = row.delta >= 0 ? "higher" : "lower";
|
||||
const mag = Math.abs(row.delta);
|
||||
const unit = UNIT_LABEL[row.display] ?? "";
|
||||
return `Your ${metric} runs about ${mag}${unit ? " " + unit : ""} ${dir} on luteal-phase days than follicular-phase days in your own data — a descriptive pattern, not a cause.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cycle snapshot block. Returns `null` when there is nothing to say
|
||||
* (no observed cycle → no phase, no prediction, no insight) so the caller omits
|
||||
* the field entirely. The caller has already confirmed `isCycleEnabled`.
|
||||
*/
|
||||
export async function buildCycleSnapshotBlock(
|
||||
userId: string,
|
||||
gender: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
timezone: string | null | undefined = DEFAULT_TIMEZONE,
|
||||
): Promise<CycleSnapshotBlock | null> {
|
||||
// Test environments may mock only part of prisma — bow out silently if the
|
||||
// cycle models are absent (matches "no cycle data" production behaviour).
|
||||
if (typeof prisma?.menstrualCycle?.findMany !== "function") return null;
|
||||
if (typeof prisma?.cycleProfile?.findUnique !== "function") return null;
|
||||
|
||||
const profile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
goal: true,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: true,
|
||||
lutealPhaseLength: true,
|
||||
typicalCycleLength: true,
|
||||
typicalPeriodLength: true,
|
||||
},
|
||||
});
|
||||
if (!profile) return null;
|
||||
|
||||
const profileInput: CycleProfileInput = {
|
||||
goal: profile.goal,
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
predictionEnabled: profile.predictionEnabled,
|
||||
rawChartMode: profile.rawChartMode,
|
||||
};
|
||||
|
||||
// Derive "today" from the user's timezone — matches how the calendar
|
||||
// (calendar/route.ts) and insights routes resolve it, so the Coach's
|
||||
// "you are on day N / period in M days" never disagrees with the calendar
|
||||
// near local midnight.
|
||||
const tz = timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(now, tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
const [cycles, dayLogRows, nightlyTempRows, measurementRows, userRow] =
|
||||
await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
flow: true,
|
||||
basalBodyTempC: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
},
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { sourcePriorityJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (cycles.length === 0) return null;
|
||||
|
||||
const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
|
||||
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
|
||||
|
||||
const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, tz),
|
||||
valueC: m.value,
|
||||
}));
|
||||
const prediction =
|
||||
profile.predictionEnabled && !profile.rawChartMode
|
||||
? predictCycle(
|
||||
toCycleInputs(cycles),
|
||||
toDayLogInputs(dayLogRows),
|
||||
profileInput,
|
||||
today,
|
||||
nights,
|
||||
)
|
||||
: null;
|
||||
|
||||
const { phase, dayOfCycle } = phaseForDate(
|
||||
today,
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
);
|
||||
|
||||
// Next predicted event — period range + confidence + method, fertile window
|
||||
// goal-gated.
|
||||
let nextEvent: NextEventBlock | null = null;
|
||||
if (prediction) {
|
||||
const daysUntilPeriod = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(Date.parse(`${prediction.nextPeriodStart}T12:00:00Z`) -
|
||||
Date.parse(`${today}T12:00:00Z`)) /
|
||||
86_400_000,
|
||||
),
|
||||
);
|
||||
nextEvent = {
|
||||
nextPeriodStart: prediction.nextPeriodStart,
|
||||
nextPeriodStartLow: prediction.nextPeriodStartLow,
|
||||
nextPeriodStartHigh: prediction.nextPeriodStartHigh,
|
||||
daysUntilPeriod,
|
||||
method: prediction.method,
|
||||
confidence: prediction.confidence,
|
||||
stillLearning: prediction.stillLearning,
|
||||
...(goalAllowsFertile && prediction.fertileWindowStart
|
||||
? { fertileWindowStart: prediction.fertileWindowStart }
|
||||
: {}),
|
||||
...(goalAllowsFertile && prediction.fertileWindowEnd
|
||||
? { fertileWindowEnd: prediction.fertileWindowEnd }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Headline phase insight — the FDR-surviving luteal-vs-follicular contrast.
|
||||
const phaseByDay = buildPhaseDayMap(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
from,
|
||||
today,
|
||||
);
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
source: m.source,
|
||||
deviceType: m.deviceType,
|
||||
}));
|
||||
const headlineRow = selectHeadlinePhaseRow(
|
||||
computePhaseMetricCrosstab({
|
||||
phaseByDay,
|
||||
measurements,
|
||||
userPriorityJson: userRow?.sourcePriorityJson ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
let phaseInsight: PhaseInsightBlock | null = null;
|
||||
if (headlineRow) {
|
||||
const cfg = PHASE_CROSSTAB_METRICS[headlineRow.metricKey];
|
||||
phaseInsight = {
|
||||
metric: METRIC_LABEL[headlineRow.metricKey] ?? headlineRow.metricKey,
|
||||
unit: UNIT_LABEL[cfg.display] ?? "",
|
||||
lutealAvg: headlineRow.lutealAvg,
|
||||
follicularAvg: headlineRow.follicularAvg,
|
||||
delta: headlineRow.delta,
|
||||
confidence: headlineRow.confidence,
|
||||
qValue: headlineRow.qValue,
|
||||
lutealDays: headlineRow.lutealDays,
|
||||
follicularDays: headlineRow.follicularDays,
|
||||
interpretation: interpretHeadline(headlineRow),
|
||||
};
|
||||
}
|
||||
|
||||
// Nothing worth saying → omit the block.
|
||||
if (phase === null && nextEvent === null && phaseInsight === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
dayOfCycle,
|
||||
goal: profile.goal,
|
||||
cyclesObserved: cycles.length,
|
||||
nextEvent,
|
||||
phaseInsight,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
|
||||
import { buildCoachMemoryBlock } from "./memory-snapshot";
|
||||
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
|
||||
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import type { BaselineProfile } from "@/lib/insights/derived";
|
||||
import {
|
||||
CLUSTER_PRIORITY,
|
||||
@@ -466,6 +468,11 @@ async function buildCoachSnapshotImpl(
|
||||
// v1.11.5 — needed to collapse a dual-source sleep night to one
|
||||
// canonical source before reconstructing per-night asleep totals.
|
||||
sourcePriorityJson: true,
|
||||
// v1.15 — the cycle snapshot block is gated on the resolved cycle
|
||||
// toggle (an explicit opt-in/out overrides the gender default). Read
|
||||
// both columns here so a non-cycle account pays no extra round-trip.
|
||||
gender: true,
|
||||
cycleProfile: { select: { cycleTrackingEnabled: true } },
|
||||
},
|
||||
});
|
||||
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
@@ -795,6 +802,18 @@ async function buildCoachSnapshotImpl(
|
||||
now,
|
||||
coachLocale,
|
||||
);
|
||||
// v1.15 — cycle/phase block, gated on the resolved cycle toggle so a
|
||||
// non-cycle account issues no query (the helper short-circuits to null
|
||||
// before any read for a disabled account). The block is descriptive only —
|
||||
// current phase + day-of-cycle, the next predicted event (period range,
|
||||
// fertile window goal-gated), and the headline phase-correlation finding.
|
||||
const cycleEnabled = isCycleEnabled(
|
||||
prefsRow?.gender,
|
||||
prefsRow?.cycleProfile ?? null,
|
||||
);
|
||||
const cycleBlockPromise = cycleEnabled
|
||||
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
|
||||
: null;
|
||||
|
||||
const [
|
||||
moodRows,
|
||||
@@ -805,6 +824,7 @@ async function buildCoachSnapshotImpl(
|
||||
derivedBlock,
|
||||
trajectoryBlock,
|
||||
memoryBlock,
|
||||
cycleBlock,
|
||||
] = await Promise.all([
|
||||
moodRowsPromise,
|
||||
intakeRowsPromise,
|
||||
@@ -814,6 +834,7 @@ async function buildCoachSnapshotImpl(
|
||||
derivedBlockPromise,
|
||||
trajectoryBlockPromise,
|
||||
memoryBlockPromise,
|
||||
cycleBlockPromise,
|
||||
]);
|
||||
|
||||
const byType = (t: string) =>
|
||||
@@ -1347,6 +1368,22 @@ async function buildCoachSnapshotImpl(
|
||||
registerBlock("memory", "skin_temp");
|
||||
}
|
||||
|
||||
// ── v1.15 — cycle/phase block ────────────────────────────────────────
|
||||
//
|
||||
// Present only for a cycle-enabled account (the promise is null otherwise,
|
||||
// so this is byte-for-byte unchanged for everyone else). The block names the
|
||||
// current phase + day-of-cycle, the next predicted event (period range +
|
||||
// confidence + method; fertile window goal-gated), and the headline
|
||||
// phase-correlation finding — all from the same deterministic engine the
|
||||
// calendar + insights surface use, never re-derived. The Coach's cycle
|
||||
// ground rule keeps replies descriptive: never contraception-grade, never a
|
||||
// "safe day" claim. Registered against the lowest-priority `skin_temp`
|
||||
// source so the soft-cap degrader sheds it before any clinical cluster.
|
||||
if (cycleBlock) {
|
||||
snapshot.cycle = cycleBlock;
|
||||
registerBlock("cycle", "skin_temp");
|
||||
}
|
||||
|
||||
if (Object.keys(snapshot).length === 0) {
|
||||
metrics.add("general");
|
||||
}
|
||||
|
||||
@@ -159,6 +159,20 @@ GROUND RULES
|
||||
Never invent or round a number the block does not carry. When
|
||||
NO "trajectory" block is present, do not project at all —
|
||||
pivot to the observed pattern instead.
|
||||
12. Cycle data is DESCRIPTIVE ONLY. When a "cycle" block is present
|
||||
in the SNAPSHOT you may state the user's current phase and
|
||||
day-of-cycle, the next predicted period as a RANGE (reading
|
||||
"nextEvent" straight from the block, always conditional), and
|
||||
the headline phase finding ("phaseInsight") in the block's own
|
||||
observational wording — never as a cause. NEVER frame any
|
||||
prediction as contraception-grade, NEVER name a "safe day" or
|
||||
"unsafe day", NEVER advise on conception timing beyond what the
|
||||
goal-gated "fertileWindow" already states descriptively, and
|
||||
NEVER present a predicted date as a certainty. If asked to use
|
||||
the cycle for contraception or to guarantee a fertile/infertile
|
||||
day, decline and say the forecast is a descriptive estimate, not
|
||||
a contraceptive method. When NO "cycle" block is present, do not
|
||||
infer or guess a phase.
|
||||
|
||||
DAY-LEVEL READINGS — USE THE TIMELINE
|
||||
|
||||
@@ -445,6 +459,22 @@ GRUNDREGELN
|
||||
Erfinde oder runde nie eine Zahl, die der Block nicht enthält.
|
||||
Wenn KEIN "trajectory"-Block vorhanden ist, projiziere
|
||||
überhaupt nicht — wechsle stattdessen zum beobachteten Muster.
|
||||
12. Zyklusdaten sind AUSSCHLIESSLICH BESCHREIBEND. Wenn ein
|
||||
"cycle"-Block im SNAPSHOT vorhanden ist, darfst du die aktuelle
|
||||
Phase und den Zyklustag nennen, die nächste vorhergesagte
|
||||
Periode als BEREICH ("nextEvent" direkt aus dem Block, immer
|
||||
konditional) und das Phasen-Hauptmuster ("phaseInsight") in der
|
||||
beobachtenden Formulierung des Blocks — nie als Ursache. Stelle
|
||||
eine Vorhersage NIE als verhütungstauglich dar, nenne NIE einen
|
||||
"sicheren" oder "unsicheren" Tag, berate NIE zum
|
||||
Empfängniszeitpunkt über das hinaus, was das ziel-gebundene
|
||||
"fertileWindow" bereits beschreibend angibt, und gib ein
|
||||
vorhergesagtes Datum NIE als Gewissheit aus. Wirst du gebeten,
|
||||
den Zyklus zur Verhütung zu nutzen oder einen fruchtbaren bzw.
|
||||
unfruchtbaren Tag zu garantieren, lehne ab und weise darauf hin,
|
||||
dass die Prognose eine beschreibende Schätzung ist, keine
|
||||
Verhütungsmethode. Wenn KEIN "cycle"-Block vorhanden ist, leite
|
||||
oder rate keine Phase ab.
|
||||
|
||||
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addDays, dayDiff, isOnOrBefore, isWithin, parseDayMs, roundHalf } from "../day-math";
|
||||
|
||||
/**
|
||||
* The day-math is the shared substrate iOS re-implements bit-for-bit. These
|
||||
* tests are written to be TIMEZONE-INDEPENDENT: every assertion is about the
|
||||
* noon-UTC anchored calendar difference, so the result is the same whether the
|
||||
* host runs under TZ=UTC, America/New_York, Pacific/Auckland, or across a DST
|
||||
* transition. No assertion anchors on a local calendar value.
|
||||
*/
|
||||
describe("cycle/day-math", () => {
|
||||
describe("parseDayMs", () => {
|
||||
it("anchors a date at noon UTC", () => {
|
||||
// 2024-03-10 is a US DST spring-forward day; the noon anchor is immune.
|
||||
expect(parseDayMs("2024-03-10")).toBe(Date.parse("2024-03-10T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("throws on a malformed date (fail-closed)", () => {
|
||||
expect(() => parseDayMs("2024-3-10")).toThrow();
|
||||
expect(() => parseDayMs("not-a-date")).toThrow();
|
||||
expect(() => parseDayMs("2024-13-01")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dayDiff", () => {
|
||||
it("counts whole days, sign follows a - b", () => {
|
||||
expect(dayDiff("2024-01-10", "2024-01-01")).toBe(9);
|
||||
expect(dayDiff("2024-01-01", "2024-01-10")).toBe(-9);
|
||||
expect(dayDiff("2024-01-01", "2024-01-01")).toBe(0);
|
||||
});
|
||||
|
||||
it("is stable across a DST spring-forward boundary (no half-day drift)", () => {
|
||||
// US/EU clocks lose an hour around 2024-03-10 / 2024-03-31. The noon-UTC
|
||||
// anchor means a calendar week is still exactly 7 days, not 6 or 8.
|
||||
expect(dayDiff("2024-03-15", "2024-03-08")).toBe(7);
|
||||
expect(dayDiff("2024-03-31", "2024-03-10")).toBe(21);
|
||||
});
|
||||
|
||||
it("is stable across a DST fall-back boundary", () => {
|
||||
expect(dayDiff("2024-11-10", "2024-11-03")).toBe(7);
|
||||
});
|
||||
|
||||
it("counts a 28-day cycle exactly across a month boundary", () => {
|
||||
expect(dayDiff("2024-02-29", "2024-02-01")).toBe(28); // leap year
|
||||
expect(dayDiff("2023-03-01", "2023-02-01")).toBe(28); // non-leap Feb
|
||||
});
|
||||
|
||||
it("spans a leap day correctly", () => {
|
||||
expect(dayDiff("2024-03-01", "2024-02-28")).toBe(2);
|
||||
expect(dayDiff("2023-03-01", "2023-02-28")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDays", () => {
|
||||
it("adds and subtracts across month + year boundaries", () => {
|
||||
expect(addDays("2024-01-31", 1)).toBe("2024-02-01");
|
||||
expect(addDays("2024-02-28", 1)).toBe("2024-02-29"); // leap
|
||||
expect(addDays("2023-02-28", 1)).toBe("2023-03-01"); // non-leap
|
||||
expect(addDays("2023-12-31", 1)).toBe("2024-01-01");
|
||||
expect(addDays("2024-01-01", -1)).toBe("2023-12-31");
|
||||
});
|
||||
|
||||
it("round-trips with dayDiff across a DST boundary", () => {
|
||||
const start = "2024-03-08";
|
||||
expect(addDays(start, 14)).toBe("2024-03-22");
|
||||
expect(dayDiff(addDays(start, 14), start)).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundHalf", () => {
|
||||
it("rounds half UP (away from zero), matching Swift toNearestOrAwayFromZero", () => {
|
||||
expect(roundHalf(2.5)).toBe(3);
|
||||
expect(roundHalf(0.5)).toBe(1);
|
||||
expect(roundHalf(-2.5)).toBe(-3);
|
||||
expect(roundHalf(-0.5)).toBe(-1);
|
||||
});
|
||||
|
||||
it("rounds to k decimal places", () => {
|
||||
expect(roundHalf(2.345, 2)).toBe(2.35);
|
||||
expect(roundHalf(-2.345, 2)).toBe(-2.35);
|
||||
expect(roundHalf(0.155, 2)).toBe(0.16);
|
||||
});
|
||||
|
||||
it("passes non-finite through unchanged", () => {
|
||||
expect(roundHalf(NaN)).toBeNaN();
|
||||
expect(roundHalf(Infinity)).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOnOrBefore / isWithin", () => {
|
||||
it("handles inclusive spans", () => {
|
||||
expect(isOnOrBefore("2024-01-01", "2024-01-01")).toBe(true);
|
||||
expect(isOnOrBefore("2024-01-02", "2024-01-01")).toBe(false);
|
||||
expect(isWithin("2024-01-05", "2024-01-01", "2024-01-10")).toBe(true);
|
||||
expect(isWithin("2024-01-01", "2024-01-01", "2024-01-10")).toBe(true);
|
||||
expect(isWithin("2024-01-10", "2024-01-01", "2024-01-10")).toBe(true);
|
||||
expect(isWithin("2024-01-11", "2024-01-01", "2024-01-10")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.15.0 — calendar composition over the deterministic engine.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildCalendar, type CalendarDayLogRow } from "../engine-adapter";
|
||||
import type { CycleProfile, MenstrualCycle } from "@/generated/prisma/client";
|
||||
|
||||
function profile(overrides: Partial<CycleProfile> = {}): CycleProfile {
|
||||
return {
|
||||
id: "p1",
|
||||
userId: "u1",
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
discreetNotifications: false,
|
||||
sensitiveCategoryEncryption: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as CycleProfile;
|
||||
}
|
||||
|
||||
function cycle(startDate: string, overrides: Partial<MenstrualCycle> = {}): MenstrualCycle {
|
||||
return {
|
||||
id: `c-${startDate}`,
|
||||
userId: "u1",
|
||||
startDate,
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
lengthDays: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
isPredicted: false,
|
||||
tz: null,
|
||||
syncVersion: 0,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as MenstrualCycle;
|
||||
}
|
||||
|
||||
describe("buildCalendar", () => {
|
||||
// Three ~28-day cycles → a real prediction.
|
||||
const cycles = [
|
||||
cycle("2026-01-01", { endDate: "2026-01-28", lengthDays: 28, periodEndDate: "2026-01-05" }),
|
||||
cycle("2026-01-29", { endDate: "2026-02-25", lengthDays: 28, periodEndDate: "2026-02-02" }),
|
||||
cycle("2026-02-26", { periodEndDate: "2026-03-02" }),
|
||||
];
|
||||
|
||||
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 },
|
||||
];
|
||||
const { prediction, days } = buildCalendar(
|
||||
profile(),
|
||||
cycles,
|
||||
dayLogs,
|
||||
[],
|
||||
"2026-02-20",
|
||||
"2026-04-15",
|
||||
"2026-03-10",
|
||||
false,
|
||||
);
|
||||
expect(prediction).not.toBeNull();
|
||||
expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const logged = days.find((d) => d.date === "2026-02-26")!;
|
||||
expect(logged.isPeriodLogged).toBe(true);
|
||||
expect(logged.flow).toBe("MEDIUM");
|
||||
expect(logged.hasSymptoms).toBe(true);
|
||||
|
||||
// The forecast next-period start falls inside a predicted-period day.
|
||||
const predStart = prediction!.nextPeriodStart;
|
||||
const predDay = days.find((d) => d.date === predStart);
|
||||
expect(predDay?.isPredictedPeriod).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses the fertile window at the grid when the goal disallows it", () => {
|
||||
const { days } = buildCalendar(
|
||||
profile({ goal: "GENERAL_HEALTH" }),
|
||||
cycles,
|
||||
[],
|
||||
[],
|
||||
"2026-02-20",
|
||||
"2026-04-15",
|
||||
"2026-03-10",
|
||||
false,
|
||||
);
|
||||
expect(days.every((d) => !d.isFertileWindow)).toBe(true);
|
||||
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces the fertile window for the conception goal", () => {
|
||||
const { days } = buildCalendar(
|
||||
profile({ goal: "TRYING_TO_CONCEIVE" }),
|
||||
cycles,
|
||||
[],
|
||||
[],
|
||||
"2026-02-20",
|
||||
"2026-04-15",
|
||||
"2026-03-10",
|
||||
true,
|
||||
);
|
||||
expect(days.some((d) => d.isFertileWindow)).toBe(true);
|
||||
});
|
||||
|
||||
it("emits no prediction in raw-chart mode", () => {
|
||||
const { prediction } = buildCalendar(
|
||||
profile({ rawChartMode: true }),
|
||||
cycles,
|
||||
[],
|
||||
[],
|
||||
"2026-02-20",
|
||||
"2026-04-15",
|
||||
"2026-03-10",
|
||||
false,
|
||||
);
|
||||
expect(prediction).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking feature gate resolver.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { isCycleEnabled, CYCLE_DISABLED_ERROR_CODE } from "../gate";
|
||||
|
||||
describe("isCycleEnabled", () => {
|
||||
it("derives from gender when the toggle is null", () => {
|
||||
expect(isCycleEnabled("FEMALE", { cycleTrackingEnabled: null })).toBe(true);
|
||||
expect(isCycleEnabled("MALE", { cycleTrackingEnabled: null })).toBe(false);
|
||||
expect(isCycleEnabled(null, { cycleTrackingEnabled: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("derives from gender when no profile row exists", () => {
|
||||
expect(isCycleEnabled("FEMALE", null)).toBe(true);
|
||||
expect(isCycleEnabled("MALE", undefined)).toBe(false);
|
||||
expect(isCycleEnabled(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("an explicit true opts a non-FEMALE account in", () => {
|
||||
expect(isCycleEnabled("MALE", { cycleTrackingEnabled: true })).toBe(true);
|
||||
expect(isCycleEnabled(null, { cycleTrackingEnabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("an explicit false opts a FEMALE account out", () => {
|
||||
expect(isCycleEnabled("FEMALE", { cycleTrackingEnabled: false })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("gates on UPPERCASE FEMALE only (server enum spelling)", () => {
|
||||
// Lowercase / other spellings never enable by derivation.
|
||||
expect(isCycleEnabled("female", { cycleTrackingEnabled: null })).toBe(false);
|
||||
expect(isCycleEnabled("OTHER", { cycleTrackingEnabled: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("exports the wire errorCode the iOS retry classifier branches on", () => {
|
||||
expect(CYCLE_DISABLED_ERROR_CODE).toBe("cycle.disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapHkCycleSample,
|
||||
isCycleHkIdentifier,
|
||||
hkSymptomIsPresent,
|
||||
HK_MENSTRUAL_FLOW,
|
||||
HK_INTERMENSTRUAL_BLEEDING,
|
||||
HK_CERVICAL_MUCUS,
|
||||
HK_OVULATION_TEST,
|
||||
HK_SEXUAL_ACTIVITY,
|
||||
HK_PREGNANCY_TEST,
|
||||
HK_PROGESTERONE_TEST,
|
||||
HK_CONTRACEPTIVE,
|
||||
HK_PREGNANCY,
|
||||
HK_LACTATION,
|
||||
} from "../healthkit-mapping";
|
||||
|
||||
describe("cycle/healthkit-mapping — identifier ownership", () => {
|
||||
it("owns the promoted reproductive identifiers", () => {
|
||||
expect(isCycleHkIdentifier(HK_MENSTRUAL_FLOW)).toBe(true);
|
||||
expect(isCycleHkIdentifier(HK_OVULATION_TEST)).toBe(true);
|
||||
expect(isCycleHkIdentifier(HK_CONTRACEPTIVE)).toBe(true);
|
||||
expect(isCycleHkIdentifier("HKCategoryTypeIdentifierAbdominalCramps")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT own pregnancy/lactation status (deferred — no schema target)", () => {
|
||||
expect(isCycleHkIdentifier(HK_PREGNANCY)).toBe(false);
|
||||
expect(isCycleHkIdentifier(HK_LACTATION)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT own unrelated identifiers", () => {
|
||||
expect(isCycleHkIdentifier("HKQuantityTypeIdentifierStepCount")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/healthkit-mapping — menstrual flow", () => {
|
||||
it("maps the symbolic value names to FlowLevel", () => {
|
||||
expect(
|
||||
mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowLight"),
|
||||
).toEqual({ kind: "day-log", fields: { flow: "LIGHT" } });
|
||||
expect(
|
||||
mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowHeavy"),
|
||||
).toEqual({ kind: "day-log", fields: { flow: "HEAVY" } });
|
||||
expect(
|
||||
mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowNone"),
|
||||
).toEqual({ kind: "day-log", fields: { flow: "NONE" } });
|
||||
});
|
||||
|
||||
it("maps the integer codepoints (live-API form)", () => {
|
||||
expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "2")).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { flow: "LIGHT" },
|
||||
});
|
||||
expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "4")).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { flow: "HEAVY" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps HK unspecified → LIGHT (conservative boundary)", () => {
|
||||
expect(
|
||||
mapHkCycleSample(
|
||||
HK_MENSTRUAL_FLOW,
|
||||
"HKCategoryValueMenstrualFlowUnspecified",
|
||||
),
|
||||
).toEqual({ kind: "day-log", fields: { flow: "LIGHT" } });
|
||||
});
|
||||
|
||||
it("skips an unrecognised flow value", () => {
|
||||
expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "garbage")).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/healthkit-mapping — ovulation + cervical mucus + tests", () => {
|
||||
it("maps ovulation results", () => {
|
||||
expect(
|
||||
mapHkCycleSample(
|
||||
HK_OVULATION_TEST,
|
||||
"HKCategoryValueOvulationTestResultLuteinizingHormoneSurge",
|
||||
),
|
||||
).toEqual({ kind: "day-log", fields: { ovulationTest: "POSITIVE_LH_SURGE" } });
|
||||
expect(mapHkCycleSample(HK_OVULATION_TEST, "4")).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { ovulationTest: "ESTROGEN_SURGE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps cervical mucus", () => {
|
||||
expect(
|
||||
mapHkCycleSample(
|
||||
HK_CERVICAL_MUCUS,
|
||||
"HKCategoryValueCervicalMucusQualityEggWhite",
|
||||
),
|
||||
).toEqual({ kind: "day-log", fields: { cervicalMucus: "EGG_WHITE" } });
|
||||
});
|
||||
|
||||
it("maps pregnancy + progesterone test results", () => {
|
||||
expect(mapHkCycleSample(HK_PREGNANCY_TEST, "2")).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { pregnancyTest: "POSITIVE" },
|
||||
});
|
||||
expect(mapHkCycleSample(HK_PROGESTERONE_TEST, "1")).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { progesteroneTest: "NEGATIVE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps intermenstrual bleeding (presence)", () => {
|
||||
expect(mapHkCycleSample(HK_INTERMENSTRUAL_BLEEDING, undefined)).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { intermenstrualBleeding: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/healthkit-mapping — sexual activity + protection", () => {
|
||||
it("records protectedSex from the protection metadata", () => {
|
||||
expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined, true)).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { sexualActivity: true, protectedSex: true },
|
||||
});
|
||||
expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined, false)).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { sexualActivity: true, protectedSex: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves protectedSex null when the metadata is absent", () => {
|
||||
expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined)).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { sexualActivity: true, protectedSex: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/healthkit-mapping — contraceptive routes to day-log + profile", () => {
|
||||
it("records the method AND carries a profile nudge", () => {
|
||||
expect(
|
||||
mapHkCycleSample(
|
||||
HK_CONTRACEPTIVE,
|
||||
"HKCategoryValueContraceptiveOral",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "day-log+profile",
|
||||
fields: { contraceptive: "ORAL" },
|
||||
profile: { contraceptive: "ORAL" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/healthkit-mapping — symptoms", () => {
|
||||
it("maps symptom identifiers to the seeded catalogue keys", () => {
|
||||
expect(
|
||||
mapHkCycleSample("HKCategoryTypeIdentifierAbdominalCramps", "3"),
|
||||
).toEqual({ kind: "day-log", fields: { symptomKey: "cramps" } });
|
||||
expect(
|
||||
mapHkCycleSample("HKCategoryTypeIdentifierLowerBackPain", "4"),
|
||||
).toEqual({ kind: "day-log", fields: { symptomKey: "back_pain" } });
|
||||
});
|
||||
|
||||
it("skips an explicit not-present symptom sample", () => {
|
||||
expect(
|
||||
mapHkCycleSample("HKCategoryTypeIdentifierHeadache", "1"),
|
||||
).toEqual({ kind: "skip" });
|
||||
expect(hkSymptomIsPresent("1")).toBe(false);
|
||||
expect(hkSymptomIsPresent("HKCategoryValueSeverityNotPresent")).toBe(false);
|
||||
expect(hkSymptomIsPresent("3")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computePhaseMetricCrosstab,
|
||||
buildPhaseDiscoverySeries,
|
||||
discoverPhaseCorrelations,
|
||||
selectHeadlinePhaseRow,
|
||||
PHASE_ORDINAL,
|
||||
PHASE_CROSSTAB_METRIC_TYPES,
|
||||
PHASE_CROSSTAB_METRICS,
|
||||
CYCLE_PHASE_CHANNEL_KEY,
|
||||
MOOD_CHANNEL_KEY,
|
||||
type PhaseMetricCrosstabRow,
|
||||
} from "../phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
import type { CyclePhase } from "../types";
|
||||
|
||||
/** Build a measurement row on a given YYYY-MM-DD at noon UTC. */
|
||||
function m(
|
||||
type: string,
|
||||
day: string,
|
||||
value: number,
|
||||
): CrossMetricMeasurement {
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
measuredAt: new Date(`${day}T12:00:00.000Z`),
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A phase-day map + matching daily metric values, with the luteal group's
|
||||
* resting heart rate clearly higher than the follicular group's. Days run
|
||||
* 2026-03-01 onward; the first `n` are LUTEAL, the next `n` FOLLICULAR.
|
||||
*/
|
||||
function fixture(opts: {
|
||||
n: number;
|
||||
lutealRhr: number;
|
||||
follicularRhr: number;
|
||||
}): {
|
||||
phaseByDay: Map<string, CyclePhase>;
|
||||
measurements: CrossMetricMeasurement[];
|
||||
} {
|
||||
const phaseByDay = new Map<string, CyclePhase>();
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
let dayIdx = 0;
|
||||
const dayKey = (i: number) => {
|
||||
const d = new Date(Date.UTC(2026, 2, 1));
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
for (let i = 0; i < opts.n; i++) {
|
||||
const k = dayKey(dayIdx++);
|
||||
phaseByDay.set(k, "LUTEAL");
|
||||
// tiny deterministic jitter so the group has non-zero variance
|
||||
measurements.push(m("RESTING_HEART_RATE", k, opts.lutealRhr + (i % 3) * 0.5));
|
||||
}
|
||||
for (let i = 0; i < opts.n; i++) {
|
||||
const k = dayKey(dayIdx++);
|
||||
phaseByDay.set(k, "FOLLICULAR");
|
||||
measurements.push(
|
||||
m("RESTING_HEART_RATE", k, opts.follicularRhr + (i % 3) * 0.5),
|
||||
);
|
||||
}
|
||||
return { phaseByDay, measurements };
|
||||
}
|
||||
|
||||
describe("PHASE_ORDINAL", () => {
|
||||
it("is the monotone menstrual→luteal progression", () => {
|
||||
expect(PHASE_ORDINAL.MENSTRUAL).toBe(0);
|
||||
expect(PHASE_ORDINAL.FOLLICULAR).toBe(1);
|
||||
expect(PHASE_ORDINAL.OVULATORY).toBe(2);
|
||||
expect(PHASE_ORDINAL.LUTEAL).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PHASE_CROSSTAB_METRIC_TYPES", () => {
|
||||
it("includes the temperature channels held out of the general matrix", () => {
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("WRIST_TEMPERATURE");
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("SKIN_TEMPERATURE");
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BODY_TEMPERATURE");
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("RESTING_HEART_RATE");
|
||||
});
|
||||
|
||||
it("includes GLUCOSE but NOT mood (mood is not a Measurement row)", () => {
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BLOOD_GLUCOSE");
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).not.toContain(MOOD_CHANNEL_KEY);
|
||||
});
|
||||
|
||||
it("declares mood + glucose as outcome metrics (QA HIGH)", () => {
|
||||
expect(PHASE_CROSSTAB_METRICS.mood.type).toBe(MOOD_CHANNEL_KEY);
|
||||
expect(PHASE_CROSSTAB_METRICS.bloodGlucose.type).toBe("BLOOD_GLUCOSE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", () => {
|
||||
/** Build a luteal-vs-follicular contrast for an arbitrary outcome `type`. */
|
||||
function contrastFor(type: string, lutealVal: number, follicularVal: number) {
|
||||
const phaseByDay = new Map<string, CyclePhase>();
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
let idx = 0;
|
||||
const dayKey = (i: number) => {
|
||||
const d = new Date(Date.UTC(2026, 2, 1));
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const k = dayKey(idx++);
|
||||
phaseByDay.set(k, "LUTEAL");
|
||||
measurements.push(m(type, k, lutealVal + (i % 3) * 0.5));
|
||||
}
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const k = dayKey(idx++);
|
||||
phaseByDay.set(k, "FOLLICULAR");
|
||||
measurements.push(m(type, k, follicularVal + (i % 3) * 0.5));
|
||||
}
|
||||
return { phaseByDay, measurements };
|
||||
}
|
||||
|
||||
it("surfaces a luteal-vs-follicular GLUCOSE contrast", () => {
|
||||
const { phaseByDay, measurements } = contrastFor("BLOOD_GLUCOSE", 105, 92);
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const glucose = rows.find((r) => r.metricKey === "bloodGlucose");
|
||||
expect(glucose).toBeDefined();
|
||||
expect(glucose!.display).toBe("glucose");
|
||||
expect(glucose!.delta).toBeGreaterThan(0);
|
||||
expect(glucose!.pValue).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it("surfaces a luteal-vs-follicular MOOD contrast (injected synthetic channel)", () => {
|
||||
const { phaseByDay, measurements } = contrastFor(MOOD_CHANNEL_KEY, 4.4, 2.6);
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const mood = rows.find((r) => r.metricKey === "mood");
|
||||
expect(mood).toBeDefined();
|
||||
expect(mood!.display).toBe("mood");
|
||||
expect(mood!.delta).toBeGreaterThan(0);
|
||||
expect(mood!.pValue).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goalAllowsFertileWindow (QA MEDIUM)", () => {
|
||||
it("shows the fertile window for TRYING_TO_CONCEIVE and AVOID_PREGNANCY", async () => {
|
||||
const { goalAllowsFertileWindow } = await import("../dto");
|
||||
expect(goalAllowsFertileWindow("TRYING_TO_CONCEIVE")).toBe(true);
|
||||
expect(goalAllowsFertileWindow("AVOID_PREGNANCY")).toBe(true);
|
||||
});
|
||||
|
||||
it("hides it for GENERAL_HEALTH / PERIMENOPAUSE / OFF", async () => {
|
||||
const { goalAllowsFertileWindow } = await import("../dto");
|
||||
expect(goalAllowsFertileWindow("GENERAL_HEALTH")).toBe(false);
|
||||
expect(goalAllowsFertileWindow("PERIMENOPAUSE")).toBe(false);
|
||||
expect(goalAllowsFertileWindow("OFF")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePhaseMetricCrosstab", () => {
|
||||
it("surfaces a clear luteal-vs-follicular RHR contrast", () => {
|
||||
const { phaseByDay, measurements } = fixture({
|
||||
n: 14,
|
||||
lutealRhr: 63,
|
||||
follicularRhr: 57,
|
||||
});
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const rhr = rows.find((r) => r.metricKey === "restingHeartRate");
|
||||
expect(rhr).toBeDefined();
|
||||
// luteal higher → positive delta (lutealAvg − follicularAvg)
|
||||
expect(rhr!.delta).toBeGreaterThan(0);
|
||||
expect(rhr!.lutealDays).toBe(14);
|
||||
expect(rhr!.follicularDays).toBe(14);
|
||||
expect(rhr!.pValue).toBeLessThan(0.05);
|
||||
expect(rhr!.qValue).toBeLessThanOrEqual(0.1);
|
||||
expect(["low", "medium", "high"]).toContain(rhr!.confidence);
|
||||
});
|
||||
|
||||
it("returns no row when a phase group is below the day floor", () => {
|
||||
// 4 luteal days only — under CROSSTAB_MIN_PRESENT_DAYS (5).
|
||||
const { phaseByDay, measurements } = fixture({
|
||||
n: 4,
|
||||
lutealRhr: 63,
|
||||
follicularRhr: 57,
|
||||
});
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fabricate a row when the phases do not differ", () => {
|
||||
const { phaseByDay, measurements } = fixture({
|
||||
n: 20,
|
||||
lutealRhr: 60,
|
||||
follicularRhr: 60,
|
||||
});
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
// No real separation → nothing clears p < 0.05.
|
||||
expect(rows.find((r) => r.metricKey === "restingHeartRate")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores MENSTRUAL / OVULATORY days (only the two contrast phases count)", () => {
|
||||
const { phaseByDay, measurements } = fixture({
|
||||
n: 14,
|
||||
lutealRhr: 63,
|
||||
follicularRhr: 57,
|
||||
});
|
||||
// Add menstrual + ovulatory days with extreme values that would skew a
|
||||
// naive mean — they must be excluded from the contrast.
|
||||
const extra = new Date(Date.UTC(2026, 5, 1));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const k = new Date(extra);
|
||||
k.setUTCDate(k.getUTCDate() + i);
|
||||
const key = k.toISOString().slice(0, 10);
|
||||
phaseByDay.set(key, i % 2 === 0 ? "MENSTRUAL" : "OVULATORY");
|
||||
measurements.push(m("RESTING_HEART_RATE", key, 200));
|
||||
}
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const rhr = rows.find((r) => r.metricKey === "restingHeartRate")!;
|
||||
// Means reflect only the luteal/follicular days, not the 200-bpm noise.
|
||||
expect(rhr.lutealAvg).toBeLessThan(70);
|
||||
expect(rhr.follicularAvg).toBeLessThan(70);
|
||||
});
|
||||
|
||||
it("returns empty for an empty phase map", () => {
|
||||
expect(
|
||||
computePhaseMetricCrosstab({
|
||||
phaseByDay: new Map(),
|
||||
measurements: [m("RESTING_HEART_RATE", "2026-03-01", 60)],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPhaseDiscoverySeries", () => {
|
||||
it("emits a sorted ordinal CYCLE_PHASE behaviour series", () => {
|
||||
const phaseByDay = new Map<string, CyclePhase>([
|
||||
["2026-03-03", "OVULATORY"],
|
||||
["2026-03-01", "MENSTRUAL"],
|
||||
["2026-03-02", "FOLLICULAR"],
|
||||
["2026-03-04", "LUTEAL"],
|
||||
]);
|
||||
const series = buildPhaseDiscoverySeries(phaseByDay);
|
||||
expect(series.key).toBe(CYCLE_PHASE_CHANNEL_KEY);
|
||||
expect(series.role).toBe("behaviour");
|
||||
expect(series.points.map((p) => p.day)).toEqual([
|
||||
"2026-03-01",
|
||||
"2026-03-02",
|
||||
"2026-03-03",
|
||||
"2026-03-04",
|
||||
]);
|
||||
expect(series.points.map((p) => p.value)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverPhaseCorrelations", () => {
|
||||
it("folds the continuous CYCLE_PHASE channel into the lagged-Pearson matrix", () => {
|
||||
// A contiguous 42-day span cycling MENSTRUAL→FOLLICULAR→OVULATORY→LUTEAL,
|
||||
// with next-day RHR tracking the phase ordinal so the lag-joined Pearson
|
||||
// is strong and clears n ≥ 20.
|
||||
const phaseByDay = new Map<string, CyclePhase>();
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
const phases: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
const base = new Date(Date.UTC(2026, 0, 1));
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(base);
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const phase = phases[i % phases.length];
|
||||
phaseByDay.set(key, phase);
|
||||
// Next-day RHR = a clean linear function of THIS day's ordinal, so the
|
||||
// D→D+1 lag join recovers a near-perfect positive correlation.
|
||||
const nextKey = new Date(d);
|
||||
nextKey.setUTCDate(nextKey.getUTCDate() + 1);
|
||||
measurements.push(
|
||||
m(
|
||||
"RESTING_HEART_RATE",
|
||||
nextKey.toISOString().slice(0, 10),
|
||||
55 + PHASE_ORDINAL[phase] * 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
const result = discoverPhaseCorrelations({ phaseByDay, measurements });
|
||||
const pair = result.discovered.find(
|
||||
(p) => p.behaviour === CYCLE_PHASE_CHANNEL_KEY,
|
||||
);
|
||||
expect(pair).toBeDefined();
|
||||
expect(pair!.outcome).toBe("RESTING_HEART_RATE");
|
||||
expect(pair!.n).toBeGreaterThanOrEqual(20);
|
||||
expect(pair!.r).toBeGreaterThan(0.5);
|
||||
expect(pair!.qValue).toBeLessThanOrEqual(result.fdrQ);
|
||||
expect(pair!.interpretation).toMatch(/not a cause/);
|
||||
});
|
||||
|
||||
it("returns no discovered pair on flat (non-correlated) data", () => {
|
||||
const phaseByDay = new Map<string, CyclePhase>();
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
const base = new Date(Date.UTC(2026, 0, 1));
|
||||
const phases: CyclePhase[] = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(base);
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
phaseByDay.set(key, phases[i % phases.length]);
|
||||
measurements.push(m("RESTING_HEART_RATE", key, 60)); // constant → no variance
|
||||
}
|
||||
const result = discoverPhaseCorrelations({ phaseByDay, measurements });
|
||||
expect(result.discovered).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectHeadlinePhaseRow", () => {
|
||||
const row = (metricKey: string): PhaseMetricCrosstabRow => ({
|
||||
metricKey: metricKey as PhaseMetricCrosstabRow["metricKey"],
|
||||
display: "bpm",
|
||||
lutealDays: 10,
|
||||
follicularDays: 10,
|
||||
lutealAvg: 63,
|
||||
follicularAvg: 57,
|
||||
delta: 6,
|
||||
pValue: 0.01,
|
||||
qValue: 0.02,
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
it("prefers resting heart rate", () => {
|
||||
const rows = [row("weight"), row("restingHeartRate"), row("heartRateVariability")];
|
||||
expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("restingHeartRate");
|
||||
});
|
||||
|
||||
it("falls back to HRV when RHR is absent", () => {
|
||||
const rows = [row("weight"), row("heartRateVariability")];
|
||||
expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("heartRateVariability");
|
||||
});
|
||||
|
||||
it("falls back to the strongest remaining row", () => {
|
||||
const rows = [row("weight")];
|
||||
expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("weight");
|
||||
});
|
||||
|
||||
it("returns null on no rows", () => {
|
||||
expect(selectHeadlinePhaseRow([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addDays } from "../day-math";
|
||||
import { phaseForDay, phaseSeries, type PhaseCycle } from "../phase";
|
||||
|
||||
/**
|
||||
* Phase boundary tests. A 28-day cycle starting 2024-01-01, period 5 days,
|
||||
* ovulation on day-14 (offset 13 → 2024-01-14). Boundaries per §"Phase channel":
|
||||
* MENSTRUAL offsets 0..4 (2024-01-01 .. 2024-01-05)
|
||||
* FOLLICULAR offsets 5..11 (2024-01-06 .. 2024-01-12)
|
||||
* OVULATORY offsets 12..14 (2024-01-13 .. 2024-01-15) [ov-1 .. ov+1]
|
||||
* LUTEAL offsets 15..27 (2024-01-16 .. 2024-01-28)
|
||||
*/
|
||||
const CYCLE: PhaseCycle = {
|
||||
startDate: "2024-01-01",
|
||||
nextStart: "2024-01-29", // 28-day cycle
|
||||
ovulationDate: "2024-01-14",
|
||||
periodLength: 5,
|
||||
lutealLength: 14,
|
||||
};
|
||||
|
||||
describe("cycle/phase — boundary mapping (§Phase channel)", () => {
|
||||
it("menstrual window [start, start+P-1]", () => {
|
||||
expect(phaseForDay("2024-01-01", CYCLE).phase).toBe("MENSTRUAL");
|
||||
expect(phaseForDay("2024-01-05", CYCLE).phase).toBe("MENSTRUAL");
|
||||
// day 6 is the first follicular day.
|
||||
expect(phaseForDay("2024-01-06", CYCLE).phase).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("follicular window (period end, ovulatory start)", () => {
|
||||
expect(phaseForDay("2024-01-06", CYCLE).phase).toBe("FOLLICULAR");
|
||||
expect(phaseForDay("2024-01-12", CYCLE).phase).toBe("FOLLICULAR");
|
||||
// 2024-01-13 is ov-1 → ovulatory begins.
|
||||
expect(phaseForDay("2024-01-13", CYCLE).phase).toBe("OVULATORY");
|
||||
});
|
||||
|
||||
it("ovulatory 3-day window [ov-1, ov+1]", () => {
|
||||
expect(phaseForDay("2024-01-13", CYCLE).phase).toBe("OVULATORY");
|
||||
expect(phaseForDay("2024-01-14", CYCLE).phase).toBe("OVULATORY");
|
||||
expect(phaseForDay("2024-01-15", CYCLE).phase).toBe("OVULATORY");
|
||||
// 2024-01-16 starts luteal.
|
||||
expect(phaseForDay("2024-01-16", CYCLE).phase).toBe("LUTEAL");
|
||||
});
|
||||
|
||||
it("luteal window (ovulatory end, nextStart)", () => {
|
||||
expect(phaseForDay("2024-01-16", CYCLE).phase).toBe("LUTEAL");
|
||||
expect(phaseForDay("2024-01-28", CYCLE).phase).toBe("LUTEAL");
|
||||
});
|
||||
|
||||
it("1-based day-of-cycle is exact", () => {
|
||||
expect(phaseForDay("2024-01-01", CYCLE).dayOfCycle).toBe(1);
|
||||
expect(phaseForDay("2024-01-14", CYCLE).dayOfCycle).toBe(14);
|
||||
expect(phaseForDay("2024-01-28", CYCLE).dayOfCycle).toBe(28);
|
||||
});
|
||||
|
||||
it("dates outside [start, nextStart) return null", () => {
|
||||
expect(phaseForDay("2023-12-31", CYCLE).phase).toBeNull();
|
||||
expect(phaseForDay("2023-12-31", CYCLE).dayOfCycle).toBeNull();
|
||||
// nextStart itself belongs to the NEXT cycle, not this one.
|
||||
expect(phaseForDay("2024-01-29", CYCLE).phase).toBeNull();
|
||||
});
|
||||
|
||||
it("MENSTRUAL precedence: a bleeding day wins even if it overlaps another window", () => {
|
||||
// A short cycle where the ovulatory window would mathematically overlap the
|
||||
// period: period 5, but ovulation pulled early to offset 4 (2024-01-05).
|
||||
const overlap: PhaseCycle = {
|
||||
startDate: "2024-01-01",
|
||||
nextStart: "2024-01-15",
|
||||
ovulationDate: "2024-01-05", // ov-1 = 2024-01-04 collides with menstrual
|
||||
periodLength: 5,
|
||||
lutealLength: 10,
|
||||
};
|
||||
// 2024-01-04 is within both the menstrual [0..4] and ovulatory [3..5] windows
|
||||
// → MENSTRUAL wins.
|
||||
expect(phaseForDay("2024-01-04", overlap).phase).toBe("MENSTRUAL");
|
||||
// 2024-01-06 is past the period → ovulatory (ov+1).
|
||||
expect(phaseForDay("2024-01-06", overlap).phase).toBe("OVULATORY");
|
||||
});
|
||||
|
||||
it("estimates ovulation from length/luteal when ovulationDate is null", () => {
|
||||
const noOv: PhaseCycle = {
|
||||
startDate: "2024-01-01",
|
||||
nextStart: "2024-01-29", // length 28
|
||||
ovulationDate: null,
|
||||
periodLength: 5,
|
||||
lutealLength: 14,
|
||||
};
|
||||
// estimated ovulation = start + (28 - 14) = offset 14 → 2024-01-15.
|
||||
expect(phaseForDay("2024-01-15", noOv).phase).toBe("OVULATORY");
|
||||
expect(phaseForDay("2024-01-14", noOv).phase).toBe("OVULATORY"); // ov-1
|
||||
expect(phaseForDay("2024-01-13", noOv).phase).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("defaults period length + luteal length when omitted", () => {
|
||||
const bare: PhaseCycle = {
|
||||
startDate: "2024-01-01",
|
||||
nextStart: "2024-01-29",
|
||||
ovulationDate: null,
|
||||
periodLength: null,
|
||||
};
|
||||
// period default 5 → day 5 menstrual, day 6 follicular.
|
||||
expect(phaseForDay("2024-01-05", bare).phase).toBe("MENSTRUAL");
|
||||
expect(phaseForDay("2024-01-06", bare).phase).toBe("FOLLICULAR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/phase — phaseSeries", () => {
|
||||
it("emits a categorical value per day across the span", () => {
|
||||
const series = phaseSeries("2024-01-01", "2024-01-28", CYCLE);
|
||||
expect(series).toHaveLength(28);
|
||||
expect(series[0]).toEqual({ date: "2024-01-01", phase: "MENSTRUAL", dayOfCycle: 1 });
|
||||
expect(series[27]).toEqual({ date: "2024-01-28", phase: "LUTEAL", dayOfCycle: 28 });
|
||||
});
|
||||
|
||||
it("marks out-of-cycle days null at the span edges", () => {
|
||||
const series = phaseSeries("2023-12-30", "2024-01-02", CYCLE);
|
||||
expect(series[0].phase).toBeNull(); // 2023-12-30 before start
|
||||
expect(series[1].phase).toBeNull(); // 2023-12-31 before start
|
||||
expect(series[2].phase).toBe("MENSTRUAL"); // 2024-01-01
|
||||
});
|
||||
|
||||
it("is timezone-independent across a DST boundary", () => {
|
||||
// A cycle spanning the 2024-03-10 US DST change; the noon-UTC anchor keeps
|
||||
// day-of-cycle exact.
|
||||
const dstCycle: PhaseCycle = {
|
||||
startDate: "2024-03-01",
|
||||
nextStart: "2024-03-29",
|
||||
ovulationDate: "2024-03-14",
|
||||
periodLength: 5,
|
||||
lutealLength: 14,
|
||||
};
|
||||
expect(phaseForDay("2024-03-14", dstCycle).dayOfCycle).toBe(14);
|
||||
expect(phaseForDay("2024-03-14", dstCycle).phase).toBe("OVULATORY");
|
||||
expect(phaseForDay(addDays("2024-03-01", 27), dstCycle).dayOfCycle).toBe(28);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addDays } from "../day-math";
|
||||
import {
|
||||
clampLuteal,
|
||||
confirmSymptothermal,
|
||||
detectTempShift,
|
||||
detectTemperatureTrend,
|
||||
estimateCycleLength,
|
||||
estimatePeriodLength,
|
||||
median,
|
||||
observedPeriodLength,
|
||||
predictCycle,
|
||||
resolveLuteal,
|
||||
} from "../prediction";
|
||||
import type {
|
||||
CycleInput,
|
||||
CycleProfileInput,
|
||||
DayLogInput,
|
||||
NightlyTempInput,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* These fixtures are the parity contract iOS reuses. Every expected date +
|
||||
* confidence is computed by hand from algorithm.md's pinned constants and
|
||||
* asserted exactly. The fixtures are constructed from a fixed seed date so the
|
||||
* assertions are timezone-independent (all day math is noon-UTC anchored).
|
||||
*/
|
||||
|
||||
const BASE_PROFILE: CycleProfileInput = {
|
||||
goal: "TRYING_TO_CONCEIVE",
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
};
|
||||
|
||||
/** Build a chain of cycles from an anchor start, each `gap[i]` days apart. */
|
||||
function cyclesFromGaps(anchor: string, gaps: number[]): CycleInput[] {
|
||||
const starts: string[] = [anchor];
|
||||
for (const g of gaps) starts.push(addDays(starts[starts.length - 1], g));
|
||||
return starts.map((startDate, i) => ({
|
||||
startDate,
|
||||
endDate: i < starts.length - 1 ? starts[i + 1] : null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("cycle/prediction — robust statistics", () => {
|
||||
it("median: odd + even counts (even = mean of two central, unrounded)", () => {
|
||||
expect(median([28, 30, 29])).toBe(29);
|
||||
expect(median([28, 30])).toBe(29);
|
||||
expect(median([28, 31])).toBe(29.5);
|
||||
expect(median([])).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — cycle-length estimator (§1, §5)", () => {
|
||||
it("regular 28-day user: median 28, MAD 0 → sigma floor 1.0", () => {
|
||||
const est = estimateCycleLength([28, 28, 28, 28, 28, 28], "TRYING_TO_CONCEIVE");
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
expect(est.sigma).toBe(1.0); // SIGMA_FLOOR (MAD = 0)
|
||||
expect(est.cyclesObserved).toBe(6);
|
||||
expect(est.cv).toBeCloseTo(1 / 28, 6);
|
||||
});
|
||||
|
||||
it("irregular user: high MAD widens sigma", () => {
|
||||
// lengths 24,28,32,26,30,28 → median 28, deviations 4,0,4,2,2,0 → MAD 2
|
||||
const est = estimateCycleLength([24, 28, 32, 26, 30, 28], "TRYING_TO_CONCEIVE");
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
expect(est.sigma).toBeCloseTo(1.4826 * 2, 6); // 2.9652
|
||||
});
|
||||
|
||||
it("excludes a 3-MAD outlier from the point estimate but counts it against confidence", () => {
|
||||
// 28,28,29,28,28 + one wild 60-day (missed-log: >= 1.75*28 = 49).
|
||||
const est = estimateCycleLength([28, 28, 29, 28, 28, 60], "TRYING_TO_CONCEIVE");
|
||||
// 60 excluded → median over kept = 28, kept count = 5.
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
expect(est.cyclesObserved).toBe(5);
|
||||
});
|
||||
|
||||
it("hard-bounds: a 14-day length is always an outlier candidate", () => {
|
||||
const est = estimateCycleLength([28, 28, 14, 28, 29], "TRYING_TO_CONCEIVE");
|
||||
expect(est.cyclesObserved).toBe(4); // 14 excluded (< HARD_CYCLE_MIN 21)
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
});
|
||||
|
||||
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");
|
||||
const peri = estimateCycleLength(lengths, "PERIMENOPAUSE");
|
||||
expect(peri.cyclesObserved).toBeGreaterThanOrEqual(normal.cyclesObserved);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — period-length estimator (§2)", () => {
|
||||
const cyc: CycleInput = {
|
||||
startDate: "2024-01-01",
|
||||
endDate: "2024-01-29",
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
};
|
||||
|
||||
function flowDay(date: string, flow: DayLogInput["flow"]): DayLogInput {
|
||||
return { date, flow, basalBodyTempC: null, ovulationTest: null, cervicalMucus: null };
|
||||
}
|
||||
|
||||
it("counts a contiguous 5-day bleeding run", () => {
|
||||
const logs = [
|
||||
flowDay("2024-01-01", "MEDIUM"),
|
||||
flowDay("2024-01-02", "HEAVY"),
|
||||
flowDay("2024-01-03", "MEDIUM"),
|
||||
flowDay("2024-01-04", "LIGHT"),
|
||||
flowDay("2024-01-05", "SPOTTING"),
|
||||
];
|
||||
expect(observedPeriodLength("2024-01-01", logs)).toBe(5);
|
||||
});
|
||||
|
||||
it("tolerates a single dry day inside the run but breaks on a 2-day gap", () => {
|
||||
const logs = [
|
||||
flowDay("2024-01-01", "MEDIUM"),
|
||||
flowDay("2024-01-02", "LIGHT"),
|
||||
// 01-03 dry (single gap, tolerated)
|
||||
flowDay("2024-01-04", "LIGHT"),
|
||||
// 01-05, 01-06 dry (2-day gap → break)
|
||||
flowDay("2024-01-07", "SPOTTING"),
|
||||
];
|
||||
expect(observedPeriodLength("2024-01-01", logs)).toBe(4); // through 01-04
|
||||
});
|
||||
|
||||
it("returns 0 when startDate itself has no bleeding", () => {
|
||||
expect(observedPeriodLength("2024-01-01", [flowDay("2024-01-02", "MEDIUM")])).toBe(0);
|
||||
});
|
||||
|
||||
it("estimatePeriodLength prefers explicit periodEndDate", () => {
|
||||
const c = { ...cyc, periodEndDate: "2024-01-04" }; // 4-day period
|
||||
expect(estimatePeriodLength([c], [], BASE_PROFILE)).toBe(4);
|
||||
});
|
||||
|
||||
it("falls back to population default (5) with no data and no prior", () => {
|
||||
expect(estimatePeriodLength([], [], BASE_PROFILE)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — predictCycle calendar path (§1–§3)", () => {
|
||||
it("regular 28-day user (≥6 cycles, no logs): exact band + medium confidence", () => {
|
||||
// 7 starts → 6 completed lengths of 28. Last start 2024-06-24.
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28, 28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
// 7 starts = anchor + 6*28 = 2024-01-01 + 168 days.
|
||||
expect(lastStart).toBe("2024-06-17");
|
||||
|
||||
const result = predictCycle(cycles, [], BASE_PROFILE, "2024-06-24");
|
||||
|
||||
expect(result.method).toBe("CALENDAR");
|
||||
expect(result.cyclesObserved).toBe(6);
|
||||
expect(result.stillLearning).toBe(false);
|
||||
expect(result.estimatedCycleLength).toBe(28);
|
||||
expect(result.estimatedCycleSd).toBe(1.0);
|
||||
|
||||
// nextStart = 2024-06-17 + 28 = 2024-07-15.
|
||||
expect(result.nextPeriodStart).toBe("2024-07-15");
|
||||
|
||||
// halfWidth: density 0 (no logs) → logSparsity 0.5 → penalty 1.5.
|
||||
// Z_BAND(1) * sigma(1) * 1.5 = 1.5 → round 2 → clamp [1,14] = 2.
|
||||
expect(result.nextPeriodStartLow).toBe("2024-07-13");
|
||||
expect(result.nextPeriodStartHigh).toBe("2024-07-17");
|
||||
|
||||
// Ovulation = nextStart - luteal(14) = 2024-07-01.
|
||||
expect(result.predictedOvulation).toBe("2024-07-01");
|
||||
// Fertile window ov-5 … ov+1.
|
||||
expect(result.fertileWindowStart).toBe("2024-06-26");
|
||||
expect(result.fertileWindowEnd).toBe("2024-07-02");
|
||||
|
||||
// confidence = cCount(1.0) * cVariance(1.0, cv≈0.036≤0.05) * cAdherence(0.4) = 0.40.
|
||||
expect(result.confidence).toBe(0.4);
|
||||
expect(result.confidenceLabel).toBe("medium");
|
||||
});
|
||||
|
||||
it("irregular user: wider band, lower confidence", () => {
|
||||
// lengths 24,32,26,30,28,28 → median 28, MAD 2 → sigma 2.9652.
|
||||
const cycles = cyclesFromGaps("2024-01-01", [24, 32, 26, 30, 28, 28]);
|
||||
const result = predictCycle(cycles, [], BASE_PROFILE, "2024-07-10");
|
||||
|
||||
expect(result.estimatedCycleLength).toBe(28);
|
||||
expect(result.estimatedCycleSd).toBeCloseTo(2.97, 2);
|
||||
// halfWidth = round(1 * 2.9652 * 1.5) = round(4.4478) = 4.
|
||||
const low = result.nextPeriodStartLow;
|
||||
const high = result.nextPeriodStartHigh;
|
||||
// band half-width is 4 days each side of nextStart.
|
||||
expect(low).toBe(addDays(result.nextPeriodStart, -4));
|
||||
expect(high).toBe(addDays(result.nextPeriodStart, 4));
|
||||
// cv = 2.9652/28 ≈ 0.1059 → cVariance 0.65; cCount 1.0; cAdherence 0.4.
|
||||
// confidence = 1.0 * 0.65 * 0.4 = 0.26 → low.
|
||||
expect(result.confidence).toBe(0.26);
|
||||
expect(result.confidenceLabel).toBe("low");
|
||||
});
|
||||
|
||||
it("<2 cycles cold start (1 length): priors-flavoured, stillLearning, band bonus", () => {
|
||||
// 2 starts → 1 completed length of 30.
|
||||
const cycles = cyclesFromGaps("2024-01-01", [30]);
|
||||
const result = predictCycle(cycles, [], BASE_PROFILE, "2024-02-05");
|
||||
|
||||
expect(result.cyclesObserved).toBe(1);
|
||||
expect(result.stillLearning).toBe(true);
|
||||
expect(result.estimatedCycleLength).toBe(30);
|
||||
// sigma falls back to SIGMA_FLOOR 1.0 (no MAD); +COLD_START_BAND_BONUS 3.
|
||||
// halfWidth = round(1*1*1.5)=2, +3 = 5 → clamp 5.
|
||||
expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -5));
|
||||
expect(result.nextPeriodStartHigh).toBe(addDays(result.nextPeriodStart, 5));
|
||||
// cCount(1)=0.35, cVariance(cv=1/30≈0.033≤0.05)=1.0, cAdherence=0.4.
|
||||
// confidence = 0.35 * 1.0 * 0.4 = 0.14 → low.
|
||||
expect(result.confidence).toBe(0.14);
|
||||
});
|
||||
|
||||
it("0 cycles priors-only: population default 28, confidence 0.20, fixed band 4", () => {
|
||||
const onlyStart: CycleInput[] = [
|
||||
{
|
||||
startDate: "2024-05-01",
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
},
|
||||
];
|
||||
const result = predictCycle(onlyStart, [], BASE_PROFILE, "2024-05-10");
|
||||
|
||||
expect(result.method).toBe("CALENDAR");
|
||||
expect(result.cyclesObserved).toBe(0);
|
||||
expect(result.stillLearning).toBe(true);
|
||||
expect(result.estimatedCycleLength).toBe(28);
|
||||
expect(result.confidence).toBe(0.2);
|
||||
// nextStart = 2024-05-01 + 28 = 2024-05-29, fixed half-width 4.
|
||||
expect(result.nextPeriodStart).toBe("2024-05-29");
|
||||
expect(result.nextPeriodStartLow).toBe("2024-05-25");
|
||||
expect(result.nextPeriodStartHigh).toBe("2024-06-02");
|
||||
});
|
||||
|
||||
it("respects a user-set typical-cycle prior on cold start", () => {
|
||||
const onlyStart: CycleInput[] = [
|
||||
{
|
||||
startDate: "2024-05-01",
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
},
|
||||
];
|
||||
const profile = { ...BASE_PROFILE, typicalCycleLength: 30 };
|
||||
const result = predictCycle(onlyStart, [], profile, "2024-05-10");
|
||||
expect(result.estimatedCycleLength).toBe(30);
|
||||
expect(result.nextPeriodStart).toBe("2024-05-31");
|
||||
});
|
||||
|
||||
it("logging density lifts confidence and tightens the band", () => {
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28, 28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate; // 2024-06-24
|
||||
// Log every day since the last start through 'today' = full density.
|
||||
const logs: DayLogInput[] = [];
|
||||
for (let i = 0; i <= 7; i++) {
|
||||
logs.push({
|
||||
date: addDays(lastStart, i),
|
||||
flow: i < 4 ? "MEDIUM" : "NONE",
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
});
|
||||
}
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 7));
|
||||
// density 1 → logSparsity 0 → penalty 1 → halfWidth round(1*1*1)=1.
|
||||
expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -1));
|
||||
// cAdherence = 0.4 + 0.6*1 = 1.0 → confidence 1*1*1.0 = 0.98 (clamped from 1.0).
|
||||
expect(result.confidence).toBe(0.98);
|
||||
expect(result.confidenceLabel).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
|
||||
function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput {
|
||||
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
const start = "2024-01-01";
|
||||
const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 36.39]; // max 36.42
|
||||
baseline.forEach((t, i) => logs.push(bbtDay(addDays(start, i), t)));
|
||||
// rise: each > 36.42; 3rd (index 8) must be >= 36.62.
|
||||
logs.push(bbtDay(addDays(start, 6), 36.55));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.6));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.62));
|
||||
|
||||
const shift = detectTempShift(logs, 0.2);
|
||||
expect(shift).not.toBeNull();
|
||||
// ovulation = day before the first elevated reading (offset 6 → 2024-01-07).
|
||||
expect(shift?.ovulationDate).toBe(addDays(start, 5));
|
||||
});
|
||||
|
||||
it("detectTempShift: rejects a rise that does not clear the 0.2°C third-measurement bar", () => {
|
||||
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)),
|
||||
);
|
||||
// 3rd reading only +0.1 over the 36.42 max.
|
||||
logs.push(bbtDay(addDays(start, 6), 36.45));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.48));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.52));
|
||||
expect(detectTempShift(logs, 0.2)).toBeNull();
|
||||
});
|
||||
|
||||
it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => {
|
||||
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. Add a mucus peak (EGG_WHITE) on 2024-01-06.
|
||||
logs.push({
|
||||
date: "2024-01-06",
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: "EGG_WHITE",
|
||||
});
|
||||
expect(confirmSymptothermal(logs)).toBe("2024-01-06");
|
||||
});
|
||||
|
||||
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;
|
||||
expect(lastStart).toBe("2024-03-25");
|
||||
|
||||
// Build a temp shift in the current (in-progress) cycle with ovulation on lastStart+13.
|
||||
const logs: DayLogInput[] = [];
|
||||
const ovDay = addDays(lastStart, 13); // ovulation we want confirmed
|
||||
// 6 baseline ending the day before the rise; rise starts ovDay+1.
|
||||
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));
|
||||
// mucus peak agreeing with the temp ovulation.
|
||||
logs.push({
|
||||
date: ovDay,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: "EGG_WHITE",
|
||||
});
|
||||
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.method).toBe("BLENDED");
|
||||
expect(result.predictedOvulation).toBe(ovDay);
|
||||
// nextStart = confirmed ovulation + luteal(14).
|
||||
expect(result.nextPeriodStart).toBe(addDays(ovDay, 14));
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
const nights: NightlyTempInput[] = [];
|
||||
// 6 trailing nights at 36.30 → mean 36.30.
|
||||
for (let i = 0; i < 6; i++) nights.push({ date: addDays(start, i), valueC: 36.3 });
|
||||
// 4-night window: 3 elevated (>= 36.45) + 1 not.
|
||||
nights.push({ date: addDays(start, 6), valueC: 36.46 });
|
||||
nights.push({ date: addDays(start, 7), valueC: 36.47 });
|
||||
nights.push({ date: addDays(start, 8), valueC: 36.3 });
|
||||
nights.push({ date: addDays(start, 9), valueC: 36.48 });
|
||||
|
||||
const ov = detectTemperatureTrend(nights);
|
||||
// rise onset = first window night (offset 6 → 2024-02-07); ovulation = night before.
|
||||
expect(ov).toBe(addDays(start, 5));
|
||||
});
|
||||
|
||||
it("predictCycle uses temperature-trend when no manual symptothermal confirmation exists", () => {
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
const start = addDays(lastStart, 5);
|
||||
const nights: NightlyTempInput[] = [];
|
||||
for (let i = 0; i < 6; i++) nights.push({ date: addDays(start, i), valueC: 36.3 });
|
||||
nights.push({ date: addDays(start, 6), valueC: 36.46 });
|
||||
nights.push({ date: addDays(start, 7), valueC: 36.47 });
|
||||
nights.push({ date: addDays(start, 8), valueC: 36.48 });
|
||||
nights.push({ date: addDays(start, 9), valueC: 36.49 });
|
||||
|
||||
const result = predictCycle(cycles, [], BASE_PROFILE, addDays(lastStart, 20), nights);
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.method).toBe("BLENDED");
|
||||
expect(result.predictedOvulation).toBe(addDays(start, 5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
|
||||
function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput {
|
||||
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
|
||||
}
|
||||
|
||||
/** Build a confirmable symptothermal shift (temp + agreeing mucus) on `ovDay`. */
|
||||
function symptothermalAround(ovDay: string): DayLogInput[] {
|
||||
const logs: DayLogInput[] = [];
|
||||
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));
|
||||
logs.push({
|
||||
date: ovDay,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: "EGG_WHITE",
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
it("does not confirm a STALE prior-cycle ovulation for a multi-cycle user", () => {
|
||||
// Three confirmed cycles; last start 2024-03-25, today well into the
|
||||
// current cycle. The ONLY BBT/mucus data is a complete shift back in the
|
||||
// FIRST cycle (months ago). Pre-fix, detectTempShift returned that stale
|
||||
// shift and nextPeriodStart landed in the past.
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
expect(lastStart).toBe("2024-03-25");
|
||||
|
||||
const staleOv = addDays("2024-01-01", 13); // 2024-01-14, two cycles back
|
||||
const logs = symptothermalAround(staleOv);
|
||||
|
||||
const today = addDays(lastStart, 16);
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, today);
|
||||
|
||||
// Stale signal is outside [lastStart − BBT_WINDOW, today] → ignored.
|
||||
expect(result.ovulationConfirmed).toBe(false);
|
||||
expect(result.method).toBe("CALENDAR");
|
||||
// Calendar next-start is in the future, never in the past.
|
||||
expect(result.nextPeriodStart >= today).toBe(true);
|
||||
});
|
||||
|
||||
it("confirms the CURRENT-cycle shift even when a prior-cycle shift also exists", () => {
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25
|
||||
|
||||
const staleOv = addDays("2024-01-01", 13); // months ago
|
||||
const currentOv = addDays(lastStart, 13); // current cycle
|
||||
const logs = [...symptothermalAround(staleOv), ...symptothermalAround(currentOv)];
|
||||
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.predictedOvulation).toBe(currentOv);
|
||||
expect(result.nextPeriodStart).toBe(addDays(currentOv, 14));
|
||||
});
|
||||
|
||||
it("detectTempShift returns the LATEST qualifying shift, not the earliest", () => {
|
||||
const logs: DayLogInput[] = [];
|
||||
// First shift around 2024-01-07.
|
||||
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.6));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.61));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.62));
|
||||
// Drop back to baseline, then a SECOND shift a month later.
|
||||
const start2 = "2024-02-01";
|
||||
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(start2, i), t)),
|
||||
);
|
||||
logs.push(bbtDay(addDays(start2, 6), 36.6));
|
||||
logs.push(bbtDay(addDays(start2, 7), 36.61));
|
||||
logs.push(bbtDay(addDays(start2, 8), 36.62));
|
||||
|
||||
const shift = detectTempShift(logs, 0.2);
|
||||
// Latest match → day before the second rise onset (2024-02-07 → 2024-02-06).
|
||||
expect(shift?.ovulationDate).toBe(addDays(start2, 5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", () => {
|
||||
it("clampLuteal pins a raw value to [10, 16]", () => {
|
||||
expect(clampLuteal(8)).toBe(10);
|
||||
expect(clampLuteal(20)).toBe(16);
|
||||
expect(clampLuteal(13)).toBe(13);
|
||||
});
|
||||
|
||||
it("resolveLuteal applies the default then the clamp", () => {
|
||||
expect(resolveLuteal({ lutealPhaseLength: null })).toBe(14);
|
||||
expect(resolveLuteal({ lutealPhaseLength: 8 })).toBe(10);
|
||||
expect(resolveLuteal({ lutealPhaseLength: 19 })).toBe(16);
|
||||
});
|
||||
|
||||
it("predictCycle ovulation uses the CLAMPED luteal for an out-of-clamp profile", () => {
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
// Profile asks for luteal 8 → engine clamps to 10.
|
||||
const profile: CycleProfileInput = { ...BASE_PROFILE, lutealPhaseLength: 8 };
|
||||
const result = predictCycle(cycles, [], profile, addDays(lastStart, 5));
|
||||
// predictedOvulation = nextStart − clampedLuteal(10).
|
||||
expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
computeSymptomPhasePatterns,
|
||||
SYMPTOM_PHASE_MIN_DAYS,
|
||||
type SymptomDay,
|
||||
} from "../symptom-phase";
|
||||
import type { CyclePhase } from "../types";
|
||||
|
||||
function phaseMap(entries: [string, CyclePhase][]): Map<string, CyclePhase> {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
describe("computeSymptomPhasePatterns", () => {
|
||||
it("clusters a symptom into its dominant phase", () => {
|
||||
const days: SymptomDay[] = [
|
||||
{ date: "2026-06-01", keys: ["cramps"] },
|
||||
{ date: "2026-06-02", keys: ["cramps"] },
|
||||
{ date: "2026-06-03", keys: ["cramps"] },
|
||||
{ date: "2026-06-20", keys: ["cramps"] },
|
||||
];
|
||||
const phases = phaseMap([
|
||||
["2026-06-01", "MENSTRUAL"],
|
||||
["2026-06-02", "MENSTRUAL"],
|
||||
["2026-06-03", "MENSTRUAL"],
|
||||
["2026-06-20", "LUTEAL"],
|
||||
]);
|
||||
const [row] = computeSymptomPhasePatterns(days, phases);
|
||||
expect(row.symptomKey).toBe("cramps");
|
||||
expect(row.total).toBe(4);
|
||||
expect(row.counts.MENSTRUAL).toBe(3);
|
||||
expect(row.counts.LUTEAL).toBe(1);
|
||||
expect(row.topPhase).toBe("MENSTRUAL");
|
||||
expect(row.topShare).toBeCloseTo(0.75, 5);
|
||||
});
|
||||
|
||||
it("drops symptoms below the min-day floor", () => {
|
||||
const days: SymptomDay[] = [
|
||||
{ date: "2026-06-01", keys: ["rare"] },
|
||||
{ date: "2026-06-02", keys: ["rare"] },
|
||||
];
|
||||
const phases = phaseMap([
|
||||
["2026-06-01", "MENSTRUAL"],
|
||||
["2026-06-02", "FOLLICULAR"],
|
||||
]);
|
||||
// 2 < SYMPTOM_PHASE_MIN_DAYS (3) → no row.
|
||||
expect(SYMPTOM_PHASE_MIN_DAYS).toBe(3);
|
||||
expect(computeSymptomPhasePatterns(days, phases)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores symptom days that fall outside any phase window", () => {
|
||||
const days: SymptomDay[] = [
|
||||
{ date: "2026-06-01", keys: ["cramps"] },
|
||||
{ date: "2026-06-02", keys: ["cramps"] },
|
||||
{ date: "2026-06-03", keys: ["cramps"] },
|
||||
{ date: "2026-06-30", keys: ["cramps"] }, // unphased → ignored
|
||||
];
|
||||
const phases = phaseMap([
|
||||
["2026-06-01", "LUTEAL"],
|
||||
["2026-06-02", "LUTEAL"],
|
||||
["2026-06-03", "LUTEAL"],
|
||||
]);
|
||||
const [row] = computeSymptomPhasePatterns(days, phases);
|
||||
expect(row.total).toBe(3);
|
||||
expect(row.counts.LUTEAL).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Cycle-tracking backup serialisation — the one place the full-backup
|
||||
* writers (the on-demand `GET /api/export/full-backup` route AND the
|
||||
* pg-boss `data-backup` worker) read the cycle tables, so the two writers
|
||||
* never drift. Mirrors the `backupPayloadSchema` cycle shapes.
|
||||
*
|
||||
* Reads are scoped to `userId` + `deletedAt: null` and exclude predicted
|
||||
* (forecast) cycle rows — only observed history round-trips. `notesEncrypted`
|
||||
* is carried as the ciphertext envelope VERBATIM: the backup never decrypts
|
||||
* the day-log note, so a wrong-surface plaintext leak is impossible and the
|
||||
* owner's note round-trips encrypted at rest.
|
||||
*/
|
||||
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
/** The cycle slice of a backup payload. */
|
||||
export interface CycleBackupSection {
|
||||
cycleProfile: {
|
||||
goal: string;
|
||||
cycleTrackingEnabled: boolean | null;
|
||||
typicalCycleLength: number | null;
|
||||
typicalPeriodLength: number | null;
|
||||
lutealPhaseLength: number | null;
|
||||
predictionEnabled: boolean;
|
||||
rawChartMode: boolean;
|
||||
discreetNotifications: boolean;
|
||||
sensitiveCategoryEncryption: boolean;
|
||||
} | null;
|
||||
cycles: Array<{
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
periodEndDate: string | null;
|
||||
lengthDays: number | null;
|
||||
ovulationDate: string | null;
|
||||
ovulationConfirmed: boolean;
|
||||
tz: string | null;
|
||||
}>;
|
||||
cycleDayLogs: Array<{
|
||||
date: string;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: string | null;
|
||||
cervicalMucus: string | null;
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
tz: string | null;
|
||||
symptomKeys: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cycle section of a user's full backup. Accepts any client with
|
||||
* the cycle delegates (the route's global client OR the worker's local
|
||||
* client) so both writers share one read.
|
||||
*/
|
||||
export async function buildCycleBackupSection(
|
||||
prisma: Pick<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
|
||||
userId: string,
|
||||
): Promise<CycleBackupSection> {
|
||||
const [profile, cycles, dayLogs] = await Promise.all([
|
||||
prisma.cycleProfile.findUnique({ where: { userId } }),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId, deletedAt: null, isPredicted: false },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { date: "asc" },
|
||||
include: {
|
||||
symptomLinks: { include: { symptom: { select: { key: true } } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
cycleProfile: profile
|
||||
? {
|
||||
goal: profile.goal,
|
||||
cycleTrackingEnabled: profile.cycleTrackingEnabled,
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
predictionEnabled: profile.predictionEnabled,
|
||||
rawChartMode: profile.rawChartMode,
|
||||
discreetNotifications: profile.discreetNotifications,
|
||||
sensitiveCategoryEncryption: profile.sensitiveCategoryEncryption,
|
||||
}
|
||||
: null,
|
||||
cycles: cycles.map((c) => ({
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
periodEndDate: c.periodEndDate,
|
||||
lengthDays: c.lengthDays,
|
||||
ovulationDate: c.ovulationDate,
|
||||
ovulationConfirmed: c.ovulationConfirmed,
|
||||
tz: c.tz,
|
||||
})),
|
||||
cycleDayLogs: dayLogs.map((d) => ({
|
||||
date: d.date,
|
||||
flow: d.flow,
|
||||
intermenstrualBleeding: d.intermenstrualBleeding,
|
||||
basalBodyTempC: d.basalBodyTempC,
|
||||
ovulationTest: d.ovulationTest,
|
||||
cervicalMucus: d.cervicalMucus,
|
||||
sexualActivity: d.sexualActivity,
|
||||
protectedSex: d.protectedSex,
|
||||
pregnancyTest: d.pregnancyTest,
|
||||
progesteroneTest: d.progesteroneTest,
|
||||
contraceptive: d.contraceptive,
|
||||
// Both ciphertext envelopes carried verbatim — never decrypted here.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
source: d.source,
|
||||
externalId: d.externalId,
|
||||
tz: d.tz,
|
||||
symptomKeys: d.symptomLinks.map((l) => l.symptom.key),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── restore ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** Counts the cycle restore wiped, for the audit trail. */
|
||||
export interface CycleRestoreCleared {
|
||||
cycles: number;
|
||||
cycleDayLogs: number;
|
||||
cycleProfile: number;
|
||||
}
|
||||
|
||||
// Closed enum allow-lists. The backup schema is `.passthrough()`, so a
|
||||
// malformed enum value would otherwise crash deep in `create()`. Guard the
|
||||
// few enum columns up-front (mirrors the measurement-type guard in the
|
||||
// restore route). Unknown values are coerced to null rather than failing
|
||||
// the whole restore — a single drifted field shouldn't strand the rest.
|
||||
const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]);
|
||||
const OVULATION_TESTS = new Set([
|
||||
"NEGATIVE",
|
||||
"POSITIVE_LH_SURGE",
|
||||
"ESTROGEN_SURGE",
|
||||
"INDETERMINATE",
|
||||
]);
|
||||
const CERVICAL_MUCUS = new Set([
|
||||
"DRY",
|
||||
"STICKY",
|
||||
"CREAMY",
|
||||
"WATERY",
|
||||
"EGG_WHITE",
|
||||
]);
|
||||
const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]);
|
||||
const CONTRACEPTIVES = new Set([
|
||||
"NONE",
|
||||
"UNSPECIFIED",
|
||||
"IMPLANT",
|
||||
"INJECTION",
|
||||
"IUD",
|
||||
"INTRAVAGINAL_RING",
|
||||
"ORAL",
|
||||
"PATCH",
|
||||
"EMERGENCY",
|
||||
]);
|
||||
const CYCLE_SOURCES = new Set(["MANUAL", "WITHINGS", "IMPORT", "APPLE_HEALTH"]);
|
||||
const CYCLE_GOALS = new Set([
|
||||
"GENERAL_HEALTH",
|
||||
"AVOID_PREGNANCY",
|
||||
"TRYING_TO_CONCEIVE",
|
||||
"PERIMENOPAUSE",
|
||||
"OFF",
|
||||
]);
|
||||
|
||||
function enumOrNull<T extends string>(
|
||||
value: string | null | undefined,
|
||||
allow: Set<string>,
|
||||
): T | null {
|
||||
return value != null && allow.has(value) ? (value as T) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the cycle tables for `ownerId` from a parsed backup payload,
|
||||
* inside an existing transaction. Delete-then-recreate, matching the
|
||||
* measurement/mood restore contract. Symptom links are re-resolved against
|
||||
* the seeded catalogue by key (unknown keys dropped). `notesEncrypted` is
|
||||
* written back as the ciphertext envelope verbatim — never re-encrypted.
|
||||
*
|
||||
* Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty
|
||||
* cycle arrays + null profile) wipes nothing and recreates nothing.
|
||||
*/
|
||||
export async function restoreCycleData(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: BackupPayload,
|
||||
): Promise<CycleRestoreCleared> {
|
||||
// Wipe (child links cascade off the day-log delete).
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
const cycles = await tx.menstrualCycle.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
const profile = await tx.cycleProfile.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
// Recreate the profile (one row / user).
|
||||
if (payload.cycleProfile) {
|
||||
const p = payload.cycleProfile;
|
||||
await tx.cycleProfile.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
goal: (enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH") as never,
|
||||
cycleTrackingEnabled: p.cycleTrackingEnabled ?? null,
|
||||
typicalCycleLength: p.typicalCycleLength ?? null,
|
||||
typicalPeriodLength: p.typicalPeriodLength ?? null,
|
||||
lutealPhaseLength: p.lutealPhaseLength ?? null,
|
||||
predictionEnabled: p.predictionEnabled ?? true,
|
||||
rawChartMode: p.rawChartMode ?? false,
|
||||
discreetNotifications: p.discreetNotifications ?? false,
|
||||
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Recreate observed cycle spans; map startDate → id so day-logs can
|
||||
// re-attach to their owning span.
|
||||
const cycleIdByStart = new Map<string, string>();
|
||||
for (const c of payload.cycles) {
|
||||
const created = await tx.menstrualCycle.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate ?? null,
|
||||
periodEndDate: c.periodEndDate ?? null,
|
||||
lengthDays: c.lengthDays ?? null,
|
||||
ovulationDate: c.ovulationDate ?? null,
|
||||
ovulationConfirmed: c.ovulationConfirmed ?? false,
|
||||
tz: c.tz ?? null,
|
||||
isPredicted: false,
|
||||
},
|
||||
});
|
||||
cycleIdByStart.set(c.startDate, created.id);
|
||||
}
|
||||
|
||||
// Resolve the seeded symptom catalogue once for the link re-creation.
|
||||
const allKeys = Array.from(
|
||||
new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])),
|
||||
);
|
||||
const symptomIdByKey = new Map<string, string>();
|
||||
if (allKeys.length > 0) {
|
||||
const rows = await tx.cycleSymptom.findMany({
|
||||
where: {
|
||||
key: { in: allKeys },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
for (const r of rows) symptomIdByKey.set(r.key, r.id);
|
||||
}
|
||||
|
||||
// Recreate day-logs (with symptom links). The owning cycle is the latest
|
||||
// span whose start is on/before the day (the cycle-attribution rule).
|
||||
const sortedStarts = payload.cycles
|
||||
.map((c) => c.startDate)
|
||||
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
const owningCycleId = (date: string): string | null => {
|
||||
let id: string | null = null;
|
||||
for (const start of sortedStarts) {
|
||||
if (start <= date) id = cycleIdByStart.get(start) ?? id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
for (const d of payload.cycleDayLogs) {
|
||||
const symptomIds = (d.symptomKeys ?? [])
|
||||
.map((k) => symptomIdByKey.get(k))
|
||||
.filter((v): v is string => v !== undefined);
|
||||
await tx.cycleDayLog.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
date: d.date,
|
||||
cycleId: owningCycleId(d.date),
|
||||
flow: enumOrNull(d.flow, FLOW_LEVELS) as never,
|
||||
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
|
||||
basalBodyTempC: d.basalBodyTempC ?? null,
|
||||
ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never,
|
||||
cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never,
|
||||
sexualActivity: d.sexualActivity ?? false,
|
||||
protectedSex: d.protectedSex ?? null,
|
||||
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never,
|
||||
progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never,
|
||||
contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never,
|
||||
// Both ciphertext envelopes written back verbatim — never re-encrypted.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted ?? null,
|
||||
notesEncrypted: d.notesEncrypted ?? null,
|
||||
source: (enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL") as never,
|
||||
externalId: d.externalId ?? null,
|
||||
tz: d.tz ?? null,
|
||||
...(symptomIds.length > 0
|
||||
? {
|
||||
symptomLinks: {
|
||||
create: symptomIds.map((symptomId) => ({ symptomId })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cycles: cycles.count,
|
||||
cycleDayLogs: dayLogs.count,
|
||||
cycleProfile: profile.count,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Attribute a day-log date to its owning `MenstrualCycle` span.
|
||||
*
|
||||
* A day-log belongs to the cycle whose `startDate <= date` and which is
|
||||
* the latest such start (the cycle is open-ended until the next cycle's
|
||||
* start). Returns the cycle id or null when no cycle precedes the date.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function findOwningCycleId(
|
||||
userId: string,
|
||||
date: string,
|
||||
): Promise<string | null> {
|
||||
const cycle = await prisma.menstrualCycle.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
startDate: { lte: date },
|
||||
},
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
return cycle?.id ?? null;
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Cycle day-log write helper — the shared upsert behind the single POST,
|
||||
* the PATCH, and the bulk drain (ios-contract §2.A / §2.B).
|
||||
*
|
||||
* UPSERT key: `(userId, source, externalId)` when `externalId` is present
|
||||
* (the NULL-distinct cross-device dedup key, like MoodEntry), else the
|
||||
* canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM).
|
||||
*
|
||||
* Sensitive-category encryption: when the resolved profile flag is ON the
|
||||
* five intent-revealing fields (sexualActivity, protectedSex,
|
||||
* pregnancyTest, progesteroneTest, contraceptive) are encrypted into the
|
||||
* single `sensitiveEncrypted` JSON envelope and the plaintext columns are
|
||||
* NULLed — they then drop out of the rollup / correlation tier (the trade
|
||||
* for at-rest secrecy). When OFF the plaintext columns are written as
|
||||
* before and the envelope is NULL.
|
||||
*
|
||||
* Partial merge: a re-post that omits a field never nulls a previously
|
||||
* stored value — the UPDATE branch only overwrites the fields actually
|
||||
* present in the input.
|
||||
*
|
||||
* Collision-safe: on a `(userId, source, externalId)`-keyed create that
|
||||
* collides with an existing canonical `(userId, date)` row (Prisma P2002),
|
||||
* the existing row is adopted and UPDATEd in place — one canonical row per
|
||||
* date regardless of provenance.
|
||||
*
|
||||
* Returns the upserted row id + whether a row already existed (so the
|
||||
* caller can map `inserted`/`updated`/`duplicate`) plus whether any field
|
||||
* actually changed on a re-post (drives the bulk `updated` vs `duplicate`
|
||||
* distinction).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import type { CycleDayLogInput } from "@/lib/validations/cycle";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export interface DayLogWriteResult {
|
||||
id: string;
|
||||
existed: boolean;
|
||||
/** A pre-existing row whose persisted fields differ from the re-post. */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** The five intent-revealing fields the encryption envelope carries. */
|
||||
interface SensitiveFields {
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
}
|
||||
|
||||
/** A symptom selection carrying its catalog key + optional 1-4 severity. */
|
||||
export interface SymptomSelection {
|
||||
key: string;
|
||||
severity?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown keys
|
||||
* silently. Returns the pairs (not bare ids) so the caller can carry the
|
||||
* per-link severity through to the join write.
|
||||
*/
|
||||
export async function resolveSymptomIds(
|
||||
userId: string,
|
||||
keys: readonly string[],
|
||||
): Promise<{ key: string; id: string }[]> {
|
||||
if (keys.length === 0) return [];
|
||||
const unique = Array.from(new Set(keys));
|
||||
// Catalog rows (userId null) plus this user's own custom symptoms.
|
||||
const rows = await prisma.cycleSymptom.findMany({
|
||||
where: {
|
||||
key: { in: unique },
|
||||
isActive: true,
|
||||
OR: [{ userId: null }, { userId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
return rows.map((r) => ({ key: r.key, id: r.id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the symptom-link set for a day-log. Shared by the upsert helper
|
||||
* and the PATCH route so both resolve + write the join identically. Each
|
||||
* selection's optional 1-4 severity is persisted on the link (NULL = a
|
||||
* plain presence link).
|
||||
*/
|
||||
export async function replaceSymptomLinks(
|
||||
userId: string,
|
||||
dayLogId: string,
|
||||
selections: readonly SymptomSelection[],
|
||||
): Promise<void> {
|
||||
const resolved = await resolveSymptomIds(
|
||||
userId,
|
||||
selections.map((s) => s.key),
|
||||
);
|
||||
// Map each resolved id back to its requested severity by key.
|
||||
const severityByKey = new Map(
|
||||
selections.map((s) => [
|
||||
s.key,
|
||||
typeof s.severity === "number" ? s.severity : null,
|
||||
]),
|
||||
);
|
||||
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId } });
|
||||
if (resolved.length > 0) {
|
||||
await prisma.cycleSymptomLink.createMany({
|
||||
data: resolved.map(({ key, id }) => ({
|
||||
dayLogId,
|
||||
symptomId: id,
|
||||
severity: severityByKey.get(key) ?? null,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt the sensitive envelope fail-soft (null on missing / undecryptable). */
|
||||
function decryptSensitive(envelope: string | null): Partial<SensitiveFields> {
|
||||
if (!envelope) return {};
|
||||
try {
|
||||
return JSON.parse(decrypt(envelope)) as Partial<SensitiveFields>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt a note fail-soft (null on missing / undecryptable). */
|
||||
function decryptNoteSoft(notesEncrypted: string | null): string | null {
|
||||
if (!notesEncrypted) return null;
|
||||
try {
|
||||
return decrypt(notesEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ExistingRow = {
|
||||
id: string;
|
||||
date: string;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: string | null;
|
||||
cervicalMucus: string | null;
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
const EXISTING_SELECT = {
|
||||
id: true,
|
||||
date: true,
|
||||
flow: true,
|
||||
intermenstrualBleeding: true,
|
||||
basalBodyTempC: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Upsert one cycle day-log. `tz` anchors the date string. When
|
||||
* `cycleId` is supplied the row is attributed to that cycle span.
|
||||
*
|
||||
* `encryptSensitiveFlag` lets a batch caller (bulk drain / Apple-Health import
|
||||
* flush) resolve the invariant `sensitiveCategoryEncryption` flag ONCE and
|
||||
* thread it in, instead of this helper re-reading the profile per row (a
|
||||
* per-day round-trip on a multi-year import — QA M-3). The single POST/PATCH
|
||||
* paths omit it and keep the lazy read.
|
||||
*/
|
||||
export async function upsertCycleDayLog(
|
||||
userId: string,
|
||||
entry: CycleDayLogInput,
|
||||
tz: string | null,
|
||||
cycleId: string | null = null,
|
||||
encryptSensitiveFlag?: boolean,
|
||||
): Promise<DayLogWriteResult> {
|
||||
const source = entry.source;
|
||||
|
||||
// Resolve the sensitive-category encryption flag once per write (or reuse the
|
||||
// flag the batch caller already resolved).
|
||||
const encryptSensitive =
|
||||
encryptSensitiveFlag ??
|
||||
(await getOrCreateCycleProfile(userId)).sensitiveCategoryEncryption;
|
||||
|
||||
const where: Prisma.CycleDayLogWhereUniqueInput = entry.externalId
|
||||
? {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source,
|
||||
externalId: entry.externalId,
|
||||
},
|
||||
}
|
||||
: { userId_date: { userId, date: entry.date } };
|
||||
|
||||
// Probe so the caller can distinguish inserted / updated / duplicate
|
||||
// and (for an externalId re-post) detect whether a field changed.
|
||||
const existing = (await prisma.cycleDayLog.findUnique({
|
||||
where,
|
||||
select: EXISTING_SELECT,
|
||||
})) as ExistingRow | null;
|
||||
|
||||
const result = await writeDayLog({
|
||||
userId,
|
||||
entry,
|
||||
tz,
|
||||
cycleId,
|
||||
source,
|
||||
where,
|
||||
existing,
|
||||
encryptSensitive,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface WriteArgs {
|
||||
userId: string;
|
||||
entry: CycleDayLogInput;
|
||||
tz: string | null;
|
||||
cycleId: string | null;
|
||||
source: CycleDayLogInput["source"];
|
||||
where: Prisma.CycleDayLogWhereUniqueInput;
|
||||
existing: ExistingRow | null;
|
||||
encryptSensitive: boolean;
|
||||
}
|
||||
|
||||
async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
|
||||
const {
|
||||
userId,
|
||||
entry,
|
||||
tz,
|
||||
cycleId,
|
||||
source,
|
||||
where,
|
||||
existing,
|
||||
encryptSensitive,
|
||||
} = args;
|
||||
|
||||
// Decrypt the existing note (fail-soft) for the change/duplicate split —
|
||||
// re-encrypting the same plaintext yields a different ciphertext (random
|
||||
// GCM IV), so a ciphertext compare would always read "changed".
|
||||
const existingNote = decryptNoteSoft(existing?.notesEncrypted ?? null);
|
||||
|
||||
// The resolved plaintext sensitive values for THIS write, after partial
|
||||
// merge against what's already stored (decrypted from the envelope when
|
||||
// the stored row was encrypted).
|
||||
const storedSensitive: SensitiveFields = existing
|
||||
? mergeStoredSensitive(existing)
|
||||
: {
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
};
|
||||
|
||||
const resolvedSensitive: SensitiveFields = {
|
||||
sexualActivity:
|
||||
entry.sexualActivity !== undefined
|
||||
? entry.sexualActivity
|
||||
: storedSensitive.sexualActivity,
|
||||
protectedSex:
|
||||
entry.protectedSex !== undefined
|
||||
? (entry.protectedSex ?? null)
|
||||
: storedSensitive.protectedSex,
|
||||
pregnancyTest:
|
||||
entry.pregnancyTest !== undefined
|
||||
? (entry.pregnancyTest ?? null)
|
||||
: storedSensitive.pregnancyTest,
|
||||
progesteroneTest:
|
||||
entry.progesteroneTest !== undefined
|
||||
? (entry.progesteroneTest ?? null)
|
||||
: storedSensitive.progesteroneTest,
|
||||
contraceptive:
|
||||
entry.contraceptive !== undefined
|
||||
? (entry.contraceptive ?? null)
|
||||
: storedSensitive.contraceptive,
|
||||
};
|
||||
|
||||
// Note resolution with partial merge: an omitted `note` keeps the stored
|
||||
// value; an explicit null clears it.
|
||||
const incomingNote =
|
||||
entry.note !== undefined
|
||||
? (entry.note ?? null)
|
||||
: decryptNoteSoft(existing?.notesEncrypted ?? null);
|
||||
const notesEncrypted = incomingNote ? encrypt(incomingNote) : null;
|
||||
|
||||
// Non-sensitive fields, partial-merged against the stored row.
|
||||
const flow =
|
||||
entry.flow !== undefined ? (entry.flow ?? null) : (existing?.flow ?? null);
|
||||
const intermenstrualBleeding =
|
||||
entry.intermenstrualBleeding !== undefined
|
||||
? entry.intermenstrualBleeding
|
||||
: (existing?.intermenstrualBleeding ?? false);
|
||||
const basalBodyTempC =
|
||||
entry.basalBodyTempC !== undefined
|
||||
? (entry.basalBodyTempC ?? null)
|
||||
: (existing?.basalBodyTempC ?? null);
|
||||
const ovulationTest =
|
||||
entry.ovulationTest !== undefined
|
||||
? (entry.ovulationTest ?? null)
|
||||
: (existing?.ovulationTest ?? null);
|
||||
const cervicalMucus =
|
||||
entry.cervicalMucus !== undefined
|
||||
? (entry.cervicalMucus ?? null)
|
||||
: (existing?.cervicalMucus ?? null);
|
||||
|
||||
// Split the sensitive fields between plaintext columns and the envelope
|
||||
// depending on the flag. When encrypting, the plaintext columns are NULL
|
||||
// (drop out of rollup/correlation) and the envelope carries the JSON.
|
||||
const sensitiveEncrypted = encryptSensitive
|
||||
? encrypt(JSON.stringify(resolvedSensitive))
|
||||
: null;
|
||||
const sensitivePlaintext = encryptSensitive
|
||||
? {
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
}
|
||||
: { ...resolvedSensitive };
|
||||
|
||||
const baseData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
||||
flow: flow as never,
|
||||
intermenstrualBleeding,
|
||||
basalBodyTempC,
|
||||
ovulationTest: ovulationTest as never,
|
||||
cervicalMucus: cervicalMucus as never,
|
||||
sexualActivity: sensitivePlaintext.sexualActivity,
|
||||
protectedSex: sensitivePlaintext.protectedSex,
|
||||
pregnancyTest: sensitivePlaintext.pregnancyTest as never,
|
||||
progesteroneTest: sensitivePlaintext.progesteroneTest as never,
|
||||
contraceptive: sensitivePlaintext.contraceptive as never,
|
||||
sensitiveEncrypted,
|
||||
notesEncrypted,
|
||||
};
|
||||
|
||||
// Did anything actually change vs the stored row? Compare the resolved
|
||||
// PLAINTEXT values (note + sensitive), never ciphertext.
|
||||
let changed = !existing;
|
||||
if (existing) {
|
||||
changed =
|
||||
(existing.flow ?? null) !== flow ||
|
||||
existing.intermenstrualBleeding !== intermenstrualBleeding ||
|
||||
(existing.basalBodyTempC ?? null) !== basalBodyTempC ||
|
||||
(existing.ovulationTest ?? null) !== ovulationTest ||
|
||||
(existing.cervicalMucus ?? null) !== cervicalMucus ||
|
||||
storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity ||
|
||||
storedSensitive.protectedSex !== resolvedSensitive.protectedSex ||
|
||||
storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest ||
|
||||
storedSensitive.progesteroneTest !== resolvedSensitive.progesteroneTest ||
|
||||
storedSensitive.contraceptive !== resolvedSensitive.contraceptive ||
|
||||
existingNote !== incomingNote ||
|
||||
existing.deletedAt !== null ||
|
||||
(Boolean(entry.externalId) && existing.date !== entry.date);
|
||||
}
|
||||
|
||||
const updateData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
||||
...baseData,
|
||||
...(entry.externalId ? { date: entry.date } : {}),
|
||||
...(cycleId !== null ? { cycleId } : {}),
|
||||
deletedAt: null,
|
||||
// Only bump syncVersion when a field actually changed — an unchanged
|
||||
// re-post must be a true no-op on the /api/sync/changes feed so other
|
||||
// devices don't re-pull an identical row.
|
||||
...(changed ? { syncVersion: { increment: 1 } } : {}),
|
||||
};
|
||||
|
||||
let rowId: string;
|
||||
let existed = existing !== null;
|
||||
try {
|
||||
const row = await prisma.cycleDayLog.upsert({
|
||||
where,
|
||||
create: {
|
||||
userId,
|
||||
date: entry.date,
|
||||
tz,
|
||||
source,
|
||||
externalId: entry.externalId ?? null,
|
||||
cycleId,
|
||||
flow: baseData.flow as never,
|
||||
intermenstrualBleeding: baseData.intermenstrualBleeding as never,
|
||||
basalBodyTempC: baseData.basalBodyTempC as never,
|
||||
ovulationTest: baseData.ovulationTest as never,
|
||||
cervicalMucus: baseData.cervicalMucus as never,
|
||||
sexualActivity: sensitivePlaintext.sexualActivity,
|
||||
protectedSex: sensitivePlaintext.protectedSex,
|
||||
pregnancyTest: sensitivePlaintext.pregnancyTest as never,
|
||||
progesteroneTest: sensitivePlaintext.progesteroneTest as never,
|
||||
contraceptive: sensitivePlaintext.contraceptive as never,
|
||||
sensitiveEncrypted,
|
||||
notesEncrypted,
|
||||
},
|
||||
update: updateData,
|
||||
select: { id: true },
|
||||
});
|
||||
rowId = row.id;
|
||||
} catch (err: unknown) {
|
||||
// Dual-unique collision: an externalId-keyed create whose date already
|
||||
// has a canonical (userId, date) row. Adopt the existing row and UPDATE
|
||||
// it in place — one canonical row per date regardless of provenance.
|
||||
if (isUniqueViolation(err) && entry.externalId) {
|
||||
const canonical = await prisma.cycleDayLog.findUnique({
|
||||
where: { userId_date: { userId, date: entry.date } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!canonical) throw err;
|
||||
const adopted = await prisma.cycleDayLog.update({
|
||||
where: { id: canonical.id },
|
||||
data: {
|
||||
...updateData,
|
||||
source,
|
||||
externalId: entry.externalId,
|
||||
...(changed ? {} : { syncVersion: { increment: 1 } }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
rowId = adopted.id;
|
||||
existed = true;
|
||||
changed = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the symptom-link set when symptoms were supplied. Absent
|
||||
// `symptoms` leaves existing links untouched.
|
||||
if (entry.symptoms !== undefined) {
|
||||
await replaceSymptomLinks(userId, rowId, entry.symptoms);
|
||||
}
|
||||
|
||||
return { id: rowId, existed, changed };
|
||||
}
|
||||
|
||||
/** Prisma P2002 (unique constraint) detector without importing the runtime. */
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the stored plaintext sensitive fields (envelope OR columns). */
|
||||
function mergeStoredSensitive(existing: ExistingRow): SensitiveFields {
|
||||
if (existing.sensitiveEncrypted) {
|
||||
const dec = decryptSensitive(existing.sensitiveEncrypted);
|
||||
return {
|
||||
sexualActivity: dec.sexualActivity ?? false,
|
||||
protectedSex: dec.protectedSex ?? null,
|
||||
pregnancyTest: dec.pregnancyTest ?? null,
|
||||
progesteroneTest: dec.progesteroneTest ?? null,
|
||||
contraceptive: dec.contraceptive ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sexualActivity: existing.sexualActivity,
|
||||
protectedSex: existing.protectedSex,
|
||||
pregnancyTest: existing.pregnancyTest,
|
||||
progesteroneTest: existing.progesteroneTest,
|
||||
contraceptive: existing.contraceptive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Shared day arithmetic for the cycle engine.
|
||||
*
|
||||
* This MUST match the iOS Swift re-implementation bit-for-bit. The whole point
|
||||
* of the cycle prediction engine is that the server (TypeScript) and the device
|
||||
* (Swift) compute byte-identical predictions from the same canonical inputs so
|
||||
* the offline calendar renders without a network round-trip and the materialised
|
||||
* server row matches the device's local recompute (a mismatch is a parity bug,
|
||||
* not last-write-wins).
|
||||
*
|
||||
* The load-bearing convention (algorithm.md §"Notation"):
|
||||
* - Dates are `YYYY-MM-DD` strings (the MoodEntry / CycleDayLog convention).
|
||||
* - A date is anchored at noon UTC (`T12:00:00Z`). Anchoring at noon — rather
|
||||
* than midnight — keeps a whole-day difference exact across every timezone
|
||||
* and DST transition (a ±1 h shift can never flip the rounded day count).
|
||||
* - `dayDiff(a, b) = round((ms_a - ms_b) / 86_400_000)`.
|
||||
* - NEVER diff with wall-clock / local time.
|
||||
* - Rounding everywhere is round-half-UP to the stated precision:
|
||||
* `Math.round(x * 10^k) / 10^k` in TS, mirrored by
|
||||
* `(x * 10^k).rounded(.toNearestOrAwayFromZero) / 10^k` in Swift.
|
||||
*/
|
||||
|
||||
/** Milliseconds in one calendar day. */
|
||||
export const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** Strict `YYYY-MM-DD` shape guard. */
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Parse a `YYYY-MM-DD` string into the epoch-ms of that date anchored at noon
|
||||
* UTC. Throws on a malformed string so a bad input fails closed rather than
|
||||
* silently producing `NaN` that would poison every downstream diff.
|
||||
*/
|
||||
export function parseDayMs(date: string): number {
|
||||
if (!ISO_DATE_RE.test(date)) {
|
||||
throw new Error(
|
||||
`cycle/day-math: malformed date "${date}" (expected YYYY-MM-DD)`,
|
||||
);
|
||||
}
|
||||
const ms = Date.parse(`${date}T12:00:00Z`);
|
||||
if (Number.isNaN(ms)) {
|
||||
throw new Error(`cycle/day-math: unparseable date "${date}"`);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-day difference `a - b` (positive when `a` is later than `b`).
|
||||
* Rounds the ms quotient so a noon-UTC anchor makes the result DST-stable.
|
||||
*/
|
||||
export function dayDiff(a: string, b: string): number {
|
||||
return Math.round((parseDayMs(a) - parseDayMs(b)) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `YYYY-MM-DD` string `n` days after `date` (`n` may be negative).
|
||||
* Computed off the noon-UTC anchor so it never crosses a day boundary by error.
|
||||
*/
|
||||
export function addDays(date: string, n: number): string {
|
||||
const ms = parseDayMs(date) + n * MS_PER_DAY;
|
||||
const d = new Date(ms);
|
||||
const y = d.getUTCFullYear().toString().padStart(4, "0");
|
||||
const m = (d.getUTCMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getUTCDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-half-up to `k` decimal places. `roundHalf(2.345, 2) === 2.35`,
|
||||
* `roundHalf(-2.345, 2) === -2.35` (away from zero on a tie), matching Swift's
|
||||
* `.toNearestOrAwayFromZero`. `k` defaults to 0 (integer rounding).
|
||||
*/
|
||||
export function roundHalf(x: number, k = 0): number {
|
||||
if (!Number.isFinite(x)) return x;
|
||||
const factor = 10 ** k;
|
||||
// Math.round is round-half-up toward +Infinity; mirror it away-from-zero for
|
||||
// negatives so the rule matches Swift's .toNearestOrAwayFromZero exactly.
|
||||
return x < 0
|
||||
? -Math.round(-x * factor) / factor
|
||||
: Math.round(x * factor) / factor;
|
||||
}
|
||||
|
||||
/** `true` when `date` is on or before `other` (string compare is safe for ISO). */
|
||||
export function isOnOrBefore(date: string, other: string): boolean {
|
||||
return dayDiff(date, other) <= 0;
|
||||
}
|
||||
|
||||
/** `true` when `date` falls within the inclusive `[start, end]` span. */
|
||||
export function isWithin(date: string, start: string, end: string): boolean {
|
||||
return dayDiff(date, start) >= 0 && dayDiff(end, date) >= 0;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Cycle row → wire-DTO mappers (ios-contract §2 + §3).
|
||||
*
|
||||
* The canonical `CycleDayLogDTO` and `MenstrualCycleDTO` shapes the iOS
|
||||
* client mirrors. `notesEncrypted` is decrypted fail-soft on read (a
|
||||
* key-rotation gap on one row reads `null`, never 500s the whole page).
|
||||
* Symptom links are flattened to `[{ key, severity }]` using the catalog
|
||||
* key; `severity` carries the persisted per-link 1-4 Likert intensity
|
||||
* (NULL = a plain presence link).
|
||||
*/
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import type { CyclePredictionResult } from "@/lib/cycle/types";
|
||||
import type {
|
||||
CycleDayLog,
|
||||
CycleSymptomLink,
|
||||
CycleSymptom,
|
||||
MenstrualCycle,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
export interface CycleSymptomDTO {
|
||||
key: string;
|
||||
severity: number | null;
|
||||
}
|
||||
|
||||
export interface CycleDayLogDTO {
|
||||
id: string;
|
||||
date: string;
|
||||
cycleId: string | null;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: string | null;
|
||||
cervicalMucus: string | null;
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
symptoms: CycleSymptomDTO[];
|
||||
note: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
periodEndDate: string | null;
|
||||
lengthDays: number | null;
|
||||
ovulationDate: string | null;
|
||||
ovulationConfirmed: boolean;
|
||||
isPredicted: boolean;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type DayLogWithLinks = CycleDayLog & {
|
||||
symptomLinks?: (CycleSymptomLink & { symptom: Pick<CycleSymptom, "key"> })[];
|
||||
};
|
||||
|
||||
/** Decrypt a day-log note fail-soft (null on missing / undecryptable). */
|
||||
function decryptNote(notesEncrypted: string | null): string | null {
|
||||
if (!notesEncrypted) return null;
|
||||
try {
|
||||
return decrypt(notesEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SensitiveEnvelope {
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: string | null;
|
||||
progesteroneTest?: string | null;
|
||||
contraceptive?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the sensitive-category envelope fail-soft. When
|
||||
* `sensitiveCategoryEncryption` was ON at write the five intent fields live
|
||||
* here instead of the plaintext columns; an undecryptable envelope reads as
|
||||
* an empty object so the page never 500s.
|
||||
*/
|
||||
function decryptSensitive(envelope: string | null): SensitiveEnvelope {
|
||||
if (!envelope) return {};
|
||||
try {
|
||||
return JSON.parse(decrypt(envelope)) as SensitiveEnvelope;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
|
||||
// Prefer the encryption envelope when present; else read the plaintext
|
||||
// columns (the flag-OFF path).
|
||||
const enc = decryptSensitive(row.sensitiveEncrypted);
|
||||
const hasEnvelope = row.sensitiveEncrypted != null;
|
||||
return {
|
||||
id: row.id,
|
||||
date: row.date,
|
||||
cycleId: row.cycleId,
|
||||
flow: row.flow,
|
||||
intermenstrualBleeding: row.intermenstrualBleeding,
|
||||
basalBodyTempC: row.basalBodyTempC,
|
||||
ovulationTest: row.ovulationTest,
|
||||
cervicalMucus: row.cervicalMucus,
|
||||
sexualActivity: hasEnvelope
|
||||
? (enc.sexualActivity ?? false)
|
||||
: row.sexualActivity,
|
||||
protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex,
|
||||
pregnancyTest: hasEnvelope
|
||||
? (enc.pregnancyTest ?? null)
|
||||
: row.pregnancyTest,
|
||||
progesteroneTest: hasEnvelope
|
||||
? (enc.progesteroneTest ?? null)
|
||||
: row.progesteroneTest,
|
||||
contraceptive: hasEnvelope
|
||||
? (enc.contraceptive ?? null)
|
||||
: row.contraceptive,
|
||||
symptoms: (row.symptomLinks ?? []).map((l) => ({
|
||||
key: l.symptom.key,
|
||||
severity: l.severity ?? null,
|
||||
})),
|
||||
note: decryptNote(row.notesEncrypted),
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toMenstrualCycleDTO(row: MenstrualCycle): MenstrualCycleDTO {
|
||||
return {
|
||||
id: row.id,
|
||||
startDate: row.startDate,
|
||||
endDate: row.endDate,
|
||||
periodEndDate: row.periodEndDate,
|
||||
lengthDays: row.lengthDays,
|
||||
ovulationDate: row.ovulationDate,
|
||||
ovulationConfirmed: row.ovulationConfirmed,
|
||||
isPredicted: row.isPredicted,
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The Prisma `include` that hydrates symptom links → DTO keys. */
|
||||
export const dayLogSymptomInclude = {
|
||||
symptomLinks: { include: { symptom: { select: { key: true } } } },
|
||||
} as const;
|
||||
|
||||
export interface CyclePredictionDTO {
|
||||
method: string;
|
||||
nextPeriodStart: string;
|
||||
nextPeriodStartLow: string;
|
||||
nextPeriodStartHigh: string;
|
||||
fertileWindowStart: string | null;
|
||||
fertileWindowEnd: string | null;
|
||||
predictedOvulation: string | null;
|
||||
/** Whether `predictedOvulation` was confirmed by a signal layer (BBT / symptothermal). */
|
||||
ovulationConfirmed: boolean;
|
||||
confidence: number;
|
||||
cyclesObserved: number;
|
||||
stillLearning: boolean;
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the engine result to the wire `CyclePredictionDTO`. Fertile-window
|
||||
* fields (and the predicted ovulation) are suppressed unless the goal
|
||||
* permits them (ios-contract §2.D — GENERAL_HEALTH / PERIMENOPAUSE / OFF
|
||||
* never surface a fertile window; the suppression is server-side, never
|
||||
* iOS).
|
||||
*/
|
||||
export function toCyclePredictionDTO(
|
||||
result: CyclePredictionResult,
|
||||
goalAllowsFertile: boolean,
|
||||
disclaimer: string,
|
||||
): CyclePredictionDTO {
|
||||
return {
|
||||
method: result.method,
|
||||
nextPeriodStart: result.nextPeriodStart,
|
||||
nextPeriodStartLow: result.nextPeriodStartLow,
|
||||
nextPeriodStartHigh: result.nextPeriodStartHigh,
|
||||
fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null,
|
||||
fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null,
|
||||
predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null,
|
||||
ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false,
|
||||
confidence: result.confidence,
|
||||
cyclesObserved: result.cyclesObserved,
|
||||
stillLearning: result.stillLearning,
|
||||
disclaimer,
|
||||
};
|
||||
}
|
||||
|
||||
/** The full profile read DTO (ios-contract §2.G). */
|
||||
export interface CycleProfileDTO {
|
||||
goal: string;
|
||||
cycleTrackingEnabled: boolean;
|
||||
rawChartMode: boolean;
|
||||
predictionEnabled: boolean;
|
||||
discreetNotifications: boolean;
|
||||
sensitiveCategoryEncryption: boolean;
|
||||
typicalCycleLength: number | null;
|
||||
typicalPeriodLength: number | null;
|
||||
lutealPhaseLength: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function toCycleProfileDTO(
|
||||
row: MenstrualCycleProfileRow,
|
||||
cycleTrackingEnabled: boolean,
|
||||
): CycleProfileDTO {
|
||||
return {
|
||||
goal: row.goal,
|
||||
cycleTrackingEnabled,
|
||||
rawChartMode: row.rawChartMode,
|
||||
predictionEnabled: row.predictionEnabled,
|
||||
discreetNotifications: row.discreetNotifications,
|
||||
sensitiveCategoryEncryption: row.sensitiveCategoryEncryption,
|
||||
typicalCycleLength: row.typicalCycleLength,
|
||||
typicalPeriodLength: row.typicalPeriodLength,
|
||||
lutealPhaseLength: row.lutealPhaseLength,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
type MenstrualCycleProfileRow = {
|
||||
goal: string;
|
||||
rawChartMode: boolean;
|
||||
predictionEnabled: boolean;
|
||||
discreetNotifications: boolean;
|
||||
sensitiveCategoryEncryption: boolean;
|
||||
typicalCycleLength: number | null;
|
||||
typicalPeriodLength: number | null;
|
||||
lutealPhaseLength: number | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a goal surfaces the fertile window. Per algorithm.md §4 the window is
|
||||
* shown for TRYING_TO_CONCEIVE and AVOID_PREGNANCY (AVOID_PREGNANCY carries a
|
||||
* stronger "estimate, not a contraceptive method" caveat in the copy), and
|
||||
* suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF (the suppression is always
|
||||
* server-side, never iOS).
|
||||
*/
|
||||
export function goalAllowsFertileWindow(goal: string): boolean {
|
||||
// algorithm.md §4: shown for TRYING_TO_CONCEIVE AND AVOID_PREGNANCY (the
|
||||
// latter carries a stronger "not a contraceptive method" caveat in copy),
|
||||
// hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF.
|
||||
return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY";
|
||||
}
|
||||
|
||||
/**
|
||||
* The i18n key for the disclaimer a goal must show. AVOID_PREGNANCY surfaces the
|
||||
* fertile window, so it MUST carry the stronger "not a contraceptive method —
|
||||
* never safe to assume unprotected sex" copy (`cycle.disclaimer`); every other
|
||||
* goal gets the standard "estimates, not medical advice" line
|
||||
* (`cycle.prediction.disclaimer`). Shared by the server route and the client so
|
||||
* web/iOS/engine show the same caveat. (algorithm.md §4 — safety-relevant.)
|
||||
*/
|
||||
export function cycleDisclaimerKey(goal: string): string {
|
||||
return goal === "AVOID_PREGNANCY"
|
||||
? "cycle.disclaimer"
|
||||
: "cycle.prediction.disclaimer";
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Prisma-row → engine-input adapters + calendar composition
|
||||
* (ios-contract §2.D). The engine itself (`predictCycle`, `phaseSeries`)
|
||||
* is pure + DB-free; this module bridges the persisted rows to the plain
|
||||
* input shapes and assembles the `CalendarDayDTO` grid the read returns.
|
||||
*/
|
||||
import {
|
||||
predictCycle,
|
||||
phaseForDay,
|
||||
addDays,
|
||||
dayDiff,
|
||||
isWithin,
|
||||
type CycleInput,
|
||||
type DayLogInput,
|
||||
type NightlyTempInput,
|
||||
type CycleProfileInput,
|
||||
type CyclePredictionResult,
|
||||
type CyclePhase,
|
||||
type PhaseCycle,
|
||||
resolveLuteal,
|
||||
} from "@/lib/cycle";
|
||||
import type {
|
||||
CycleProfile,
|
||||
CycleDayLog,
|
||||
MenstrualCycle,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
export interface CalendarDayDTO {
|
||||
date: string;
|
||||
phase: CyclePhase | null;
|
||||
isPredictedPeriod: boolean;
|
||||
isFertileWindow: boolean;
|
||||
isPredictedOvulation: boolean;
|
||||
isPeriodLogged: boolean;
|
||||
flow: string | null;
|
||||
hasSymptoms: boolean;
|
||||
confidence: number;
|
||||
/** Logged basal body temperature (°C), or null. Feeds the web BBT chart. */
|
||||
basalBodyTempC: number | null;
|
||||
/** Logged ovulation-test result, or null. */
|
||||
ovulationTest: string | null;
|
||||
/** Logged cervical-mucus quality, or null. */
|
||||
cervicalMucus: string | null;
|
||||
}
|
||||
|
||||
/** Rows the calendar needs from each day-log. */
|
||||
export type CalendarDayLogRow = Pick<
|
||||
CycleDayLog,
|
||||
"date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus"
|
||||
> & { hasSymptoms: boolean };
|
||||
|
||||
/** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */
|
||||
export function toCycleInputs(
|
||||
cycles: readonly Pick<
|
||||
MenstrualCycle,
|
||||
| "startDate"
|
||||
| "endDate"
|
||||
| "periodEndDate"
|
||||
| "ovulationDate"
|
||||
| "ovulationConfirmed"
|
||||
>[],
|
||||
): CycleInput[] {
|
||||
return cycles.map((c) => ({
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
periodEndDate: c.periodEndDate,
|
||||
ovulationDate: c.ovulationDate,
|
||||
ovulationConfirmed: c.ovulationConfirmed,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Map CycleDayLog rows to engine `DayLogInput`. */
|
||||
export function toDayLogInputs(
|
||||
logs: readonly Pick<
|
||||
CycleDayLog,
|
||||
"date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus"
|
||||
>[],
|
||||
): DayLogInput[] {
|
||||
return logs.map((l) => ({
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Map a CycleProfile row to the engine's `CycleProfileInput`. */
|
||||
export function toProfileInput(profile: CycleProfile): CycleProfileInput {
|
||||
return {
|
||||
goal: profile.goal,
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
predictionEnabled: profile.predictionEnabled,
|
||||
rawChartMode: profile.rawChartMode,
|
||||
};
|
||||
}
|
||||
|
||||
/** Period-length of an observed cycle: periodEnd − start + 1, or default. */
|
||||
function periodLengthOf(
|
||||
cycle: Pick<MenstrualCycle, "startDate" | "periodEndDate">,
|
||||
): number | null {
|
||||
if (!cycle.periodEndDate) return null;
|
||||
return dayDiff(cycle.periodEndDate, cycle.startDate) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's
|
||||
* span ends where the next begins; the latest open cycle runs to the
|
||||
* predicted next-period start). Used to label every calendar day's phase.
|
||||
*/
|
||||
export function buildPhaseCycles(
|
||||
cycles: readonly Pick<
|
||||
MenstrualCycle,
|
||||
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
): PhaseCycle[] {
|
||||
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
|
||||
const out: PhaseCycle[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
const next = sorted[i + 1];
|
||||
const nextStart =
|
||||
next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28);
|
||||
out.push({
|
||||
startDate: c.startDate,
|
||||
nextStart,
|
||||
ovulationDate: c.ovulationDate,
|
||||
periodLength: periodLengthOf(c),
|
||||
lutealLength,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Phase of a single date across the contiguous cycle list (or null). */
|
||||
export function phaseAcross(
|
||||
date: string,
|
||||
phaseCycles: readonly PhaseCycle[],
|
||||
): CyclePhase | null {
|
||||
for (const pc of phaseCycles) {
|
||||
const r = phaseForDay(date, pc);
|
||||
if (r.phase !== null) return r.phase;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the phase + 1-based day-of-cycle of a single `YYYY-MM-DD` date
|
||||
* across the observed cycles, with the latest open cycle running to
|
||||
* `predictedNextStart` (or +28d). Returns `{ phase: null, dayOfCycle: null }`
|
||||
* when the date falls outside every cycle window. Used by the Coach snapshot
|
||||
* to state "you are on day N, luteal phase".
|
||||
*/
|
||||
export function phaseForDate(
|
||||
date: string,
|
||||
cycles: readonly Pick<
|
||||
MenstrualCycle,
|
||||
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
): { phase: CyclePhase | null; dayOfCycle: number | null } {
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
predictedNextStart,
|
||||
lutealLength,
|
||||
);
|
||||
for (const pc of phaseCycles) {
|
||||
const r = phaseForDay(date, pc);
|
||||
if (r.phase !== null) return r;
|
||||
}
|
||||
return { phase: null, dayOfCycle: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `YYYY-MM-DD → CyclePhase` map across `[from, to]` inclusive from the
|
||||
* observed cycles. Days that fall outside every cycle window are omitted (no
|
||||
* key) rather than carrying a null — a consumer joins on presence. The latest
|
||||
* open cycle runs to `predictedNextStart` (or +28d) like `buildCalendar`, so
|
||||
* the trailing window is labelled even before the next period is logged.
|
||||
*
|
||||
* This is the per-day phase series the CYCLE_PHASE correlation channel and the
|
||||
* Coach phase block both consume, derived identically to the calendar grid so
|
||||
* the phase a day shows in the UI matches the phase the stats engine uses.
|
||||
*/
|
||||
export function buildPhaseDayMap(
|
||||
cycles: readonly Pick<
|
||||
MenstrualCycle,
|
||||
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Map<string, CyclePhase> {
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
predictedNextStart,
|
||||
lutealLength,
|
||||
);
|
||||
const out = new Map<string, CyclePhase>();
|
||||
const span = dayDiff(to, from);
|
||||
for (let i = 0; i <= span; i++) {
|
||||
const date = addDays(from, i);
|
||||
const phase = phaseAcross(date, phaseCycles);
|
||||
if (phase !== null) out.set(date, phase);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CalendarBuildResult {
|
||||
prediction: CyclePredictionResult | null;
|
||||
days: CalendarDayDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the calendar grid for `[from, to]` inclusive. Runs the engine
|
||||
* once for the forward forecast, then labels each day's phase, predicted
|
||||
* period bar, fertile window, and logged-flow overlay.
|
||||
*
|
||||
* Fertile-window fields are caller-gated: pass `goalAllowsFertile=false`
|
||||
* (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).
|
||||
*/
|
||||
export function buildCalendar(
|
||||
profile: CycleProfile,
|
||||
cycles: readonly MenstrualCycle[],
|
||||
dayLogs: readonly CalendarDayLogRow[],
|
||||
nights: readonly NightlyTempInput[],
|
||||
from: string,
|
||||
to: string,
|
||||
today: string,
|
||||
goalAllowsFertile: boolean,
|
||||
): CalendarBuildResult {
|
||||
// Clamp identically to the engine's resolveLuteal so the predicted-ovulation
|
||||
// dot (engine-clamped) and the OVULATORY phase band (this value) never
|
||||
// diverge for an out-of-clamp stored luteal length (QA HIGH).
|
||||
const lutealLength = resolveLuteal(profile);
|
||||
|
||||
// Forecast (only when prediction is on + not raw-chart mode).
|
||||
let prediction: CyclePredictionResult | null = null;
|
||||
if (profile.predictionEnabled && !profile.rawChartMode) {
|
||||
prediction = predictCycle(
|
||||
toCycleInputs(cycles),
|
||||
toDayLogInputs(dayLogs),
|
||||
toProfileInput(profile),
|
||||
today,
|
||||
nights,
|
||||
);
|
||||
}
|
||||
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
);
|
||||
|
||||
// Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).
|
||||
const predictedPeriodStart = prediction?.nextPeriodStart ?? null;
|
||||
const predictedPeriodEnd =
|
||||
prediction && predictedPeriodStart
|
||||
? addDays(
|
||||
predictedPeriodStart,
|
||||
Math.max(0, prediction.predictedPeriodLength - 1),
|
||||
)
|
||||
: null;
|
||||
|
||||
const logByDate = new Map<string, CalendarDayLogRow>();
|
||||
for (const l of dayLogs) logByDate.set(l.date, l);
|
||||
|
||||
const days: CalendarDayDTO[] = [];
|
||||
const span = dayDiff(to, from);
|
||||
for (let i = 0; i <= span; i++) {
|
||||
const date = addDays(from, i);
|
||||
const log = logByDate.get(date);
|
||||
|
||||
const isPredictedPeriod =
|
||||
predictedPeriodStart !== null &&
|
||||
predictedPeriodEnd !== null &&
|
||||
isWithin(date, predictedPeriodStart, predictedPeriodEnd);
|
||||
|
||||
const isFertileWindow =
|
||||
goalAllowsFertile &&
|
||||
prediction?.fertileWindowStart != null &&
|
||||
prediction.fertileWindowEnd != null &&
|
||||
isWithin(
|
||||
date,
|
||||
prediction.fertileWindowStart,
|
||||
prediction.fertileWindowEnd,
|
||||
);
|
||||
|
||||
const isPredictedOvulation =
|
||||
goalAllowsFertile &&
|
||||
prediction?.predictedOvulation != null &&
|
||||
prediction.predictedOvulation === date;
|
||||
|
||||
days.push({
|
||||
date,
|
||||
phase: phaseAcross(date, phaseCycles),
|
||||
isPredictedPeriod,
|
||||
isFertileWindow,
|
||||
isPredictedOvulation,
|
||||
isPeriodLogged: log?.flow != null && log.flow !== "NONE",
|
||||
flow: log?.flow ?? null,
|
||||
hasSymptoms: log?.hasSymptoms ?? false,
|
||||
confidence: prediction?.confidence ?? 0,
|
||||
basalBodyTempC: log?.basalBodyTempC ?? null,
|
||||
ovulationTest: log?.ovulationTest ?? null,
|
||||
cervicalMucus: log?.cervicalMucus ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return { prediction, days };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Cycle export summary — the shared read both the FHIR Observation emitter
|
||||
* and the doctor-PDF cycle section consume, so the two surfaces describe
|
||||
* identical numbers (the source-of-truth property the export family holds).
|
||||
*
|
||||
* Reads cycle data scoped to `userId` + `deletedAt: null`. Computes the
|
||||
* concise clinical summary: last menstrual period (LMP), recent observed
|
||||
* cycles, average length + variability, average period length, and the
|
||||
* current phase. Pure stats; no prediction (predictions are forecasts, not
|
||||
* a clinical record of what happened).
|
||||
*
|
||||
* `notesEncrypted` is NEVER touched here — the export summary is
|
||||
* statistics only, so no plaintext free-text ever reaches a report surface
|
||||
* through this path.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { median, mad } from "@/lib/cycle/prediction";
|
||||
import { phaseForDay } from "@/lib/cycle/phase";
|
||||
import { dayDiff, addDays } from "@/lib/cycle/day-math";
|
||||
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
|
||||
|
||||
/** One observed cycle in the recent-cycles table. */
|
||||
export interface CycleExportCycle {
|
||||
/** YYYY-MM-DD period start. */
|
||||
startDate: string;
|
||||
/** Observed cycle length in days (start→next start), null for the open cycle. */
|
||||
lengthDays: number | null;
|
||||
/** Observed period (bleeding) length in days, null when unknown. */
|
||||
periodLengthDays: number | null;
|
||||
}
|
||||
|
||||
/** The concise cycle summary surfaced in FHIR + the doctor PDF. */
|
||||
export interface CycleExportSummary {
|
||||
/** YYYY-MM-DD of the most recent period start (LMP). */
|
||||
lastPeriodStart: string | null;
|
||||
/** Observed cycles within the window, newest first (capped). */
|
||||
recentCycles: CycleExportCycle[];
|
||||
/** Count of observed cycles used for the stats. */
|
||||
observedCycleCount: number;
|
||||
/** Median observed cycle length (days). Null with < 1 completed cycle. */
|
||||
averageCycleLengthDays: number | null;
|
||||
/**
|
||||
* Cycle-length variability (days) — the median absolute deviation of the
|
||||
* observed lengths, a robust spread measure. Null with < 2 cycles.
|
||||
*/
|
||||
cycleLengthVariabilityDays: number | null;
|
||||
/** Median observed period length (days). Null when no period length known. */
|
||||
averagePeriodLengthDays: number | null;
|
||||
/** The phase the report date falls in, or null when it can't be resolved. */
|
||||
currentPhase: CyclePhase | null;
|
||||
}
|
||||
|
||||
/** Max observed cycles surfaced in the recent-cycles table. */
|
||||
const RECENT_CYCLES_CAP = 12;
|
||||
|
||||
/**
|
||||
* Build the cycle export summary for a user. `asOf` anchors the
|
||||
* current-phase calculation (defaults to today). Returns null when the
|
||||
* user has no observed cycles at all (the caller then omits the section).
|
||||
*/
|
||||
export async function buildCycleExportSummary(
|
||||
userId: string,
|
||||
asOf: string,
|
||||
lutealLength: number = LUTEAL_DEFAULT,
|
||||
): Promise<CycleExportSummary | null> {
|
||||
const cycles = await prisma.menstrualCycle.findMany({
|
||||
where: { userId, deletedAt: null, isPredicted: false },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: {
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
periodEndDate: true,
|
||||
lengthDays: true,
|
||||
ovulationDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (cycles.length === 0) return null;
|
||||
|
||||
// Observed length: prefer the stored lengthDays, else derive from the
|
||||
// next cycle's start (the canonical start→next-start definition).
|
||||
const observed: CycleExportCycle[] = cycles.map((c, i) => {
|
||||
const next = cycles[i + 1];
|
||||
const lengthDays =
|
||||
c.lengthDays ?? (next ? dayDiff(next.startDate, c.startDate) : null);
|
||||
const periodLengthDays =
|
||||
c.periodEndDate !== null
|
||||
? dayDiff(c.periodEndDate, c.startDate) + 1
|
||||
: null;
|
||||
return { startDate: c.startDate, lengthDays, periodLengthDays };
|
||||
});
|
||||
|
||||
const lengths = observed
|
||||
.map((c) => c.lengthDays)
|
||||
.filter((n): n is number => n !== null && n > 0);
|
||||
const periodLengths = observed
|
||||
.map((c) => c.periodLengthDays)
|
||||
.filter((n): n is number => n !== null && n > 0);
|
||||
|
||||
const averageCycleLengthDays =
|
||||
lengths.length >= 1 ? Math.round(median(lengths) * 10) / 10 : null;
|
||||
const cycleLengthVariabilityDays =
|
||||
lengths.length >= 2
|
||||
? Math.round(mad(lengths, median(lengths)) * 10) / 10
|
||||
: null;
|
||||
const averagePeriodLengthDays =
|
||||
periodLengths.length >= 1
|
||||
? Math.round(median(periodLengths) * 10) / 10
|
||||
: null;
|
||||
|
||||
const lastPeriodStart = cycles[cycles.length - 1]?.startDate ?? null;
|
||||
|
||||
// Current phase: resolve against the cycle whose window contains `asOf`.
|
||||
let currentPhase: CyclePhase | null = null;
|
||||
const last = cycles[cycles.length - 1];
|
||||
if (last) {
|
||||
// The open cycle's nextStart is unknown; estimate it from the median
|
||||
// length so the phase math has a window.
|
||||
const estLength = averageCycleLengthDays ?? 28;
|
||||
const nextStart = last.endDate
|
||||
? addDays(last.endDate, 1)
|
||||
: addDays(last.startDate, Math.round(estLength));
|
||||
const periodLen = observed[observed.length - 1]?.periodLengthDays ?? null;
|
||||
const { phase } = phaseForDay(asOf, {
|
||||
startDate: last.startDate,
|
||||
nextStart,
|
||||
ovulationDate: last.ovulationDate,
|
||||
periodLength: periodLen,
|
||||
lutealLength,
|
||||
});
|
||||
currentPhase = phase;
|
||||
}
|
||||
|
||||
return {
|
||||
lastPeriodStart,
|
||||
recentCycles: observed.slice(-RECENT_CYCLES_CAP).reverse(),
|
||||
observedCycleCount: observed.length,
|
||||
averageCycleLengthDays,
|
||||
cycleLengthVariabilityDays,
|
||||
averagePeriodLengthDays,
|
||||
currentPhase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Cycle-tracking feature gate.
|
||||
*
|
||||
* Two gates collapse into one resolved boolean (ios-contract §1):
|
||||
* (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and
|
||||
* (b) a gender-derived default when that toggle is NULL.
|
||||
*
|
||||
* `isCycleEnabled` is the single source of truth. The server gender enum
|
||||
* ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys on
|
||||
* `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit
|
||||
* `true`/`false` overrides the derivation (so a non-FEMALE account can
|
||||
* opt in and a FEMALE account can opt out).
|
||||
*
|
||||
* NOTE: the gender enum normalisation (adding `OTHER` / lowercasing) is a
|
||||
* separate, iOS-blocked decision (migration 0131, reserved). Until that
|
||||
* lands, `OTHER` / non-binary accounts gate purely on the explicit
|
||||
* `cycleTrackingEnabled = true` opt-in via the Settings toggle.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import type { CycleProfile } from "@/generated/prisma/client";
|
||||
|
||||
/** Wire `errorCode` the iOS retry classifier branches on. */
|
||||
export const CYCLE_DISABLED_ERROR_CODE = "cycle.disabled";
|
||||
|
||||
/**
|
||||
* Resolve whether cycle tracking is enabled for an account.
|
||||
*
|
||||
* @param gender the user's `gender` column (`"MALE" | "FEMALE" | null`).
|
||||
* @param profile the user's `CycleProfile`, or just its
|
||||
* `cycleTrackingEnabled` field (NULL ⇒ derive from gender).
|
||||
*/
|
||||
export function isCycleEnabled(
|
||||
gender: string | null | undefined,
|
||||
profile: { cycleTrackingEnabled: boolean | null } | null | undefined,
|
||||
): boolean {
|
||||
const toggle = profile?.cycleTrackingEnabled;
|
||||
if (toggle === true) return true;
|
||||
if (toggle === false) return false;
|
||||
// NULL toggle → derive from gender.
|
||||
return gender === "FEMALE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-guard outcome: either the resolved profile (enabled) or a ready
|
||||
* 403 envelope (disabled) to return verbatim.
|
||||
*/
|
||||
export type CycleGateResult =
|
||||
| { enabled: true; profile: CycleProfile }
|
||||
| { enabled: false; response: Response };
|
||||
|
||||
/**
|
||||
* Enforce the gate for a `/api/cycle/*` route. Lazily upserts the
|
||||
* `CycleProfile` (so the toggle row exists), resolves `isCycleEnabled`,
|
||||
* and returns a 403 `cycle.disabled` envelope when the account may not
|
||||
* write/read cycle data — even with a valid Bearer token.
|
||||
*/
|
||||
export async function requireCycleEnabled(
|
||||
userId: string,
|
||||
gender: string | null | undefined,
|
||||
): Promise<CycleGateResult> {
|
||||
const profile = await getOrCreateCycleProfile(userId);
|
||||
if (!isCycleEnabled(gender, profile)) {
|
||||
return {
|
||||
enabled: false,
|
||||
response: apiError("Cycle tracking is not enabled", 403, {
|
||||
errorCode: CYCLE_DISABLED_ERROR_CODE,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { enabled: true, profile };
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* HealthKit reproductive-category → CycleDayLog / CycleProfile mapping.
|
||||
*
|
||||
* The single source of truth for how an Apple Health reproductive sample
|
||||
* folds into HealthLog's cycle model. Both the `export.xml` streaming
|
||||
* importer (`src/lib/measurements/import-apple-health-export.ts`) and the
|
||||
* iOS batch contract reference this table; the canonical key tables also
|
||||
* live in `docs/api/cycle-healthkit-mapping.md` so a self-hoster reading
|
||||
* the importer and a developer reading the docs see one mapping.
|
||||
*
|
||||
* Routing rules (per `.planning/v1.15-cycle/ios-contract.md` §5):
|
||||
* - MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality /
|
||||
* OvulationTestResult / SexualActivity / Pregnancy/Progesterone test
|
||||
* results → fields on a `CycleDayLog` (the day-log write helper), NOT
|
||||
* `Measurement`.
|
||||
* - Pregnancy / Lactation / Contraceptive STATUS → `CycleProfile` flags
|
||||
* (post-partum / contraception mode), NOT day-logs.
|
||||
* - Symptom category types → seeded `CycleSymptom` keys via the
|
||||
* `CycleSymptomLink` join.
|
||||
* - BBT (`basalBodyTemperature`) stays a `Measurement(BODY_TEMPERATURE)`
|
||||
* AND mirrors `CycleDayLog.basalBodyTempC`; it is NOT routed here (it
|
||||
* is a quantity sample handled by the measurement mapping + the cycle
|
||||
* day-log mirror).
|
||||
* - BleedingDuringPregnancy / BleedingAfterPregnancy stay DEFERRED
|
||||
* (pregnancy-mode is a later release).
|
||||
*
|
||||
* Apple's `export.xml` writes a category sample's `value` as the symbolic
|
||||
* `HKCategoryValue…` name; the live HealthKit API uses the integer
|
||||
* codepoint. Every lookup table below is keyed by BOTH so the importer and
|
||||
* the batch ingest resolve a sample regardless of which form it carries.
|
||||
*/
|
||||
import type {
|
||||
FlowLevel,
|
||||
OvulationTest,
|
||||
CervicalMucus,
|
||||
HomeTestResult,
|
||||
ContraceptiveKind,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/* ── HK identifier constants ─────────────────────────────────────── */
|
||||
|
||||
export const HK_MENSTRUAL_FLOW = "HKCategoryTypeIdentifierMenstrualFlow";
|
||||
export const HK_INTERMENSTRUAL_BLEEDING =
|
||||
"HKCategoryTypeIdentifierIntermenstrualBleeding";
|
||||
export const HK_CERVICAL_MUCUS = "HKCategoryTypeIdentifierCervicalMucusQuality";
|
||||
export const HK_OVULATION_TEST = "HKCategoryTypeIdentifierOvulationTestResult";
|
||||
export const HK_SEXUAL_ACTIVITY = "HKCategoryTypeIdentifierSexualActivity";
|
||||
export const HK_PREGNANCY_TEST = "HKCategoryTypeIdentifierPregnancyTestResult";
|
||||
export const HK_PROGESTERONE_TEST =
|
||||
"HKCategoryTypeIdentifierProgesteroneTestResult";
|
||||
export const HK_CONTRACEPTIVE = "HKCategoryTypeIdentifierContraceptive";
|
||||
export const HK_PREGNANCY = "HKCategoryTypeIdentifierPregnancy";
|
||||
export const HK_LACTATION = "HKCategoryTypeIdentifierLactation";
|
||||
|
||||
/**
|
||||
* The HealthKit metadata key carrying the protection-used boolean on a
|
||||
* SexualActivity sample. Apple writes it as a `<MetadataEntry>` child with
|
||||
* a `"0"`/`"1"` (or `"true"`/`"false"`) value.
|
||||
*/
|
||||
export const HK_SEXUAL_ACTIVITY_PROTECTION_META =
|
||||
"HKMetadataKeySexualActivityProtectionUsed";
|
||||
|
||||
/* ── category-value lookup tables (symbolic name + integer) ──────── */
|
||||
|
||||
/**
|
||||
* HKCategoryValueMenstrualFlow: unspecified(1)/light(2)/medium(3)/heavy(4)/
|
||||
* none(5). HK `unspecified` maps to LIGHT as the conservative boundary
|
||||
* (a logged-but-unspecified bleeding day is at least light flow); HK
|
||||
* `none` maps to our NONE. Documented with iOS in the spotting boundary
|
||||
* note — HealthLog's SPOTTING has no HK counterpart, so it is only ever
|
||||
* produced by a manual MANUAL-source entry.
|
||||
*/
|
||||
export const HK_MENSTRUAL_FLOW_VALUES: Record<string, FlowLevel> = {
|
||||
"1": "LIGHT", // HKCategoryValueMenstrualFlowUnspecified
|
||||
HKCategoryValueMenstrualFlowUnspecified: "LIGHT",
|
||||
"2": "LIGHT", // HKCategoryValueMenstrualFlowLight
|
||||
HKCategoryValueMenstrualFlowLight: "LIGHT",
|
||||
"3": "MEDIUM", // HKCategoryValueMenstrualFlowMedium
|
||||
HKCategoryValueMenstrualFlowMedium: "MEDIUM",
|
||||
"4": "HEAVY", // HKCategoryValueMenstrualFlowHeavy
|
||||
HKCategoryValueMenstrualFlowHeavy: "HEAVY",
|
||||
"5": "NONE", // HKCategoryValueMenstrualFlowNone
|
||||
HKCategoryValueMenstrualFlowNone: "NONE",
|
||||
};
|
||||
|
||||
/**
|
||||
* HKCategoryValueOvulationTestResult: negative(1)/luteinizingHormoneSurge(2)/
|
||||
* indeterminate(3)/estrogenSurge(4).
|
||||
*/
|
||||
export const HK_OVULATION_TEST_VALUES: Record<string, OvulationTest> = {
|
||||
"1": "NEGATIVE",
|
||||
HKCategoryValueOvulationTestResultNegative: "NEGATIVE",
|
||||
"2": "POSITIVE_LH_SURGE",
|
||||
HKCategoryValueOvulationTestResultLuteinizingHormoneSurge:
|
||||
"POSITIVE_LH_SURGE",
|
||||
"3": "INDETERMINATE",
|
||||
HKCategoryValueOvulationTestResultIndeterminate: "INDETERMINATE",
|
||||
"4": "ESTROGEN_SURGE",
|
||||
HKCategoryValueOvulationTestResultEstrogenSurge: "ESTROGEN_SURGE",
|
||||
};
|
||||
|
||||
/**
|
||||
* HKCategoryValueCervicalMucusQuality: dry(1)/sticky(2)/creamy(3)/watery(4)/
|
||||
* eggWhite(5).
|
||||
*/
|
||||
export const HK_CERVICAL_MUCUS_VALUES: Record<string, CervicalMucus> = {
|
||||
"1": "DRY",
|
||||
HKCategoryValueCervicalMucusQualityDry: "DRY",
|
||||
"2": "STICKY",
|
||||
HKCategoryValueCervicalMucusQualitySticky: "STICKY",
|
||||
"3": "CREAMY",
|
||||
HKCategoryValueCervicalMucusQualityCreamy: "CREAMY",
|
||||
"4": "WATERY",
|
||||
HKCategoryValueCervicalMucusQualityWatery: "WATERY",
|
||||
"5": "EGG_WHITE",
|
||||
HKCategoryValueCervicalMucusQualityEggWhite: "EGG_WHITE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared home-test result enum for the pregnancy- and progesterone-test
|
||||
* category types: negative(1)/positive(2)/indeterminate(3).
|
||||
*/
|
||||
export const HK_HOME_TEST_VALUES: Record<string, HomeTestResult> = {
|
||||
"1": "NEGATIVE",
|
||||
HKCategoryValuePregnancyTestResultNegative: "NEGATIVE",
|
||||
HKCategoryValueProgesteroneTestResultNegative: "NEGATIVE",
|
||||
"2": "POSITIVE",
|
||||
HKCategoryValuePregnancyTestResultPositive: "POSITIVE",
|
||||
HKCategoryValueProgesteroneTestResultPositive: "POSITIVE",
|
||||
"3": "INDETERMINATE",
|
||||
HKCategoryValuePregnancyTestResultIndeterminate: "INDETERMINATE",
|
||||
HKCategoryValueProgesteroneTestResultIndeterminate: "INDETERMINATE",
|
||||
};
|
||||
|
||||
/**
|
||||
* HKCategoryValueContraceptive: unspecified(1)/implant(2)/injection(3)/
|
||||
* intrauterineDevice(4)/intravaginalRing(5)/oral(6)/patch(7)/emergency(8).
|
||||
* Routed to the CycleProfile contraception flag (status), but a same-day
|
||||
* contraceptive sample also lands on the day-log `contraceptive` field so
|
||||
* the timeline shows the method that was active.
|
||||
*/
|
||||
export const HK_CONTRACEPTIVE_VALUES: Record<string, ContraceptiveKind> = {
|
||||
"1": "UNSPECIFIED",
|
||||
HKCategoryValueContraceptiveUnspecified: "UNSPECIFIED",
|
||||
"2": "IMPLANT",
|
||||
HKCategoryValueContraceptiveImplant: "IMPLANT",
|
||||
"3": "INJECTION",
|
||||
HKCategoryValueContraceptiveInjection: "INJECTION",
|
||||
"4": "IUD",
|
||||
HKCategoryValueContraceptiveIntrauterineDevice: "IUD",
|
||||
"5": "INTRAVAGINAL_RING",
|
||||
HKCategoryValueContraceptiveIntravaginalRing: "INTRAVAGINAL_RING",
|
||||
"6": "ORAL",
|
||||
HKCategoryValueContraceptiveOral: "ORAL",
|
||||
"7": "PATCH",
|
||||
HKCategoryValueContraceptivePatch: "PATCH",
|
||||
"8": "EMERGENCY",
|
||||
HKCategoryValueContraceptiveEmergency: "EMERGENCY",
|
||||
};
|
||||
|
||||
/**
|
||||
* Symptom category types → seeded `CycleSymptom.key` (the catalogue keys
|
||||
* from migration 0129). One HK identifier per canonical symptom key; the
|
||||
* symptom severity (none/mild/moderate/severe) is carried on the link's
|
||||
* severity in the batch contract but the seeded catalogue join is
|
||||
* key-only, so severity is dropped on the XML import path (the symptom is
|
||||
* still recorded). Identifiers without a seeded counterpart are omitted —
|
||||
* they fall through to the importer's `deferred` tally rather than
|
||||
* inventing a catalogue row.
|
||||
*/
|
||||
export const HK_SYMPTOM_KEY_BY_IDENTIFIER: Record<string, string> = {
|
||||
HKCategoryTypeIdentifierAbdominalCramps: "cramps",
|
||||
HKCategoryTypeIdentifierHeadache: "headache",
|
||||
HKCategoryTypeIdentifierBloating: "bloating",
|
||||
HKCategoryTypeIdentifierAcne: "acne",
|
||||
HKCategoryTypeIdentifierBreastPain: "breast_tenderness",
|
||||
HKCategoryTypeIdentifierFatigue: "fatigue",
|
||||
HKCategoryTypeIdentifierLowerBackPain: "back_pain",
|
||||
HKCategoryTypeIdentifierSleepChanges: "insomnia",
|
||||
HKCategoryTypeIdentifierMoodChanges: "mood_swings",
|
||||
HKCategoryTypeIdentifierAppetiteChanges: "food_cravings",
|
||||
HKCategoryTypeIdentifierNausea: "nausea",
|
||||
};
|
||||
|
||||
/**
|
||||
* HK severity codepoints for a symptom category sample:
|
||||
* notPresent(1)/present(2)/mild(3)/moderate(4)/severe(5). A `notPresent`
|
||||
* sample is an explicit "I did not have this" record — it must NOT create
|
||||
* a symptom link. Returns true when the sample asserts the symptom.
|
||||
*/
|
||||
export function hkSymptomIsPresent(rawValue: string | undefined): boolean {
|
||||
if (rawValue === undefined) return true; // sample present, no severity
|
||||
if (
|
||||
rawValue === "1" ||
|
||||
rawValue === "HKCategoryValueSeverityNotPresent" ||
|
||||
rawValue === "HKCategoryValuePresenceNotPresent"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── routed-output shape ─────────────────────────────────────────── */
|
||||
|
||||
/** Partial CycleDayLog field set a single HK reproductive sample fills. */
|
||||
export interface CycleDayLogFields {
|
||||
flow?: FlowLevel;
|
||||
intermenstrualBleeding?: boolean;
|
||||
ovulationTest?: OvulationTest;
|
||||
cervicalMucus?: CervicalMucus;
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: HomeTestResult;
|
||||
progesteroneTest?: HomeTestResult;
|
||||
contraceptive?: ContraceptiveKind;
|
||||
/** Seeded symptom catalogue key to link on the day. */
|
||||
symptomKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CycleProfile status fields a HK STATUS sample updates. The v1.15.0
|
||||
* `CycleProfile` schema carries no dedicated pregnant/lactating columns
|
||||
* (pregnancy-mode is a deferred release), so the only profile signal a
|
||||
* status sample can land today is a goal nudge: an active contraceptive
|
||||
* method nudges the goal toward AVOID_PREGNANCY. The nudge is conservative
|
||||
* — the importer only applies it when the profile is still on its
|
||||
* GENERAL_HEALTH default, never clobbering an explicit user choice.
|
||||
*/
|
||||
export interface CycleProfileFields {
|
||||
/**
|
||||
* The contraceptive method the sample asserts, also written to the
|
||||
* day-log `contraceptive` field for the timeline. Drives the goal nudge.
|
||||
*/
|
||||
contraceptive?: ContraceptiveKind;
|
||||
}
|
||||
|
||||
export type HkCycleRoute =
|
||||
/** Fold the fields into the day's `CycleDayLog` (the common case). */
|
||||
| { kind: "day-log"; fields: CycleDayLogFields }
|
||||
/**
|
||||
* A contraceptive STATUS sample: record the method on the day-log AND
|
||||
* nudge the CycleProfile goal. Carries both payloads so the importer
|
||||
* applies them in one pass.
|
||||
*/
|
||||
| {
|
||||
kind: "day-log+profile";
|
||||
fields: CycleDayLogFields;
|
||||
profile: CycleProfileFields;
|
||||
}
|
||||
/** No cycle destination (unrecognised value, or deferred pregnancy-mode). */
|
||||
| { kind: "skip" };
|
||||
|
||||
/** Every HK identifier this module routes (status + day-log + symptoms). */
|
||||
export const HK_CYCLE_IDENTIFIERS: ReadonlySet<string> = new Set<string>([
|
||||
HK_MENSTRUAL_FLOW,
|
||||
HK_INTERMENSTRUAL_BLEEDING,
|
||||
HK_CERVICAL_MUCUS,
|
||||
HK_OVULATION_TEST,
|
||||
HK_SEXUAL_ACTIVITY,
|
||||
HK_PREGNANCY_TEST,
|
||||
HK_PROGESTERONE_TEST,
|
||||
HK_CONTRACEPTIVE,
|
||||
...Object.keys(HK_SYMPTOM_KEY_BY_IDENTIFIER),
|
||||
]);
|
||||
|
||||
/** Does this module own the inbound identifier? */
|
||||
export function isCycleHkIdentifier(hkIdentifier: string): boolean {
|
||||
return HK_CYCLE_IDENTIFIERS.has(hkIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one inbound HK reproductive sample to its cycle destination.
|
||||
*
|
||||
* @param hkIdentifier the `HKCategoryTypeIdentifier…` string.
|
||||
* @param rawValue the sample's `value` attribute (symbolic name OR integer
|
||||
* codepoint as a string), `undefined` for a presence-only sample.
|
||||
* @param protectionUsed resolved `HKMetadataKeySexualActivityProtectionUsed`
|
||||
* metadata for a SexualActivity sample (`undefined` when absent).
|
||||
* @returns a routed destination, or `{ kind: "skip" }` when the value is
|
||||
* unrecognised or the identifier carries no cycle meaning.
|
||||
*/
|
||||
export function mapHkCycleSample(
|
||||
hkIdentifier: string,
|
||||
rawValue: string | undefined,
|
||||
protectionUsed?: boolean,
|
||||
): HkCycleRoute {
|
||||
// Symptom category types → catalogue key (skip an explicit not-present).
|
||||
const symptomKey = HK_SYMPTOM_KEY_BY_IDENTIFIER[hkIdentifier];
|
||||
if (symptomKey) {
|
||||
if (!hkSymptomIsPresent(rawValue)) return { kind: "skip" };
|
||||
return { kind: "day-log", fields: { symptomKey } };
|
||||
}
|
||||
|
||||
switch (hkIdentifier) {
|
||||
case HK_MENSTRUAL_FLOW: {
|
||||
const flow = rawValue ? HK_MENSTRUAL_FLOW_VALUES[rawValue] : undefined;
|
||||
if (!flow) return { kind: "skip" };
|
||||
return { kind: "day-log", fields: { flow } };
|
||||
}
|
||||
case HK_INTERMENSTRUAL_BLEEDING:
|
||||
// A present sample asserts spotting outside the period window.
|
||||
return { kind: "day-log", fields: { intermenstrualBleeding: true } };
|
||||
case HK_CERVICAL_MUCUS: {
|
||||
const cervicalMucus = rawValue
|
||||
? HK_CERVICAL_MUCUS_VALUES[rawValue]
|
||||
: undefined;
|
||||
if (!cervicalMucus) return { kind: "skip" };
|
||||
return { kind: "day-log", fields: { cervicalMucus } };
|
||||
}
|
||||
case HK_OVULATION_TEST: {
|
||||
const ovulationTest = rawValue
|
||||
? HK_OVULATION_TEST_VALUES[rawValue]
|
||||
: undefined;
|
||||
if (!ovulationTest) return { kind: "skip" };
|
||||
return { kind: "day-log", fields: { ovulationTest } };
|
||||
}
|
||||
case HK_SEXUAL_ACTIVITY:
|
||||
return {
|
||||
kind: "day-log",
|
||||
fields: {
|
||||
sexualActivity: true,
|
||||
protectedSex: protectionUsed === undefined ? null : protectionUsed,
|
||||
},
|
||||
};
|
||||
case HK_PREGNANCY_TEST: {
|
||||
const pregnancyTest = rawValue
|
||||
? HK_HOME_TEST_VALUES[rawValue]
|
||||
: undefined;
|
||||
if (!pregnancyTest) return { kind: "skip" };
|
||||
return { kind: "day-log", fields: { pregnancyTest } };
|
||||
}
|
||||
case HK_PROGESTERONE_TEST: {
|
||||
const progesteroneTest = rawValue
|
||||
? HK_HOME_TEST_VALUES[rawValue]
|
||||
: undefined;
|
||||
if (!progesteroneTest) return { kind: "skip" };
|
||||
return { kind: "day-log", fields: { progesteroneTest } };
|
||||
}
|
||||
case HK_CONTRACEPTIVE: {
|
||||
const contraceptive = rawValue
|
||||
? HK_CONTRACEPTIVE_VALUES[rawValue]
|
||||
: undefined;
|
||||
if (!contraceptive) return { kind: "skip" };
|
||||
// Record the method on the day-log timeline AND nudge the profile
|
||||
// goal toward AVOID_PREGNANCY (the importer applies the nudge only
|
||||
// when the goal is still on its GENERAL_HEALTH default).
|
||||
return {
|
||||
kind: "day-log+profile",
|
||||
fields: { contraceptive },
|
||||
profile: { contraceptive },
|
||||
};
|
||||
}
|
||||
// HK_PREGNANCY / HK_LACTATION carry no v1.15.0 schema destination
|
||||
// (pregnancy-mode is deferred); they fall through to skip and the
|
||||
// importer leaves them in its `deferred` tally rather than inventing a
|
||||
// status column.
|
||||
default:
|
||||
return { kind: "skip" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Cycle-import accumulator — folds the reproductive HealthKit samples a
|
||||
* `export.xml` parse emits into one merged `CycleDayLog` per user-local day.
|
||||
*
|
||||
* The streaming importer (`import-apple-health-export.ts`) sees one
|
||||
* `<Record>` per HK sample. Several reproductive samples can share a day
|
||||
* (flow + cervical mucus + a symptom). The `CycleDayLog` is one canonical
|
||||
* row per `(user, date)`, so we merge same-day samples in memory and flush
|
||||
* one upsert per day at the end of the parse.
|
||||
*
|
||||
* Dedup / idempotency: each merged day writes with `source:APPLE_HEALTH` and
|
||||
* a synthetic per-day `externalId` (`hkcycle:<YYYY-MM-DD>`), so a re-import
|
||||
* upserts the same row on the `(userId, source, externalId)` NULL-distinct
|
||||
* key — first-write-wins at the day grain, last-writer-wins on the field
|
||||
* set (the day-log write helper's contract). This mirrors the cumulative
|
||||
* `stats:<HKType>:<day>` convention the measurement importer already uses.
|
||||
*
|
||||
* A contraceptive STATUS sample additionally nudges the `CycleProfile`
|
||||
* goal toward AVOID_PREGNANCY — but only when the goal is still on its
|
||||
* GENERAL_HEALTH default, so an explicit user choice is never clobbered.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
mapHkCycleSample,
|
||||
isCycleHkIdentifier,
|
||||
type CycleDayLogFields,
|
||||
} from "@/lib/cycle/healthkit-mapping";
|
||||
import type { CycleDayLogInput } from "@/lib/validations/cycle";
|
||||
import type { ContraceptiveKind } from "@/generated/prisma/client";
|
||||
|
||||
/** Synthetic per-day externalId prefix for HealthKit-imported cycle rows. */
|
||||
export const HK_CYCLE_DAY_EXTERNAL_PREFIX = "hkcycle:";
|
||||
|
||||
/** A zeroed cycle-import result — the no-cycle / aborted-flush fallback. */
|
||||
export const EMPTY_CYCLE_IMPORT_STATS: CycleImportStats = {
|
||||
samplesConsumed: 0,
|
||||
daysUpserted: 0,
|
||||
daysInserted: 0,
|
||||
goalNudged: false,
|
||||
};
|
||||
|
||||
/** Per-day merge state held during the parse. */
|
||||
interface DayBucket {
|
||||
fields: CycleDayLogFields;
|
||||
symptomKeys: Set<string>;
|
||||
/** A contraceptive method seen this day → triggers the profile nudge. */
|
||||
contraceptiveNudge: ContraceptiveKind | null;
|
||||
}
|
||||
|
||||
/** Terminal stats the importer folds into its `ImportJobResult`. */
|
||||
export interface CycleImportStats {
|
||||
/** Reproductive samples consumed into a day-bucket. */
|
||||
samplesConsumed: number;
|
||||
/** Day-logs upserted at flush. */
|
||||
daysUpserted: number;
|
||||
/** Day-logs that the write created (vs refreshed). */
|
||||
daysInserted: number;
|
||||
/** Whether a CycleProfile goal nudge fired. */
|
||||
goalNudged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fold of the reproductive HK samples for one user's import.
|
||||
* Construct once per parse; `consume()` per matching `<Record>`; `flush()`
|
||||
* once at the end.
|
||||
*/
|
||||
export class CycleImportAccumulator {
|
||||
private readonly byDay = new Map<string, DayBucket>();
|
||||
private samplesConsumed = 0;
|
||||
private gated: boolean | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly userTimezone: string,
|
||||
) {}
|
||||
|
||||
/** Does this importer own the inbound HK identifier? */
|
||||
static handles(hkIdentifier: string): boolean {
|
||||
return isCycleHkIdentifier(hkIdentifier);
|
||||
}
|
||||
|
||||
/** True once at least one reproductive sample folded into a day-bucket. */
|
||||
hasSamples(): boolean {
|
||||
return this.byDay.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one reproductive HK sample into its day-bucket. `dayKey` is the
|
||||
* already-resolved user-local `YYYY-MM-DD`. `rawValue` is the sample's
|
||||
* `value` attribute (symbolic name or integer string). `protectionUsed`
|
||||
* is the resolved SexualActivity protection metadata (or undefined).
|
||||
* Returns true when the sample was consumed (recognised + routed).
|
||||
*/
|
||||
consume(
|
||||
hkIdentifier: string,
|
||||
dayKey: string,
|
||||
rawValue: string | undefined,
|
||||
protectionUsed?: boolean,
|
||||
): boolean {
|
||||
const route = mapHkCycleSample(hkIdentifier, rawValue, protectionUsed);
|
||||
if (route.kind === "skip") return false;
|
||||
|
||||
const bucket = this.bucketFor(dayKey);
|
||||
this.mergeFields(bucket, route.fields);
|
||||
|
||||
if (route.kind === "day-log+profile" && route.profile.contraceptive) {
|
||||
bucket.contraceptiveNudge = route.profile.contraceptive;
|
||||
}
|
||||
|
||||
this.samplesConsumed += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bucketFor(dayKey: string): DayBucket {
|
||||
let bucket = this.byDay.get(dayKey);
|
||||
if (!bucket) {
|
||||
bucket = { fields: {}, symptomKeys: new Set(), contraceptiveNudge: null };
|
||||
this.byDay.set(dayKey, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/** Last-writer-wins merge of a sample's fields into the day-bucket. */
|
||||
private mergeFields(bucket: DayBucket, fields: CycleDayLogFields): void {
|
||||
if (fields.symptomKey) bucket.symptomKeys.add(fields.symptomKey);
|
||||
if (fields.flow !== undefined) bucket.fields.flow = fields.flow;
|
||||
if (fields.intermenstrualBleeding !== undefined) {
|
||||
bucket.fields.intermenstrualBleeding = fields.intermenstrualBleeding;
|
||||
}
|
||||
if (fields.ovulationTest !== undefined) {
|
||||
bucket.fields.ovulationTest = fields.ovulationTest;
|
||||
}
|
||||
if (fields.cervicalMucus !== undefined) {
|
||||
bucket.fields.cervicalMucus = fields.cervicalMucus;
|
||||
}
|
||||
if (fields.sexualActivity !== undefined) {
|
||||
bucket.fields.sexualActivity = fields.sexualActivity;
|
||||
}
|
||||
if (fields.protectedSex !== undefined) {
|
||||
bucket.fields.protectedSex = fields.protectedSex;
|
||||
}
|
||||
if (fields.pregnancyTest !== undefined) {
|
||||
bucket.fields.pregnancyTest = fields.pregnancyTest;
|
||||
}
|
||||
if (fields.progesteroneTest !== undefined) {
|
||||
bucket.fields.progesteroneTest = fields.progesteroneTest;
|
||||
}
|
||||
if (fields.contraceptive !== undefined) {
|
||||
bucket.fields.contraceptive = fields.contraceptive;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether cycle tracking is enabled for the user (gender-derived
|
||||
* or the explicit toggle). Cached for the parse lifetime. The importer
|
||||
* skips the whole cycle fold when this is false so a non-cycle account's
|
||||
* Apple Health export never silently provisions cycle rows.
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
if (this.gated !== null) return this.gated;
|
||||
const [user, profile] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: this.userId },
|
||||
select: { gender: true },
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: this.userId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
]);
|
||||
// The single source-of-truth gate (no profile auto-create on import).
|
||||
this.gated = isCycleEnabled(user?.gender, profile);
|
||||
return this.gated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert one merged `CycleDayLog` per accumulated day. No-op when nothing
|
||||
* was consumed. Returns the terminal stats for the importer result.
|
||||
*/
|
||||
async flush(): Promise<CycleImportStats> {
|
||||
const stats: CycleImportStats = {
|
||||
samplesConsumed: this.samplesConsumed,
|
||||
daysUpserted: 0,
|
||||
daysInserted: 0,
|
||||
goalNudged: false,
|
||||
};
|
||||
if (this.byDay.size === 0) return stats;
|
||||
|
||||
let contraceptiveNudge: ContraceptiveKind | null = null;
|
||||
|
||||
// Hoist cycle attribution out of the per-day loop: load the user's
|
||||
// (non-deleted) cycle start dates once and resolve the owning span in
|
||||
// memory (latest start <= date) rather than a query per day.
|
||||
const cycleRows = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: this.userId, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { id: true, startDate: true },
|
||||
});
|
||||
const owningCycleId = (date: string): string | null => {
|
||||
let id: string | null = null;
|
||||
for (const c of cycleRows) {
|
||||
if (c.startDate <= date) id = c.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
// Resolve the invariant encryption flag once for the whole flush, instead
|
||||
// of re-reading the profile per upserted day (QA M-3). Default ON if the
|
||||
// profile somehow vanished between the gate check and here.
|
||||
const encProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: this.userId },
|
||||
select: { sensitiveCategoryEncryption: true },
|
||||
});
|
||||
const encryptSensitive = encProfile?.sensitiveCategoryEncryption ?? true;
|
||||
|
||||
for (const [dayKey, bucket] of this.byDay.entries()) {
|
||||
const symptoms =
|
||||
bucket.symptomKeys.size > 0
|
||||
? Array.from(bucket.symptomKeys).map((key) => ({ key }))
|
||||
: undefined;
|
||||
|
||||
const entry: CycleDayLogInput = {
|
||||
date: dayKey,
|
||||
...bucket.fields,
|
||||
...(symptoms ? { symptoms } : {}),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: `${HK_CYCLE_DAY_EXTERNAL_PREFIX}${dayKey}`,
|
||||
// The day-log write helper requires a `loggedAt`; anchor it to noon
|
||||
// of the day so it is stable across re-imports (the row's measured
|
||||
// moment is the day, not a wall-clock instant from the sample).
|
||||
loggedAt: `${dayKey}T12:00:00.000Z`,
|
||||
};
|
||||
|
||||
const cycleId = owningCycleId(dayKey);
|
||||
const result = await upsertCycleDayLog(
|
||||
this.userId,
|
||||
entry,
|
||||
this.userTimezone,
|
||||
cycleId,
|
||||
encryptSensitive,
|
||||
);
|
||||
stats.daysUpserted += 1;
|
||||
if (!result.existed) stats.daysInserted += 1;
|
||||
|
||||
// The most-recent contraceptive method across the import drives the
|
||||
// single goal nudge (applied once after the day loop).
|
||||
if (bucket.contraceptiveNudge) {
|
||||
contraceptiveNudge = bucket.contraceptiveNudge;
|
||||
}
|
||||
}
|
||||
|
||||
// Goal nudge: an active contraceptive method moves a still-default
|
||||
// GENERAL_HEALTH profile to AVOID_PREGNANCY. NONE/UNSPECIFIED do not
|
||||
// assert avoidance intent, so they leave the goal untouched.
|
||||
if (
|
||||
contraceptiveNudge &&
|
||||
contraceptiveNudge !== "NONE" &&
|
||||
contraceptiveNudge !== "UNSPECIFIED"
|
||||
) {
|
||||
const updated = await prisma.cycleProfile.updateMany({
|
||||
where: { userId: this.userId, goal: "GENERAL_HEALTH" },
|
||||
data: { goal: "AVOID_PREGNANCY" },
|
||||
});
|
||||
if (updated.count > 0) {
|
||||
stats.goalNudged = true;
|
||||
// A server-side mutation of an intent-revealing setting must not be
|
||||
// invisible — record it on the audit trail.
|
||||
await auditLog("cycle.profile.goal-nudged", {
|
||||
userId: this.userId,
|
||||
details: {
|
||||
from: "GENERAL_HEALTH",
|
||||
to: "AVOID_PREGNANCY",
|
||||
via: "apple-health-import",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Cycle prediction + phase engine — public surface.
|
||||
*
|
||||
* Pure, deterministic, DB-free. The iOS team re-implements this 1:1 in Swift
|
||||
* from `.planning/v1.15-cycle/algorithm.md`; the unit-test fixtures under
|
||||
* `__tests__/` are the shared parity contract.
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./day-math";
|
||||
export {
|
||||
predictCycle,
|
||||
estimateCycleLength,
|
||||
estimatePeriodLength,
|
||||
observedPeriodLength,
|
||||
detectTempShift,
|
||||
detectMucusPeak,
|
||||
confirmSymptothermal,
|
||||
detectTemperatureTrend,
|
||||
median,
|
||||
mad,
|
||||
resolveLuteal,
|
||||
clampLuteal,
|
||||
} from "./prediction";
|
||||
export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase";
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* v1.15 — CYCLE_PHASE × vital crosstab + discovery channel.
|
||||
*
|
||||
* The cycle phase becomes one more channel in the same FDR-guarded discovery
|
||||
* machinery the RATED mood factors were folded into in v1.14.0 — no new
|
||||
* statistics. Two complementary mechanisms (integration-audit §1):
|
||||
*
|
||||
* A. PHASE-CONTRAST (categorical → Welch + BH-FDR). Per outcome metric,
|
||||
* compare the LUTEAL day-group against the FOLLICULAR day-group — the two
|
||||
* phases with the largest documented physiological contrast — through the
|
||||
* identical `welchTTest` + `benjaminiHochberg` pipeline the mood-factor
|
||||
* crosstab runs (`computeFactorMetricCrosstab` in
|
||||
* `src/lib/insights/mood-aggregates.ts`). Same day floors, same q = 0.10,
|
||||
* same confidence bands.
|
||||
*
|
||||
* B. CONTINUOUS ordinal channel for the lagged-Pearson matrix. The phase maps
|
||||
* to {MENSTRUAL:0, FOLLICULAR:1, OVULATORY:2, LUTEAL:3} and enters
|
||||
* `discoverCorrelations` as a `NamedSeries{ key: "CYCLE_PHASE", role:
|
||||
* "behaviour" }` exactly the way a `FACTOR:` channel does.
|
||||
*
|
||||
* Pure + DB-free: every function here takes already-fetched rows (the gated
|
||||
* `/api/cycle/insights` route does the reads + the gender gate). The phase-day
|
||||
* membership comes from `buildPhaseDayMap` in `engine-adapter.ts`, derived
|
||||
* identically to the calendar grid so the phase the stats use matches the
|
||||
* phase the UI shows.
|
||||
*
|
||||
* Privacy: this lives ONLY behind the cycle gate. `WRIST_TEMPERATURE` /
|
||||
* `SKIN_TEMPERATURE` are deliberately held OUT of the GENERAL correlation
|
||||
* matrix (`correlation-discovery.ts`) precisely so a cycle-phase relation can
|
||||
* never leak into a non-gender-gated surface; the cycle feature is the
|
||||
* deliberate decision that un-blocks them here, and nowhere else.
|
||||
*/
|
||||
import {
|
||||
metricDayMap,
|
||||
influenceConfidence,
|
||||
CROSSTAB_MIN_PRESENT_DAYS,
|
||||
CROSSTAB_MIN_ABSENT_DAYS,
|
||||
CROSSTAB_FDR_Q,
|
||||
CROSSTAB_MAX_ROWS,
|
||||
type CrossMetricMeasurement,
|
||||
type InfluenceConfidence,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import { welchTTest } from "@/lib/insights/correlations";
|
||||
import {
|
||||
benjaminiHochberg as fdrAdjust,
|
||||
discoverCorrelations,
|
||||
type NamedSeries,
|
||||
type DailySeriesPoint,
|
||||
type CorrelationDiscoveryResult,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
import type { CyclePhase } from "@/lib/cycle/types";
|
||||
|
||||
/** Stable channel key for the continuous CYCLE_PHASE discovery series. */
|
||||
export const CYCLE_PHASE_CHANNEL_KEY = "CYCLE_PHASE";
|
||||
|
||||
/**
|
||||
* Ordinal encoding of the phase for the continuous lagged-Pearson channel —
|
||||
* the menstrual→luteal progression as a monotone 0..3 scale. The contrast
|
||||
* crosstab (mechanism A) uses the categorical label directly and never this
|
||||
* ordinal; the ordinal exists only for mechanism B.
|
||||
*/
|
||||
export const PHASE_ORDINAL: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 1,
|
||||
OVULATORY: 2,
|
||||
LUTEAL: 3,
|
||||
};
|
||||
|
||||
/** Display-unit hint the client formatter branches on. */
|
||||
export type PhaseCrosstabDisplay =
|
||||
| "hours"
|
||||
| "steps"
|
||||
| "bpm"
|
||||
| "ms"
|
||||
| "kg"
|
||||
| "celsius"
|
||||
| "glucose"
|
||||
| "mood";
|
||||
|
||||
/**
|
||||
* Synthetic non-MeasurementType channel key for the MOOD outcome. Mood lives in
|
||||
* the separate `MoodEntry` model (1–5 score), not a Measurement row, so the
|
||||
* caller injects it into the crosstab `measurements` array tagged with this key
|
||||
* and `metricDayMap` groups it like any other type (its pass-through fallback
|
||||
* keeps every row — there is no source ladder for mood). It is deliberately NOT
|
||||
* a member of `PHASE_CROSSTAB_METRIC_TYPES`, so it never enters the Measurement
|
||||
* read query.
|
||||
*/
|
||||
export const MOOD_CHANNEL_KEY = "MOOD";
|
||||
|
||||
/**
|
||||
* The outcome metric grid the phase contrast walks. Mirrors
|
||||
* `FACTOR_CROSSTAB_METRICS` (RHR / HRV / sleep / steps / weight) and EXTENDS it
|
||||
* with the temperature channels (BBT + passive wrist/skin temp) that the
|
||||
* audit flags as both a phase-correlation outcome AND the symptothermal
|
||||
* ovulation input — surfaced here, never on the general matrix.
|
||||
*/
|
||||
export const PHASE_CROSSTAB_METRICS: Record<
|
||||
string,
|
||||
{
|
||||
type: MeasurementType | typeof MOOD_CHANNEL_KEY;
|
||||
display: PhaseCrosstabDisplay;
|
||||
}
|
||||
> = {
|
||||
restingHeartRate: { type: "RESTING_HEART_RATE", display: "bpm" },
|
||||
heartRateVariability: { type: "HEART_RATE_VARIABILITY", display: "ms" },
|
||||
sleepDuration: { type: "SLEEP_DURATION", display: "hours" },
|
||||
steps: { type: "ACTIVITY_STEPS", display: "steps" },
|
||||
weight: { type: "WEIGHT", display: "kg" },
|
||||
basalBodyTemp: { type: "BODY_TEMPERATURE", display: "celsius" },
|
||||
wristTemperature: { type: "WRIST_TEMPERATURE", display: "celsius" },
|
||||
skinTemperature: { type: "SKIN_TEMPERATURE", display: "celsius" },
|
||||
// QA HIGH (features): the crosstab must cover MOOD + GLUCOSE as outcomes,
|
||||
// under the identical FDR / day-floor guards. Glucose is a MeasurementType;
|
||||
// mood is the synthetic MOOD_CHANNEL_KEY the route injects (see above).
|
||||
bloodGlucose: { type: "BLOOD_GLUCOSE", display: "glucose" },
|
||||
mood: { type: MOOD_CHANNEL_KEY, display: "mood" },
|
||||
} as const;
|
||||
|
||||
export type PhaseCrosstabMetricKey = keyof typeof PHASE_CROSSTAB_METRICS;
|
||||
|
||||
/**
|
||||
* Distinct MeasurementType values the phase crosstab reads — single-sourced.
|
||||
* MOOD_CHANNEL_KEY is excluded: it is not a Measurement row (the route reads it
|
||||
* from MoodEntry and injects it), so it must never enter the Measurement query.
|
||||
*/
|
||||
export const PHASE_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
|
||||
new Set(
|
||||
Object.values(PHASE_CROSSTAB_METRICS)
|
||||
.map((m) => m.type)
|
||||
.filter((t): t is MeasurementType => t !== MOOD_CHANNEL_KEY),
|
||||
),
|
||||
);
|
||||
|
||||
/** The two phases the categorical contrast compares (largest contrast). */
|
||||
const CONTRAST_HIGH: CyclePhase = "LUTEAL";
|
||||
const CONTRAST_LOW: CyclePhase = "FOLLICULAR";
|
||||
|
||||
export interface PhaseMetricCrosstabRow {
|
||||
/** Which metric channel this row compares against. */
|
||||
metricKey: PhaseCrosstabMetricKey;
|
||||
/** Display-unit hint for the client formatter. */
|
||||
display: PhaseCrosstabDisplay;
|
||||
/** Days in the luteal group with a paired metric value. */
|
||||
lutealDays: number;
|
||||
/** Days in the follicular group with a paired metric value. */
|
||||
follicularDays: number;
|
||||
/** Mean metric on luteal days (display unit). */
|
||||
lutealAvg: number;
|
||||
/** Mean metric on follicular days (display unit). */
|
||||
follicularAvg: number;
|
||||
/** lutealAvg − follicularAvg (display unit). Positive = higher in luteal. */
|
||||
delta: number;
|
||||
/** Welch two-sided p-value for the difference of means. */
|
||||
pValue: number;
|
||||
/** Benjamini-Hochberg adjusted q-value across the tested family. */
|
||||
qValue: number;
|
||||
/** Discrete confidence band (p + min per-group day count). */
|
||||
confidence: InfluenceConfidence;
|
||||
}
|
||||
|
||||
/** Convert a raw metric value to its phase-crosstab display unit. */
|
||||
function toDisplayUnit(value: number, display: PhaseCrosstabDisplay): number {
|
||||
return display === "hours" ? value / 60 : value;
|
||||
}
|
||||
|
||||
interface PhaseCrosstabCandidate {
|
||||
row: Omit<PhaseMetricCrosstabRow, "qValue">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the CYCLE_PHASE × metric crosstab. Pure over already-fetched rows.
|
||||
*
|
||||
* For every configured metric, group the per-day deduped metric values by
|
||||
* whether the day was LUTEAL or FOLLICULAR (the phase-day map). Each group's
|
||||
* samples feed `welchTTest`; rows that clear both day floors are tested,
|
||||
* FDR-corrected as ONE family, and the survivors (p < 0.05 AND q ≤
|
||||
* `CROSSTAB_FDR_Q`) surface ranked by q then |delta|.
|
||||
*
|
||||
* Honest: thin data never fabricates a row (the day floors + FDR gate are the
|
||||
* mood crosstab's, unchanged); a population-typical relation that does not
|
||||
* appear in the user's own data simply yields no row.
|
||||
*/
|
||||
export function computePhaseMetricCrosstab(args: {
|
||||
/** `YYYY-MM-DD → CyclePhase` for every dated day in the window. */
|
||||
phaseByDay: Map<string, CyclePhase>;
|
||||
measurements: CrossMetricMeasurement[];
|
||||
/** The user's source-priority blob (threaded into `metricDayMap`). */
|
||||
userPriorityJson?: unknown;
|
||||
}): PhaseMetricCrosstabRow[] {
|
||||
const { phaseByDay, measurements } = args;
|
||||
const userPriorityJson = args.userPriorityJson ?? null;
|
||||
if (phaseByDay.size === 0) return [];
|
||||
|
||||
const candidates: PhaseCrosstabCandidate[] = [];
|
||||
|
||||
for (const [metricKey, cfg] of Object.entries(
|
||||
PHASE_CROSSTAB_METRICS,
|
||||
) as Array<
|
||||
[
|
||||
PhaseCrosstabMetricKey,
|
||||
(typeof PHASE_CROSSTAB_METRICS)[PhaseCrosstabMetricKey],
|
||||
]
|
||||
>) {
|
||||
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
|
||||
if (metricByDay.size === 0) continue;
|
||||
|
||||
const lutealVals: number[] = [];
|
||||
const follicularVals: number[] = [];
|
||||
for (const [dayKey, phase] of phaseByDay) {
|
||||
if (phase !== CONTRAST_HIGH && phase !== CONTRAST_LOW) continue;
|
||||
const metricValue = metricByDay.get(dayKey);
|
||||
if (metricValue == null || !Number.isFinite(metricValue)) continue;
|
||||
const display = toDisplayUnit(metricValue, cfg.display);
|
||||
if (phase === CONTRAST_HIGH) lutealVals.push(display);
|
||||
else follicularVals.push(display);
|
||||
}
|
||||
|
||||
if (
|
||||
lutealVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
|
||||
follicularVals.length < CROSSTAB_MIN_ABSENT_DAYS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const welch = welchTTest(lutealVals, follicularVals);
|
||||
const lutealAvg = lutealVals.reduce((s, v) => s + v, 0) / lutealVals.length;
|
||||
const follicularAvg =
|
||||
follicularVals.reduce((s, v) => s + v, 0) / follicularVals.length;
|
||||
const delta = lutealAvg - follicularAvg;
|
||||
if (delta === 0) continue;
|
||||
|
||||
const pValue = welch.status === "ok" ? welch.pValue : 1;
|
||||
const minGroupDays = Math.min(lutealVals.length, follicularVals.length);
|
||||
|
||||
candidates.push({
|
||||
row: {
|
||||
metricKey,
|
||||
display: cfg.display,
|
||||
lutealDays: lutealVals.length,
|
||||
follicularDays: follicularVals.length,
|
||||
lutealAvg: round(lutealAvg, 2),
|
||||
follicularAvg: round(follicularAvg, 2),
|
||||
delta: round(delta, 2),
|
||||
pValue,
|
||||
confidence: influenceConfidence(pValue, minGroupDays),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// One BH family across every metric tested — the same step-up the mood
|
||||
// crosstab + discovery engine run.
|
||||
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
|
||||
|
||||
return candidates
|
||||
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
|
||||
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.qValue - b.qValue ||
|
||||
Math.abs(b.delta) - Math.abs(a.delta) ||
|
||||
a.metricKey.localeCompare(b.metricKey),
|
||||
)
|
||||
.slice(0, CROSSTAB_MAX_ROWS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the continuous CYCLE_PHASE behaviour series for the lagged-Pearson
|
||||
* discovery matrix (mechanism B). One ordinal value per labelled day. The
|
||||
* caller folds this into `discoverCorrelations(series)` alongside the existing
|
||||
* behaviour/outcome channels — the engine is pure and parameterised over
|
||||
* `NamedSeries[]`, so this never touches the general (non-gated) route.
|
||||
*/
|
||||
export function buildPhaseDiscoverySeries(
|
||||
phaseByDay: Map<string, CyclePhase>,
|
||||
): NamedSeries {
|
||||
const points: DailySeriesPoint[] = [];
|
||||
for (const [day, phase] of phaseByDay) {
|
||||
points.push({ day, value: PHASE_ORDINAL[phase] });
|
||||
}
|
||||
points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
return { key: CYCLE_PHASE_CHANNEL_KEY, role: "behaviour", points };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mechanism B — run the lagged-Pearson FDR discovery with the continuous
|
||||
* CYCLE_PHASE ordinal folded in as a behaviour channel against the outcome
|
||||
* metric series. Pure: the caller builds the per-day outcome series (from
|
||||
* `metricDayMap`) and passes the phase map. Reuses `discoverCorrelations`
|
||||
* verbatim — no new statistics, the same n ≥ 20 / p < 0.05 / BH-FDR gate the
|
||||
* general matrix enforces — but scoped to the cycle-gated route only, so the
|
||||
* phase channel never reaches the non-gated `/api/insights/correlations`.
|
||||
*/
|
||||
export function discoverPhaseCorrelations(args: {
|
||||
phaseByDay: Map<string, CyclePhase>;
|
||||
measurements: CrossMetricMeasurement[];
|
||||
userPriorityJson?: unknown;
|
||||
}): CorrelationDiscoveryResult {
|
||||
const { phaseByDay, measurements } = args;
|
||||
const userPriorityJson = args.userPriorityJson ?? null;
|
||||
|
||||
const phaseSeries = buildPhaseDiscoverySeries(phaseByDay);
|
||||
const outcomeSeries: NamedSeries[] = [];
|
||||
for (const [, cfg] of Object.entries(PHASE_CROSSTAB_METRICS)) {
|
||||
const byDay = metricDayMap(measurements, cfg.type, userPriorityJson);
|
||||
if (byDay.size === 0) continue;
|
||||
const points: DailySeriesPoint[] = [];
|
||||
for (const [day, value] of byDay) points.push({ day, value });
|
||||
points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
outcomeSeries.push({ key: cfg.type, role: "outcome", points });
|
||||
}
|
||||
|
||||
return discoverCorrelations([phaseSeries, ...outcomeSeries]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the ONE headline phase insight for the v1.15.0 MVP card (research
|
||||
* §7.1): resting-heart-rate-by-phase first (near-daily, the highest-value,
|
||||
* most-reachable n), falling back to HRV-by-phase, then to the strongest
|
||||
* remaining FDR-surviving row. Returns null when nothing cleared the gate —
|
||||
* honest "still learning", never a fabricated headline.
|
||||
*/
|
||||
export function selectHeadlinePhaseRow(
|
||||
rows: PhaseMetricCrosstabRow[],
|
||||
): PhaseMetricCrosstabRow | null {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
rows.find((r) => r.metricKey === "restingHeartRate") ??
|
||||
rows.find((r) => r.metricKey === "heartRateVariability") ??
|
||||
rows[0]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Phase assignment — pure, per calendar date, both engines identical.
|
||||
*
|
||||
* Implements algorithm.md §"Phase channel". Given a cycle [start, nextStart)
|
||||
* with length L and an ovulation day (confirmed or estimated as
|
||||
* start + (L − lutealLength)):
|
||||
*
|
||||
* - MENSTRUAL = [start, start + P̂ − 1]
|
||||
* - OVULATORY = [ovulationDay − 1, ovulationDay + 1] (3-day window)
|
||||
* - FOLLICULAR = (end of menstrual, ovulatory start) day after period → ovulatory
|
||||
* - LUTEAL = (ovulatory end, nextStart) after ovulatory → day before next start
|
||||
*
|
||||
* Precedence on overlap: MENSTRUAL > OVULATORY > FOLLICULAR/LUTEAL (a day is
|
||||
* menstrual if bleeding even when the calendar window says otherwise).
|
||||
*
|
||||
* This MUST match the calendar route, the CYCLE_PHASE correlation channel, and
|
||||
* what iOS computes offline.
|
||||
*/
|
||||
|
||||
import { addDays, dayDiff } from "./day-math";
|
||||
import { clampLuteal } from "./prediction";
|
||||
import {
|
||||
LUTEAL_DEFAULT,
|
||||
POPULATION_DEFAULT_PERIOD,
|
||||
type CyclePhase,
|
||||
} from "./types";
|
||||
|
||||
/** A resolved cycle window the phase mapper needs. */
|
||||
export interface PhaseCycle {
|
||||
/** First bleeding day, `YYYY-MM-DD`. */
|
||||
startDate: string;
|
||||
/** First day of the NEXT cycle, `YYYY-MM-DD`. The cycle is [start, nextStart). */
|
||||
nextStart: string;
|
||||
/** Ovulation day (confirmed or estimated). Null → estimate from length/luteal. */
|
||||
ovulationDate: string | null;
|
||||
/** Period (bleeding) length in days. Null → POPULATION_DEFAULT_PERIOD. */
|
||||
periodLength: number | null;
|
||||
/** Luteal length used to estimate ovulation when ovulationDate is null. */
|
||||
lutealLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a single date to its phase within the supplied cycle, plus the 1-based
|
||||
* day-of-cycle. Returns `{ phase: null, dayOfCycle: null }` when the date is
|
||||
* outside `[startDate, nextStart)`.
|
||||
*/
|
||||
export function phaseForDay(
|
||||
date: string,
|
||||
cycle: PhaseCycle,
|
||||
): { phase: CyclePhase | null; dayOfCycle: number | null } {
|
||||
const offsetFromStart = dayDiff(date, cycle.startDate);
|
||||
const cycleLength = dayDiff(cycle.nextStart, cycle.startDate);
|
||||
|
||||
// Outside [start, nextStart): not part of this cycle.
|
||||
if (offsetFromStart < 0 || offsetFromStart >= cycleLength) {
|
||||
return { phase: null, dayOfCycle: null };
|
||||
}
|
||||
|
||||
const dayOfCycle = offsetFromStart + 1; // 1-based
|
||||
|
||||
const periodLength = cycle.periodLength ?? POPULATION_DEFAULT_PERIOD;
|
||||
// Clamp identically to the prediction engine (QA HIGH: one source of truth).
|
||||
const lutealLength = clampLuteal(cycle.lutealLength ?? LUTEAL_DEFAULT);
|
||||
|
||||
// Ovulation: confirmed/estimated date, or back-calculated from length.
|
||||
const ovulationDate =
|
||||
cycle.ovulationDate ?? addDays(cycle.startDate, cycleLength - lutealLength);
|
||||
|
||||
// Boundary days (inclusive offsets from start).
|
||||
const menstrualEnd = periodLength - 1; // [0, P-1]
|
||||
const ovulationOffset = dayDiff(ovulationDate, cycle.startDate);
|
||||
const ovulatoryStart = ovulationOffset - 1; // [ov-1, ov+1]
|
||||
const ovulatoryEnd = ovulationOffset + 1;
|
||||
|
||||
// Precedence: MENSTRUAL first (bleeding wins).
|
||||
if (offsetFromStart <= menstrualEnd) {
|
||||
return { phase: "MENSTRUAL", dayOfCycle };
|
||||
}
|
||||
// OVULATORY next.
|
||||
if (offsetFromStart >= ovulatoryStart && offsetFromStart <= ovulatoryEnd) {
|
||||
return { phase: "OVULATORY", dayOfCycle };
|
||||
}
|
||||
// FOLLICULAR = after menstrual, before the ovulatory window.
|
||||
if (offsetFromStart < ovulatoryStart) {
|
||||
return { phase: "FOLLICULAR", dayOfCycle };
|
||||
}
|
||||
// LUTEAL = after the ovulatory window, to the day before nextStart.
|
||||
return { phase: "LUTEAL", dayOfCycle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: produce the daily phase series across `[from, to]` inclusive for
|
||||
* a single cycle. Days outside the cycle window carry `phase: null`. This is the
|
||||
* shape the CYCLE_PHASE behaviour channel consumes (one categorical value/day).
|
||||
*/
|
||||
export function phaseSeries(
|
||||
from: string,
|
||||
to: string,
|
||||
cycle: PhaseCycle,
|
||||
): { date: string; phase: CyclePhase | null; dayOfCycle: number | null }[] {
|
||||
const span = dayDiff(to, from);
|
||||
const out: {
|
||||
date: string;
|
||||
phase: CyclePhase | null;
|
||||
dayOfCycle: number | null;
|
||||
}[] = [];
|
||||
for (let i = 0; i <= span; i++) {
|
||||
const date = addDays(from, i);
|
||||
out.push({ date, ...phaseForDay(date, cycle) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Cycle prediction cache — debounced stale-while-revalidate (the v1.8.7
|
||||
* insight-assessment cache pattern, ios-contract §2.D).
|
||||
*
|
||||
* `GET /api/cycle/calendar` runs the pure engine synchronously (it is
|
||||
* cheap) for the live forecast it returns, AND persists that forecast to
|
||||
* the `CyclePrediction` cache row so the Coach snapshot + notifications
|
||||
* read a stable materialised value without re-running the engine. The
|
||||
* persist is debounced: a re-write only lands when the prior row is older
|
||||
* than the debounce window OR the forecast actually changed, so a tight
|
||||
* read loop never thrashes the row.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { CyclePredictionResult } from "@/lib/cycle/types";
|
||||
|
||||
/** Don't re-persist an unchanged forecast more often than this. */
|
||||
const PREDICTION_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h
|
||||
|
||||
/**
|
||||
* Persist (or refresh) the cached forecast for a user, debounced. A
|
||||
* no-op when a fresh-enough identical row already exists. Best-effort:
|
||||
* never throws into the read path.
|
||||
*/
|
||||
export async function persistPredictionCache(
|
||||
userId: string,
|
||||
result: CyclePredictionResult,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = await prisma.cyclePrediction.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
method: true,
|
||||
nextPeriodStart: true,
|
||||
nextPeriodStartLow: true,
|
||||
nextPeriodStartHigh: true,
|
||||
confidence: true,
|
||||
cyclesObserved: true,
|
||||
generatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const unchanged =
|
||||
existing !== null &&
|
||||
existing.method === result.method &&
|
||||
existing.nextPeriodStart === result.nextPeriodStart &&
|
||||
existing.nextPeriodStartLow === result.nextPeriodStartLow &&
|
||||
existing.nextPeriodStartHigh === result.nextPeriodStartHigh &&
|
||||
existing.confidence === result.confidence &&
|
||||
existing.cyclesObserved === result.cyclesObserved;
|
||||
|
||||
const fresh =
|
||||
existing !== null &&
|
||||
now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS;
|
||||
|
||||
// Skip the write only when the row is BOTH unchanged AND fresh.
|
||||
if (unchanged && fresh) return;
|
||||
|
||||
const data = {
|
||||
method: result.method,
|
||||
nextPeriodStart: result.nextPeriodStart,
|
||||
nextPeriodStartLow: result.nextPeriodStartLow,
|
||||
nextPeriodStartHigh: result.nextPeriodStartHigh,
|
||||
fertileWindowStart: result.fertileWindowStart,
|
||||
fertileWindowEnd: result.fertileWindowEnd,
|
||||
predictedOvulation: result.predictedOvulation,
|
||||
confidence: result.confidence,
|
||||
cyclesObserved: result.cyclesObserved,
|
||||
generatedAt: now,
|
||||
};
|
||||
|
||||
await prisma.cyclePrediction.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
update: data,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort cache write — the live engine result is authoritative */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Cycle prediction engine — pure, deterministic, DB-free.
|
||||
*
|
||||
* Implements algorithm.md §1–§5 exactly. The iOS team re-implements this 1:1 in
|
||||
* Swift from the same spec, so every constant, every rounding step, and every
|
||||
* day-diff MUST match. See `day-math.ts` for the shared arithmetic and
|
||||
* `types.ts` for the pinned constants.
|
||||
*
|
||||
* The symptothermal layer re-implements the published sensiplan rules
|
||||
* (3-over-6 temperature double-check + cervical-mucus peak). It does NOT copy
|
||||
* drip's GPL-3.0 code; the rule LOGIC is the gold-standard FAM method and is not
|
||||
* itself copyrightable. drip is cited as prior art only.
|
||||
*/
|
||||
|
||||
import { addDays, dayDiff, roundHalf } from "./day-math";
|
||||
import {
|
||||
ADHERENCE_FLOOR,
|
||||
ADHERENCE_SLOPE,
|
||||
BBT_WINDOW,
|
||||
COLD_START_BAND_BONUS,
|
||||
CONFIDENCE_LABEL_HIGH_MIN,
|
||||
CONFIDENCE_LABEL_LOW_MAX,
|
||||
CONFIDENCE_MAX,
|
||||
CONFIDENCE_MIN,
|
||||
FERTILE_POST,
|
||||
FERTILE_PRE,
|
||||
HALF_WIDTH_MAX,
|
||||
HALF_WIDTH_MIN,
|
||||
HALF_WIDTH_MULT_SYMPTOTHERMAL,
|
||||
HALF_WIDTH_MULT_TEMP_TREND,
|
||||
HARD_CYCLE_MAX,
|
||||
HARD_CYCLE_MIN,
|
||||
HISTORY_WINDOW_N,
|
||||
LOG_SPARSITY_SCALE,
|
||||
LUTEAL_DEFAULT,
|
||||
LUTEAL_MAX,
|
||||
LUTEAL_MIN,
|
||||
MAD_NORMAL_CONST,
|
||||
MISSED_LOG_FACTOR,
|
||||
OUTLIER_K,
|
||||
OUTLIER_K_PERIMENOPAUSE,
|
||||
PERIOD_MAX,
|
||||
PERIOD_MIN,
|
||||
PERIOD_WINDOW_N,
|
||||
POPULATION_DEFAULT_CYCLE,
|
||||
POPULATION_DEFAULT_PERIOD,
|
||||
PRIORS_ONLY_HALF_WIDTH,
|
||||
SIGMA_FLOOR,
|
||||
SYMPTOTHERMAL_AGREE_DAYS,
|
||||
TEMP_SHIFT_C_MANUAL,
|
||||
TEMP_SHIFT_C_PASSIVE,
|
||||
Z_BAND,
|
||||
type ConfidenceLabel,
|
||||
type CycleInput,
|
||||
type CyclePredictionResult,
|
||||
type CycleProfileInput,
|
||||
type DayLogInput,
|
||||
type NightlyTempInput,
|
||||
type PredictionMethod,
|
||||
} from "./types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Robust statistics primitives */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Median of a numeric array. For an even count, the mean of the two central
|
||||
* order statistics (NOT rounded — fractional precision is kept through the CI
|
||||
* math per §1 step 3). Empty input returns NaN; callers guard against that.
|
||||
*/
|
||||
export function median(values: readonly number[]): number {
|
||||
if (values.length === 0) return NaN;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Median absolute deviation about a given centre. */
|
||||
export function mad(values: readonly number[], centre: number): number {
|
||||
if (values.length === 0) return NaN;
|
||||
return median(values.map((v) => Math.abs(v - centre)));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* §1 — cycle-length estimation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Completed cycle = one whose successor's start exists, giving a known length. */
|
||||
function completedLengths(cycles: readonly CycleInput[]): number[] {
|
||||
// Sort by startDate ascending (the canonical order), then diff consecutive
|
||||
// starts. A cycle is completed iff a later start exists.
|
||||
const starts = cycles.map((c) => c.startDate).sort((a, b) => dayDiff(a, b));
|
||||
const lengths: number[] = [];
|
||||
for (let i = 0; i < starts.length - 1; i++) {
|
||||
lengths.push(dayDiff(starts[i + 1], starts[i]));
|
||||
}
|
||||
return lengths;
|
||||
}
|
||||
|
||||
interface LengthEstimate {
|
||||
/** Robust central length, fractional (rounded only at point-estimate use). */
|
||||
lengthFractional: number;
|
||||
/** Rounded typical length (days). */
|
||||
lengthRounded: number;
|
||||
/** Robust SD (days), >= SIGMA_FLOOR. */
|
||||
sigma: number;
|
||||
/** Coefficient of robust variation sigma/length. */
|
||||
cv: number;
|
||||
/** Count of lengths used post outlier-exclusion. */
|
||||
cyclesObserved: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* §1 + §5 — robust median+MAD over the trailing window with 3-MAD outlier
|
||||
* exclusion and missed-log detection. Returns the central length, robust SD,
|
||||
* cv, and post-exclusion count.
|
||||
*/
|
||||
export function estimateCycleLength(
|
||||
lengths: readonly number[],
|
||||
goal: CycleProfileInput["goal"],
|
||||
): LengthEstimate {
|
||||
// Trailing window: most recent N completed lengths.
|
||||
const Ls = lengths.slice(-HISTORY_WINDOW_N);
|
||||
|
||||
if (Ls.length === 0) {
|
||||
return {
|
||||
lengthFractional: NaN,
|
||||
lengthRounded: NaN,
|
||||
sigma: SIGMA_FLOOR,
|
||||
cv: 0,
|
||||
cyclesObserved: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-exclusion robust dispersion for the fence (§5).
|
||||
const preMedian = median(Ls);
|
||||
const sigmaPre = MAD_NORMAL_CONST * mad(Ls, preMedian);
|
||||
|
||||
const k = goal === "PERIMENOPAUSE" ? OUTLIER_K_PERIMENOPAUSE : OUTLIER_K;
|
||||
const fenceLow = preMedian - k * sigmaPre;
|
||||
const fenceHigh = preMedian + k * sigmaPre;
|
||||
const missedLogThreshold = MISSED_LOG_FACTOR * preMedian;
|
||||
|
||||
// Outlier exclusion: robust MAD fence OR hard physiological bounds OR a
|
||||
// probable missed-log (a single length >= 1.75 * median).
|
||||
const kept = Ls.filter((L) => {
|
||||
const beyondFence = sigmaPre > 0 && (L < fenceLow || L > fenceHigh);
|
||||
const beyondHard = L < HARD_CYCLE_MIN || L > HARD_CYCLE_MAX;
|
||||
const missedLog = L >= missedLogThreshold;
|
||||
return !(beyondFence || beyondHard || missedLog);
|
||||
});
|
||||
|
||||
// If exclusion empties the array, fall back to the un-excluded median (§1.2),
|
||||
// but the cyclesObserved still reflects the excluded count (§5: outliers don't
|
||||
// bias the point estimate but they DO erode confidence).
|
||||
const usable = kept.length >= 1 ? kept : Ls;
|
||||
|
||||
const lengthFractional = median(usable);
|
||||
const lengthRounded = roundHalf(lengthFractional);
|
||||
const sigmaRaw = MAD_NORMAL_CONST * mad(usable, lengthFractional);
|
||||
const sigma = sigmaRaw > 0 ? sigmaRaw : SIGMA_FLOOR;
|
||||
const cv = lengthFractional > 0 ? sigma / lengthFractional : 0;
|
||||
|
||||
return {
|
||||
lengthFractional,
|
||||
lengthRounded,
|
||||
sigma,
|
||||
cv,
|
||||
// cyclesObserved counts the kept (non-excluded) lengths — excluded cycles
|
||||
// are not counted toward the cycle-count confidence factor (§5).
|
||||
cyclesObserved: kept.length,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* §2 — period-length estimation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Bleeding day = flow in {SPOTTING, LIGHT, MEDIUM, HEAVY}. */
|
||||
function isBleeding(flow: DayLogInput["flow"]): boolean {
|
||||
return (
|
||||
flow === "SPOTTING" ||
|
||||
flow === "LIGHT" ||
|
||||
flow === "MEDIUM" ||
|
||||
flow === "HEAVY"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* §2 — observed period length for one cycle: the maximal CONTIGUOUS run of
|
||||
* bleeding days starting at `startDate`. A single isolated dry day inside the
|
||||
* run does NOT break it; a gap of >= 2 consecutive non-bleeding days does.
|
||||
*/
|
||||
export function observedPeriodLength(
|
||||
startDate: string,
|
||||
dayLogs: readonly DayLogInput[],
|
||||
): number {
|
||||
const bleedingByDate = new Map<string, boolean>();
|
||||
for (const log of dayLogs) {
|
||||
if (isBleeding(log.flow)) bleedingByDate.set(log.date, true);
|
||||
}
|
||||
if (!bleedingByDate.get(startDate)) return 0;
|
||||
|
||||
let lastBleeding = 0; // offset (days) of the last confirmed bleeding day
|
||||
let gap = 0; // consecutive non-bleeding days seen since last bleeding
|
||||
// Walk forward up to PERIOD_MAX + 1 days; the run breaks on a >=2-day gap.
|
||||
for (let offset = 0; offset <= PERIOD_MAX + 1; offset++) {
|
||||
const date = addDays(startDate, offset);
|
||||
if (bleedingByDate.get(date)) {
|
||||
lastBleeding = offset;
|
||||
gap = 0;
|
||||
} else {
|
||||
gap++;
|
||||
if (gap >= 2) break; // two consecutive dry days ends the run
|
||||
}
|
||||
}
|
||||
return lastBleeding + 1; // inclusive count of days from start to last bleeding
|
||||
}
|
||||
|
||||
/** §2 — robust period-length estimate over the last 6 cycles, clamped [1,10]. */
|
||||
export function estimatePeriodLength(
|
||||
cycles: readonly CycleInput[],
|
||||
dayLogs: readonly DayLogInput[],
|
||||
profile: CycleProfileInput,
|
||||
): number {
|
||||
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
|
||||
const recent = sorted.slice(-PERIOD_WINDOW_N);
|
||||
const observed: number[] = [];
|
||||
for (const c of recent) {
|
||||
// Prefer an explicit periodEndDate if present; else derive from day logs.
|
||||
if (c.periodEndDate) {
|
||||
const len = dayDiff(c.periodEndDate, c.startDate) + 1;
|
||||
if (len >= PERIOD_MIN) observed.push(len);
|
||||
} else {
|
||||
const len = observedPeriodLength(c.startDate, dayLogs);
|
||||
if (len >= PERIOD_MIN) observed.push(len);
|
||||
}
|
||||
}
|
||||
|
||||
if (observed.length === 0) {
|
||||
const prior = profile.typicalPeriodLength ?? POPULATION_DEFAULT_PERIOD;
|
||||
return clamp(roundHalf(prior), PERIOD_MIN, PERIOD_MAX);
|
||||
}
|
||||
return clamp(roundHalf(median(observed)), PERIOD_MIN, PERIOD_MAX);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* §4 — symptothermal + temperature-trend ovulation detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TempShiftResult {
|
||||
/** Confirmed ovulation day = day before the first of the 3 elevated readings. */
|
||||
ovulationDate: 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).
|
||||
*/
|
||||
export function detectTempShift(
|
||||
dayLogs: readonly DayLogInput[],
|
||||
thresholdC: number,
|
||||
): TempShiftResult | null {
|
||||
const temps = dayLogs
|
||||
.filter((l) => l.basalBodyTempC != null)
|
||||
.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).
|
||||
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) };
|
||||
}
|
||||
}
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* §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.
|
||||
*/
|
||||
export function confirmSymptothermal(
|
||||
dayLogs: readonly DayLogInput[],
|
||||
): string | null {
|
||||
const shift = detectTempShift(dayLogs, TEMP_SHIFT_C_MANUAL);
|
||||
if (!shift) return null;
|
||||
const peak = detectMucusPeak(dayLogs);
|
||||
if (!peak) return null;
|
||||
if (
|
||||
Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS
|
||||
) {
|
||||
return shift.ovulationDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §4.3 — passive TEMPERATURE_TREND: detect the post-ovulatory sustained thermal
|
||||
* shift on a nightly series — 3-of-4 consecutive nights with deviation
|
||||
* >= +0.15°C above the trailing 6-night mean. Ovulation ≈ the night before the
|
||||
* sustained rise onset. Returns the ovulation day or null.
|
||||
*/
|
||||
export function detectTemperatureTrend(
|
||||
nights: readonly NightlyTempInput[],
|
||||
): string | null {
|
||||
const series = [...nights].sort((a, b) => dayDiff(a.date, b.date));
|
||||
// Need 6 trailing nights + a 4-night window.
|
||||
for (let i = 6; i + 3 < series.length; i++) {
|
||||
const trailing = series.slice(i - 6, i);
|
||||
const mean = trailing.reduce((s, x) => s + x.valueC, 0) / trailing.length;
|
||||
const window = series.slice(i, i + 4);
|
||||
const elevated = window.filter(
|
||||
(n) => roundHalf(n.valueC - mean, 2) >= TEMP_SHIFT_C_PASSIVE,
|
||||
).length;
|
||||
if (elevated >= 3) {
|
||||
// Ovulation ≈ night before the sustained rise onset (the first window day).
|
||||
return addDays(window[0].date, -1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* §3 — confidence + band */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** §3(b) — cycle-count factor. */
|
||||
function countFactor(cyclesObserved: number): number {
|
||||
if (cyclesObserved <= 0) return 0.2;
|
||||
if (cyclesObserved === 1) return 0.35;
|
||||
if (cyclesObserved === 2) return 0.55;
|
||||
if (cyclesObserved <= 5) return 0.75;
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/** §3(b) — regularity factor from the coefficient of robust variation. */
|
||||
function varianceFactor(cv: number): number {
|
||||
if (cv <= 0.05) return 1.0;
|
||||
if (cv <= 0.09) return 0.85;
|
||||
if (cv <= 0.14) return 0.65;
|
||||
if (cv <= 0.2) return 0.45;
|
||||
return 0.25;
|
||||
}
|
||||
|
||||
/**
|
||||
* §3(b)/§3 — logging-density derived factors. Returns the adherence confidence
|
||||
* factor and the LOG_SPARSITY band-penalty term.
|
||||
*/
|
||||
function adherenceFactors(
|
||||
lastStart: string,
|
||||
estimatedLength: number,
|
||||
dayLogs: readonly DayLogInput[],
|
||||
today: string,
|
||||
): { cAdherence: number; logSparsity: number } {
|
||||
// expectedDays = days since the most recent cycle start, capped at length.
|
||||
const sinceStart = Math.max(0, dayDiff(today, lastStart));
|
||||
const expectedDays = Math.min(
|
||||
sinceStart,
|
||||
Math.max(1, Math.round(estimatedLength)),
|
||||
);
|
||||
// loggedDays = day logs with any non-null observation in that span.
|
||||
let loggedDays = 0;
|
||||
for (const log of dayLogs) {
|
||||
const offset = dayDiff(log.date, lastStart);
|
||||
if (offset < 0 || offset > expectedDays) continue;
|
||||
const hasObservation =
|
||||
log.flow != null ||
|
||||
log.basalBodyTempC != null ||
|
||||
log.ovulationTest != null ||
|
||||
log.cervicalMucus != null;
|
||||
if (hasObservation) loggedDays++;
|
||||
}
|
||||
const density = clamp(loggedDays / Math.max(1, expectedDays), 0, 1);
|
||||
const cAdherence = ADHERENCE_FLOOR + ADHERENCE_SLOPE * density;
|
||||
const logSparsity = (1 - density) * LOG_SPARSITY_SCALE;
|
||||
return { cAdherence, logSparsity };
|
||||
}
|
||||
|
||||
/** §3 — confidence → label mapping. */
|
||||
function confidenceLabel(confidence: number): ConfidenceLabel {
|
||||
if (confidence < CONFIDENCE_LABEL_LOW_MAX) return "low";
|
||||
if (confidence > CONFIDENCE_LABEL_HIGH_MIN) return "high";
|
||||
return "medium";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a raw luteal length to the physiological range [LUTEAL_MIN, LUTEAL_MAX]
|
||||
* (§4). The ONE source of truth for resolving luteal length — the engine,
|
||||
* calendar adapter, and phase mapper all route through this so the
|
||||
* predicted-ovulation dot and the OVULATORY band can never diverge for a user
|
||||
* whose stored value is out of clamp (QA HIGH: luteal clamp divergence).
|
||||
*/
|
||||
export function clampLuteal(raw: number): number {
|
||||
return clamp(raw, LUTEAL_MIN, LUTEAL_MAX);
|
||||
}
|
||||
|
||||
export function resolveLuteal(
|
||||
profile: Pick<CycleProfileInput, "lutealPhaseLength">,
|
||||
): number {
|
||||
return clampLuteal(profile.lutealPhaseLength ?? LUTEAL_DEFAULT);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* main entry */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* §1–§5 — compute the cycle prediction.
|
||||
*
|
||||
* The fertile window is ALWAYS returned (goal-gating — suppressing it for
|
||||
* non-conception goals — is the caller's responsibility per the spec; the
|
||||
* 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 profile user priors + mode flags.
|
||||
* @param today `YYYY-MM-DD` reference day (for adherence density).
|
||||
* @param nights optional nightly passive-temperature series (Apple Watch).
|
||||
*/
|
||||
export function predictCycle(
|
||||
cycles: readonly CycleInput[],
|
||||
dayLogs: readonly DayLogInput[],
|
||||
profile: CycleProfileInput,
|
||||
today: string,
|
||||
nights: readonly NightlyTempInput[] = [],
|
||||
): CyclePredictionResult {
|
||||
const lengths = completedLengths(cycles);
|
||||
const lutealLength = resolveLuteal(profile);
|
||||
const sortedStarts = cycles
|
||||
.map((c) => c.startDate)
|
||||
.sort((a, b) => dayDiff(a, b));
|
||||
const lastConfirmedStart = sortedStarts[sortedStarts.length - 1] ?? today;
|
||||
|
||||
// -------- 0-cycle priors-only cold start (§5) --------
|
||||
if (lengths.length === 0) {
|
||||
const estLen = profile.typicalCycleLength ?? POPULATION_DEFAULT_CYCLE;
|
||||
const periodLen = clamp(
|
||||
roundHalf(profile.typicalPeriodLength ?? POPULATION_DEFAULT_PERIOD),
|
||||
PERIOD_MIN,
|
||||
PERIOD_MAX,
|
||||
);
|
||||
const nextStart = addDays(lastConfirmedStart, Math.round(estLen));
|
||||
const predictedOvulation = addDays(nextStart, -lutealLength);
|
||||
return finalize({
|
||||
method: "CALENDAR",
|
||||
nextStart,
|
||||
halfWidth: PRIORS_ONLY_HALF_WIDTH,
|
||||
predictedPeriodLength: periodLen,
|
||||
predictedOvulation,
|
||||
ovulationConfirmed: false,
|
||||
confidence: 0.2,
|
||||
cyclesObserved: 0,
|
||||
estimatedCycleLength: Math.round(estLen),
|
||||
estimatedCycleSd: SIGMA_FLOOR,
|
||||
lutealLength,
|
||||
goal: profile.goal,
|
||||
});
|
||||
}
|
||||
|
||||
// -------- robust estimation (§1, §2) --------
|
||||
const est = estimateCycleLength(lengths, profile.goal);
|
||||
const periodLen = estimatePeriodLength(cycles, dayLogs, profile);
|
||||
|
||||
// -------- calendar baseline next-period point estimate (§1) --------
|
||||
let method: PredictionMethod = "CALENDAR";
|
||||
let nextStart = addDays(lastConfirmedStart, est.lengthRounded);
|
||||
let predictedOvulation = addDays(nextStart, -lutealLength);
|
||||
let ovulationConfirmed = false;
|
||||
let confirmMultiplier = 1.0;
|
||||
|
||||
// -------- §4 method ladder + blend (confirmed-current overrides) --------
|
||||
// Precedence: confirmed-symptothermal > temperature-trend > calendar. A
|
||||
// confirmed signal overrides the CURRENT cycle's ovulation/next-start; the
|
||||
// calendar predictor still fills all FUTURE cycles (BLENDED records that mix).
|
||||
//
|
||||
// Window scoping (QA HIGH): the symptothermal/temperature-trend detectors
|
||||
// must only see the CURRENT cycle's signal, otherwise a multi-cycle user's
|
||||
// stale prior-cycle BBT/mucus confirms a months-old ovulation and lands
|
||||
// nextPeriodStart in the past. Scope to [lastConfirmedStart − BBT_WINDOW,
|
||||
// today]; the BBT_WINDOW reach-back keeps the 6-reading baseline intact for a
|
||||
// young current cycle. detectTempShift returns the LATEST qualifying shift.
|
||||
const windowStart = addDays(lastConfirmedStart, -BBT_WINDOW);
|
||||
const isInCurrentWindow = (date: string) =>
|
||||
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 trendOvulation = symptoOvulation
|
||||
? null
|
||||
: detectTemperatureTrend(currentNights);
|
||||
|
||||
if (symptoOvulation) {
|
||||
const confirmedNextStart = addDays(symptoOvulation, lutealLength);
|
||||
// Reject a confirmation whose next-start is already in the past (a stale
|
||||
// signal that slipped through the window) — fall back to the calendar.
|
||||
if (dayDiff(confirmedNextStart, today) >= 0) {
|
||||
predictedOvulation = symptoOvulation;
|
||||
nextStart = confirmedNextStart;
|
||||
ovulationConfirmed = true;
|
||||
confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL;
|
||||
method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL";
|
||||
}
|
||||
} else if (trendOvulation) {
|
||||
const confirmedNextStart = addDays(trendOvulation, lutealLength);
|
||||
if (dayDiff(confirmedNextStart, today) >= 0) {
|
||||
predictedOvulation = trendOvulation;
|
||||
nextStart = confirmedNextStart;
|
||||
ovulationConfirmed = true;
|
||||
confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND;
|
||||
method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND";
|
||||
}
|
||||
}
|
||||
|
||||
// -------- §3 band half-width --------
|
||||
const { cAdherence, logSparsity } = adherenceFactors(
|
||||
lastConfirmedStart,
|
||||
est.lengthFractional,
|
||||
dayLogs,
|
||||
today,
|
||||
);
|
||||
const adherencePenalty = 1 + logSparsity;
|
||||
let halfWidth =
|
||||
roundHalf(Z_BAND * est.sigma * adherencePenalty) * confirmMultiplier;
|
||||
// Single-cycle cold-start gets a fixed band bonus (§5).
|
||||
if (est.cyclesObserved === 1) halfWidth += COLD_START_BAND_BONUS;
|
||||
halfWidth = clamp(Math.round(halfWidth), HALF_WIDTH_MIN, HALF_WIDTH_MAX);
|
||||
|
||||
// -------- §3 confidence scalar --------
|
||||
const cCount = countFactor(est.cyclesObserved);
|
||||
const cVariance = varianceFactor(est.cv);
|
||||
const confidence = clamp(
|
||||
cCount * cVariance * cAdherence,
|
||||
CONFIDENCE_MIN,
|
||||
CONFIDENCE_MAX,
|
||||
);
|
||||
|
||||
return finalize({
|
||||
method,
|
||||
nextStart,
|
||||
halfWidth,
|
||||
predictedPeriodLength: periodLen,
|
||||
predictedOvulation,
|
||||
ovulationConfirmed,
|
||||
confidence,
|
||||
cyclesObserved: est.cyclesObserved,
|
||||
estimatedCycleLength: est.lengthRounded,
|
||||
estimatedCycleSd: roundHalf(est.sigma, 2),
|
||||
lutealLength,
|
||||
goal: profile.goal,
|
||||
});
|
||||
}
|
||||
|
||||
interface FinalizeArgs {
|
||||
method: PredictionMethod;
|
||||
nextStart: string;
|
||||
halfWidth: number;
|
||||
predictedPeriodLength: number;
|
||||
predictedOvulation: string;
|
||||
ovulationConfirmed: boolean;
|
||||
confidence: number;
|
||||
cyclesObserved: number;
|
||||
estimatedCycleLength: number;
|
||||
estimatedCycleSd: number;
|
||||
lutealLength: number;
|
||||
goal: CycleProfileInput["goal"];
|
||||
}
|
||||
|
||||
/** Assemble the result struct, deriving the band + fertile window. */
|
||||
function finalize(a: FinalizeArgs): CyclePredictionResult {
|
||||
const halfWidth = clamp(
|
||||
Math.round(a.halfWidth),
|
||||
HALF_WIDTH_MIN,
|
||||
HALF_WIDTH_MAX,
|
||||
);
|
||||
const confidence = roundHalf(a.confidence, 2);
|
||||
return {
|
||||
method: a.method,
|
||||
nextPeriodStart: a.nextStart,
|
||||
nextPeriodStartLow: addDays(a.nextStart, -halfWidth),
|
||||
nextPeriodStartHigh: addDays(a.nextStart, halfWidth),
|
||||
predictedPeriodLength: a.predictedPeriodLength,
|
||||
fertileWindowStart: addDays(a.predictedOvulation, -FERTILE_PRE),
|
||||
fertileWindowEnd: addDays(a.predictedOvulation, FERTILE_POST),
|
||||
predictedOvulation: a.predictedOvulation,
|
||||
ovulationConfirmed: a.ovulationConfirmed,
|
||||
confidence,
|
||||
confidenceLabel: confidenceLabel(confidence),
|
||||
cyclesObserved: a.cyclesObserved,
|
||||
stillLearning: a.cyclesObserved < 3,
|
||||
estimatedCycleLength: a.estimatedCycleLength,
|
||||
estimatedCycleSd: a.estimatedCycleSd,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user