From a5946e62098a53db6220a74a3b2b18c35eac7149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 08:18:43 +0200 Subject: [PATCH 01/19] feat(cycle): add cycle-tracking schema foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the persistence layer for cycle tracking: eight enums (CycleTrackingGoal, FlowLevel, OvulationTest, CervicalMucus, HomeTestResult, ContraceptiveKind, CyclePhase, PredictionMethod) and seven models. CycleProfile is a 1:1 per-user settings row (lazily upserted on first access via getOrCreateCycleProfile). MenstrualCycle and CycleDayLog are the capture rows, carrying the MoodEntry soft-delete + syncVersion shape so they ride the sync delta feed; CycleDayLog repeats the MoodEntry dual-unique — the canonical (userId, date) key plus the NULL-distinct (userId, source, externalId) key for HealthKit re-ingest. Free-text notes encrypt at rest; queryable physiology (flow, BBT, OPK, mucus, dates) stays plaintext for the rollup and correlation tier. The symptom taxonomy (CycleSymptomCategory / CycleSymptom / CycleSymptomLink) clones the mood-tag trio and seeds fifteen symptoms across three categories with cycle.symptom.* i18n keys across all six locales. CyclePrediction caches one forecast per user, regenerated debounced. Migrations 0128-0130 are additive; the testcontainer integration suite applies the full history from scratch and passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 24 + messages/en.json | 24 + messages/es.json | 24 + messages/fr.json | 24 + messages/it.json | 24 + messages/pl.json | 24 + .../0128_v1150_cycle_core/migration.sql | 150 +++++++ .../migration.sql | 112 +++++ .../0130_v1150_cycle_prediction/migration.sql | 35 ++ prisma/schema.prisma | 409 ++++++++++++++++++ src/lib/cycle/profile.ts | 36 ++ 11 files changed, 886 insertions(+) create mode 100644 prisma/migrations/0128_v1150_cycle_core/migration.sql create mode 100644 prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql create mode 100644 prisma/migrations/0130_v1150_cycle_prediction/migration.sql create mode 100644 src/lib/cycle/profile.ts diff --git a/messages/de.json b/messages/de.json index 8497c0d13..29aa90b09 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4867,5 +4867,29 @@ "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" + } } } diff --git a/messages/en.json b/messages/en.json index 6fb5b62e9..0d19a389f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4867,5 +4867,29 @@ "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" + } } } diff --git a/messages/es.json b/messages/es.json index 513ca1b2e..9eb7616a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4867,5 +4867,29 @@ "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" + } } } diff --git a/messages/fr.json b/messages/fr.json index b2543a04a..4274933f8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4867,5 +4867,29 @@ "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" + } } } diff --git a/messages/it.json b/messages/it.json index e94caef04..420309e26 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4867,5 +4867,29 @@ "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" + } } } diff --git a/messages/pl.json b/messages/pl.json index 6e87401a3..a4475a321 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4867,5 +4867,29 @@ "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" + } } } diff --git a/prisma/migrations/0128_v1150_cycle_core/migration.sql b/prisma/migrations/0128_v1150_cycle_core/migration.sql new file mode 100644 index 000000000..10314d75c --- /dev/null +++ b/prisma/migrations/0128_v1150_cycle_core/migration.sql @@ -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; diff --git a/prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql b/prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql new file mode 100644 index 000000000..9ee9075c0 --- /dev/null +++ b/prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql @@ -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; diff --git a/prisma/migrations/0130_v1150_cycle_prediction/migration.sql b/prisma/migrations/0130_v1150_cycle_prediction/migration.sql new file mode 100644 index 000000000..3a58389df --- /dev/null +++ b/prisma/migrations/0130_v1150_cycle_prediction/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52d1e7cbe..49ed7ec50 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,400 @@ 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) + + /// 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 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.`). + 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.`). + 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:` 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") + 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") +} diff --git a/src/lib/cycle/profile.ts b/src/lib/cycle/profile.ts new file mode 100644 index 000000000..f59bd56c3 --- /dev/null +++ b/src/lib/cycle/profile.ts @@ -0,0 +1,36 @@ +/** + * Cycle-tracking per-user settings row. + * + * `CycleProfile` is a 1:1 settings row (one per user, `@@unique(userId)`) + * that holds the goal + prediction / privacy preferences. There is no + * onboarding step that creates it eagerly; instead the read path lazily + * upserts a default row on first cycle-surface access, exactly like the + * other upsert-on-read settings rows in the tree (the Coach usage ledger + * in `src/lib/ai/coach/budget.ts`). The default goal is `GENERAL_HEALTH` + * — the inclusive, conception-framing-free default — and + * `sensitiveCategoryEncryption` defaults ON for this category given the + * reproductive-health threat model. + */ +import { prisma } from "@/lib/db"; +import type { CycleProfile } from "@/generated/prisma/client"; + +/** + * Return the user's `CycleProfile`, creating a default row on first + * access. Idempotent + race-safe: the `(userId)` unique lets the upsert + * collapse concurrent first-reads to a single row (a second caller's + * `create` loses the race and falls through to the no-op update). + */ +export async function getOrCreateCycleProfile( + userId: string, +): Promise { + return prisma.cycleProfile.upsert({ + where: { userId }, + // Field-by-field create (no mass assignment): every default is the + // schema default, spelled out so the row shape is explicit at the + // call site. + create: { userId }, + // No-op on an existing row — `update: {}` keeps the upsert a pure + // get-or-create without touching the stored preferences. + update: {}, + }); +} From ea0d7681e24ac8746456995f20346c8f878863bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 08:29:16 +0200 Subject: [PATCH 02/19] feat(cycle): add deterministic prediction + phase engine Pure, DB-free cycle prediction and phase-assignment engine under src/lib/cycle/. The math mirrors the offline-parity spec exactly so the server and the native client compute byte-identical forecasts from the same canonical inputs. - day-math: noon-UTC anchored YYYY-MM-DD arithmetic (dayDiff, addDays, round-half-up) that stays stable across timezones and DST transitions. - prediction: robust median+MAD cycle-length estimator over a trailing 12-cycle window with 3-MAD outlier exclusion and missed-log detection; period-length estimator; one-sigma Low/High date band widened by logging sparsity; luteal back-calculated ovulation + six-day fertile window; the CALENDAR -> symptothermal -> temperature-trend method ladder (sensiplan 3-over-6 + mucus-peak re-implemented from the published method) with documented blend precedence; a multiplicative confidence scalar shrinking with length variance, low cycle count, and sparse logging. - phase: day-of-cycle -> MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL boundary mapping with menstrual precedence on overlap. - 48 unit tests covering the regular, irregular, cold-start, outlier, symptothermal, temperature-trend, and every phase-boundary case; all assertions are timezone-independent and pin exact dates + confidence. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/cycle/__tests__/day-math.test.ts | 101 ++++ src/lib/cycle/__tests__/phase.test.ts | 136 +++++ src/lib/cycle/__tests__/prediction.test.ts | 403 ++++++++++++++ src/lib/cycle/day-math.ts | 87 +++ src/lib/cycle/index.ts | 23 + src/lib/cycle/phase.ts | 102 ++++ src/lib/cycle/prediction.ts | 584 +++++++++++++++++++++ src/lib/cycle/types.ts | 248 +++++++++ 8 files changed, 1684 insertions(+) create mode 100644 src/lib/cycle/__tests__/day-math.test.ts create mode 100644 src/lib/cycle/__tests__/phase.test.ts create mode 100644 src/lib/cycle/__tests__/prediction.test.ts create mode 100644 src/lib/cycle/day-math.ts create mode 100644 src/lib/cycle/index.ts create mode 100644 src/lib/cycle/phase.ts create mode 100644 src/lib/cycle/prediction.ts create mode 100644 src/lib/cycle/types.ts diff --git a/src/lib/cycle/__tests__/day-math.test.ts b/src/lib/cycle/__tests__/day-math.test.ts new file mode 100644 index 000000000..03bedaca2 --- /dev/null +++ b/src/lib/cycle/__tests__/day-math.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/cycle/__tests__/phase.test.ts b/src/lib/cycle/__tests__/phase.test.ts new file mode 100644 index 000000000..0ad4c1eb2 --- /dev/null +++ b/src/lib/cycle/__tests__/phase.test.ts @@ -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); + }); +}); diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts new file mode 100644 index 000000000..4791bdd9d --- /dev/null +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; + +import { addDays } from "../day-math"; +import { + confirmSymptothermal, + detectTempShift, + detectTemperatureTrend, + estimateCycleLength, + estimatePeriodLength, + median, + observedPeriodLength, + predictCycle, +} 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)); + }); +}); diff --git a/src/lib/cycle/day-math.ts b/src/lib/cycle/day-math.ts new file mode 100644 index 000000000..68014e15c --- /dev/null +++ b/src/lib/cycle/day-math.ts @@ -0,0 +1,87 @@ +/** + * 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; +} diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts new file mode 100644 index 000000000..13ff49741 --- /dev/null +++ b/src/lib/cycle/index.ts @@ -0,0 +1,23 @@ +/** + * 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, +} from "./prediction"; +export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase"; diff --git a/src/lib/cycle/phase.ts b/src/lib/cycle/phase.ts new file mode 100644 index 000000000..211f6bc1a --- /dev/null +++ b/src/lib/cycle/phase.ts @@ -0,0 +1,102 @@ +/** + * 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 { 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; + const lutealLength = 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; +} diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts new file mode 100644 index 000000000..94ac6b64e --- /dev/null +++ b/src/lib/cycle/prediction.ts @@ -0,0 +1,584 @@ +/** + * 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, + 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(); + 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. + for (let i = 6; i + 2 < temps.length; 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)); +} + +function resolveLuteal(profile: CycleProfileInput): number { + const raw = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; + return clamp(raw, LUTEAL_MIN, LUTEAL_MAX); +} + +/* ------------------------------------------------------------------ */ +/* 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). + const symptoOvulation = confirmSymptothermal(dayLogs); + const trendOvulation = symptoOvulation ? null : detectTemperatureTrend(nights); + + if (symptoOvulation) { + predictedOvulation = symptoOvulation; + nextStart = addDays(symptoOvulation, lutealLength); + ovulationConfirmed = true; + confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL; + method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL"; + } else if (trendOvulation) { + predictedOvulation = trendOvulation; + nextStart = addDays(trendOvulation, lutealLength); + 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, + }; +} diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts new file mode 100644 index 000000000..87d64aa13 --- /dev/null +++ b/src/lib/cycle/types.ts @@ -0,0 +1,248 @@ +/** + * Plain TypeScript input/output contracts for the cycle prediction + phase + * engine. + * + * These interfaces deliberately do NOT depend on Prisma row types. The engine + * is a pure, deterministic, DB-free function set: it takes the canonical inputs + * the iOS device also holds in its local mirror and returns the same struct iOS + * computes offline. Keeping the inputs as plain shapes (a) makes the engine + * unit-testable without a database and (b) makes the parity contract with the + * Swift re-implementation explicit — these fixtures are what iOS reuses. + * + * The literal union types below mirror the Prisma enums (FlowLevel, + * OvulationTest, CervicalMucus, CyclePhase, PredictionMethod, CycleTrackingGoal) + * named in data-model-ux.md, but are declared locally so the engine never + * imports the generated client. + */ + +/* ------------------------------------------------------------------ */ +/* Literal union types (mirror the Prisma enums) */ +/* ------------------------------------------------------------------ */ + +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 CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL"; + +export type PredictionMethod = + | "CALENDAR" + | "SYMPTOTHERMAL" + | "TEMPERATURE_TREND" + | "BLENDED"; + +export type CycleTrackingGoal = + | "GENERAL_HEALTH" + | "AVOID_PREGNANCY" + | "TRYING_TO_CONCEIVE" + | "PERIMENOPAUSE" + | "OFF"; + +/** Coarse confidence label derived from the scalar (for badge/copy). */ +export type ConfidenceLabel = "low" | "medium" | "high"; + +/* ------------------------------------------------------------------ */ +/* Pinned constants (algorithm.md §1–§5, the parity substrate) */ +/* ------------------------------------------------------------------ */ + +/** §1 — at most the 12 most recent COMPLETED cycles feed the length estimator. */ +export const HISTORY_WINDOW_N = 12; + +/** §1 — need ≥2 completed cycles for a real (non-priors) prediction. */ +export const MIN_CYCLES_TO_PREDICT = 2; + +/** §1 — optional recency half-life (cycles). OFF in MVP (see below). */ +export const RECENCY_HALF_LIFE = 6; + +/** + * §1 — recency weighting is OFF in the v1.15 MVP (plain median) so the TS and + * Swift engines stay trivially identical. Flip in lockstep later. + */ +export const RECENCY_WEIGHTING_ENABLED = false; + +/** §1 — floor on the robust SD so the band is never zero-width (false precision). */ +export const SIGMA_FLOOR = 1.0; + +/** §1 — normal-consistency constant for MAD → SD (sigma = 1.4826 * MAD). */ +export const MAD_NORMAL_CONST = 1.4826; + +/** §3 — one-sigma (~68%) band multiplier. Deliberately NOT 1.96. */ +export const Z_BAND = 1.0; + +/** §3 — date-band half-width clamp (days); never zero, never absurd. */ +export const HALF_WIDTH_MIN = 1; +export const HALF_WIDTH_MAX = 14; + +/** §5 — fixed half-width (days) for the 0-cycle priors-only path. */ +export const PRIORS_ONLY_HALF_WIDTH = 4; + +/** §5 — extra half-width (days) added on the single-cycle cold-start path. */ +export const COLD_START_BAND_BONUS = 3; + +/** §2 — last 6 cycles feed the period-length estimator. */ +export const PERIOD_WINDOW_N = 6; + +/** §5 — population fallbacks when the user has no data and no prior. */ +export const POPULATION_DEFAULT_CYCLE = 28; +export const POPULATION_DEFAULT_PERIOD = 5; + +/** §2 — period-length clamp (days). */ +export const PERIOD_MIN = 1; +export const PERIOD_MAX = 10; + +/** §4 — luteal-phase length default + physiological clamp. */ +export const LUTEAL_DEFAULT = 14; +export const LUTEAL_MIN = 10; +export const LUTEAL_MAX = 16; + +/** §4 — six-day fertile window: ovulation − 5 … ovulation + 1. */ +export const FERTILE_PRE = 5; +export const FERTILE_POST = 1; + +/** §5 — robust MAD-fence multiplier (3-MAD normal, 4-MAD perimenopause). */ +export const OUTLIER_K = 3.0; +export const OUTLIER_K_PERIMENOPAUSE = 4.0; + +/** §5 — hard physiological cycle-length bounds (always outlier candidates). */ +export const HARD_CYCLE_MIN = 21; +export const HARD_CYCLE_MAX = 45; + +/** §5 — a single length ≥ this × median is treated as a probable missed-log. */ +export const MISSED_LOG_FACTOR = 1.75; + +/** §4 — symptothermal (manual BBT) "third measurement" rise threshold (°C). */ +export const TEMP_SHIFT_C_MANUAL = 0.2; + +/** §4 — passive wrist/skin temperature-trend deviation threshold (°C). */ +export const TEMP_SHIFT_C_PASSIVE = 0.15; + +/** §4 — half-width multipliers when ovulation is confirmed by each layer. */ +export const HALF_WIDTH_MULT_SYMPTOTHERMAL = 0.6; +export const HALF_WIDTH_MULT_TEMP_TREND = 0.75; + +/** §4 — symptothermal agreement tolerance between temp-shift and mucus peak (days). */ +export const SYMPTOTHERMAL_AGREE_DAYS = 2; + +/** §3 — confidence scalar clamp (never certain, never zero). */ +export const CONFIDENCE_MIN = 0.05; +export const CONFIDENCE_MAX = 0.98; + +/** §3 — confidence → label cut points. */ +export const CONFIDENCE_LABEL_LOW_MAX = 0.4; // < 0.40 → low +export const CONFIDENCE_LABEL_HIGH_MIN = 0.7; // > 0.70 → high + +/** §3 — c_adherence floor + slope (0.4 + 0.6 * density). */ +export const ADHERENCE_FLOOR = 0.4; +export const ADHERENCE_SLOPE = 0.6; + +/** §3 — max band widening from sparse logging (LOG_SPARSITY scale). */ +export const LOG_SPARSITY_SCALE = 0.5; + +/* ------------------------------------------------------------------ */ +/* Input contracts */ +/* ------------------------------------------------------------------ */ + +/** + * One confirmed (or predicted) cycle. `startDate` is the first bleeding day. + * The engine treats a cycle as COMPLETED only when a later cycle's `startDate` + * exists, giving it a known length. + */ +export interface CycleInput { + /** First bleeding day of the menstruation, `YYYY-MM-DD`. */ + startDate: string; + /** First bleeding day of the NEXT cycle, or null if this is the latest. */ + endDate: string | null; + /** Last bleeding day of THIS cycle's period run, or null if unknown. */ + periodEndDate: string | null; + /** Confirmed/estimated ovulation day, or null. */ + ovulationDate: string | null; + /** Whether `ovulationDate` was confirmed (symptothermal/temp), not estimated. */ + ovulationConfirmed: boolean; +} + +/** One day's observations (the HealthKit / manual sync unit). */ +export interface DayLogInput { + /** The logged day, `YYYY-MM-DD`. */ + date: string; + flow: FlowLevel | null; + /** Basal body temperature in °C, already rounded to 2 dp before input. */ + basalBodyTempC: number | null; + ovulationTest: OvulationTest | null; + cervicalMucus: CervicalMucus | null; +} + +/** + * One nightly passive-temperature deviation reading (Apple Watch wrist / + * skin temperature), used by the TEMPERATURE_TREND layer. `deviationC` is the + * nightly value's deviation from the user's own baseline in °C (the engine also + * accepts a raw nightly value and derives the trailing-mean deviation itself — + * see prediction.ts). + */ +export interface NightlyTempInput { + date: string; + /** Nightly temperature value in °C (raw; baseline derived internally). */ + valueC: number; +} + +/** User-set priors + mode flags. All optional; the engine applies defaults. */ +export interface CycleProfileInput { + goal: CycleTrackingGoal; + typicalCycleLength: number | null; + typicalPeriodLength: number | null; + lutealPhaseLength: number | null; + /** When false the engine emits no prediction (caller checks before calling). */ + predictionEnabled: boolean; + /** Read-Your-Body mode: suppress all interpretation. */ + rawChartMode: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Output contracts */ +/* ------------------------------------------------------------------ */ + +/** The materialised forecast (mirrors the CyclePrediction row). */ +export interface CyclePredictionResult { + method: PredictionMethod; + /** Point estimate of the next period's first day, `YYYY-MM-DD`. */ + nextPeriodStart: string; + /** Low end of the one-sigma date band, `YYYY-MM-DD`. */ + nextPeriodStartLow: string; + /** High end of the one-sigma date band, `YYYY-MM-DD`. */ + nextPeriodStartHigh: string; + /** Predicted length of the next period bar (days), clamped [1,10]. */ + predictedPeriodLength: number; + /** Fertile window start (ovulation − 5), or null when goal-gated off upstream. */ + fertileWindowStart: string | null; + /** Fertile window end (ovulation + 1), or null. */ + fertileWindowEnd: string | null; + /** Estimated/confirmed ovulation day for the upcoming cycle. */ + predictedOvulation: string | null; + /** Whether `predictedOvulation` was confirmed by a signal layer. */ + ovulationConfirmed: boolean; + /** Confidence scalar in [0.05, 0.98]. */ + confidence: number; + /** Coarse label for the badge/copy. */ + confidenceLabel: ConfidenceLabel; + /** Completed cycle lengths used (post outlier-exclusion). */ + cyclesObserved: number; + /** <3 observed cycles → "still learning" UI state, independent of score. */ + stillLearning: boolean; + /** Estimated typical cycle length (days, rounded). */ + estimatedCycleLength: number; + /** Robust SD of cycle length (days), >= SIGMA_FLOOR. */ + estimatedCycleSd: number; +} + +/** The phase of a single calendar date relative to its containing cycle. */ +export interface PhaseResult { + date: string; + phase: CyclePhase | null; + /** 1-based day-of-cycle (1 = startDate), or null if the date is outside any cycle. */ + dayOfCycle: number | null; +} From 5f485ca78042a8bff6341171359d3765fa97624b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 08:56:28 +0200 Subject: [PATCH 03/19] feat(cycle): add cycle-tracking API surface + offline sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `/api/cycle/*` capture, calendar, history, and settings routes plus the offline-sync delta extension for the v1.15 cycle-tracking feature, all against the locked iOS contract. - Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile (migration 0132). A null toggle derives from gender (FEMALE → on). `isCycleEnabled()` is the single gate resolver; every `/api/cycle/*` route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved flag surfaces additively on GET /api/auth/me. - Routes (apiHandler + Zod + envelope + gate + annotate + auditLog): POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent, capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/ skipped), POST period boundary, GET calendar (runs the deterministic engine, debounced stale-while-revalidate cache, goal-gated fertile window), GET cycles (history + median/MAD stats), GET profile, DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge). - Day-log note encrypts at rest (notesEncrypted); intent fields stay queryable plaintext. Upsert keys on (userId, source, externalId) when externalId present, else (userId, date). - Offline sync: extend GET /api/sync/changes + the cursor with the `cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays tombstones key on externalId else id; cycles on id. - OpenAPI: register every route + DTO; regenerate the committed spec. - i18n: cycle prediction disclaimer across all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 1653 +++++++++++++++-- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../migration.sql | 17 + prisma/schema.prisma | 8 + src/app/api/auth/me/cycle-prefs/route.ts | 131 ++ src/app/api/auth/me/route.ts | 14 + src/app/api/cycle/calendar/route.ts | 171 ++ src/app/api/cycle/cycles/[id]/route.ts | 55 + src/app/api/cycle/cycles/route.ts | 99 + src/app/api/cycle/day-logs/[id]/route.ts | 192 ++ src/app/api/cycle/day-logs/bulk/route.ts | 169 ++ src/app/api/cycle/day-logs/route.ts | 83 + src/app/api/cycle/period/route.ts | 151 ++ src/app/api/cycle/profile/route.ts | 27 + src/app/api/sync/changes/route.ts | 98 +- .../cycle/__tests__/engine-adapter.test.ts | 126 ++ src/lib/cycle/__tests__/gate.test.ts | 41 + src/lib/cycle/cycle-attribution.ts | 24 + src/lib/cycle/day-log-write.ts | 171 ++ src/lib/cycle/dto.ts | 217 +++ src/lib/cycle/engine-adapter.ts | 233 +++ src/lib/cycle/gate.ts | 72 + src/lib/cycle/prediction-cache.ts | 80 + src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes.ts | 480 +++++ src/lib/sync/__tests__/cursor.test.ts | 23 +- src/lib/sync/cursor.ts | 15 +- src/lib/validations/cycle.ts | 185 ++ tests/integration/cycle-routes.test.ts | 362 ++++ 34 files changed, 4738 insertions(+), 178 deletions(-) create mode 100644 prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql create mode 100644 src/app/api/auth/me/cycle-prefs/route.ts create mode 100644 src/app/api/cycle/calendar/route.ts create mode 100644 src/app/api/cycle/cycles/[id]/route.ts create mode 100644 src/app/api/cycle/cycles/route.ts create mode 100644 src/app/api/cycle/day-logs/[id]/route.ts create mode 100644 src/app/api/cycle/day-logs/bulk/route.ts create mode 100644 src/app/api/cycle/day-logs/route.ts create mode 100644 src/app/api/cycle/period/route.ts create mode 100644 src/app/api/cycle/profile/route.ts create mode 100644 src/lib/cycle/__tests__/engine-adapter.test.ts create mode 100644 src/lib/cycle/__tests__/gate.test.ts create mode 100644 src/lib/cycle/cycle-attribution.ts create mode 100644 src/lib/cycle/day-log-write.ts create mode 100644 src/lib/cycle/dto.ts create mode 100644 src/lib/cycle/engine-adapter.ts create mode 100644 src/lib/cycle/gate.ts create mode 100644 src/lib/cycle/prediction-cache.ts create mode 100644 src/lib/validations/cycle.ts create mode 100644 tests/integration/cycle-routes.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4b2c1c1db..6ae909ef1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -37,9 +37,664 @@ tags: - name: Devices - name: Export - name: Sync + - name: Cycle - name: Admin - name: Meta paths: + /api/cycle/day-logs: + post: + tags: + - Cycle + summary: Capture a single cycle day-log (v1.15.0) + description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts + at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + flow: + type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + intermenstrualBleeding: + type: boolean + basalBodyTempC: + type: number + minimum: 30 + maximum: 45 + ovulationTest: + type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + cervicalMucus: + type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + progesteroneTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + contraceptive: + type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + type: string + maxLength: 500 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - date + - loggedAt + responses: + "200": + description: Existing day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogEnvelope" + "201": + description: New day-log created. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" + "401": &a1 + description: Authentication required or invalid credentials. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": &a2 + description: Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": &a3 + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": &a4 + description: Rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/cycle/day-logs/{id}: + patch: + tags: + - Cycle + summary: Edit a single cycle day-log (v1.15.0) + description: Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + minimum: 30 + maximum: 45 + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + - type: "null" + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + responses: + "200": + description: Day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogPatchEnvelope" + "401": *a1 + "403": *a2 + "404": + description: Day-log not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Cycle + summary: Soft-delete a cycle day-log (v1.15.0) + description: Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. + Idempotent. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "403": *a2 + "404": + description: Day-log not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/cycle/day-logs/bulk: + post: + tags: + - Cycle + summary: Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0) + description: "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry + status: inserted | duplicate | updated | skipped. Always 200." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + entries: + minItems: 1 + maxItems: 500 + type: array + items: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + flow: + type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + intermenstrualBleeding: + type: boolean + basalBodyTempC: + type: number + minimum: 30 + maximum: 45 + ovulationTest: + type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + cervicalMucus: + type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + progesteroneTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + contraceptive: + type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + type: string + maxLength: 500 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - date + - loggedAt + required: + - entries + responses: + "200": + description: Batch processed (per-entry results). + content: + application/json: + schema: + $ref: "#/components/schemas/CycleBulkEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/period: + post: + tags: + - Cycle + summary: Period-boundary shortcut (v1.15.0) + description: One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. + Writes the boundary day-log. Gated. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: + - start + - end + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + externalId: + type: string + minLength: 1 + maxLength: 120 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - action + - date + - loggedAt + responses: + "200": + description: Cycle + boundary day-log. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePeriodEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/calendar: + get: + tags: + - Cycle + summary: Predicted cycle calendar (v1.15.0) + description: "Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are + server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated." + parameters: + - in: query + name: from + schema: + type: string + - in: query + name: to + schema: + type: string + responses: + "200": + description: Calendar grid + forecast. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleCalendarEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/cycles: + get: + tags: + - Cycle + summary: Cycle history + stats (v1.15.0) + description: Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, + regularity }`. `limit` default 24. Gated. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 60 + responses: + "200": + description: Cycle history. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleHistoryEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/cycles/{id}: + delete: + tags: + - Cycle + summary: Soft-delete a menstrual cycle (v1.15.0) + description: Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "403": *a2 + "404": + description: Cycle not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/cycle/profile: + get: + tags: + - Cycle + summary: Read the full cycle profile (v1.15.0) + description: Returns the resolved CycleProfileDTO. Gated. + responses: + "200": + description: Cycle profile. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleProfileEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/auth/me/cycle-prefs: + get: + tags: + - Cycle + summary: Read cycle preferences (v1.15.0) + description: Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate. + responses: + "200": + description: Cycle preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePrefsGetEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Cycle + summary: Update cycle preferences (v1.15.0) + description: Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged + CycleProfileDTO. NOT gated. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + discreetNotifications: + type: boolean + sensitiveCategoryEncryption: + type: boolean + typicalCycleLength: + anyOf: + - type: integer + minimum: 15 + maximum: 60 + - type: "null" + typicalPeriodLength: + anyOf: + - type: integer + minimum: 1 + maximum: 15 + - type: "null" + lutealPhaseLength: + anyOf: + - type: integer + minimum: 8 + maximum: 20 + - type: "null" + responses: + "200": + description: Merged cycle preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePrefsPatchEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/meta/capabilities: get: tags: @@ -57,24 +712,9 @@ paths: application/json: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" - "401": &a1 - description: Authentication required or invalid credentials. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "422": &a2 - description: Request validation failed. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": &a3 - description: Rate limit exceeded. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/export/health-record: post: tags: @@ -207,8 +847,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/share-links: post: tags: @@ -323,8 +963,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 get: tags: - Export @@ -339,8 +979,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/share-links/{id}: delete: tags: @@ -368,8 +1008,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/metadata: get: tags: @@ -387,8 +1027,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/Patient: get: tags: @@ -414,8 +1054,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/Observation: get: tags: @@ -441,8 +1081,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/MedicationStatement: get: tags: @@ -468,8 +1108,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/MedicationAdministration: get: tags: @@ -495,8 +1135,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/$everything: get: tags: @@ -523,8 +1163,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/login: post: tags: @@ -556,8 +1196,8 @@ paths: schema: $ref: "#/components/schemas/LoginResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/passkey/login-verify: post: tags: @@ -577,8 +1217,8 @@ paths: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/refresh: post: tags: @@ -608,8 +1248,8 @@ paths: schema: $ref: "#/components/schemas/RefreshResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sync/state: get: tags: @@ -626,8 +1266,8 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sync/changes: get: tags: @@ -663,8 +1303,8 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements: get: tags: @@ -819,8 +1459,8 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 post: tags: - Measurements @@ -940,8 +1580,8 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements/batch: post: tags: @@ -965,8 +1605,8 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements/by-external-ids: delete: tags: @@ -988,8 +1628,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sleep/night: get: tags: @@ -1017,8 +1657,8 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts: get: tags: @@ -1065,8 +1705,8 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts/{id}: get: tags: @@ -1095,8 +1735,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts/batch: post: tags: @@ -1310,8 +1950,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/devices: post: tags: @@ -1339,8 +1979,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications: get: tags: @@ -1358,8 +1998,8 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 post: tags: - Medications @@ -1381,8 +2021,8 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}: get: tags: @@ -1410,8 +2050,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Medications @@ -1446,8 +2086,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Medications @@ -1474,8 +2114,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}/intake: post: tags: @@ -1517,8 +2157,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/extract: post: tags: @@ -1543,8 +2183,8 @@ paths: schema: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 "502": description: Upstream provider returned an empty, unparseable, or off-schema reply. content: @@ -1594,8 +2234,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}/compliance: get: tags: @@ -1626,8 +2266,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -1643,8 +2283,8 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Auth @@ -1725,8 +2365,8 @@ paths: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/disable-coach: get: tags: @@ -1742,8 +2382,8 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 patch: tags: - Auth @@ -1764,8 +2404,8 @@ paths: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/source-priority: get: tags: @@ -1781,8 +2421,8 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Auth @@ -2278,8 +2918,8 @@ paths: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/avatar: post: tags: @@ -2322,8 +2962,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Auth @@ -2334,8 +2974,8 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/avatar/{id}: get: tags: @@ -2381,8 +3021,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/profile: get: tags: @@ -2398,8 +3038,8 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 patch: tags: - Auth @@ -2421,8 +3061,8 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -2438,8 +3078,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Insights @@ -2460,8 +3100,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutSaved" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Insights @@ -2475,8 +3115,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/coach/facts: get: tags: @@ -2494,8 +3134,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Insights @@ -2510,8 +3150,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/coach/facts/{id}: delete: tags: @@ -2528,8 +3168,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/chat/messages/{id}/feedback: post: tags: @@ -2564,8 +3204,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/admin/notifications/diagnostic: get: tags: @@ -2588,8 +3228,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/dashboard/snapshot: get: tags: @@ -2606,8 +3246,8 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/comprehensive: get: tags: @@ -2623,8 +3263,8 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/pregenerate: post: tags: @@ -2650,8 +3290,8 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/analytics/range: get: tags: @@ -2756,8 +3396,8 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/blood-pressure-status: get: tags: @@ -2784,8 +3424,8 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/pulse-status: get: tags: @@ -2812,8 +3452,8 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/weight-status: get: tags: @@ -2840,8 +3480,8 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/bmi-status: get: tags: @@ -2868,8 +3508,8 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/mood-status: get: tags: @@ -2896,8 +3536,8 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/medication-compliance-status: get: tags: @@ -2924,8 +3564,8 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/metric-status: get: tags: @@ -3003,8 +3643,8 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -3075,8 +3715,8 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/derived/batch: get: tags: @@ -3109,8 +3749,8 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -3129,8 +3769,8 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -4063,6 +4703,699 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + CycleDayLogEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleDayLogDTO: + type: object + properties: + id: + type: string + date: + type: string + cycleId: + anyOf: + - type: string + - type: "null" + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + - type: "null" + symptoms: + type: array + items: + type: object + properties: + key: + type: string + severity: + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" + required: + - key + - severity + additionalProperties: false + note: + anyOf: + - type: string + - type: "null" + source: + type: string + externalId: + anyOf: + - type: string + - type: "null" + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + deletedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + required: + - id + - date + - cycleId + - flow + - intermenstrualBleeding + - basalBodyTempC + - ovulationTest + - cervicalMucus + - sexualActivity + - protectedSex + - pregnancyTest + - progesteroneTest + - contraceptive + - symptoms + - note + - source + - externalId + - syncVersion + - updatedAt + - deletedAt + additionalProperties: false + description: The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride + `/api/sync/changes` as tombstones. + CycleDayLogCreatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ErrorEnvelope: + type: object + properties: + data: + type: "null" + error: + type: string + meta: + type: object + properties: + requestId: + type: string + errorCode: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + description: "Standard error response: data is null, error is human prose." + CycleDayLogPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleBulkEnvelope: + type: object + properties: + data: + type: object + properties: + processed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inserted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updated: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + duplicates: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + skipped: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + entries: + type: array + items: + type: object + properties: + index: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + status: + type: string + enum: + - inserted + - duplicate + - updated + - skipped + id: + type: string + externalId: + type: string + reason: + type: string + required: + - index + - status + additionalProperties: false + required: + - processed + - inserted + - updated + - duplicates + - skipped + - entries + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePeriodEnvelope: + type: object + properties: + data: + type: object + properties: + cycle: + anyOf: + - $ref: "#/components/schemas/MenstrualCycleDTO" + - type: "null" + dayLog: + anyOf: + - $ref: "#/components/schemas/CycleDayLogDTO" + - type: "null" + required: + - cycle + - dayLog + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MenstrualCycleDTO: + type: object + properties: + id: + type: string + startDate: + type: string + endDate: + anyOf: + - type: string + - type: "null" + periodEndDate: + anyOf: + - type: string + - type: "null" + lengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + ovulationDate: + anyOf: + - type: string + - type: "null" + ovulationConfirmed: + type: boolean + isPredicted: + type: boolean + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - startDate + - endDate + - periodEndDate + - lengthDays + - ovulationDate + - ovulationConfirmed + - isPredicted + - syncVersion + - updatedAt + additionalProperties: false + description: One menstrual cycle (observed or forward-predicted). + CycleCalendarEnvelope: + type: object + properties: + data: + type: object + properties: + profile: + type: object + properties: + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - goal + - rawChartMode + - predictionEnabled + - cyclesObserved + additionalProperties: false + prediction: + anyOf: + - $ref: "#/components/schemas/CyclePredictionDTO" + - type: "null" + days: + type: array + items: + type: object + properties: + date: + type: string + phase: + anyOf: + - $ref: "#/components/schemas/CyclePhase" + - type: "null" + isPredictedPeriod: + type: boolean + isFertileWindow: + type: boolean + isPredictedOvulation: + type: boolean + isPeriodLogged: + type: boolean + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + hasSymptoms: + type: boolean + confidence: + type: number + required: + - date + - phase + - isPredictedPeriod + - isFertileWindow + - isPredictedOvulation + - isPeriodLogged + - flow + - hasSymptoms + - confidence + additionalProperties: false + meta: + type: object + properties: + generatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - generatedAt + additionalProperties: false + required: + - profile + - prediction + - days + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePredictionDTO: + type: object + properties: + method: + $ref: "#/components/schemas/CyclePredictionMethod" + nextPeriodStart: + type: string + nextPeriodStartLow: + type: string + nextPeriodStartHigh: + type: string + fertileWindowStart: + anyOf: + - type: string + - type: "null" + fertileWindowEnd: + anyOf: + - type: string + - type: "null" + predictedOvulation: + anyOf: + - type: string + - type: "null" + confidence: + type: number + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stillLearning: + type: boolean + disclaimer: + type: string + required: + - method + - nextPeriodStart + - nextPeriodStartLow + - nextPeriodStartHigh + - fertileWindowStart + - fertileWindowEnd + - predictedOvulation + - confidence + - cyclesObserved + - stillLearning + - disclaimer + additionalProperties: false + description: The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is + TRYING_TO_CONCEIVE. + CyclePredictionMethod: + type: string + enum: + - CALENDAR + - SYMPTOTHERMAL + - TEMPERATURE_TREND + - BLENDED + CyclePhase: + type: string + enum: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + CycleHistoryEnvelope: + type: object + properties: + data: + type: object + properties: + cycles: + type: array + items: + $ref: "#/components/schemas/MenstrualCycleDTO" + stats: + type: object + properties: + avgLengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lengthVariabilityDays: + anyOf: + - type: number + - type: "null" + avgPeriodLengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + regularity: + type: string + enum: + - REGULAR + - IRREGULAR + - LEARNING + required: + - avgLengthDays + - lengthVariabilityDays + - avgPeriodLengthDays + - regularity + additionalProperties: false + required: + - cycles + - stats + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleProfileEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleProfileDTO: + type: object + properties: + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + cycleTrackingEnabled: + type: boolean + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + discreetNotifications: + type: boolean + sensitiveCategoryEncryption: + type: boolean + typicalCycleLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + typicalPeriodLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lutealPhaseLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - goal + - cycleTrackingEnabled + - rawChartMode + - predictionEnabled + - discreetNotifications + - sensitiveCategoryEncryption + - typicalCycleLength + - typicalPeriodLength + - lutealPhaseLength + - updatedAt + additionalProperties: false + description: The full per-user cycle settings row. + CyclePrefsGetEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePrefsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CapabilitiesEnvelope: type: object properties: @@ -4237,26 +5570,6 @@ components: additionalProperties: false description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit. - ErrorEnvelope: - type: object - properties: - data: - type: "null" - error: - type: string - meta: - type: object - properties: - requestId: - type: string - errorCode: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - description: "Standard error response: data is null, error is human prose." ShareLinkCreated: type: object properties: diff --git a/messages/de.json b/messages/de.json index 29aa90b09..d0a4d488b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4890,6 +4890,9 @@ "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." } } } diff --git a/messages/en.json b/messages/en.json index 0d19a389f..c46256bba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4890,6 +4890,9 @@ "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." } } } diff --git a/messages/es.json b/messages/es.json index 9eb7616a7..3df96040b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4890,6 +4890,9 @@ "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." } } } diff --git a/messages/fr.json b/messages/fr.json index 4274933f8..85653fb21 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4890,6 +4890,9 @@ "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." } } } diff --git a/messages/it.json b/messages/it.json index 420309e26..ebdcd177d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4890,6 +4890,9 @@ "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." } } } diff --git a/messages/pl.json b/messages/pl.json index a4475a321..2b1eea8b6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4890,6 +4890,9 @@ "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." } } } diff --git a/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql b/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql new file mode 100644 index 000000000..2d8c53bc2 --- /dev/null +++ b/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49ed7ec50..e49884e87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3308,6 +3308,14 @@ model CycleProfile { /// 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. diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts new file mode 100644 index 000000000..3de329d1c --- /dev/null +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -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)); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 49f74a96a..22b43748f 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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, }); }); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts new file mode 100644 index 000000000..ee72d97c5 --- /dev/null +++ b/src/app/api/cycle/calendar/route.ts @@ -0,0 +1,171 @@ +/** + * `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 (null unless TRYING_TO_CONCEIVE), + * suppressed 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, +} from "@/lib/cycle/dto"; +import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; +import { addDays } from "@/lib/cycle/day-math"; +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" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id, deletedAt: null }, + 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); + const disclaimer = t.t("cycle.prediction.disclaimer"); + + 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 }, + }); +}); diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts new file mode 100644 index 000000000..2810bc90e --- /dev/null +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts new file mode 100644 index 000000000..d714c010b --- /dev/null +++ b/src/app/api/cycle/cycles/route.ts @@ -0,0 +1,99 @@ +/** + * `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, + }, + }); +}); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts new file mode 100644 index 000000000..362d039b0 --- /dev/null +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -0,0 +1,192 @@ +/** + * `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 } from "@/lib/crypto"; +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 }, + }); + 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; + + // Field-by-field update (no mass assignment). Every field is + // optional; an omitted field is left untouched. `note` re-encrypts; + // an explicit null clears it. + 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, + }), + ...(body.sexualActivity !== undefined && { + sexualActivity: body.sexualActivity, + }), + ...(body.protectedSex !== undefined && { + protectedSex: body.protectedSex, + }), + ...(body.pregnancyTest !== undefined && { + pregnancyTest: body.pregnancyTest, + }), + ...(body.progesteroneTest !== undefined && { + progesteroneTest: body.progesteroneTest, + }), + ...(body.contraceptive !== undefined && { + contraceptive: body.contraceptive, + }), + ...(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) { + const keys = Array.from(new Set(body.symptoms.map((s) => s.key))); + const symptoms = + keys.length > 0 + ? await prisma.cycleSymptom.findMany({ + where: { + key: { in: keys }, + isActive: true, + OR: [{ userId: null }, { userId: user.id }], + }, + select: { id: true }, + }) + : []; + await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: id } }); + if (symptoms.length > 0) { + await prisma.cycleSymptomLink.createMany({ + data: symptoms.map((s) => ({ dayLogId: id, symptomId: s.id })), + skipDuplicates: true, + }); + } + } + + 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 }); + }, +); diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts new file mode 100644 index 000000000..27272e80c --- /dev/null +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -0,0 +1,169 @@ +/** + * `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 { 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 { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; +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 { + 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; + + 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 = await findOwningCycleId(user.id, entry.date); + const r = await upsertCycleDayLog(user.id, entry, tz, cycleId); + + 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) { + const reason = + err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; + 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, + }); +} diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts new file mode 100644 index 000000000..1490559ac --- /dev/null +++ b/src/app/api/cycle/day-logs/route.ts @@ -0,0 +1,83 @@ +/** + * `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, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleDayLogInputSchema } 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)); + +async function postDayLog(request: NextRequest): Promise { + 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); + const result = await upsertCycleDayLog(user.id, entry, tz, cycleId); + + 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); +} diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts new file mode 100644 index 000000000..cc866aa62 --- /dev/null +++ b/src/app/api/cycle/period/route.ts @@ -0,0 +1,151 @@ +/** + * `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 { cyclePeriodSchema } from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +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(async (request: NextRequest) => { + 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; + + let cycleId: string; + + if (action === "start") { + // Close the prior open cycle (its end is the day before this start) + // and record its observed length. + const prior = await prisma.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lt: date } }, + orderBy: { startDate: "desc" }, + select: { id: true, startDate: true }, + }); + if (prior) { + await prisma.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 prisma.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 } }, + }); + cycleId = cycle.id; + } else { + // `end`: stamp the current cycle's periodEndDate. + const current = await prisma.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lte: date } }, + orderBy: { startDate: "desc" }, + select: { id: true }, + }); + if (!current) { + return apiSuccessNoCycle(); + } + await prisma.menstrualCycle.update({ + where: { id: current.id }, + data: { periodEndDate: date, syncVersion: { increment: 1 } }, + }); + cycleId = current.id; + } + + // Boundary day-log. Start → MEDIUM flow opens the bleed; end → SPOTTING + // tail (the last logged bleeding day). + await upsertCycleDayLog( + user.id, + { + date, + flow: action === "start" ? "MEDIUM" : "SPOTTING", + 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 }); +} diff --git a/src/app/api/cycle/profile/route.ts b/src/app/api/cycle/profile/route.ts new file mode 100644 index 000000000..051d916d0 --- /dev/null +++ b/src/app/api/cycle/profile/route.ts @@ -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)); +}); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 952b8ab56..6a6d62e1b 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -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 }, }, }); }); diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts new file mode 100644 index 000000000..900a3031d --- /dev/null +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/src/lib/cycle/__tests__/gate.test.ts b/src/lib/cycle/__tests__/gate.test.ts new file mode 100644 index 000000000..df6dd2266 --- /dev/null +++ b/src/lib/cycle/__tests__/gate.test.ts @@ -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"); + }); +}); diff --git a/src/lib/cycle/cycle-attribution.ts b/src/lib/cycle/cycle-attribution.ts new file mode 100644 index 000000000..17f91763b --- /dev/null +++ b/src/lib/cycle/cycle-attribution.ts @@ -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 { + const cycle = await prisma.menstrualCycle.findFirst({ + where: { + userId, + deletedAt: null, + startDate: { lte: date }, + }, + orderBy: { startDate: "desc" }, + select: { id: true }, + }); + return cycle?.id ?? null; +} diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts new file mode 100644 index 000000000..c02c1c01c --- /dev/null +++ b/src/lib/cycle/day-log-write.ts @@ -0,0 +1,171 @@ +/** + * 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); + * every other field is queryable plaintext. Symptom keys resolve against + * the seeded catalog (unknown keys dropped silently) and write the + * `CycleSymptomLink` join. + * + * 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 } from "@/lib/crypto"; +import type { CycleDayLogInput } from "@/lib/validations/cycle"; + +export interface DayLogWriteResult { + id: string; + existed: boolean; + /** A pre-existing row whose persisted fields differ from the re-post. */ + changed: boolean; +} + +/** Resolve catalog symptom keys → ids, dropping unknown keys silently. */ +async function resolveSymptomIds( + userId: string, + keys: readonly string[], +): Promise { + 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 }, + }); + return rows.map((r) => r.id); +} + +/** + * Upsert one cycle day-log. `tz` anchors the date string. When + * `cycleId` is supplied the row is attributed to that cycle span. + */ +export async function upsertCycleDayLog( + userId: string, + entry: CycleDayLogInput, + tz: string | null, + cycleId: string | null = null, +): Promise { + const source = entry.source; + const notesEncrypted = entry.note ? encrypt(entry.note) : null; + + const where = 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: { + id: true, + date: true, + flow: true, + intermenstrualBleeding: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + notesEncrypted: true, + deletedAt: true, + }, + }); + + // Field-by-field create data (no mass assignment). + const baseData = { + flow: entry.flow ?? null, + intermenstrualBleeding: entry.intermenstrualBleeding ?? false, + basalBodyTempC: entry.basalBodyTempC ?? null, + ovulationTest: entry.ovulationTest ?? null, + cervicalMucus: entry.cervicalMucus ?? null, + sexualActivity: entry.sexualActivity ?? false, + protectedSex: entry.protectedSex ?? null, + pregnancyTest: entry.pregnancyTest ?? null, + progesteroneTest: entry.progesteroneTest ?? null, + contraceptive: entry.contraceptive ?? null, + notesEncrypted, + }; + + // Last-writer-wins on the field set. An externalId re-post also refreshes + // `date` (a re-zoned re-import lands the corrected wall-clock). A + // soft-deleted row is resurrected on a fresh write (deletedAt cleared). + const updateData = { + ...baseData, + ...(entry.externalId ? { date: entry.date } : {}), + ...(cycleId !== null ? { cycleId } : {}), + deletedAt: null, + syncVersion: { increment: 1 }, + }; + + const row = await prisma.cycleDayLog.upsert({ + where, + create: { + userId, + date: entry.date, + tz, + source, + externalId: entry.externalId ?? null, + cycleId, + ...baseData, + }, + update: updateData, + select: { id: true }, + }); + + // Replace the symptom-link set when symptoms were supplied. Absent + // `symptoms` leaves existing links untouched (a partial day-log edit + // that doesn't mention symptoms must not wipe them). + if (entry.symptoms !== undefined) { + const ids = await resolveSymptomIds( + userId, + entry.symptoms.map((s) => s.key), + ); + await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: row.id } }); + if (ids.length > 0) { + await prisma.cycleSymptomLink.createMany({ + data: ids.map((symptomId) => ({ dayLogId: row.id, symptomId })), + skipDuplicates: true, + }); + } + } + + // `changed` only matters for the bulk `updated` vs `duplicate` split on + // an existing row: did any persisted field differ from the re-post? + let changed = false; + if (existing) { + changed = + existing.flow !== baseData.flow || + existing.intermenstrualBleeding !== baseData.intermenstrualBleeding || + existing.basalBodyTempC !== baseData.basalBodyTempC || + existing.ovulationTest !== baseData.ovulationTest || + existing.cervicalMucus !== baseData.cervicalMucus || + existing.sexualActivity !== baseData.sexualActivity || + existing.protectedSex !== baseData.protectedSex || + existing.pregnancyTest !== baseData.pregnancyTest || + existing.progesteroneTest !== baseData.progesteroneTest || + existing.contraceptive !== baseData.contraceptive || + existing.notesEncrypted !== baseData.notesEncrypted || + existing.deletedAt !== null || + (Boolean(entry.externalId) && existing.date !== entry.date); + } + + return { id: row.id, existed: existing !== null, changed }; +} diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts new file mode 100644 index 000000000..a5c651695 --- /dev/null +++ b/src/lib/cycle/dto.ts @@ -0,0 +1,217 @@ +/** + * 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 is not yet persisted per-link in v1.15.0 — the join is a + * presence link; severity rides the input but the catalog link carries + * presence only, so it reads back `null`). + */ +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 })[]; +}; + +/** 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; + } +} + +export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { + 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: row.sexualActivity, + protectedSex: row.protectedSex, + pregnancyTest: row.pregnancyTest, + progesteroneTest: row.progesteroneTest, + contraceptive: row.contraceptive, + symptoms: (row.symptomLinks ?? []).map((l) => ({ + key: l.symptom.key, + 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; + 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, + 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 the v1.15 contract the + * window is gated to the conception goal only — GENERAL_HEALTH / + * AVOID_PREGNANCY / PERIMENOPAUSE / OFF suppress it (the suppression is + * always server-side, never iOS). + */ +export function goalAllowsFertileWindow(goal: string): boolean { + return goal === "TRYING_TO_CONCEIVE"; +} diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts new file mode 100644 index 000000000..94b471388 --- /dev/null +++ b/src/lib/cycle/engine-adapter.ts @@ -0,0 +1,233 @@ +/** + * 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, + LUTEAL_DEFAULT, +} 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; +} + +/** 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, +): 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. + */ +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). */ +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; +} + +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 { + const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; + + // 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(); + 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, + }); + } + + return { prediction, days }; +} diff --git a/src/lib/cycle/gate.ts b/src/lib/cycle/gate.ts new file mode 100644 index 000000000..8f4ce1062 --- /dev/null +++ b/src/lib/cycle/gate.ts @@ -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 { + 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 }; +} diff --git a/src/lib/cycle/prediction-cache.ts b/src/lib/cycle/prediction-cache.ts new file mode 100644 index 000000000..4ad546003 --- /dev/null +++ b/src/lib/cycle/prediction-cache.ts @@ -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 { + 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 */ + } +} diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 3ce0fab6a..77cebceb3 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -67,6 +67,7 @@ const openApiBase: Pick< { name: "Devices" }, { name: "Export" }, { name: "Sync" }, + { name: "Cycle" }, { name: "Admin" }, { name: "Meta" }, ], diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index ac8bd49a2..4914a9c18 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -54,6 +54,18 @@ import { VITALS_BASELINE_TYPES, } from "@/lib/insights/derived/registry"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; +import { + flowLevelEnum, + ovulationTestEnum, + cervicalMucusEnum, + homeTestResultEnum, + cycleTrackingGoalEnum, + cycleDayLogInputSchema, + cycleDayLogPatchSchema, + cycleBulkSchema, + cyclePeriodSchema, + cyclePrefsSchema, +} from "@/lib/validations/cycle"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -2148,9 +2160,477 @@ const coachFactDeletedResponse = z.object({ ), }); +// ── Cycle tracking (v1.15.0) ───────────────────────────────────────── +// The `/api/cycle/*` capture / calendar / history / settings surface + +// the cycle-prefs PATCH. Request bodies come from the Zod validation +// module so the spec stays single-source; response DTOs are declared +// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also +// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off. + +const predictionMethodEnumOpenapi = z + .enum(["CALENDAR", "SYMPTOTHERMAL", "TEMPERATURE_TREND", "BLENDED"]) + .meta({ id: "CyclePredictionMethod" }); + +const cyclePhaseEnumOpenapi = z + .enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"]) + .meta({ id: "CyclePhase" }); + +flowLevelEnum.meta({ id: "FlowLevel", description: "Menstrual-flow intensity." }); +ovulationTestEnum.meta({ + id: "OvulationTest", + description: "Ovulation predictor-kit (OPK) reading.", +}); +cervicalMucusEnum.meta({ id: "CervicalMucus", description: "Cervical-mucus quality." }); +homeTestResultEnum.meta({ + id: "HomeTestResult", + description: "At-home test result (pregnancy / progesterone).", +}); +cycleTrackingGoalEnum.meta({ + id: "CycleTrackingGoal", + description: "Drives cycle copy + fertile-window gating.", +}); + +cycleDayLogInputSchema.meta({ + id: "CycleDayLogInput", + description: + "One day's cycle capture. `note` is encrypted at rest; every other field is queryable plaintext. UPSERT key: `(userId, source, externalId)` when externalId present, else `(userId, date)`. Shared by the single POST, the bulk drain, and the period shortcut.", +}); + +cycleBulkSchema.meta({ + id: "CycleDayLogBulkRequest", + description: + "Outbox / HealthKit drain. Up to 500 entries per call; wrapped in `withIdempotency`; rate-limited 60/min. Each entry upserts per the day-log key.", +}); + +cyclePeriodSchema.meta({ + id: "CyclePeriodRequest", + description: + "One-tap period boundary. `start` opens a new cycle (closing the prior), `end` stamps the current cycle's periodEndDate; both write a boundary day-log.", +}); + +cyclePrefsSchema.meta({ + id: "CyclePrefsRequest", + description: + "Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.", +}); + +cycleDayLogPatchSchema.meta({ + id: "CycleDayLogPatchRequest", + description: + "Partial day-log edit. Every field optional; `note` re-encrypts (explicit null clears it). `date` / `source` / `externalId` are immutable on update.", +}); + +const cycleSymptomDto = z.object({ + key: z.string(), + severity: z.number().int().min(1).max(4).nullable(), +}); + +const cycleDayLogDto = z + .object({ + id: z.string(), + date: z.string(), + cycleId: z.string().nullable(), + flow: flowLevelEnum.nullable(), + intermenstrualBleeding: z.boolean(), + basalBodyTempC: z.number().nullable(), + ovulationTest: ovulationTestEnum.nullable(), + cervicalMucus: cervicalMucusEnum.nullable(), + sexualActivity: z.boolean(), + protectedSex: z.boolean().nullable(), + pregnancyTest: homeTestResultEnum.nullable(), + progesteroneTest: homeTestResultEnum.nullable(), + contraceptive: z.string().nullable(), + symptoms: z.array(cycleSymptomDto), + note: z.string().nullable(), + source: z.string(), + externalId: z.string().nullable(), + syncVersion: z.number().int(), + updatedAt: z.iso.datetime({ offset: true }), + deletedAt: z.iso.datetime({ offset: true }).nullable(), + }) + .meta({ + id: "CycleDayLogDTO", + description: + "The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.", + }); + +const menstrualCycleDto = z + .object({ + id: z.string(), + startDate: z.string(), + endDate: z.string().nullable(), + periodEndDate: z.string().nullable(), + lengthDays: z.number().int().nullable(), + ovulationDate: z.string().nullable(), + ovulationConfirmed: z.boolean(), + isPredicted: z.boolean(), + syncVersion: z.number().int(), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MenstrualCycleDTO", + description: "One menstrual cycle (observed or forward-predicted).", + }); + +const cyclePredictionDto = z + .object({ + method: predictionMethodEnumOpenapi, + nextPeriodStart: z.string(), + nextPeriodStartLow: z.string(), + nextPeriodStartHigh: z.string(), + fertileWindowStart: z.string().nullable(), + fertileWindowEnd: z.string().nullable(), + predictedOvulation: z.string().nullable(), + confidence: z.number(), + cyclesObserved: z.number().int(), + stillLearning: z.boolean(), + disclaimer: z.string(), + }) + .meta({ + id: "CyclePredictionDTO", + description: + "The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is TRYING_TO_CONCEIVE.", + }); + +const cycleCalendarDayDto = z.object({ + date: z.string(), + phase: cyclePhaseEnumOpenapi.nullable(), + isPredictedPeriod: z.boolean(), + isFertileWindow: z.boolean(), + isPredictedOvulation: z.boolean(), + isPeriodLogged: z.boolean(), + flow: flowLevelEnum.nullable(), + hasSymptoms: z.boolean(), + confidence: z.number(), +}); + +const cycleProfileDto = z + .object({ + goal: cycleTrackingGoalEnum, + cycleTrackingEnabled: z.boolean(), + rawChartMode: z.boolean(), + predictionEnabled: z.boolean(), + discreetNotifications: z.boolean(), + sensitiveCategoryEncryption: z.boolean(), + typicalCycleLength: z.number().int().nullable(), + typicalPeriodLength: z.number().int().nullable(), + lutealPhaseLength: z.number().int().nullable(), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CycleProfileDTO", + description: "The full per-user cycle settings row.", + }); + +const cycleCalendarResponse = z.object({ + profile: z.object({ + goal: cycleTrackingGoalEnum, + rawChartMode: z.boolean(), + predictionEnabled: z.boolean(), + cyclesObserved: z.number().int(), + }), + prediction: cyclePredictionDto.nullable(), + days: z.array(cycleCalendarDayDto), + meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }), +}); + +const cycleHistoryResponse = z.object({ + cycles: z.array(menstrualCycleDto), + stats: z.object({ + avgLengthDays: z.number().int().nullable(), + lengthVariabilityDays: z.number().nullable(), + avgPeriodLengthDays: z.number().int().nullable(), + regularity: z.enum(["REGULAR", "IRREGULAR", "LEARNING"]), + }), +}); + +const cyclePeriodResponse = z.object({ + cycle: menstrualCycleDto.nullable(), + dayLog: cycleDayLogDto.nullable(), +}); + +const cycleBulkEntryResult = z.object({ + index: z.number().int(), + status: z.enum(["inserted", "duplicate", "updated", "skipped"]), + id: z.string().optional(), + externalId: z.string().optional(), + reason: z.string().optional(), +}); + +const cycleBulkResponse = z.object({ + processed: z.number().int(), + inserted: z.number().int(), + updated: z.number().int(), + duplicates: z.number().int(), + skipped: z.number().int(), + entries: z.array(cycleBulkEntryResult), +}); + +// A reusable 403 the cycle routes carry (the feature gate). +const cycleDisabledResponse = { + "403": { + description: + "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + // ── Path table ─────────────────────────────────────────────────────── export const openApiPaths: NonNullable = { + "/api/cycle/day-logs": { + post: { + tags: ["Cycle"], + summary: "Capture a single cycle day-log (v1.15.0)", + description: + "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.", + requestBody: { + required: true, + content: { "application/json": { schema: cycleDayLogInputSchema } }, + }, + responses: { + "200": { + description: "Existing day-log updated.", + content: { + "application/json": { + schema: dataEnvelope(cycleDayLogDto, "CycleDayLogEnvelope"), + }, + }, + }, + "201": { + description: "New day-log created.", + content: { + "application/json": { + schema: dataEnvelope(cycleDayLogDto, "CycleDayLogCreatedEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/day-logs/{id}": { + patch: { + tags: ["Cycle"], + summary: "Edit a single cycle day-log (v1.15.0)", + description: + "Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: cycleDayLogPatchSchema } }, + }, + responses: { + "200": { + description: "Day-log updated.", + content: { + "application/json": { + schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"), + }, + }, + }, + "404": { + description: "Day-log not found / not owned.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + delete: { + tags: ["Cycle"], + summary: "Soft-delete a cycle day-log (v1.15.0)", + description: + "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "204": { description: "Soft-deleted (no body)." }, + "404": { + description: "Day-log not found / not owned.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/day-logs/bulk": { + post: { + tags: ["Cycle"], + summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)", + description: + "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.", + requestBody: { + required: true, + content: { "application/json": { schema: cycleBulkSchema } }, + }, + responses: { + "200": { + description: "Batch processed (per-entry results).", + content: { + "application/json": { + schema: dataEnvelope(cycleBulkResponse, "CycleBulkEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/period": { + post: { + tags: ["Cycle"], + summary: "Period-boundary shortcut (v1.15.0)", + description: + "One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.", + requestBody: { + required: true, + content: { "application/json": { schema: cyclePeriodSchema } }, + }, + responses: { + "200": { + description: "Cycle + boundary day-log.", + content: { + "application/json": { + schema: dataEnvelope(cyclePeriodResponse, "CyclePeriodEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/calendar": { + get: { + tags: ["Cycle"], + summary: "Predicted cycle calendar (v1.15.0)", + description: + "Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated.", + requestParams: { + query: z.object({ + from: z.string().optional(), + to: z.string().optional(), + }), + }, + responses: { + "200": { + description: "Calendar grid + forecast.", + content: { + "application/json": { + schema: dataEnvelope(cycleCalendarResponse, "CycleCalendarEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/cycles": { + get: { + tags: ["Cycle"], + summary: "Cycle history + stats (v1.15.0)", + description: + "Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, regularity }`. `limit` default 24. Gated.", + requestParams: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(60).optional(), + }), + }, + responses: { + "200": { + description: "Cycle history.", + content: { + "application/json": { + schema: dataEnvelope(cycleHistoryResponse, "CycleHistoryEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/cycles/{id}": { + delete: { + tags: ["Cycle"], + summary: "Soft-delete a menstrual cycle (v1.15.0)", + description: + "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "204": { description: "Soft-deleted (no body)." }, + "404": { + description: "Cycle not found / not owned.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/profile": { + get: { + tags: ["Cycle"], + summary: "Read the full cycle profile (v1.15.0)", + description: "Returns the resolved CycleProfileDTO. Gated.", + responses: { + "200": { + description: "Cycle profile.", + content: { + "application/json": { + schema: dataEnvelope(cycleProfileDto, "CycleProfileEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/auth/me/cycle-prefs": { + get: { + tags: ["Cycle"], + summary: "Read cycle preferences (v1.15.0)", + description: + "Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate.", + responses: { + "200": { + description: "Cycle preferences.", + content: { + "application/json": { + schema: dataEnvelope(cycleProfileDto, "CyclePrefsGetEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Cycle"], + summary: "Update cycle preferences (v1.15.0)", + description: + "Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.", + requestBody: { + required: true, + content: { "application/json": { schema: cyclePrefsSchema } }, + }, + responses: { + "200": { + description: "Merged cycle preferences.", + content: { + "application/json": { + schema: dataEnvelope(cycleProfileDto, "CyclePrefsPatchEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/meta/capabilities": { get: { tags: ["Meta"], diff --git a/src/lib/sync/__tests__/cursor.test.ts b/src/lib/sync/__tests__/cursor.test.ts index e4020537e..d8faff9bc 100644 --- a/src/lib/sync/__tests__/cursor.test.ts +++ b/src/lib/sync/__tests__/cursor.test.ts @@ -42,21 +42,36 @@ describe("sync cursor codec", () => { expect(decodeCursor("")).toBeNull(); // valid base64url but not the expected JSON shape expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull(); - // wrong version + // wrong version — v1.15.0 bumped CURSOR_VERSION to 2, so a v1 token + // (the pre-cycle format) now decodes to null → clean re-init. expect( - decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")), + decodeCursor(Buffer.from('{"v":1,"d":{}}').toString("base64url")), ).toBeNull(); // missing the `d` envelope expect( - decodeCursor(Buffer.from('{"v":1}').toString("base64url")), + decodeCursor(Buffer.from('{"v":2}').toString("base64url")), ).toBeNull(); }); + it("decodes a current-version empty envelope to a fresh-scan map", () => { + expect( + decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")), + ).toEqual({}); + }); + + it("round-trips the v1.15.0 cycle domains", () => { + const cursor: SyncCursor = { + cycleDays: { updatedAtMs: 1_717_001_000_000, id: "clx0cday" }, + cycles: { updatedAtMs: 1_717_001_500_000, id: "clx0cyc" }, + }; + expect(decodeCursor(encodeCursor(cursor))).toEqual(cursor); + }); + it("drops a domain whose watermark is malformed but keeps valid ones", () => { // measurements has a string `u` (invalid); mood is well-formed. const token = Buffer.from( JSON.stringify({ - v: 1, + v: 2, d: { measurements: { u: "x", i: "z" }, mood: { u: 5, i: "clx0mood" }, diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index bcf1925a6..b74d40152 100644 --- a/src/lib/sync/cursor.ts +++ b/src/lib/sync/cursor.ts @@ -20,12 +20,19 @@ */ /** The sync domains the feed serves. */ -export type SyncDomain = "measurements" | "mood" | "intakes"; +export type SyncDomain = + | "measurements" + | "mood" + | "intakes" + | "cycleDays" + | "cycles"; export const SYNC_DOMAINS: readonly SyncDomain[] = [ "measurements", "mood", "intakes", + "cycleDays", + "cycles", ]; /** A single domain's keyset position. */ @@ -42,7 +49,11 @@ export interface DomainWatermark { */ export type SyncCursor = Partial>; -const CURSOR_VERSION = 1; +// v1.15.0 — bumped to 2 when the `cycleDays` + `cycles` domains were +// added. An in-flight v1 token decodes to null (wrong version) → clean +// re-init, which is acceptable because the cursor is opaque and the +// client re-syncs from scratch. +const CURSOR_VERSION = 2; /** Encode a per-domain keyset map into an opaque token. */ export function encodeCursor(cursor: SyncCursor): string { diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts new file mode 100644 index 000000000..e4f490f1b --- /dev/null +++ b/src/lib/validations/cycle.ts @@ -0,0 +1,185 @@ +/** + * Cycle-tracking request/response validation (v1.15.0). + * + * Source of truth for the `/api/cycle/*` wire contract (ios-contract §2). + * The Zod schemas here are reused by the OpenAPI registry so the spec + * stays single-source. `userId` is NEVER a body field — it is narrowed + * from the session/Bearer in every route. + * + * Dates are `YYYY-MM-DD` strings (the MoodEntry tz-anchored convention); + * instants are ISO-8601 with offset. + */ +import { z } from "zod/v4"; + +/* ── enums (mirror the Prisma enums) ─────────────────────────────── */ + +export const flowLevelEnum = z.enum([ + "NONE", + "SPOTTING", + "LIGHT", + "MEDIUM", + "HEAVY", +]); + +export const ovulationTestEnum = z.enum([ + "NEGATIVE", + "POSITIVE_LH_SURGE", + "ESTROGEN_SURGE", + "INDETERMINATE", +]); + +export const cervicalMucusEnum = z.enum([ + "DRY", + "STICKY", + "CREAMY", + "WATERY", + "EGG_WHITE", +]); + +export const homeTestResultEnum = z.enum([ + "NEGATIVE", + "POSITIVE", + "INDETERMINATE", +]); + +export const contraceptiveKindEnum = z.enum([ + "NONE", + "UNSPECIFIED", + "IMPLANT", + "INJECTION", + "IUD", + "INTRAVAGINAL_RING", + "ORAL", + "PATCH", + "EMERGENCY", +]); + +export const cycleTrackingGoalEnum = z.enum([ + "GENERAL_HEALTH", + "AVOID_PREGNANCY", + "TRYING_TO_CONCEIVE", + "PERIMENOPAUSE", + "OFF", +]); + +/** + * Day-log source. MANUAL (web / single POST) or APPLE_HEALTH (the + * HealthKit batch ingest). Mirrors `MeasurementSource`; the wire spelling + * is APPLE_HEALTH, not "HEALTHKIT". + */ +export const cycleSourceEnum = z.enum(["MANUAL", "APPLE_HEALTH"]); + +const dateString = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date"); + +/* ── day-log capture (single + bulk shared input) ────────────────── */ + +/** + * The canonical day-log input. Shared by the single POST, the PATCH, and + * the bulk drain. `note` is encrypted at rest (`notesEncrypted`); every + * other field is queryable plaintext (feeds rollups / correlation). + */ +export const cycleDayLogInputSchema = z.object({ + date: dateString, + flow: flowLevelEnum.optional(), + intermenstrualBleeding: z.boolean().optional(), + basalBodyTempC: z.number().min(30).max(45).optional(), + ovulationTest: ovulationTestEnum.optional(), + cervicalMucus: cervicalMucusEnum.optional(), + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: homeTestResultEnum.optional(), + progesteroneTest: homeTestResultEnum.optional(), + contraceptive: contraceptiveKindEnum.optional(), + symptoms: z + .array( + z.object({ + key: z.string().min(1).max(80), + severity: z.number().int().min(1).max(4).optional(), + }), + ) + .max(40) + .optional(), + note: z.string().max(500).optional(), + loggedAt: z.iso.datetime({ offset: true }), + source: cycleSourceEnum.optional().default("MANUAL"), + externalId: z.string().min(1).max(120).optional(), +}); + +export type CycleDayLogInput = z.infer; + +/** PATCH body — every field optional; `date`/`source`/`externalId` are immutable on update. */ +export const cycleDayLogPatchSchema = z.object({ + flow: flowLevelEnum.nullable().optional(), + intermenstrualBleeding: z.boolean().optional(), + basalBodyTempC: z.number().min(30).max(45).nullable().optional(), + ovulationTest: ovulationTestEnum.nullable().optional(), + cervicalMucus: cervicalMucusEnum.nullable().optional(), + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: homeTestResultEnum.nullable().optional(), + progesteroneTest: homeTestResultEnum.nullable().optional(), + contraceptive: contraceptiveKindEnum.nullable().optional(), + symptoms: z + .array( + z.object({ + key: z.string().min(1).max(80), + severity: z.number().int().min(1).max(4).optional(), + }), + ) + .max(40) + .optional(), + note: z.string().max(500).nullable().optional(), + loggedAt: z.iso.datetime({ offset: true }).optional(), +}); + +export const MAX_CYCLE_BULK_ENTRIES = 500; + +export const cycleBulkSchema = z.object({ + entries: z.array(cycleDayLogInputSchema).min(1).max(MAX_CYCLE_BULK_ENTRIES), +}); + +/* ── period-boundary shortcut ────────────────────────────────────── */ + +export const cyclePeriodSchema = z.object({ + action: z.enum(["start", "end"]), + date: dateString, + externalId: z.string().min(1).max(120).optional(), + loggedAt: z.iso.datetime({ offset: true }), +}); + +/* ── calendar + history queries ──────────────────────────────────── */ + +export const cycleCalendarQuerySchema = z.object({ + from: dateString.optional(), + to: dateString.optional(), +}); + +export const cycleHistoryQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(60).optional(), +}); + +/* ── cycle preferences (PATCH /api/auth/me/cycle-prefs) ──────────── */ + +/** + * Partial deep-merge body for the cycle-prefs PATCH. Mirrors the + * notification-prefs precedent: an omitted field is left untouched. + */ +export const cyclePrefsSchema = z + .object({ + enabled: z.boolean().optional(), + goal: cycleTrackingGoalEnum.optional(), + rawChartMode: z.boolean().optional(), + predictionEnabled: z.boolean().optional(), + discreetNotifications: z.boolean().optional(), + sensitiveCategoryEncryption: z.boolean().optional(), + typicalCycleLength: z.number().int().min(15).max(60).nullable().optional(), + typicalPeriodLength: z.number().int().min(1).max(15).nullable().optional(), + lutealPhaseLength: z.number().int().min(8).max(20).nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "At least one preference field is required", + }); + +export type CyclePrefsInput = z.infer; diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts new file mode 100644 index 000000000..d7855fe4d --- /dev/null +++ b/tests/integration/cycle-routes.test.ts @@ -0,0 +1,362 @@ +/** + * Integration suite for the v1.15.0 cycle-tracking routes against a real + * Postgres in a testcontainer. Asserts the iOS-facing contract end-to-end: + * + * - the feature gate (`cycle.disabled` 403) keys on gender + the + * `cycleTrackingEnabled` toggle, even with a valid session + * - single day-log capture upserts (201 insert → 200 update), encrypts + * the note, and reads it back decrypted + * - the bulk drain returns per-entry inserted / updated / duplicate + * - the period shortcut opens a cycle + boundary day-log + * - the calendar read runs the engine + goal-gates the fertile window + * - the cycle-prefs PATCH flips the gate for a non-FEMALE account + * - `/api/sync/changes` carries the cycleDays + cycles domains incl. + * tombstones after a soft-delete + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-cycle-routes-integration-32-bytes-min-1234567890"; +// Crypto-at-rest needs a key for the note encrypt/decrypt round-trip. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const FEMALE_USER_ID = "user-cycle-female"; +const MALE_USER_ID = "user-cycle-male"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function loginAs(userId: string): Promise { + cookieJar.clear(); + headerJar.clear(); + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: FEMALE_USER_ID, + username: "cycle-female", + email: "cycle-female@example.test", + gender: "FEMALE", + }, + }); + await prisma.user.create({ + data: { + id: MALE_USER_ID, + username: "cycle-male", + email: "cycle-male@example.test", + gender: "MALE", + }, + }); +}); + +function jsonRequest( + path: string, + method: string, + body?: unknown, + opts: { idempotencyKey?: string } = {}, +): NextRequest { + const headers: Record = { + "content-type": "application/json", + }; + if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey; + return new NextRequest(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +describe("cycle routes — feature gate", () => { + it("403s a MALE account on every cycle route", async () => { + await loginAs(MALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + const res = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-01", + flow: "MEDIUM", + loggedAt: "2026-03-01T08:00:00.000Z", + }), + ); + expect(res.status).toBe(403); + const json = await res.json(); + expect(json.meta?.errorCode).toBe("cycle.disabled"); + }); + + it("admits a FEMALE account by gender derivation", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + const res = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-01", + flow: "MEDIUM", + loggedAt: "2026-03-01T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + }); +}); + +describe("cycle day-logs — single capture", () => { + it("upserts (201 insert → 200 update) and round-trips the encrypted note", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + + const insert = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-02", + flow: "LIGHT", + note: "tender, mild cramps", + loggedAt: "2026-03-02T08:00:00.000Z", + }), + ); + expect(insert.status).toBe(201); + const insertJson = await insert.json(); + expect(insertJson.data.flow).toBe("LIGHT"); + expect(insertJson.data.note).toBe("tender, mild cramps"); + + // The note column is ciphertext, not plaintext. + const row = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: FEMALE_USER_ID, date: "2026-03-02" }, + }); + expect(row.notesEncrypted).not.toBeNull(); + expect(row.notesEncrypted).not.toContain("cramps"); + + const update = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-02", + flow: "HEAVY", + loggedAt: "2026-03-02T09:00:00.000Z", + }), + ); + expect(update.status).toBe(200); + const updateJson = await update.json(); + expect(updateJson.data.flow).toBe("HEAVY"); + expect(updateJson.data.id).toBe(insertJson.data.id); + }); +}); + +describe("cycle day-logs — bulk drain", () => { + it("returns per-entry inserted / updated / duplicate", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/bulk/route"); + + const first = await POST( + jsonRequest("/api/cycle/day-logs/bulk", "POST", { + entries: [ + { + date: "2026-03-05", + flow: "MEDIUM", + source: "APPLE_HEALTH", + externalId: "hk-aaa", + loggedAt: "2026-03-05T08:00:00.000Z", + }, + ], + }), + ); + expect(first.status).toBe(200); + const firstJson = await first.json(); + expect(firstJson.data.inserted).toBe(1); + expect(firstJson.data.entries[0].status).toBe("inserted"); + + // Re-post unchanged → duplicate; re-post changed → updated. + const second = await POST( + jsonRequest("/api/cycle/day-logs/bulk", "POST", { + entries: [ + { + date: "2026-03-05", + flow: "MEDIUM", + source: "APPLE_HEALTH", + externalId: "hk-aaa", + loggedAt: "2026-03-05T08:00:00.000Z", + }, + { + date: "2026-03-05", + flow: "HEAVY", + source: "APPLE_HEALTH", + externalId: "hk-aaa", + loggedAt: "2026-03-05T08:00:00.000Z", + }, + ], + }), + ); + const secondJson = await second.json(); + expect(secondJson.data.entries[0].status).toBe("duplicate"); + expect(secondJson.data.entries[1].status).toBe("updated"); + }); +}); + +describe("cycle period shortcut", () => { + it("opens a cycle + writes the boundary day-log", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/period/route"); + const res = await POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date: "2026-03-10", + loggedAt: "2026-03-10T08:00:00.000Z", + }), + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.cycle.startDate).toBe("2026-03-10"); + expect(json.data.dayLog.flow).toBe("MEDIUM"); + }); +}); + +describe("cycle calendar", () => { + it("runs the engine and goal-gates the fertile window", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + // Three ~28d cycles → a real prediction. + for (const [start, end, len] of [ + ["2026-01-01", "2026-01-28", 28], + ["2026-01-29", "2026-02-25", 28], + ] as const) { + await prisma.menstrualCycle.create({ + data: { + userId: FEMALE_USER_ID, + startDate: start, + endDate: end, + lengthDays: len, + periodEndDate: null, + }, + }); + } + await prisma.menstrualCycle.create({ + data: { userId: FEMALE_USER_ID, startDate: "2026-02-26" }, + }); + + const { GET } = await import("@/app/api/cycle/calendar/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/cycle/calendar?from=2026-02-20&to=2026-04-15", + ), + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.prediction).not.toBeNull(); + expect(json.data.days.length).toBeGreaterThan(0); + // GENERAL_HEALTH default → fertile window suppressed. + expect(json.data.prediction.fertileWindowStart).toBeNull(); + + // The cache row is persisted fire-and-forget (the route never blocks + // the read on it), so poll briefly for it to land. + let cache = null; + for (let i = 0; i < 20 && cache === null; i++) { + cache = await prisma.cyclePrediction.findUnique({ + where: { userId: FEMALE_USER_ID }, + }); + if (cache === null) await new Promise((r) => setTimeout(r, 25)); + } + expect(cache).not.toBeNull(); + }); +}); + +describe("cycle-prefs gate flip", () => { + it("enables a MALE account via the prefs PATCH, then admits cycle routes", async () => { + await loginAs(MALE_USER_ID); + const { PATCH } = await import("@/app/api/auth/me/cycle-prefs/route"); + const patch = await PATCH( + jsonRequest("/api/auth/me/cycle-prefs", "PATCH", { enabled: true }), + ); + expect(patch.status).toBe(200); + const patchJson = await patch.json(); + expect(patchJson.data.cycleTrackingEnabled).toBe(true); + + // Re-login (fresh session) and confirm the gate now opens. + await loginAs(MALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + const res = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-15", + flow: "LIGHT", + loggedAt: "2026-03-15T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + }); +}); + +describe("sync/changes — cycle domains", () => { + it("carries cycleDays + cycles upserts and tombstones after a soft-delete", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + + const cycle = await prisma.menstrualCycle.create({ + data: { userId: FEMALE_USER_ID, startDate: "2026-03-20" }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: FEMALE_USER_ID, + date: "2026-03-20", + flow: "MEDIUM", + source: "APPLE_HEALTH", + externalId: "hk-sync-1", + }, + }); + + const { GET } = await import("@/app/api/sync/changes/route"); + const first = await GET( + new NextRequest("http://localhost/api/sync/changes"), + ); + const firstJson = await first.json(); + expect(firstJson.data.changes.cycleDays.upserts.length).toBe(1); + expect(firstJson.data.changes.cycles.upserts.length).toBe(1); + expect(firstJson.data.changes.cycleDays.upserts[0].externalId).toBe( + "hk-sync-1", + ); + + // Soft-delete both → next pull surfaces tombstones. + await prisma.cycleDayLog.update({ + where: { id: dayLog.id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + await prisma.menstrualCycle.update({ + where: { id: cycle.id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + + const second = await GET( + new NextRequest("http://localhost/api/sync/changes"), + ); + const secondJson = await second.json(); + const dayTombs = secondJson.data.changes.cycleDays.tombstones; + const cycleTombs = secondJson.data.changes.cycles.tombstones; + expect(dayTombs.length).toBe(1); + expect(dayTombs[0].externalId).toBe("hk-sync-1"); + expect(cycleTombs.length).toBe(1); + expect(cycleTombs[0].id).toBe(cycle.id); + }); +}); From 77a652a47cee7a64dc85997308d285a19056bf14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 09:58:13 +0200 Subject: [PATCH 04/19] feat(cycle): cycle-phase correlation insight + Coach phase dimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the menstrual-cycle phase as a channel in the existing FDR-guarded correlation machinery, the same way rated mood factors were folded in — no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same day floors and q = 0.10 gate. Two complementary mechanisms, both confined to a gender-gated surface: - a categorical luteal-vs-follicular contrast per outcome metric (resting heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature), surfacing one headline finding (resting-heart-rate-by-phase, falling back to HRV); and - the continuous phase ordinal folded into the lagged-Pearson matrix. Phase never reaches the general correlations route — the temperature channels held out of the general matrix are un-blocked only here, behind the cycle gate, so a reproductive inference can never leak onto a non-gated surface. Every row shows n, effect size, p and q; nothing is fabricated on thin data and nothing is framed as causal. Surface it through a new gated GET /api/cycle/insights and a drop-in card that mirrors the mood-factor crosstab. Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle, the next predicted event (period range + confidence + method, fertile window goal-gated), and the headline phase finding — present only for a cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground rule keeps the Coach descriptive: never contraception-grade, never a "safe day" claim. OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added; copy localised across all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 195 +++++++++++ messages/de.json | 34 ++ messages/en.json | 34 ++ messages/es.json | 34 ++ messages/fr.json | 34 ++ messages/it.json | 34 ++ messages/pl.json | 34 ++ src/app/api/cycle/insights/route.ts | 192 +++++++++++ src/components/cycle/cycle-phase-crosstab.tsx | 202 +++++++++++ .../ai/coach/__tests__/cycle-snapshot.test.ts | 155 +++++++++ src/lib/ai/coach/cycle-snapshot.ts | 321 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 37 ++ src/lib/ai/coach/system-prompt.ts | 30 ++ .../cycle/__tests__/phase-crosstab.test.ts | 274 +++++++++++++++ src/lib/cycle/engine-adapter.ts | 63 +++- src/lib/cycle/phase-crosstab.ts | 302 ++++++++++++++++ src/lib/insights/mood-aggregates.ts | 4 +- src/lib/openapi/routes.ts | 82 +++++ 18 files changed, 2057 insertions(+), 4 deletions(-) create mode 100644 src/app/api/cycle/insights/route.ts create mode 100644 src/components/cycle/cycle-phase-crosstab.tsx create mode 100644 src/lib/ai/coach/__tests__/cycle-snapshot.test.ts create mode 100644 src/lib/ai/coach/cycle-snapshot.ts create mode 100644 src/lib/cycle/__tests__/phase-crosstab.test.ts create mode 100644 src/lib/cycle/phase-crosstab.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ae909ef1..53bb940db 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -578,6 +578,27 @@ paths: "403": *a2 "422": *a3 "429": *a4 + /api/cycle/insights: + get: + tags: + - Cycle + summary: Cycle-phase correlation insights (v1.15.0) + description: "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / + temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same + Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 + surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. + Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off." + responses: + "200": + description: Phase-correlation rows + headline. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleInsightsEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 /api/cycle/cycles/{id}: delete: tags: @@ -5289,6 +5310,180 @@ components: - data - error additionalProperties: false + CycleInsightsEnvelope: + type: object + properties: + data: + type: object + properties: + rows: + type: array + items: + $ref: "#/components/schemas/CyclePhaseCrosstabRow" + headline: + anyOf: + - $ref: "#/components/schemas/CyclePhaseCrosstabRow" + - type: "null" + lagged: + type: object + properties: + discovered: + type: array + items: + $ref: "#/components/schemas/CyclePhaseLaggedPair" + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + fdrQ: + type: number + minPairs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - discovered + - pairsTested + - fdrQ + - minPairs + additionalProperties: false + contrast: + type: object + properties: + high: + type: string + const: LUTEAL + low: + type: string + const: FOLLICULAR + required: + - high + - low + additionalProperties: false + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - rows + - headline + - lagged + - contrast + - windowDays + - cyclesObserved + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePhaseCrosstabRow: + type: object + properties: + metricKey: + type: string + enum: + - restingHeartRate + - heartRateVariability + - sleepDuration + - steps + - weight + - basalBodyTemp + - wristTemperature + - skinTemperature + display: + type: string + enum: + - hours + - steps + - bpm + - ms + - kg + - celsius + lutealDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + follicularDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lutealAvg: + type: number + follicularAvg: + type: number + delta: + type: number + pValue: + type: number + qValue: + type: number + confidence: + type: string + enum: + - low + - medium + - high + required: + - metricKey + - display + - lutealDays + - follicularDays + - lutealAvg + - follicularAvg + - delta + - pValue + - qValue + - confidence + additionalProperties: false + description: One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. + Observational, never causal. + CyclePhaseLaggedPair: + type: object + properties: + behaviour: + type: string + outcome: + type: string + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + r: + type: number + pValue: + type: number + qValue: + type: number + interpretation: + type: string + lagDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - behaviour + - outcome + - n + - r + - pValue + - qValue + - interpretation + - lagDays + additionalProperties: false + description: One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism + B). Descriptive, never causal. CycleProfileEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index d0a4d488b..eb288c858 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4893,6 +4893,40 @@ }, "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" + } } } } diff --git a/messages/en.json b/messages/en.json index c46256bba..585a03828 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4893,6 +4893,40 @@ }, "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" + } } } } diff --git a/messages/es.json b/messages/es.json index 3df96040b..c08a026c9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4893,6 +4893,40 @@ }, "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" + } } } } diff --git a/messages/fr.json b/messages/fr.json index 85653fb21..1f56a7aba 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4893,6 +4893,40 @@ }, "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" + } } } } diff --git a/messages/it.json b/messages/it.json index ebdcd177d..5114e3a61 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4893,6 +4893,40 @@ }, "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" + } } } } diff --git a/messages/pl.json b/messages/pl.json index 2b1eea8b6..c10fdef07 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4893,6 +4893,40 @@ }, "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ść" + } } } } diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts new file mode 100644 index 000000000..e6bd24db4 --- /dev/null +++ b/src/app/api/cycle/insights/route.ts @@ -0,0 +1,192 @@ +/** + * `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, +} 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). */ +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] = + await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + }, + }), + // Passive wrist temperature feeds the temperature-trend ovulation layer + // (used only to sharpen the prediction's next-start the phase-day map + // extends to). Not a crosstab input. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), + }, + }, + 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 }, + }), + ]); + + // 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, + })); + + 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, + }); + + 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, + }, + }); + + return apiSuccess({ + rows, + headline, + lagged: { + discovered: lagged.discovered, + pairsTested: lagged.pairsTested, + fdrQ: lagged.fdrQ, + minPairs: lagged.minPairs, + }, + contrast: { high: "LUTEAL", low: "FOLLICULAR" }, + windowDays: WINDOW_DAYS, + cyclesObserved: cycles.length, + }); +}); diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx new file mode 100644 index 000000000..f2b52ee55 --- /dev/null +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -0,0 +1,202 @@ +"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 = { + 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 = { + 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 = { + low: "cycle.insights.crosstab.confidenceLow", + medium: "cycle.insights.crosstab.confidenceMedium", + high: "cycle.insights.crosstab.confidenceHigh", +}; + +const CONFIDENCE_CLASS: Record = { + 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 = { + 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 ( +

