mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
9f1efaffa1
commit
a5946e6209
11 files changed
+886
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user