feat(cycle): add cycle-tracking schema foundation

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 08:19:32 +02:00
1 parent 9f1efaffa1
commit a5946e6209
11 files changed
+886

No files matched your search

+24
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
}
@@ -0,0 +1,150 @@
-- v1.15.0 — cycle tracking core.
--
-- Adds the eight cycle enums plus the three capture tables: the 1:1
-- CycleProfile settings row, the soft-deleted MenstrualCycle + CycleDayLog
-- capture rows (same syncVersion + deletedAt shape as MoodEntry so they
-- ride the `/api/sync/changes` delta feed), and their indexes / uniques.
-- Purely additive — no existing table is touched.
--
-- CycleDayLog carries the MoodEntry dual-unique: the canonical-day key
-- `(user_id, date)` plus the NULL-distinct external-dedup key
-- `(user_id, source, external_id)` for HealthKit re-ingest.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "cycle_day_logs";
-- DROP TABLE IF EXISTS "menstrual_cycles";
-- DROP TABLE IF EXISTS "cycle_profiles";
-- DROP TYPE IF EXISTS "prediction_method";
-- DROP TYPE IF EXISTS "cycle_phase";
-- DROP TYPE IF EXISTS "contraceptive_kind";
-- DROP TYPE IF EXISTS "home_test_result";
-- DROP TYPE IF EXISTS "cervical_mucus";
-- DROP TYPE IF EXISTS "ovulation_test";
-- DROP TYPE IF EXISTS "flow_level";
-- DROP TYPE IF EXISTS "cycle_tracking_goal";
-- CreateEnum
CREATE TYPE "cycle_tracking_goal" AS ENUM ('GENERAL_HEALTH', 'AVOID_PREGNANCY', 'TRYING_TO_CONCEIVE', 'PERIMENOPAUSE', 'OFF');
-- CreateEnum
CREATE TYPE "flow_level" AS ENUM ('NONE', 'SPOTTING', 'LIGHT', 'MEDIUM', 'HEAVY');
-- CreateEnum
CREATE TYPE "ovulation_test" AS ENUM ('NEGATIVE', 'POSITIVE_LH_SURGE', 'ESTROGEN_SURGE', 'INDETERMINATE');
-- CreateEnum
CREATE TYPE "cervical_mucus" AS ENUM ('DRY', 'STICKY', 'CREAMY', 'WATERY', 'EGG_WHITE');
-- CreateEnum
CREATE TYPE "home_test_result" AS ENUM ('NEGATIVE', 'POSITIVE', 'INDETERMINATE');
-- CreateEnum
CREATE TYPE "contraceptive_kind" AS ENUM ('NONE', 'UNSPECIFIED', 'IMPLANT', 'INJECTION', 'IUD', 'INTRAVAGINAL_RING', 'ORAL', 'PATCH', 'EMERGENCY');
-- CreateEnum
CREATE TYPE "cycle_phase" AS ENUM ('MENSTRUAL', 'FOLLICULAR', 'OVULATORY', 'LUTEAL');
-- CreateEnum
CREATE TYPE "prediction_method" AS ENUM ('CALENDAR', 'SYMPTOTHERMAL', 'TEMPERATURE_TREND', 'BLENDED');
-- CreateTable
CREATE TABLE "cycle_profiles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"goal" "cycle_tracking_goal" NOT NULL DEFAULT 'GENERAL_HEALTH',
"typical_cycle_length" INTEGER,
"typical_period_length" INTEGER,
"luteal_phase_length" INTEGER,
"prediction_enabled" BOOLEAN NOT NULL DEFAULT true,
"raw_chart_mode" BOOLEAN NOT NULL DEFAULT false,
"discreet_notifications" BOOLEAN NOT NULL DEFAULT false,
"sensitive_category_encryption" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cycle_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "menstrual_cycles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"start_date" TEXT NOT NULL,
"end_date" TEXT,
"period_end_date" TEXT,
"length_days" INTEGER,
"ovulation_date" TEXT,
"ovulation_confirmed" BOOLEAN NOT NULL DEFAULT false,
"is_predicted" BOOLEAN NOT NULL DEFAULT false,
"tz" TEXT,
"sync_version" INTEGER NOT NULL DEFAULT 0,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "menstrual_cycles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cycle_day_logs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"date" TEXT NOT NULL,
"cycle_id" TEXT,
"flow" "flow_level",
"intermenstrual_bleeding" BOOLEAN NOT NULL DEFAULT false,
"basal_body_temp_c" DOUBLE PRECISION,
"ovulation_test" "ovulation_test",
"cervical_mucus" "cervical_mucus",
"sexual_activity" BOOLEAN NOT NULL DEFAULT false,
"protected_sex" BOOLEAN,
"pregnancy_test" "home_test_result",
"progesterone_test" "home_test_result",
"contraceptive" "contraceptive_kind",
"notes_encrypted" TEXT,
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
"external_id" TEXT,
"tz" TEXT,
"sync_version" INTEGER NOT NULL DEFAULT 0,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cycle_day_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cycle_profiles_user_id_key" ON "cycle_profiles"("user_id");
-- CreateIndex
CREATE INDEX "menstrual_cycles_user_id_start_date_idx" ON "menstrual_cycles"("user_id", "start_date");
-- CreateIndex
CREATE INDEX "menstrual_cycles_user_id_updated_at_id_idx" ON "menstrual_cycles"("user_id", "updated_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "menstrual_cycles_user_id_start_date_key" ON "menstrual_cycles"("user_id", "start_date");
-- CreateIndex
CREATE INDEX "cycle_day_logs_user_id_date_idx" ON "cycle_day_logs"("user_id", "date");
-- CreateIndex
CREATE INDEX "cycle_day_logs_user_id_updated_at_id_idx" ON "cycle_day_logs"("user_id", "updated_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "cycle_day_logs_user_id_date_key" ON "cycle_day_logs"("user_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "cycle_day_logs_user_id_source_external_id_key" ON "cycle_day_logs"("user_id", "source", "external_id");
-- AddForeignKey
ALTER TABLE "cycle_profiles" ADD CONSTRAINT "cycle_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "menstrual_cycles" ADD CONSTRAINT "menstrual_cycles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "menstrual_cycles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,112 @@
-- v1.15.0 — cycle symptom taxonomy.
--
-- Clones the mood-tag trio (0101): a global Category -> Symptom catalog
-- plus a per-day-log join. `CycleSymptom` additionally carries
-- `user_id` + `label_encrypted` so a future release can mint per-user
-- custom symptoms; v1.15.0 ships seeded-catalogue-only.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales) rather than a hard-coded label, so the seeded
-- catalog renders localised everywhere. The seed at the bottom uses
-- deterministic ids + ON CONFLICT DO NOTHING on `key` so re-running is a
-- no-op (idempotent).
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "cycle_symptom_links";
-- DROP TABLE IF EXISTS "cycle_symptoms";
-- DROP TABLE IF EXISTS "cycle_symptom_categories";
-- CreateTable
CREATE TABLE "cycle_symptom_categories" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "cycle_symptom_categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cycle_symptoms" (
"id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"user_id" TEXT,
"label_encrypted" TEXT,
CONSTRAINT "cycle_symptoms_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cycle_symptom_links" (
"day_log_id" TEXT NOT NULL,
"symptom_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "cycle_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cycle_symptom_categories_key_key" ON "cycle_symptom_categories"("key");
-- CreateIndex
CREATE UNIQUE INDEX "cycle_symptoms_key_key" ON "cycle_symptoms"("key");
-- CreateIndex
CREATE INDEX "cycle_symptoms_category_id_sort_order_idx" ON "cycle_symptoms"("category_id", "sort_order");
-- CreateIndex
CREATE INDEX "cycle_symptoms_user_id_idx" ON "cycle_symptoms"("user_id");
-- CreateIndex
CREATE INDEX "cycle_symptom_links_symptom_id_idx" ON "cycle_symptom_links"("symptom_id");
-- AddForeignKey
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "cycle_symptom_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "cycle_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "cycle_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ─── Seed the default symptom taxonomy ───────────────────────────────
-- Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING (the
-- mood-tag seed precedent 0101 / 0118 / 0126). Three categories
-- (physical, emotional, digestive) with ~15 high-signal symptoms. Keep
-- in sync with `src/lib/cycle/symptom-taxonomy.ts` if either changes.
-- Categories
INSERT INTO "cycle_symptom_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
('csc_physical', 'physical', 'cycle.symptomCategory.physical', 'Activity', 0, true),
('csc_emotional', 'emotional', 'cycle.symptomCategory.emotional', 'Heart', 1, true),
('csc_digestive', 'digestive', 'cycle.symptomCategory.digestive', 'Soup', 2, true)
ON CONFLICT ("key") DO NOTHING;
-- Symptoms
INSERT INTO "cycle_symptoms" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
('cs_cramps', 'csc_physical', 'cramps', 'cycle.symptom.cramps', 'Zap', 0, true),
('cs_headache', 'csc_physical', 'headache', 'cycle.symptom.headache', 'Brain', 1, true),
('cs_bloating', 'csc_physical', 'bloating', 'cycle.symptom.bloating', 'CircleDot', 2, true),
('cs_acne', 'csc_physical', 'acne', 'cycle.symptom.acne', 'Droplet', 3, true),
('cs_breast_tenderness', 'csc_physical', 'breast_tenderness', 'cycle.symptom.breastTenderness', 'HeartPulse', 4, true),
('cs_fatigue', 'csc_physical', 'fatigue', 'cycle.symptom.fatigue', 'BatteryLow', 5, true),
('cs_back_pain', 'csc_physical', 'back_pain', 'cycle.symptom.backPain', 'PersonStanding', 6, true),
('cs_insomnia', 'csc_physical', 'insomnia', 'cycle.symptom.insomnia', 'MoonStar', 7, true),
('cs_libido_high', 'csc_emotional', 'libido_high', 'cycle.symptom.libidoHigh', 'Flame', 0, true),
('cs_libido_low', 'csc_emotional', 'libido_low', 'cycle.symptom.libidoLow', 'Snowflake', 1, true),
('cs_mood_swings', 'csc_emotional', 'mood_swings', 'cycle.symptom.moodSwings', 'Drama', 2, true),
('cs_food_cravings', 'csc_digestive', 'food_cravings', 'cycle.symptom.foodCravings', 'Cookie', 0, true),
('cs_nausea', 'csc_digestive', 'nausea', 'cycle.symptom.nausea', 'Frown', 1, true),
('cs_diarrhea', 'csc_digestive', 'diarrhea', 'cycle.symptom.diarrhea', 'Toilet', 2, true),
('cs_constipation', 'csc_digestive', 'constipation', 'cycle.symptom.constipation', 'CircleSlash', 3, true)
ON CONFLICT ("key") DO NOTHING;
@@ -0,0 +1,35 @@
-- v1.15.0 — cycle prediction cache.
--
-- One cached forecast per user, regenerated debounced /
-- stale-while-revalidate (the InsightNarrative + v1.8.7 assessment cache
-- pattern), NOT per-read. Derived data — not soft-deleted. The ranged
-- Low / High band is rendered as a band, never a single date.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "cycle_predictions";
-- CreateTable
CREATE TABLE "cycle_predictions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"method" "prediction_method" NOT NULL,
"next_period_start" TEXT NOT NULL,
"next_period_start_low" TEXT NOT NULL,
"next_period_start_high" TEXT NOT NULL,
"fertile_window_start" TEXT,
"fertile_window_end" TEXT,
"predicted_ovulation" TEXT,
"confidence" DOUBLE PRECISION NOT NULL,
"cycles_observed" INTEGER NOT NULL,
"generated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cycle_predictions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cycle_predictions_user_id_key" ON "cycle_predictions"("user_id");
-- AddForeignKey
ALTER TABLE "cycle_predictions" ADD CONSTRAINT "cycle_predictions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+409
View File
@@ -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.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the category header.
icon String?
sortOrder Int @default(0) @map("sort_order")
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
symptoms CycleSymptom[]
@@map("cycle_symptom_categories")
}
model CycleSymptom {
id String @id @default(cuid())
categoryId String @map("category_id")
/// Stable machine key (e.g. `cramps`). Unique across the catalog.
key String @unique
/// i18n message key for the symptom label (`cycle.symptom.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the symptom chip.
icon String?
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
/// Owner of a per-user custom symptom. NULL = the global seeded
/// catalogue (resolves `labelKey` against the locale). Set = a user's
/// own custom symptom carrying a `custom:<cuid>` key and a free-text
/// label held in `labelEncrypted` (the MoodTag custom-tag precedent).
userId String? @map("user_id")
/// AES-256-GCM ciphertext of a custom symptom's free-text label. NULL
/// for catalogue rows. Decrypted server-side for the effective read.
labelEncrypted String? @map("label_encrypted")
category CycleSymptomCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
user User? @relation("UserCycleSymptoms", fields: [userId], references: [id], onDelete: Cascade)
links CycleSymptomLink[]
@@index([categoryId, sortOrder])
@@index([userId])
@@map("cycle_symptoms")
}
/// Join: which symptoms a cycle day-log carries. Composite PK keeps the
/// pairing unique; cascade on both sides so deleting a day-log (or a
/// catalog symptom) cleans up its links.
model CycleSymptomLink {
dayLogId String @map("day_log_id")
symptomId String @map("symptom_id")
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")
}
+36
View File
@@ -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<CycleProfile> {
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: {},
});
}