+ {t("cycle.insights.headline.empty")} +

+ ); + } + 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 ( +

+ {t(key, params)} +

+ ); +} + +export function CyclePhaseCrosstab({ + rows, +}: { + rows: CyclePhaseCrosstabRow[]; +}) { + const { t } = useTranslations(); + if (rows.length === 0) return null; + + return ( +
+

+ {t("cycle.insights.crosstab.description")} +

+
    + {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 ( +
  • +
    + + {t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })} + + + {deltaText} + + + {t(CONFIDENCE_KEY[row.confidence])} + +
    +

    + {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, + })} +

    +
  • + ); + })} +
+

+ {t("cycle.insights.crosstab.footer")} +

+
+ ); +} diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts new file mode 100644 index 000000000..586004099 --- /dev/null +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -0,0 +1,155 @@ +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 }; + menstrualCycle: { findMany: ReturnType }; + cycleDayLog: { findMany: ReturnType }; + measurement: { findMany: ReturnType }; + user: { findUnique: ReturnType }; +}; + +const NOW = new Date("2026-03-22T12:00:00.000Z"); + +function profile(overrides: Record = {}) { + 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> = []; + 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("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/); + }); +}); diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts new file mode 100644 index 000000000..ad090cc06 --- /dev/null +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -0,0 +1,321 @@ +/** + * 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 = { + 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 = { + 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(), +): Promise { + // 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, + }; + + const tz = 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, + }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index ea68dc0e0..78e42ca89 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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) + : 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"); } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 9ae0d0542..c0f0cc0d5 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -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 diff --git a/src/lib/cycle/__tests__/phase-crosstab.test.ts b/src/lib/cycle/__tests__/phase-crosstab.test.ts new file mode 100644 index 000000000..98142de80 --- /dev/null +++ b/src/lib/cycle/__tests__/phase-crosstab.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { + computePhaseMetricCrosstab, + buildPhaseDiscoverySeries, + discoverPhaseCorrelations, + selectHeadlinePhaseRow, + PHASE_ORDINAL, + PHASE_CROSSTAB_METRIC_TYPES, + CYCLE_PHASE_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; + measurements: CrossMetricMeasurement[]; +} { + const phaseByDay = new Map(); + 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"); + }); +}); + +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([ + ["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(); + 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(); + 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(); + }); +}); diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 94b471388..1474d0fdb 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -100,7 +100,7 @@ function periodLengthOf( * 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. */ -function buildPhaseCycles( +export function buildPhaseCycles( cycles: readonly Pick< MenstrualCycle, "startDate" | "endDate" | "periodEndDate" | "ovulationDate" @@ -127,7 +127,10 @@ function buildPhaseCycles( } /** Phase of a single date across the contiguous cycle list (or null). */ -function phaseAcross(date: string, phaseCycles: readonly PhaseCycle[]): CyclePhase | 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; @@ -135,6 +138,62 @@ function phaseAcross(date: string, phaseCycles: readonly PhaseCycle[]): CyclePha 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 { + const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength); + const out = new Map(); + 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[]; diff --git a/src/lib/cycle/phase-crosstab.ts b/src/lib/cycle/phase-crosstab.ts new file mode 100644 index 000000000..7732cd790 --- /dev/null +++ b/src/lib/cycle/phase-crosstab.ts @@ -0,0 +1,302 @@ +/** + * 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 = { + 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"; + +/** + * 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; 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" }, +} as const; + +export type PhaseCrosstabMetricKey = keyof typeof PHASE_CROSSTAB_METRICS; + +/** Distinct measurement types the phase crosstab reads — single-sourced. */ +export const PHASE_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( + new Set(Object.values(PHASE_CROSSTAB_METRICS).map((m) => m.type)), +); + +/** 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; +} + +/** + * 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; + 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, +): 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; + 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] + ); +} diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index cf43fabff..ec751e441 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -897,7 +897,7 @@ function berlinDayKey(measuredAt: Date): string { * test fixtures, or a metric whose source isn't in the ladder) fall * through the picker's pass-through branch unchanged. */ -function metricDayMap( +export function metricDayMap( measurements: CrossMetricMeasurement[], type: string, userPriorityJson: unknown, @@ -944,7 +944,7 @@ function metricDayMap( } /** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */ -function shiftDayKey(day: string, lagDays: number): string { +export function shiftDayKey(day: string, lagDays: number): string { const [y, m, d] = day.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + lagDays); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 4914a9c18..ca6fb438f 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -2349,6 +2349,68 @@ const cyclePeriodResponse = z.object({ dayLog: cycleDayLogDto.nullable(), }); +const cyclePhaseCrosstabRow = z + .object({ + metricKey: z.enum([ + "restingHeartRate", + "heartRateVariability", + "sleepDuration", + "steps", + "weight", + "basalBodyTemp", + "wristTemperature", + "skinTemperature", + ]), + display: z.enum(["hours", "steps", "bpm", "ms", "kg", "celsius"]), + lutealDays: z.number().int(), + follicularDays: z.number().int(), + lutealAvg: z.number(), + follicularAvg: z.number(), + delta: z.number(), + pValue: z.number(), + qValue: z.number(), + confidence: z.enum(["low", "medium", "high"]), + }) + .meta({ + id: "CyclePhaseCrosstabRow", + description: + "One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal.", + }); + +const cyclePhaseLaggedPair = z + .object({ + behaviour: z.string(), + outcome: z.string(), + n: z.number().int(), + r: z.number(), + pValue: z.number(), + qValue: z.number(), + interpretation: z.string(), + lagDays: z.number().int(), + }) + .meta({ + id: "CyclePhaseLaggedPair", + description: + "One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.", + }); + +const cycleInsightsResponse = z.object({ + rows: z.array(cyclePhaseCrosstabRow), + headline: cyclePhaseCrosstabRow.nullable(), + lagged: z.object({ + discovered: z.array(cyclePhaseLaggedPair), + pairsTested: z.number().int(), + fdrQ: z.number(), + minPairs: z.number().int(), + }), + contrast: z.object({ + high: z.literal("LUTEAL"), + low: z.literal("FOLLICULAR"), + }), + windowDays: z.number().int(), + cyclesObserved: z.number().int(), +}); + const cycleBulkEntryResult = z.object({ index: z.number().int(), status: z.enum(["inserted", "duplicate", "updated", "skipped"]), @@ -2554,6 +2616,26 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/cycle/insights": { + get: { + tags: ["Cycle"], + summary: "Cycle-phase correlation insights (v1.15.0)", + description: + "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.", + responses: { + "200": { + description: "Phase-correlation rows + headline.", + content: { + "application/json": { + schema: dataEnvelope(cycleInsightsResponse, "CycleInsightsEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, "/api/cycle/cycles/{id}": { delete: { tags: ["Cycle"], From 6fdbb8d889dc902d40ba31b521d7d729ac6dbb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 09:58:54 +0200 Subject: [PATCH 05/19] feat(cycle): predicted-period notifications + reproductive-data privacy docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two cycle reminders through the existing notification cascade and the honest self-hosting privacy framing for reproductive-health data. Notifications: - New event types CYCLE_PERIOD_SOON (fires ~2 days before the predicted next-period start, from the cached prediction) and CYCLE_PERIOD_CONFIRM (a gentle "did your period start?" on/after the predicted start while no period is logged, within a short grace window). Both default OFF and are gated again on cycleTrackingEnabled — opt in twice for a sensitive surface. - A 15-minute pg-boss cron mirrors the mood-reminder worker: it scans users with cycle tracking enabled + predictionEnabled, computes who is in-window from the cached forecast, and dispatches one push per event per local day. Idempotency anchors on the push_attempts ledger (no new table). The queue is declared, registered in allQueues, scheduled, and wired to a boss.work handler, with a source-grep guard so it cannot ship unregistered. - Honours notificationPrefs.cycle.clientManaged (skip the server push when the device owns local reminders, with a suppressed annotation, mirroring the medication path) and CycleProfile.discreetNotifications (generic "HealthLog reminder" body so no cycle event is named on the lock screen). - Every attempt is recorded in push_attempts via the unchanged dispatcher. Privacy / disclaimer: - docs/self-hosting/reproductive-health-data.md — the "no cloud vendor to subpoena" framing with the honest residual-threat caveat (your own host, provider, backups, and devices remain part of the surface), citing the 2021 FTC settlement and 2025 verdict as public record only. - docs/security/cycle-data-threat-model.md — a compact "protects against / does not protect against" note, plus README pointers. - CyclePredictionDisclaimer component (reusing the GLP-1 estimate-disclaimer pattern) for every fertility/prediction surface: descriptive, not a contraceptive method, not medical advice, never a safe-to-have-unprotected -sex claim. 6-locale copy under cycle.disclaimer. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- docs/security/cycle-data-threat-model.md | 52 ++ docs/self-hosting/reproductive-health-data.md | 103 ++++ messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/pl.json | 11 +- .../__tests__/route.test.ts | 11 + .../cycle/cycle-prediction-disclaimer.tsx | 41 ++ .../__tests__/cycle-reminder-queue.test.ts | 50 ++ src/lib/jobs/__tests__/cycle-reminder.test.ts | 464 ++++++++++++++++++ src/lib/jobs/cycle-reminder.ts | 421 ++++++++++++++++ src/lib/jobs/reminder-worker.ts | 66 +++ src/lib/notifications/types.ts | 16 + .../__tests__/notification-prefs.test.ts | 37 ++ src/lib/validations/notification-prefs.ts | 50 +- 18 files changed, 1372 insertions(+), 8 deletions(-) create mode 100644 docs/security/cycle-data-threat-model.md create mode 100644 docs/self-hosting/reproductive-health-data.md create mode 100644 src/components/cycle/cycle-prediction-disclaimer.tsx create mode 100644 src/lib/jobs/__tests__/cycle-reminder-queue.test.ts create mode 100644 src/lib/jobs/__tests__/cycle-reminder.test.ts create mode 100644 src/lib/jobs/cycle-reminder.ts diff --git a/README.md b/README.md index 510606216..b52e88a11 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/security/cycle-data-threat-model.md b/docs/security/cycle-data-threat-model.md new file mode 100644 index 000000000..83c148430 --- /dev/null +++ b/docs/security/cycle-data-threat-model.md @@ -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. diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md new file mode 100644 index 000000000..bb30fd909 --- /dev/null +++ b/docs/self-hosting/reproductive-health-data.md @@ -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. diff --git a/messages/de.json b/messages/de.json index eb288c858..bdafdb4d7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Mittlere Sicherheit", "confidenceHigh": "Hohe Sicherheit" } - } + }, + "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." + }, + "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." } } diff --git a/messages/en.json b/messages/en.json index 585a03828..dccfc63de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Medium confidence", "confidenceHigh": "High confidence" } - } + }, + "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." + }, + "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." } } diff --git a/messages/es.json b/messages/es.json index c08a026c9..a37f55d4e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Confianza media", "confidenceHigh": "Confianza alta" } - } + }, + "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." + }, + "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." } } diff --git a/messages/fr.json b/messages/fr.json index 1f56a7aba..b0f4ea28f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Confiance moyenne", "confidenceHigh": "Confiance élevée" } - } + }, + "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é." + }, + "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." } } diff --git a/messages/it.json b/messages/it.json index 5114e3a61..ea0d1780c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Affidabilità media", "confidenceHigh": "Affidabilità alta" } - } + }, + "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." + }, + "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." } } diff --git a/messages/pl.json b/messages/pl.json index c10fdef07..830347919 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4927,6 +4927,15 @@ "confidenceMedium": "Średnia pewność", "confidenceHigh": "Wysoka pewność" } - } + }, + "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." + }, + "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." } } diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 8de3e2566..b6ba3810f 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -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 }, }, }, }); diff --git a/src/components/cycle/cycle-prediction-disclaimer.tsx b/src/components/cycle/cycle-prediction-disclaimer.tsx new file mode 100644 index 000000000..90c3e7793 --- /dev/null +++ b/src/components/cycle/cycle-prediction-disclaimer.tsx @@ -0,0 +1,41 @@ +/** + * Fixed non-medical disclaimer for every fertility / prediction surface. + * + * Mirrors the GLP-1 drug-level-estimate disclaimer pattern + * (`DrugLevelChart`): a styled, always-attached note rendered as plain + * React text children (no markdown), so it cannot be dismissed or styled + * away and carries no XSS surface. + * + * The copy is deliberate and load-bearing: predictions are descriptive + * estimates from the user's own logged data — never a contraceptive + * method, never medical advice, and never a "safe to have unprotected + * sex" signal. Drop this under any calendar, wheel, fertile-window, or + * predicted-period surface. The copy lives in `messages/.json` + * under `cycle.disclaimer` and is translated for all six locales. + */ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +export interface CyclePredictionDisclaimerProps { + /** Optional extra classes for layout (margins, width) at the call site. */ + className?: string; +} + +export function CyclePredictionDisclaimer({ + className, +}: CyclePredictionDisclaimerProps) { + const { t } = useTranslations(); + return ( +

