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