+ {t("cycle.disclaimer")} +

+ ); +} diff --git a/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts new file mode 100644 index 000000000..6e06f9ed7 --- /dev/null +++ b/src/lib/jobs/__tests__/cycle-reminder-queue.test.ts @@ -0,0 +1,50 @@ +/** + * v1.15 — cycle-reminder queue registration guard. + * + * Same source-text-grep approach as the mood-reminder / drain / mean- + * consolidation registration guards: assert the cycle-reminder queue is + * declared, listed in `allQueues` (the v1.4.37 dead-queue lesson — an + * unregistered queue is never provisioned and the schedule silently + * no-ops), scheduled on its cron, and wired to a `boss.work` handler that + * delegates to `runCycleReminderTick`. No pg-boss / Prisma boot. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +describe("reminder-worker — cycle-reminder wiring", () => { + it("imports the cycle-reminder tick runner", () => { + expect(source).toMatch(/from\s*["']@\/lib\/jobs\/cycle-reminder["']/); + expect(source).toMatch(/\brunCycleReminderTick\b/); + }); + + it("declares the queue + cron constants", () => { + expect(source).toMatch( + /CYCLE_REMINDER_QUEUE\s*=\s*["']cycle-reminder-check["']/, + ); + expect(source).toMatch(/CYCLE_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/); + }); + + it("registers the cycle-reminder queue in the allQueues loop", () => { + const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bCYCLE_REMINDER_QUEUE\b/); + }); + + it("schedules the cycle-reminder cron", () => { + expect(source).toMatch(/\[CYCLE_REMINDER_QUEUE,\s*CYCLE_REMINDER_CRON\]/); + }); + + it("registers a boss.work handler that runs the cycle-reminder tick", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}CYCLE_REMINDER_QUEUE[\s\S]{0,200}handleCycleReminderCheck/, + ); + expect(source).toMatch( + /handleCycleReminderCheck[\s\S]{0,400}runCycleReminderTick/, + ); + }); +}); diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts new file mode 100644 index 000000000..fd32fbf0a --- /dev/null +++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts @@ -0,0 +1,464 @@ +/** + * v1.15 — cycle-reminder dispatcher unit tests. + * + * Pins the windowing + suppression contract: + * - Window gating: only fires inside the 09:00 local-time hour. + * - PERIOD_SOON fires exactly `PERIOD_SOON_LEAD_DAYS` before the + * predicted next-period start. + * - PERIOD_CONFIRM fires on/after the predicted start, inside the grace + * window, while no observed period is logged — and goes quiet once a + * period IS logged near the prediction. + * - Gate: a cycle-disabled account never receives a push. + * - predictionEnabled=false suppresses everything. + * - clientManaged suppression: skips the server push (iOS owns it). + * - discreetNotifications: swaps the body to the generic "HealthLog + * reminder" so no cycle event is named on the lock screen. + * - Idempotency: an existing `ok` push_attempt for the event today + * blocks a second push. + * - Locale: title + body resolve from the user's locale. + * - Per-user try-wrapper: one bad row never aborts the tick. + * + * The tests stub the Prisma surface manually (no testcontainer boot). + */ +import { describe, it, expect, vi } from "vitest"; + +import { + buildCycleReminderPayload, + cycleReminderLocalDate, + evaluateCycleReminder, + runCycleReminderTick, + CYCLE_REMINDER_LOCAL_HOUR, + PERIOD_SOON_LEAD_DAYS, + PERIOD_CONFIRM_GRACE_DAYS, +} from "../cycle-reminder"; +import type { NotificationPayload } from "@/lib/notifications/types"; +import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; + +type DispatchFn = (payload: NotificationPayload) => Promise; + +const OK: DispatchOutcome = { + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, +}; +const NO_CHANNEL: DispatchOutcome = { + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, +}; + +interface FakeUser { + id: string; + gender: string | null; + timezone: string; + locale: string | null; + notificationPrefs?: unknown; + cycleProfile: { + cycleTrackingEnabled: boolean | null; + predictionEnabled: boolean; + discreetNotifications: boolean; + } | null; +} + +interface FakeState { + predictions: Array<{ userId: string; nextPeriodStart: string; user: FakeUser }>; + /** Observed (non-predicted) cycle start dates per userId. */ + observedStarts: Record; + /** Existing `ok` push attempts: `${userId}:${eventType}` already sent today. */ + alreadyOk: Set; +} + +function makePrisma(state: FakeState) { + return { + cyclePrediction: { + findMany: vi.fn(async () => state.predictions), + }, + pushAttempt: { + findFirst: vi.fn( + async ({ + where, + }: { + where: { userId: string; eventType: string }; + }) => { + const key = `${where.userId}:${where.eventType}`; + return state.alreadyOk.has(key) ? { id: "pa-1" } : null; + }, + ), + }, + menstrualCycle: { + findMany: vi.fn( + async ({ where }: { where: { userId: string } }) => { + const starts = state.observedStarts[where.userId] ?? []; + return starts.map((startDate) => ({ startDate })); + }, + ), + }, + }; +} + +/** 09:00 Europe/Berlin (CEST in May → 07:00Z). */ +const AT_0900_BERLIN = new Date("2026-05-17T07:00:00Z"); + +function baseUser(over: Partial = {}): FakeUser { + return { + id: "u1", + gender: "FEMALE", + timezone: "Europe/Berlin", + locale: "en", + notificationPrefs: null, + cycleProfile: { + cycleTrackingEnabled: null, + predictionEnabled: true, + discreetNotifications: false, + }, + ...over, + }; +} + +describe("cycleReminderLocalDate", () => { + it("returns the local date inside the 09:00 hour", () => { + expect(cycleReminderLocalDate(AT_0900_BERLIN, "Europe/Berlin")).toBe( + "2026-05-17", + ); + expect(CYCLE_REMINDER_LOCAL_HOUR).toBe(9); + }); + + it("returns null at 08:59 and 10:00 local time", () => { + expect( + cycleReminderLocalDate(new Date("2026-05-17T06:59:00Z"), "Europe/Berlin"), + ).toBeNull(); + expect( + cycleReminderLocalDate(new Date("2026-05-17T08:00:00Z"), "Europe/Berlin"), + ).toBeNull(); + }); + + it("respects a non-Berlin timezone", () => { + // 09:00 America/New_York (EDT) = 13:00Z + expect( + cycleReminderLocalDate(new Date("2026-05-17T13:00:00Z"), "America/New_York"), + ).toBe("2026-05-17"); + }); +}); + +describe("evaluateCycleReminder", () => { + it("fires PERIOD_SOON exactly lead-days before the predicted start", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-18", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe("CYCLE_PERIOD_SOON"); + expect(PERIOD_SOON_LEAD_DAYS).toBe(2); + }); + + it("does not fire PERIOD_SOON one day off the lead", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-19", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe(null); // 1 day before → neither soon (lead=2) nor confirm (not yet due) + }); + + it("fires PERIOD_CONFIRM on the predicted day when no period logged", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-20", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe("CYCLE_PERIOD_CONFIRM"); + }); + + it("fires PERIOD_CONFIRM within the grace window after the predicted day", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-23", // +3 days, grace=3 + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe("CYCLE_PERIOD_CONFIRM"); + expect(PERIOD_CONFIRM_GRACE_DAYS).toBe(3); + }); + + it("stops PERIOD_CONFIRM past the grace window", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-24", // +4 days, grace=3 + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: false, + }), + ).toBe(null); + }); + + it("suppresses PERIOD_CONFIRM once a period is logged", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-20", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: true, + }), + ).toBe(null); + }); + + it("suppresses PERIOD_SOON once a period is logged (early period)", () => { + expect( + evaluateCycleReminder({ + today: "2026-05-18", + nextPeriodStart: "2026-05-20", + periodAlreadyLogged: true, + }), + ).toBe(null); + }); +}); + +describe("buildCycleReminderPayload", () => { + it("names the event when not discreet", () => { + const soon = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", false); + expect(soon.title.length).toBeGreaterThan(0); + expect(soon.title.toLowerCase()).toContain("period"); + const confirm = buildCycleReminderPayload( + "CYCLE_PERIOD_CONFIRM", + "en", + false, + ); + expect(confirm.title).not.toBe(soon.title); + }); + + it("collapses to the generic HealthLog body when discreet", () => { + const soon = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", true); + const confirm = buildCycleReminderPayload( + "CYCLE_PERIOD_CONFIRM", + "en", + true, + ); + expect(soon).toEqual(confirm); // identical generic copy regardless of event + expect(soon.title.toLowerCase()).not.toContain("period"); + expect(soon.title).toContain("HealthLog"); + }); + + it("resolves a non-default locale", () => { + const de = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "de", false); + const en = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", false); + expect(de.title).not.toBe(en.title); + }); +}); + +describe("runCycleReminderTick", () => { + function run(state: FakeState, dispatch: DispatchFn, now = AT_0900_BERLIN) { + const prisma = makePrisma(state); + return runCycleReminderTick( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + now, + { dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification }, + ); + } + + it("dispatches PERIOD_SOON two days before the predicted start", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodSoon).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_PERIOD_SOON"); + }); + + it("dispatches PERIOD_CONFIRM on the predicted day with no logged period", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-17", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodConfirm).toBe(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_PERIOD_CONFIRM"); + }); + + it("suppresses PERIOD_CONFIRM once a period is logged near the prediction", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-17", user: baseUser() }, + ], + observedStarts: { u1: ["2026-05-17"] }, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodConfirm).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedAlreadyLogged).toBe(1); + }); + + it("gates entirely on cycle tracking — a MALE account never gets a push", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ gender: "MALE", cycleProfile: { cycleTrackingEnabled: null, predictionEnabled: true, discreetNotifications: false } }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.dispatchedPeriodSoon).toBe(0); + }); + + it("opts a non-FEMALE account in via the explicit cycleTrackingEnabled flag", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + gender: "MALE", + cycleProfile: { cycleTrackingEnabled: true, predictionEnabled: true, discreetNotifications: false }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodSoon).toBe(1); + }); + + it("suppresses everything when predictionEnabled is false", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + cycleProfile: { cycleTrackingEnabled: null, predictionEnabled: false, discreetNotifications: false }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedPredictionDisabled).toBe(1); + }); + + it("suppresses the server push when clientManaged is true", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + notificationPrefs: { cycle: { clientManaged: true } }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.suppressedClientManaged).toBe(1); + }); + + it("swaps to the generic body when discreetNotifications is true", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-05-19", + user: baseUser({ + cycleProfile: { cycleTrackingEnabled: null, predictionEnabled: true, discreetNotifications: true }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedPeriodSoon).toBe(1); + expect(summary.suppressedDiscreet).toBe(1); + const payload = dispatch.mock.calls[0][0]; + // Generic body — no cycle event named on the lock screen. + expect(payload.title.toLowerCase()).not.toContain("period"); + expect(payload.title).toContain("HealthLog"); + // The eventType is still the real one (so prefs gating + ledger work). + expect(payload.eventType).toBe("CYCLE_PERIOD_SOON"); + }); + + it("does not double-fire when an ok attempt already landed today", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(["u1:CYCLE_PERIOD_SOON"]), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedAlreadyNotified).toBe(1); + }); + + it("counts a no-channel dispatch as skipped (free to retry)", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => NO_CHANNEL); + const summary = await run(state, dispatch); + expect(summary.skippedNoChannel).toBe(1); + expect(summary.dispatchedPeriodSoon).toBe(0); + }); + + it("skips outside the 09:00 window", async () => { + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch, new Date("2026-05-17T20:00:00Z")); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.skippedOutsideWindow).toBe(1); + }); + + it("isolates a per-user failure from the rest of the tick", async () => { + const state: FakeState = { + predictions: [ + { userId: "bad", nextPeriodStart: "not-a-date", user: baseUser({ id: "bad" }) }, + { userId: "u2", nextPeriodStart: "2026-05-19", user: baseUser({ id: "u2" }) }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.failed).toBe(1); + expect(summary.dispatchedPeriodSoon).toBe(1); // u2 still processed + }); +}); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts new file mode 100644 index 000000000..ee402181d --- /dev/null +++ b/src/lib/jobs/cycle-reminder.ts @@ -0,0 +1,421 @@ +/** + * v1.15 — cycle reminder dispatcher (period-soon + period-start-confirm). + * + * Mirrors `mood-reminder.ts` and the medication-reminder cron: a cron tick + * fires every 15 minutes, and when it lands inside the user's reminder hour + * in their local timezone this module scans every cycle-enabled user with a + * cached `CyclePrediction`, decides who is in-window, and dispatches at most + * one push per event per local day through the existing + * APNs → Telegram → ntfy → Web-Push cascade (`dispatchNotification`), which + * records every attempt in `push_attempts`. + * + * Two events, both default-OFF and gated TWICE (per-event default + the + * `CycleProfile.cycleTrackingEnabled` flag): + * + * - CYCLE_PERIOD_SOON — a couple of days before the predicted next-period + * start (`PERIOD_SOON_LEAD_DAYS`). + * - CYCLE_PERIOD_CONFIRM — a gentle "did your period start?" on/after the + * predicted start while no observed cycle is logged, + * for up to `PERIOD_CONFIRM_GRACE_DAYS`. + * + * Two suppression rules ride on top of the dispatcher cascade: + * + * - `notificationPrefs.cycle.clientManaged` — when true, iOS owns the local + * reminders, so the server skips its push and emits a + * `cycle_reminder.suppressed_client_managed` wide-event annotation (the + * medication `clientManaged` precedent). + * - `CycleProfile.discreetNotifications` — when true, the push body is the + * generic "HealthLog reminder" so no cycle event is named on the lock + * screen. The decision is made here, where the localised body is composed. + * + * Idempotency is anchored on the `push_attempts` ledger itself (no new table): + * before dispatching, the runner checks whether an `ok` attempt for the same + * event already landed for the user's local day. The cron worker runs + * single-flight (`localConcurrency: 1`) so two ticks never race the + * fire-and-forget ledger write. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; +import { getLocalDateParts, getUserTodayBounds } from "@/lib/timezone"; +import { dayDiff } from "@/lib/cycle/day-math"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import type { EventType } from "@/lib/notifications/types"; +import { isCycleReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { getEvent } from "@/lib/logging/context"; + +/** + * Local-time hour (0–23) at which the daily cycle reminder fires. 09:00 is + * a calm morning slot that does not collide with the medication-reminder + * hot path (top of the hour) or the mood reminder (22:00). Fixed for v1.15 + * (no per-user override yet); exported so the tests can pin the window. + */ +export const CYCLE_REMINDER_LOCAL_HOUR = 9; + +/** Days before the predicted next-period start that PERIOD_SOON fires. */ +export const PERIOD_SOON_LEAD_DAYS = 2; + +/** + * Days on/after the predicted next-period start that PERIOD_CONFIRM keeps + * nudging while no period is logged. Day 0 (the predicted start) through + * this many days inclusive; after that the runner gives up for the cycle. + */ +export const PERIOD_CONFIRM_GRACE_DAYS = 3; + +export type CycleReminderEvent = "CYCLE_PERIOD_SOON" | "CYCLE_PERIOD_CONFIRM"; + +export interface CycleReminderSummary { + candidatesScanned: number; + inWindow: number; + dispatchedPeriodSoon: number; + dispatchedPeriodConfirm: number; + suppressedClientManaged: number; + suppressedDiscreet: number; + skippedAlreadyNotified: number; + skippedNoChannel: number; + skippedOutsideWindow: number; + skippedPredictionDisabled: number; + skippedAlreadyLogged: number; + failed: number; +} + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +function emptySummary(): CycleReminderSummary { + return { + candidatesScanned: 0, + inWindow: 0, + dispatchedPeriodSoon: 0, + dispatchedPeriodConfirm: 0, + suppressedClientManaged: 0, + suppressedDiscreet: 0, + skippedAlreadyNotified: 0, + skippedNoChannel: 0, + skippedOutsideWindow: 0, + skippedPredictionDisabled: 0, + skippedAlreadyLogged: 0, + failed: 0, + }; +} + +/** + * Compute the user's local `YYYY-MM-DD` date when `now` falls inside the + * cycle-reminder hour; `null` otherwise (outside the window). Pulled out so + * the unit tests can pin the boundary (08:59 → null, 09:00 → date, + * 09:59 → date, 10:00 → null) without a DB. + */ +export function cycleReminderLocalDate( + now: Date, + timezone: string, +): string | null { + const parts = getLocalDateParts(now, timezone || "Europe/Berlin"); + if (parts.hour !== CYCLE_REMINDER_LOCAL_HOUR) return null; + return `${parts.year.toString().padStart(4, "0")}-${parts.month + .toString() + .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`; +} + +/** + * Pure decision predicate: given the user's local date and their cached + * forecast, which cycle reminder (if any) should fire today? + * + * Returns the event to dispatch, or `null` to skip. Kept DB-free so the + * windowing contract is unit-testable in isolation: + * + * - PERIOD_SOON when `dayDiff(nextPeriodStart, today) === lead` and no + * period has already been logged near the prediction. + * - PERIOD_CONFIRM when today is on/after the predicted start and within + * the grace window AND no observed cycle has been logged yet + * (`periodAlreadyLogged === false`). + * + * Either nudge is suppressed once a period IS logged near the prediction — + * an early period makes the "soon" nudge stale and the "confirm" nudge moot. + */ +export function evaluateCycleReminder( + args: { + today: string; + nextPeriodStart: string; + periodAlreadyLogged: boolean; + }, + leadDays: number = PERIOD_SOON_LEAD_DAYS, + graceDays: number = PERIOD_CONFIRM_GRACE_DAYS, +): CycleReminderEvent | null { + // A logged period near the prediction silences both nudges — the forecast + // is already confirmed (the cache regenerates to the next cycle shortly). + if (args.periodAlreadyLogged) return null; + + const daysUntil = dayDiff(args.nextPeriodStart, args.today); + + if (daysUntil === leadDays) { + return "CYCLE_PERIOD_SOON"; + } + + // On/after the predicted start (daysUntil <= 0), within the grace window + // → nudge to confirm (the already-logged case returned null above). + if (daysUntil <= 0 && daysUntil >= -graceDays) { + return "CYCLE_PERIOD_CONFIRM"; + } + + return null; +} + +/** + * Build the localised title + body for a cycle reminder push. When + * `discreet` is true, both collapse to the generic "HealthLog reminder" + * copy so no cycle event is named on the lock screen (the §6.4 discreet + * mode). Pulled out so a template change is testable without the runner. + */ +export function buildCycleReminderPayload( + event: CycleReminderEvent, + locale: string | null | undefined, + discreet: boolean, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + if (discreet) { + return { + title: t("cycleReminders.discreetTitle"), + body: t("cycleReminders.discreetBody"), + }; + } + if (event === "CYCLE_PERIOD_SOON") { + return { + title: t("cycleReminders.periodSoonTitle"), + body: t("cycleReminders.periodSoonBody"), + }; + } + return { + title: t("cycleReminders.periodConfirmTitle"), + body: t("cycleReminders.periodConfirmBody"), + }; +} + +/** + * Has an `ok` push attempt for `event` already landed for the user today + * (their local day window)? The `push_attempts` ledger doubles as the + * idempotency anchor so no extra table is needed. Best-effort: a DB error + * biases toward "not yet notified" so a transient blip never silently nukes + * the day's nudge (the at-most-once trade the mood reminder also makes). + */ +async function alreadyNotifiedToday( + prisma: PrismaClient, + userId: string, + event: EventType, + localDayStartUtc: Date, + localDayEndUtc: Date, +): Promise { + try { + const row = await prisma.pushAttempt.findFirst({ + where: { + userId, + eventType: event, + result: "ok", + createdAt: { gte: localDayStartUtc, lt: localDayEndUtc }, + }, + select: { id: true }, + }); + return row !== null; + } catch { + return false; + } +} + +/** + * Run one cycle-reminder cron tick. Iterates every user whose cycle + * tracking is enabled and who has a cached forecast, checks the local-time + * window + the prediction window, and dispatches at most one push per event + * per local day, honouring `clientManaged` and `discreetNotifications`. + * + * `dispatch` is injectable so the unit tests exercise the decision + + * suppression logic without a live dispatcher. + */ +export async function runCycleReminderTick( + prisma: PrismaClient, + now: Date, + options: { + dispatch?: typeof dispatchNotification; + } = {}, +): Promise { + const dispatchImpl = options.dispatch ?? dispatchNotification; + const summary = emptySummary(); + + // Only users with a cached forecast can be in-window; the join also + // narrows to the cohort that has logged enough to predict. + const predictions = await prisma.cyclePrediction.findMany({ + select: { + userId: true, + nextPeriodStart: true, + user: { + select: { + id: true, + gender: true, + timezone: true, + locale: true, + notificationPrefs: true, + cycleProfile: { + select: { + cycleTrackingEnabled: true, + predictionEnabled: true, + discreetNotifications: true, + }, + }, + }, + }, + }, + }); + + for (const pred of predictions) { + summary.candidatesScanned += 1; + const user = pred.user; + try { + const profile = user.cycleProfile; + + // Gate ENTIRELY on cycle tracking being enabled (gender-derived or + // explicit opt-in) — a disabled account never receives a cycle push. + if (!isCycleEnabled(user.gender, profile)) { + summary.skippedOutsideWindow += 1; + continue; + } + + // No predictions when the user opted out of algorithmic interpretation. + if (profile && profile.predictionEnabled === false) { + summary.skippedPredictionDisabled += 1; + continue; + } + + const timezone = user.timezone || "Europe/Berlin"; + const today = cycleReminderLocalDate(now, timezone); + if (!today) { + summary.skippedOutsideWindow += 1; + continue; + } + + summary.inWindow += 1; + + // Has the user already logged an observed (non-predicted) cycle + // starting on/around the predicted day? If so, the confirm nudge is + // moot. We look for any observed cycle whose start is within the + // grace window of the prediction. + const periodAlreadyLogged = await hasObservedPeriodNearPrediction( + prisma, + user.id, + pred.nextPeriodStart, + ); + + const event = evaluateCycleReminder({ + today, + nextPeriodStart: pred.nextPeriodStart, + periodAlreadyLogged, + }); + + if (!event) { + if (periodAlreadyLogged) summary.skippedAlreadyLogged += 1; + else summary.skippedOutsideWindow += 1; + continue; + } + + // clientManaged suppression — iOS owns the local reminders. Mirror the + // medication path: skip the server push, emit the annotation. + if (isCycleReminderClientManaged(user.notificationPrefs)) { + summary.suppressedClientManaged += 1; + getEvent()?.addMeta( + "cycle_reminder_suppressed_client_managed", + `${event}:${today}`, + ); + getEvent()?.addMeta("cycle_reminder_suppressed_meta", { + user_id: user.id, + event, + local_date: today, + }); + continue; + } + + // Idempotency: one push per event per local day. Compute the local + // day's UTC bounds for the ledger lookup. `end` is the last + // millisecond of the day, so add 1 ms to make the range half-open. + const { start: dayStartUtc, end: dayEndInclusive } = getUserTodayBounds( + now, + timezone, + ); + const seen = await alreadyNotifiedToday( + prisma, + user.id, + event, + dayStartUtc, + new Date(dayEndInclusive.getTime() + 1), + ); + if (seen) { + summary.skippedAlreadyNotified += 1; + continue; + } + + const discreet = profile?.discreetNotifications === true; + if (discreet) summary.suppressedDiscreet += 1; + + const { title, body } = buildCycleReminderPayload( + event, + user.locale, + discreet, + ); + + const outcome = await dispatchImpl({ + eventType: event, + userId: user.id, + title, + message: body, + metadata: { + scheduledAt: now.toISOString(), + localDate: today, + }, + }); + + if (!outcome.dispatched) { + summary.skippedNoChannel += 1; + continue; + } + + if (event === "CYCLE_PERIOD_SOON") summary.dispatchedPeriodSoon += 1; + else summary.dispatchedPeriodConfirm += 1; + } catch (err: unknown) { + summary.failed += 1; + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `cycle-reminder per-user dispatch failed for ${user.id}: ${message}`, + ); + } + } + + return summary; +} + +/** + * Observed (non-predicted, non-deleted) period logged within the grace + * window of the predicted start? Used to suppress the confirm nudge once + * the user has logged their period. A query failure biases toward "not + * logged" so the nudge still fires rather than silently going quiet. + */ +async function hasObservedPeriodNearPrediction( + prisma: PrismaClient, + userId: string, + predictedStart: string, +): Promise { + try { + const candidates = await prisma.menstrualCycle.findMany({ + where: { userId, isPredicted: false, deletedAt: null }, + select: { startDate: true }, + orderBy: { startDate: "desc" }, + take: 12, + }); + return candidates.some((c) => { + const delta = dayDiff(c.startDate, predictedStart); + return delta >= -PERIOD_CONFIRM_GRACE_DAYS && delta <= PERIOD_SOON_LEAD_DAYS; + }); + } catch { + return false; + } +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index af50e25d6..e80b6fac7 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -223,6 +223,7 @@ import { getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; +import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder"; import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs"; import { withBackgroundEvent } from "@/lib/logging/background"; import { assertSubsystemEnabled } from "@/lib/process-type"; @@ -393,6 +394,16 @@ const PUSH_ATTEMPT_RETENTION_DAYS = 90; // and the drain (03:45) inside the existing 03:xx maintenance window. const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup"; const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; +// v1.15 — daily cycle reminder cron (period-soon + period-start-confirm). +// +// Runs every 15 minutes for the same reason as the mood reminder: the +// handler short-circuits unless the candidate user's local time is the +// cycle-reminder hour (09:00), so the 15-min cadence picks up every IANA +// timezone crossing that hour without one cron entry per zone. At most one +// push per event per user per local day — the `push_attempts` ledger is the +// idempotency anchor inside the handler. +const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; +const CYCLE_REMINDER_CRON = "*/15 * * * *"; // v1.4.38 — the per-sample cutoff hours constant now lives on the // helper module so the worker, the admin route, and the CLI all read // the same source of truth. Re-export pulled in alongside @@ -497,6 +508,10 @@ interface MoodReminderPayload { triggeredAt: string; } +interface CycleReminderPayload { + triggeredAt: string; +} + interface TlsPinMonitorPayload { triggeredAt: string; } @@ -1659,6 +1674,39 @@ async function handleMoodReminderCheck(jobs: Job[]) { }); } +/** + * v1.15 — daily cycle-reminder dispatcher (period-soon + period-start-confirm). + * + * Thin shim around `runCycleReminderTick` in `cycle-reminder.ts` so the + * unit tests exercise the windowing + suppression logic without pg-boss. + */ +async function handleCycleReminderCheck(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.cycle_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runCycleReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.cycle_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched_period_soon: summary.dispatchedPeriodSoon, + dispatched_period_confirm: summary.dispatchedPeriodConfirm, + suppressed_client_managed: summary.suppressedClientManaged, + suppressed_discreet: summary.suppressedDiscreet, + skipped_already_notified: summary.skippedAlreadyNotified, + skipped_outside_window: summary.skippedOutsideWindow, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + async function handleRateLimitCleanup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => { @@ -2427,6 +2475,11 @@ export async function startReminderWorker() { // dispatcher never fires. MOOD_REMINDER_QUEUE, MOOD_REMINDER_CLEANUP_QUEUE, + // v1.15 — cycle-reminder cron tick (period-soon + period-start-confirm). + // Same pg-boss v12 createQueue contract as the mood-reminder queue; + // without this entry the every-15-min schedule silently no-ops and the + // cycle dispatcher never fires (the v1.4.37 dead-queue class). + CYCLE_REMINDER_QUEUE, // v1.4.49 — push-attempt ledger cleanup. Same createQueue contract // as the other cleanup jobs; the daily schedule below would // silently no-op without this entry. @@ -2575,6 +2628,11 @@ export async function startReminderWorker() { // per tick for the entire opted-in cohort. [MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON], [MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON], + // v1.15 — every-15-min tick for the daily cycle reminder. The handler + // short-circuits unless the candidate user's local time is the 09:00 + // hour, so the cron costs ~one prediction-row scan per tick for the + // opted-in cohort. + [CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON], // v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts. [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight @@ -2801,6 +2859,14 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleMoodReminderCleanup, ); + // v1.15 — single-flight cycle-reminder worker. localConcurrency=1 keeps + // two ticks from racing the fire-and-forget `push_attempts` ledger that + // anchors the per-day idempotency, exactly like the mood-reminder worker. + await boss.work( + CYCLE_REMINDER_QUEUE, + { localConcurrency: 1 }, + handleCycleReminderCheck, + ); // v1.4.49 — daily prune of the push-attempt ledger. Single-flight // matches every other cleanup queue; two ticks racing on the same // DELETE statement is wasted work and the second tick's payload diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 158d62302..f28bbe534 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -23,6 +23,17 @@ export const EVENT_TYPES = [ // in twice before the server starts nudging them about an // emotionally-loaded surface. "MOOD_REMINDER", + // v1.15 — cycle reminders. Both default OFF (see EVENT_DEFAULT_ENABLED) + // and are gated again on `CycleProfile.cycleTrackingEnabled`, so a user + // opts in twice before the server nudges about an intent-revealing, + // privacy-sensitive surface. `CYCLE_PERIOD_SOON` fires a couple of days + // before the predicted next-period start; `CYCLE_PERIOD_CONFIRM` is a + // gentle "did your period start?" on/after the predicted start while no + // period is logged. When `CycleProfile.discreetNotifications` is true the + // push body is the generic "HealthLog reminder" so no cycle event is + // named on the lock screen. + "CYCLE_PERIOD_SOON", + "CYCLE_PERIOD_CONFIRM", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -49,6 +60,11 @@ export const EVENT_DEFAULT_ENABLED: Record = { // `User.moodReminderEnabled`, this per-event default also has to be // flipped on before the dispatcher will surface the channel. MOOD_REMINDER: false, + // v1.15 — cycle reminders default OFF. Even if a future surface forgets + // to gate on `CycleProfile.cycleTrackingEnabled`, this per-event default + // also has to be flipped on before the dispatcher surfaces the channel. + CYCLE_PERIOD_SOON: false, + CYCLE_PERIOD_CONFIRM: false, }; export const CHANNEL_TYPE_LABELS: Record = { diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index fccf733e5..f199b12d3 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MOOD_REMINDER_HOUR, DEFAULT_NOTIFICATION_PREFS, + isCycleReminderClientManaged, isMedicationReminderClientManaged, notificationPrefsSchema, parseNotificationPrefs, @@ -89,6 +90,7 @@ describe("parseNotificationPrefs", () => { ).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -96,6 +98,7 @@ describe("parseNotificationPrefs", () => { expect(parseNotificationPrefs({ medication: {} })).toEqual({ medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -105,6 +108,7 @@ describe("parseNotificationPrefs", () => { ).toEqual({ medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -130,6 +134,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -143,6 +148,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -153,6 +159,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -163,6 +170,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, + cycle: { clientManaged: false }, }); }); @@ -174,6 +182,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { expect(out).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 8 }, + cycle: { clientManaged: false }, }); }); @@ -217,6 +226,34 @@ describe("isMedicationReminderClientManaged — cron-skip gate", () => { }); }); +describe("isCycleReminderClientManaged — cron-skip gate", () => { + it("returns false for a null / undefined row (server-managed default)", () => { + expect(isCycleReminderClientManaged(null)).toBe(false); + expect(isCycleReminderClientManaged(undefined)).toBe(false); + }); + + it("returns false when the user has not opted in", () => { + expect( + isCycleReminderClientManaged({ cycle: { clientManaged: false } }), + ).toBe(false); + }); + + it("returns true when the iOS app owns local cycle reminders", () => { + expect( + isCycleReminderClientManaged({ cycle: { clientManaged: true } }), + ).toBe(true); + }); + + it("is independent of the medication delivery default", () => { + // The medication `deliveryDefault: client` must NOT suppress cycle pushes. + expect( + isCycleReminderClientManaged({ + medication: { deliveryDefault: "client" }, + }), + ).toBe(false); + }); +}); + describe("resolveMoodReminderHour — cron hour gate", () => { it("returns the default hour for a null row", () => { expect(resolveMoodReminderHour(null)).toBe(DEFAULT_MOOD_REMINDER_HOUR); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 76f482038..f42c1f183 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -10,7 +10,8 @@ * Shape (additive — future categories slot in next to `medication`): * { * "medication": { "clientManaged": boolean }, - * "mood": { "reminderHour": 0..23 } + * "mood": { "reminderHour": 0..23 }, + * "cycle": { "clientManaged": boolean } * } * * The schema is intentionally additive — every new category grows the @@ -55,6 +56,20 @@ const moodPrefsSchema = z }) .partial(); +/** + * v1.15 — cycle category schema. `clientManaged` mirrors the medication + * gate: when the iOS app owns local cycle reminders (period-soon / + * period-start-confirm), it flips this to `true` and the server-side + * cycle cron suppresses its APNs send for that user, exactly as the + * medication path does. Sub-object form keeps the layout open for future + * cycle-notification knobs. + */ +const cyclePrefsSchema = z + .object({ + clientManaged: z.boolean(), + }) + .partial(); + /** * v1.7.0 — per-device delivery override schema for the device PATCH. * NULL clears the override (the device inherits the user-level roaming @@ -72,6 +87,7 @@ export const notificationPrefsSchema = z .object({ medication: medicationPrefsSchema, mood: moodPrefsSchema, + cycle: cyclePrefsSchema, }) .partial(); @@ -92,6 +108,14 @@ export interface NotificationPrefs { /** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */ reminderHour: number; }; + cycle: { + /** + * v1.15 — when true the iOS app owns local cycle reminders and the + * server-side cycle cron suppresses its APNs send (the medication + * `clientManaged` precedent). + */ + clientManaged: boolean; + }; } /** @@ -114,6 +138,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR, }, + cycle: { + clientManaged: false, + }, }; /** @@ -150,6 +177,10 @@ export function resolveNotificationPrefs( ...base.mood, ...(incoming.mood ?? {}), }, + cycle: { + ...base.cycle, + ...(incoming.cycle ?? {}), + }, }); } @@ -174,6 +205,7 @@ function applyDeliveryDefaultMapping(prefs: NotificationPrefs): NotificationPref clientManaged, }, mood: { ...prefs.mood }, + cycle: { ...prefs.cycle }, }; } @@ -191,6 +223,17 @@ export function isMedicationReminderClientManaged(raw: unknown): boolean { ); } +/** + * Cron-side helper. Returns `true` when the user has opted in to + * client-managed CYCLE reminders (iOS owns the local period-soon / + * period-start-confirm nudges) and the server-side push should be + * suppressed. Tolerates a null / missing prefs row. Mirrors + * `isMedicationReminderClientManaged`. + */ +export function isCycleReminderClientManaged(raw: unknown): boolean { + return parseNotificationPrefs(raw).cycle.clientManaged === true; +} + /** * v1.7.0 — resolve the effective delivery channel for a device. The * per-device override wins; otherwise the user-level roaming default; @@ -223,6 +266,7 @@ function cloneDefaults(): NotificationPrefs { return { medication: { ...DEFAULT_NOTIFICATION_PREFS.medication }, mood: { ...DEFAULT_NOTIFICATION_PREFS.mood }, + cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle }, }; } @@ -238,5 +282,9 @@ function mergeOverDefaults( ...DEFAULT_NOTIFICATION_PREFS.mood, ...(input.mood ?? {}), }, + cycle: { + ...DEFAULT_NOTIFICATION_PREFS.cycle, + ...(input.cycle ?? {}), + }, }); } From 7333d8e40fb916c5d1caf9386db370713c20f8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 10:02:47 +0200 Subject: [PATCH 06/19] feat(cycle): add the cycle-tracking web vertical Build the web UI for the cycle-tracking category against the existing `/api/cycle/*` routes: a month calendar + circular phase wheel, a fast log-day sheet, a predictions panel, phase-insight cards, and a single-page settings form, all under a gated `/cycle` tab. - Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the wellness-ring aesthetic with calm per-phase hues) showing the current phase + day-of-cycle, alongside a month grid where logged period days show a filled pip, the predicted next period renders as a ranged band (never a single date), the fertile window shows only when the goal allows it (the API suppresses it server-side otherwise), and ovulation + per-day symptoms carry markers. Reads `GET /api/cycle/calendar`. - Log-day sheet: one-tap period start, flow / symptom / ovulation-test / mucus chips, basal-temperature, intercourse + protection, and a free-text note. Optimistic write to the day-log + period routes; invalidates the cycle query keys. - Predictions panel: next-period range with a confidence pill, goal-gated fertile window, a still-learning state for fewer than three cycles or raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer (reusing the drug-level-estimate disclaimer pattern). - Insights cards render whatever phase-correlation cards the API surfaces, with a graceful empty-state until the feed lands. - Settings: a single dynamic form where the inclusive goal selector drives the copy (conception framing only for trying-to-conceive), plus typical- length priors, raw-chart mode, sensitive-category encryption, discreet notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`. - Nav: a Cycle entry in the sidebar and the mobile More hub, gated on `cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise. Add the cycle query keys + dependent-key bundle to the factory, full copy across all six locales, and dedicated per-theme phase hues. Plain second-person voice, WCAG 2.1 AA, no new dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 159 +++++++- messages/en.json | 159 +++++++- messages/es.json | 159 +++++++- messages/fr.json | 159 +++++++- messages/it.json | 159 +++++++- messages/pl.json | 159 +++++++- src/app/cycle/page.tsx | 44 +++ src/app/globals.css | 22 ++ .../cycle/__tests__/cycle-calendar.test.tsx | 73 ++++ .../cycle/__tests__/cycle-insights.test.tsx | 43 +++ .../cycle/__tests__/cycle-ring.test.tsx | 56 +++ .../__tests__/predictions-panel.test.tsx | 103 +++++ .../cycle/__tests__/wheel-state.test.ts | 58 +++ src/components/cycle/cycle-calendar.tsx | 260 +++++++++++++ src/components/cycle/cycle-disclaimer.tsx | 32 ++ src/components/cycle/cycle-insights.tsx | 86 +++++ src/components/cycle/cycle-ring.tsx | 198 ++++++++++ src/components/cycle/cycle-settings.tsx | 294 ++++++++++++++ src/components/cycle/cycle-view.tsx | 182 +++++++++ src/components/cycle/log-day-sheet.tsx | 361 ++++++++++++++++++ src/components/cycle/phase-tokens.ts | 24 ++ src/components/cycle/predictions-panel.tsx | 242 ++++++++++++ src/components/cycle/symptom-catalog.ts | 114 ++++++ src/components/cycle/types.ts | 134 +++++++ src/components/cycle/use-cycle.ts | 134 +++++++ src/components/cycle/wheel-state.ts | 91 +++++ .../layout/__tests__/bottom-nav.test.tsx | 33 ++ .../layout/__tests__/sidebar-nav.test.tsx | 18 +- src/components/layout/bottom-nav.tsx | 21 +- src/components/layout/sidebar-nav.tsx | 17 +- .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + src/hooks/use-auth.ts | 10 + .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/query-keys.ts | 28 ++ 35 files changed, 3613 insertions(+), 22 deletions(-) create mode 100644 src/app/cycle/page.tsx create mode 100644 src/components/cycle/__tests__/cycle-calendar.test.tsx create mode 100644 src/components/cycle/__tests__/cycle-insights.test.tsx create mode 100644 src/components/cycle/__tests__/cycle-ring.test.tsx create mode 100644 src/components/cycle/__tests__/predictions-panel.test.tsx create mode 100644 src/components/cycle/__tests__/wheel-state.test.ts create mode 100644 src/components/cycle/cycle-calendar.tsx create mode 100644 src/components/cycle/cycle-disclaimer.tsx create mode 100644 src/components/cycle/cycle-insights.tsx create mode 100644 src/components/cycle/cycle-ring.tsx create mode 100644 src/components/cycle/cycle-settings.tsx create mode 100644 src/components/cycle/cycle-view.tsx create mode 100644 src/components/cycle/log-day-sheet.tsx create mode 100644 src/components/cycle/phase-tokens.ts create mode 100644 src/components/cycle/predictions-panel.tsx create mode 100644 src/components/cycle/symptom-catalog.ts create mode 100644 src/components/cycle/types.ts create mode 100644 src/components/cycle/use-cycle.ts create mode 100644 src/components/cycle/wheel-state.ts diff --git a/messages/de.json b/messages/de.json index bdafdb4d7..7474bcc21 100644 --- a/messages/de.json +++ b/messages/de.json @@ -216,7 +216,8 @@ "medicationDescription": "Eine Dosis als genommen markieren", "mood": "Stimmung", "moodDescription": "Stimmung und Tags erfassen" - } + }, + "cycle": "Zyklus" }, "auth": { "login": "Anmelden", @@ -4926,9 +4927,161 @@ "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}" }, - "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." + "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" + }, + "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}" + }, + "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." + }, + "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." + }, + "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" + }, + "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" + } }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", diff --git a/messages/en.json b/messages/en.json index dccfc63de..0dd870fa6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -216,7 +216,8 @@ "medicationDescription": "Mark a dose as taken", "mood": "Mood", "moodDescription": "Log your mood and tags" - } + }, + "cycle": "Cycle" }, "auth": { "login": "Sign in", @@ -4926,9 +4927,161 @@ "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}" }, - "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." + "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" + }, + "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}" + }, + "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." + }, + "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." + }, + "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", + "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" + }, + "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" + } }, "cycleReminders": { "periodSoonTitle": "Period likely soon", diff --git a/messages/es.json b/messages/es.json index a37f55d4e..642b8c297 100644 --- a/messages/es.json +++ b/messages/es.json @@ -216,7 +216,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", @@ -4926,9 +4927,161 @@ "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}" }, - "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." + "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" + }, + "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}" + }, + "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." + }, + "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." + }, + "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" + }, + "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" + } }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", diff --git a/messages/fr.json b/messages/fr.json index b0f4ea28f..9af7ef8e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -216,7 +216,8 @@ "medicationDescription": "Marquer une dose comme prise", "mood": "Humeur", "moodDescription": "Enregistrer l'humeur et les tags" - } + }, + "cycle": "Cycle" }, "auth": { "login": "Se connecter", @@ -4926,9 +4927,161 @@ "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}" }, - "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é." + "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" + }, + "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}" + }, + "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." + }, + "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." + }, + "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" + }, + "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" + } }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", diff --git a/messages/it.json b/messages/it.json index ea0d1780c..f5ccfdffb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -216,7 +216,8 @@ "medicationDescription": "Segna una dose come assunta", "mood": "Umore", "moodDescription": "Registra umore e tag" - } + }, + "cycle": "Ciclo" }, "auth": { "login": "Accedi", @@ -4926,9 +4927,161 @@ "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}" }, - "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." + "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" + }, + "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}" + }, + "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." + }, + "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." + }, + "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" + }, + "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" + } }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", diff --git a/messages/pl.json b/messages/pl.json index 830347919..01fdf341d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -216,7 +216,8 @@ "medicationDescription": "Oznacz dawkę jako przyjętą", "mood": "Nastrój", "moodDescription": "Zapisz nastrój i tagi" - } + }, + "cycle": "Cykl" }, "auth": { "login": "Zaloguj się", @@ -4926,9 +4927,161 @@ "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}" }, - "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." + "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" + }, + "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}" + }, + "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ąć." + }, + "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." + }, + "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" + }, + "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" + } }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx new file mode 100644 index 000000000..54034d4a5 --- /dev/null +++ b/src/app/cycle/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index 4806a0bed..9e6d54b2d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx new file mode 100644 index 000000000..7039550ad --- /dev/null +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -0,0 +1,73 @@ +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( + {node}, + ); +} + +function dayBase(date: string): CalendarDay { + return { + date, + phase: null, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + flow: null, + hasSymptoms: false, + confidence: 1, + }; +} + +describe("", () => { + const today = "2026-06-15"; + + it("renders the month grid for today's month", () => { + const html = render( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} />, + ); + // 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( + {}} />, + ); + expect(html).toContain("Fertile window"); + }); + + it("renders a localized Monday-first weekday header row", () => { + const html = render( + {}} />, + ); + expect(html).toContain('role="columnheader"'); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-insights.test.tsx b/src/components/cycle/__tests__/cycle-insights.test.tsx new file mode 100644 index 000000000..2430d347e --- /dev/null +++ b/src/components/cycle/__tests__/cycle-insights.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CycleInsights } from "../cycle-insights"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the empty-state when no cards are surfaced", () => { + const html = render(); + expect(html).toContain("Phase insights appear here"); + expect(html).not.toContain('data-slot="cycle-insights"'); + }); + + it("renders the card list with the open statistics when cards are present", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-insights"'); + expect(html).toContain("Resting heart rate runs higher"); + expect(html).toContain("n = 42"); + expect(html).toContain("0.61"); + expect(html).toContain("0.040"); + expect(html).toContain("Descriptive only."); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-ring.test.tsx b/src/components/cycle/__tests__/cycle-ring.test.tsx new file mode 100644 index 000000000..4cb0073de --- /dev/null +++ b/src/components/cycle/__tests__/cycle-ring.test.tsx @@ -0,0 +1,56 @@ +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( + {node}, + ); +} + +describe("", () => { + it("renders the root data-slot tagged with the active phase", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toContain(">12<"); + }); + + it("carries a phase-aware aria-label (never colour-only)", () => { + const html = render( + , + ); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + // The label restates the day + phase. + expect(html).toContain("3"); + }); + + it("renders the no-cycle state for a null day with a fallback aria-label", () => { + const html = render( + , + ); + expect(html).toContain('data-phase="none"'); + expect(html).toContain('role="img"'); + }); +}); diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx new file mode 100644 index 000000000..a941b560c --- /dev/null +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -0,0 +1,103 @@ +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( + {node}, + ); +} + +const basePrediction: CyclePrediction = { + method: "CALENDAR", + nextPeriodStart: "2026-07-01", + nextPeriodStartLow: "2026-06-29", + nextPeriodStartHigh: "2026-07-03", + fertileWindowStart: null, + fertileWindowEnd: null, + predictedOvulation: null, + 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("", () => { + it("renders the next-period range + the fixed disclaimer", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).not.toContain("Fertile window"); + }); + + it("shows the still-learning state for an under-3-cycle prediction", () => { + const html = render( + , + ); + expect(html).toContain("Still learning"); + }); + + it("shows the raw-chart-mode state when predictions are off", () => { + const html = render( + , + ); + expect(html).toContain("Prediction is off"); + }); +}); diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts new file mode 100644 index 000000000..c5ee28772 --- /dev/null +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; + +import { deriveWheelState } 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, + }; +} + +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"); + }); +}); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx new file mode 100644 index 000000000..ed4b43d83 --- /dev/null +++ b/src/components/cycle/cycle-calendar.tsx @@ -0,0 +1,260 @@ +"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 ` + {monthLabel} + + + +
+ {weekdays.map((wk, i) => ( +
+ {wk} +
+ ))} + + {grid.map((cell, i) => { + if (!cell) return + + +
+ ); +} + +function CalendarLegend() { + const { t } = useTranslations(); + const items: { hue: string; labelKey: string; dashed?: boolean }[] = [ + { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod" }, + { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPredicted", dashed: true }, + { hue: FERTILE_HUE, labelKey: "cycle.calendar.legendFertile" }, + { hue: OVULATION_HUE, labelKey: "cycle.calendar.legendOvulation" }, + ]; + return ( +
    + {items.map((it) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/src/components/cycle/cycle-disclaimer.tsx b/src/components/cycle/cycle-disclaimer.tsx new file mode 100644 index 000000000..371fcd9eb --- /dev/null +++ b/src/components/cycle/cycle-disclaimer.tsx @@ -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 ( +

+ {text} +

+ ); +} diff --git a/src/components/cycle/cycle-insights.tsx b/src/components/cycle/cycle-insights.tsx new file mode 100644 index 000000000..e18954671 --- /dev/null +++ b/src/components/cycle/cycle-insights.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Activity } from "lucide-react"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CycleInsightCard } from "./types"; + +/** + * v1.15.0 — phase-correlation insight cards. + * + * Renders whatever phase-correlation cards the API surfaces (another wave + * adds the data feed; this UI renders the list gracefully and shows the + * empty-state when none). Each card carries the open statistics — sample + * size, effect size, q-value — alongside the prose, matching the + * FDR-guarded honest-reporting posture of the rest of Insights. + */ +export function CycleInsights({ + cards, +}: { + cards: CycleInsightCard[]; +}) { + const { t } = useTranslations(); + + if (cards.length === 0) { + return ( + + + + + + +

+ {t("cycle.insights.empty")} +

+
+
+ ); + } + + return ( +
+ {cards.map((card) => ( + + + {card.title} + + +

{card.body}

+
+ {card.n != null ? ( + {t("cycle.insights.sampleSize", { n: card.n })} + ) : null} + {card.effectSize != null ? ( + + {t("cycle.insights.effectSize", { + value: card.effectSize.toFixed(2), + })} + + ) : null} + {card.qValue != null ? ( + + {t("cycle.insights.qValue", { + value: card.qValue.toFixed(3), + })} + + ) : null} +
+ {card.caveat ? ( +

+ {card.caveat} +

+ ) : null} +
+
+ ))} +
+ ); +} diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx new file mode 100644 index 000000000..051ad38c4 --- /dev/null +++ b/src/components/cycle/cycle-ring.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +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), matching the premium wellness-ring aesthetic (calm distinct + * hues, thin round-cap arcs, a faint segmented track). Unlike the 0–100 + * score gauge this is a FULL-circle dial: the four cycle phases render as + * proportional arc segments around the ring, and a marker dot sits at the + * current day-of-cycle. The day number is real centred DOM text. + * + * a11y: `role="img"` + a phase-aware aria-label so the ring is never + * colour-only. The marker + segments are JSX SVG (`` is NOT + * `dangerouslySetInnerHTML`). + */ + +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[]; + size?: number; + className?: string; +} + +function evenSpans(): PhaseSpan[] { + return PHASE_ORDER.map((phase) => ({ phase, fraction: 0.25 })); +} + +export function CycleRing({ + dayOfCycle, + cycleLength, + phase, + spans, + size = 220, + className, +}: CycleRingProps) { + const { t } = useTranslations(); + const reduced = prefersReducedMotion(); + + 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. + const markerProgress = + dayOfCycle != null && cycleLength != null && cycleLength > 0 + ? Math.min(dayOfCycle / cycleLength, 1) + : null; + const markerAngle = + markerProgress != null ? markerProgress * 2 * Math.PI - Math.PI / 2 : null; + const markerX = markerAngle != null ? CX + R * Math.cos(markerAngle) : 0; + const markerY = markerAngle != null ? CX + R * Math.sin(markerAngle) : 0; + + 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"); + + return ( +
+ + {/* Centred day number + phase caption as real DOM text. */} +
+ + {dayOfCycle != null ? dayOfCycle : t("cycle.ring.dayUnknown")} + + + {phaseLabel} + +
+
+ ); +} diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx new file mode 100644 index 000000000..4636e00b4 --- /dev/null +++ b/src/components/cycle/cycle-settings.tsx @@ -0,0 +1,294 @@ +"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 } 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 [goal, setGoal] = useState(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 ( + + + {t("cycle.settings.title")} + {t("cycle.goal.description")} + + + {/* Inclusive goal selector — drives the rest of the copy. */} +
+ + setGoal(e.target.value as CycleGoal)} + > + {GOALS.map((g) => ( + + ))} + +
+ + + + {/* Predictions — when the goal hides conception framing, the + description never names a fertile window. */} + + + + + + {/* Priors */} +
+

+ {t("cycle.settings.priorsDescription")} +

+
+ + + +
+
+ + + + {/* Privacy toggles */} + + + + + + {/* Data export / delete */} +
+

{t("cycle.settings.dataTitle")}

+

+ {t("cycle.settings.dataDescription")} +

+
+ + +
+
+ +
+ {update.isSuccess ? ( + + {t("cycle.settings.saved")} + + ) : null} + {update.isError ? ( + + {t("cycle.settings.saveError")} + + ) : null} + +
+
+
+ ); +} + +function ToggleRow({ + id, + label, + description, + checked, + onChange, +}: { + id: string; + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+ +

{description}

+
+ +
+ ); +} + +function PriorField({ + id, + label, + value, + onChange, + min, + max, +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + min: number; + max: number; +}) { + return ( +
+ + onChange(e.target.value)} + className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2" + /> +
+ ); +} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx new file mode 100644 index 000000000..b9d4c57b6 --- /dev/null +++ b/src/components/cycle/cycle-view.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2, Plus } 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 { CycleRing } from "./cycle-ring"; +import { CycleCalendar } from "./cycle-calendar"; +import { LogDaySheet } from "./log-day-sheet"; +import { PredictionsPanel } from "./predictions-panel"; +import { CycleInsights } from "./cycle-insights"; +import { CycleSettings } from "./cycle-settings"; +import { deriveWheelState } from "./wheel-state"; +import { + useCycleCalendar, + useCycleHistory, + useCycleProfile, +} from "./use-cycle"; +import type { CycleInsightCard } from "./types"; + +/** + * v1.15.0 — the cycle vertical's client orchestrator. + * + * Holds the four tabs (Calendar · Predictions · Insights · Settings), the + * wheel + month calendar above the tab strip, and the log-day sheet. The + * calendar read drives the ring (day-of-cycle + phase) and the grid; the + * predictions read powers the panel; history powers the stats. The page-level + * RSC has already gated on `cycleTrackingEnabled`. + */ + +/** YYYY-MM-DD for `n` days from now in the local tz. */ +function shiftToday(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} + +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 [sheetOpen, setSheetOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(today); + + // Phase-correlation cards arrive from another wave; render the graceful + // empty-state until the feed lands. + const insightCards: CycleInsightCard[] = []; + + 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; + + return ( +
+
+
+

+ {t("cycle.title")} +

+

+ {t("cycle.subtitle")} +

+
+ +
+ + {/* Wheel — the signature ring above the tabs. */} + + + {loading ? ( +
+ +
+ ) : ( + + )} +

+ {t("cycle.ring.caption")} +

+
+
+ + + + + {t("cycle.tabs.calendar")} + + + {t("cycle.tabs.predictions")} + + + {t("cycle.tabs.insights")} + + + {t("cycle.tabs.settings")} + + + + + + + {loading ? ( +

+ {t("cycle.calendar.loading")} +

+ ) : ( + + )} +
+
+
+ + + + + + + + + + + {profileQuery.data ? ( + + ) : ( +
+ +
+ )} +
+
+ + +
+ ); +} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx new file mode 100644 index 000000000..d0225ac6e --- /dev/null +++ b/src/components/cycle/log-day-sheet.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useState } from "react"; +import { Droplets, Loader2 } 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 { useLogDay, useStartPeriod } from "./use-cycle"; +import type { + CervicalMucus, + CycleDayLogInput, + FlowLevel, + 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 ` + ); +} + +export function LogDaySheet({ + open, + onOpenChange, + date, + today, +}: LogDaySheetProps) { + const { t } = useTranslations(); + const logDay = useLogDay(); + const startPeriod = useStartPeriod(); + + const [flow, setFlow] = useState(null); + const [symptoms, setSymptoms] = useState>(new Set()); + const [bbt, setBbt] = useState(""); + const [opk, setOpk] = useState(null); + const [mucus, setMucus] = useState(null); + const [intercourse, setIntercourse] = useState(false); + const [protectedSex, setProtectedSex] = useState(false); + const [note, setNote] = useState(""); + + // Reset the form to empty whenever the sheet (re)opens on a day. 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 pair, so a + // re-open on a different calendar day always starts blank. + const formKey = open ? date : null; + const [lastFormKey, setLastFormKey] = useState(null); + if (open && formKey !== lastFormKey) { + setLastFormKey(formKey); + setFlow(null); + setSymptoms(new Set()); + setBbt(""); + setOpk(null); + setMucus(null); + setIntercourse(false); + setProtectedSex(false); + setNote(""); + } else if (!open && lastFormKey !== null) { + setLastFormKey(null); + } + + function toggleSymptom(key: string) { + setSymptoms((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + async function handleSave() { + const bbtNum = bbt.trim() === "" ? undefined : Number(bbt); + const input: CycleDayLogInput = { + date, + loggedAt: new Date().toISOString(), + source: "MANUAL", + ...(flow ? { flow } : {}), + ...(bbtNum != null && Number.isFinite(bbtNum) + ? { basalBodyTempC: bbtNum } + : {}), + ...(opk ? { ovulationTest: opk } : {}), + ...(mucus ? { cervicalMucus: mucus } : {}), + ...(intercourse + ? { sexualActivity: true, protectedSex } + : {}), + ...(symptoms.size > 0 + ? { symptoms: Array.from(symptoms).map((key) => ({ key })) } + : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }; + await logDay.mutateAsync(input); + onOpenChange(false); + } + + async function handleStartPeriod() { + await startPeriod.mutateAsync(today); + onOpenChange(false); + } + + const busy = logDay.isPending || startPeriod.isPending; + + return ( + + + + + } + > + {/* One-tap period start — only meaningful for today. */} + {date === today ? ( + + ) : null} + + {/* Flow */} + +
+ {FLOW_LEVELS.map((f) => ( + setFlow((cur) => (cur === f ? null : f))} + > + {t(`cycle.flow.${f}`)} + + ))} +
+
+ + {/* Symptoms */} + +
+ {CYCLE_SYMPTOM_CATALOG.map((cat) => ( +
+
+
+
+ {cat.symptoms.map((s) => ( + toggleSymptom(s.key)} + > + + + + ))} +
+
+ ))} +
+
+ + {/* BBT */} + + setBbt(e.target.value)} + placeholder={t("cycle.sheet.temperaturePlaceholder")} + className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2" + aria-label={t("cycle.sheet.temperature")} + /> + + + {/* Ovulation test */} + +
+ {OPK_VALUES.map((v) => ( + setOpk((cur) => (cur === v ? null : v))} + > + {t(`cycle.ovulationTest.${v}`)} + + ))} +
+
+ + {/* Cervical mucus */} + +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ + {/* Intercourse + protection */} + +
+
+ + +
+ {intercourse ? ( +
+ + +
+ ) : null} +
+
+ + {/* Note */} + +