diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8f0c632..d6be35587 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -134,6 +134,21 @@ paths: - CREAMY - WATERY - EGG_WHITE + cervixPosition: + type: string + enum: + - LOW + - HIGH + cervixFirmness: + type: string + enum: + - FIRM + - SOFT + cervixOpening: + type: string + enum: + - CLOSED + - OPEN sexualActivity: type: boolean protectedSex: @@ -275,6 +290,27 @@ paths: - WATERY - EGG_WHITE - type: "null" + cervixPosition: + anyOf: + - type: string + enum: + - LOW + - HIGH + - type: "null" + cervixFirmness: + anyOf: + - type: string + enum: + - FIRM + - SOFT + - type: "null" + cervixOpening: + anyOf: + - type: string + enum: + - CLOSED + - OPEN + - type: "null" sexualActivity: type: boolean protectedSex: @@ -433,6 +469,21 @@ paths: - CREAMY - WATERY - EGG_WHITE + cervixPosition: + type: string + enum: + - LOW + - HIGH + cervixFirmness: + type: string + enum: + - FIRM + - SOFT + cervixOpening: + type: string + enum: + - CLOSED + - OPEN sexualActivity: type: boolean protectedSex: @@ -730,6 +781,11 @@ paths: - TRYING_TO_CONCEIVE - PERIMENOPAUSE - OFF + secondarySymptom: + type: string + enum: + - MUCUS + - CERVIX rawChartMode: type: boolean predictionEnabled: @@ -6267,6 +6323,27 @@ components: - WATERY - EGG_WHITE - type: "null" + cervixPosition: + anyOf: + - type: string + enum: + - LOW + - HIGH + - type: "null" + cervixFirmness: + anyOf: + - type: string + enum: + - FIRM + - SOFT + - type: "null" + cervixOpening: + anyOf: + - type: string + enum: + - CLOSED + - OPEN + - type: "null" sexualActivity: type: boolean protectedSex: @@ -6344,6 +6421,9 @@ components: - temperatureExcluded - ovulationTest - cervicalMucus + - cervixPosition + - cervixFirmness + - cervixOpening - sexualActivity - protectedSex - pregnancyTest @@ -6677,6 +6757,27 @@ components: - WATERY - EGG_WHITE - type: "null" + cervixPosition: + anyOf: + - type: string + enum: + - LOW + - HIGH + - type: "null" + cervixFirmness: + anyOf: + - type: string + enum: + - FIRM + - SOFT + - type: "null" + cervixOpening: + anyOf: + - type: string + enum: + - CLOSED + - OPEN + - type: "null" required: - date - phase @@ -6691,6 +6792,9 @@ components: - temperatureExcluded - ovulationTest - cervicalMucus + - cervixPosition + - cervixFirmness + - cervixOpening additionalProperties: false meta: type: object @@ -7156,6 +7260,11 @@ components: - OFF cycleTrackingEnabled: type: boolean + secondarySymptom: + type: string + enum: + - MUCUS + - CERVIX rawChartMode: type: boolean predictionEnabled: @@ -7189,6 +7298,7 @@ components: required: - goal - cycleTrackingEnabled + - secondarySymptom - rawChartMode - predictionEnabled - discreetNotifications diff --git a/prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql b/prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql new file mode 100644 index 000000000..cf3d7762a --- /dev/null +++ b/prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql @@ -0,0 +1,29 @@ +-- v1.17.0 — symptothermal SECONDARY-symptom choice: cervical mucus (default) +-- OR cervix observation, mirroring the Sensiplan/NFP double-check where the +-- temperature primary sign pairs with one chosen second indicator. +-- +-- `cycle_profiles.secondary_symptom` picks which second sign the engine uses +-- (MUCUS default | CERVIX); the casual user keeps temperature + mucus and never +-- sees the choice (it lives behind the advanced cycle settings). +-- +-- `cycle_day_logs` gains the three standard Sensiplan cervix signs — position +-- (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — used as the +-- secondary indicator when secondary_symptom = CERVIX. Fertile = HIGH+SOFT+OPEN; +-- infertile (closure) = LOW+FIRM+CLOSED. +-- +-- Additive + back-compatible: every existing profile backfills MUCUS (the prior +-- behaviour) and every existing day-log row keeps NULL cervix signs. iOS +-- inherits the fields without a migration of its own. + +CREATE TYPE "secondary_symptom" AS ENUM ('MUCUS', 'CERVIX'); +CREATE TYPE "cervix_position" AS ENUM ('LOW', 'HIGH'); +CREATE TYPE "cervix_firmness" AS ENUM ('FIRM', 'SOFT'); +CREATE TYPE "cervix_opening" AS ENUM ('CLOSED', 'OPEN'); + +ALTER TABLE "cycle_profiles" + ADD COLUMN "secondary_symptom" "secondary_symptom" NOT NULL DEFAULT 'MUCUS'; + +ALTER TABLE "cycle_day_logs" + ADD COLUMN "cervix_position" "cervix_position", + ADD COLUMN "cervix_firmness" "cervix_firmness", + ADD COLUMN "cervix_opening" "cervix_opening"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8214510e4..6a09c701b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3615,6 +3615,47 @@ enum CyclePhase { @@map("cycle_phase") } +/// Which symptothermal SECONDARY sign the user observes alongside the +/// temperature primary sign for the Sensiplan double-check. `MUCUS` +/// (cervical-mucus quality) is the default; `CERVIX` swaps in the cervix +/// observation (position / firmness / opening). The temperature primary +/// sign is unchanged either way — only the second indicator of the +/// double-check differs. Mirrors the `drip` app's Settings → +/// Customization → Secondary symptom choice. +enum SecondarySymptom { + MUCUS + CERVIX + + @@map("secondary_symptom") +} + +/// Cervix vertical position, a Sensiplan cervix sign. Fertile = `HIGH` +/// (the cervix rises around ovulation); infertile = `LOW`. +enum CervixPosition { + LOW + HIGH + + @@map("cervix_position") +} + +/// Cervix firmness, a Sensiplan cervix sign. Fertile = `SOFT` (the cervix +/// softens around ovulation); infertile = `FIRM`. +enum CervixFirmness { + FIRM + SOFT + + @@map("cervix_firmness") +} + +/// Cervix os / opening, a Sensiplan cervix sign. Fertile = `OPEN`; +/// infertile = `CLOSED`. +enum CervixOpening { + CLOSED + OPEN + + @@map("cervix_opening") +} + /// Which method produced a cached CyclePrediction. `CALENDAR` is the /// median+MAD variability predictor; `SYMPTOTHERMAL` folds the /// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the @@ -3654,6 +3695,12 @@ model CycleProfile { typicalPeriodLength Int? @map("typical_period_length") lutealPhaseLength Int? @map("luteal_phase_length") + /// Which symptothermal SECONDARY sign the user observes for the Sensiplan + /// double-check: `MUCUS` (default) or `CERVIX`. Lives behind the advanced + /// cycle settings — the casual user keeps temperature + mucus and never + /// sees the choice. The temperature primary sign is unchanged either way. + secondarySymptom SecondarySymptom @default(MUCUS) @map("secondary_symptom") + /// When false, the app computes / stores NO predictions — pure logging /// (the Read-Your-Body posture for users who distrust algorithmic /// interpretation). @@ -3761,6 +3808,16 @@ model CycleDayLog { /// Cervical-mucus quality. cervicalMucus CervicalMucus? @map("cervical_mucus") + /// Cervix observation — the three standard Sensiplan cervix signs, used + /// as the symptothermal secondary indicator when + /// CycleProfile.secondarySymptom is CERVIX. All NULL = not observed. + /// The fertile combination is HIGH + SOFT + OPEN; the infertile + /// (closure) combination is LOW + FIRM + CLOSED. Queryable-plaintext like + /// the other physiology signs (mucus / BBT / OPK). + cervixPosition CervixPosition? @map("cervix_position") + cervixFirmness CervixFirmness? @map("cervix_firmness") + cervixOpening CervixOpening? @map("cervix_opening") + /// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption /// is ON these are encrypted at the application layer (the dispatch /// envelope), default-ON for this category. diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index 3de329d1c..ec6d97132 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -92,6 +92,9 @@ export const PATCH = apiHandler(async (req: Request) => { data: { ...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }), ...(p.goal !== undefined && { goal: p.goal }), + ...(p.secondarySymptom !== undefined && { + secondarySymptom: p.secondarySymptom, + }), ...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }), ...(p.predictionEnabled !== undefined && { predictionEnabled: p.predictionEnabled, diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index cd03590f9..0f4c4f38b 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -102,6 +102,9 @@ export const GET = apiHandler(async (request: NextRequest) => { temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, _count: { select: { symptomLinks: true } }, }, }), @@ -129,6 +132,9 @@ export const GET = apiHandler(async (request: NextRequest) => { temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, hasSymptoms: l._count.symptomLinks > 0, })); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 0bff32a89..3f16a02ed 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -93,6 +93,9 @@ export const GET = apiHandler(async () => { temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, symptomLinks: { select: { symptom: { select: { key: true } } } }, }, }), diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index 91369c3f3..1e36b1333 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -142,6 +142,7 @@ export async function buildCycleSnapshotBlock( lutealPhaseLength: true, typicalCycleLength: true, typicalPeriodLength: true, + secondarySymptom: true, }, }); if (!profile) return null; @@ -153,6 +154,7 @@ export async function buildCycleSnapshotBlock( lutealPhaseLength: profile.lutealPhaseLength, predictionEnabled: profile.predictionEnabled, rawChartMode: profile.rawChartMode, + secondarySymptom: profile.secondarySymptom, }; // Derive "today" from the user's timezone — matches how the calendar @@ -179,6 +181,9 @@ export async function buildCycleSnapshotBlock( temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, }, }), prisma.measurement.findMany({ diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 4630c2088..b7169ade6 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -55,7 +55,7 @@ describe("buildCalendar", () => { it("labels logged-flow days and emits a forecast for ≥2 cycles", () => { const dayLogs: CalendarDayLogRow[] = [ - { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, hasSymptoms: true }, + { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, cervixPosition: null, cervixFirmness: null, cervixOpening: null, hasSymptoms: true }, ]; const { prediction, days } = buildCalendar( profile(), diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 28928b6ce..418f9f6b2 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -4,6 +4,7 @@ import { addDays } from "../day-math"; import { clampLuteal, confirmSymptothermal, + detectCervixPeak, detectLhSurgeOvulation, detectMucusPeak, detectTempShift, @@ -297,6 +298,24 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus }; } + function cervixDay( + date: string, + cervixPosition: DayLogInput["cervixPosition"], + cervixFirmness: DayLogInput["cervixFirmness"], + cervixOpening: DayLogInput["cervixOpening"], + ): DayLogInput { + return { + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + cervixPosition, + cervixFirmness, + cervixOpening, + }; + } + it("detectTempShift: 3-over-6 with the 3rd reading >= 0.2°C over the 6-day max", () => { // 6 low baseline ~36.40, then 3 elevated clearing the max by 0.2. const logs: DayLogInput[] = []; @@ -522,6 +541,115 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { expect(confirmSymptothermal(logs)).toBe("2024-01-06"); }); + it("confirmSymptothermal defaults to the mucus secondary symptom (cervix ignored)", () => { + // A confirmed temp shift + a confirmed CERVIX peak but NO mucus must NOT + // confirm on the default (mucus) path — the default behaviour is unchanged. + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(bbtDay(addDays(start, 6), 36.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + // cervix peak on 2024-01-06, confirmed by 3 closed days — but no mucus. + logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED")); + // Default secondary = mucus → no mucus peak → no confirmation. + expect(confirmSymptothermal(logs)).toBeNull(); + }); + + it("detectCervixPeak: confirms the last fertile-cervix day followed by 3 closed days", () => { + const start = "2024-01-01"; + const logs = [ + cervixDay(addDays(start, 0), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 1), "HIGH", "SOFT", "OPEN"), // fertile peak + cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 3), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 4), "LOW", "FIRM", "CLOSED"), + ]; + expect(detectCervixPeak(logs)).toBe(addDays(start, 1)); + }); + + it("detectCervixPeak: returns null until 3 closed days follow the fertile peak", () => { + const start = "2024-01-01"; + const logs = [ + cervixDay(addDays(start, 0), "HIGH", "SOFT", "OPEN"), + cervixDay(addDays(start, 1), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"), + ]; + expect(detectCervixPeak(logs)).toBeNull(); + }); + + it("confirmSymptothermal(CERVIX): a cervix peak + temp shift confirms like mucus does", () => { + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(bbtDay(addDays(start, 6), 36.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + // temp ovulation = 2024-01-06. Cervix peak on 2024-01-06, confirmed by 3 + // closed days after it (cervix-closure rule). + logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED")); + expect(confirmSymptothermal(logs, "CERVIX")).toBe("2024-01-06"); + }); + + it("confirmSymptothermal(CERVIX): a cervix sign alone (no temp shift) never confirms", () => { + const start = "2024-01-01"; + // A confirmed cervix peak but a FLAT temperature series (no rise). + const logs: DayLogInput[] = []; + [36.4, 36.41, 36.39, 36.4, 36.42, 36.4, 36.41, 36.4, 36.39].forEach( + (t, i) => logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(cervixDay(addDays(start, 4), "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay(addDays(start, 5), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(start, 6), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(start, 7), "LOW", "FIRM", "CLOSED")); + // Single sign (cervix) without the temperature double-check → no confirmation. + expect(confirmSymptothermal(logs, "CERVIX")).toBeNull(); + }); + + it("predictCycle(CERVIX): a cervix-peak + temp shift confirms the same boundary as mucus", () => { + const cervixProfile: CycleProfileInput = { + ...BASE_PROFILE, + secondarySymptom: "CERVIX", + }; + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + const logs: DayLogInput[] = []; + const ovDay = addDays(lastStart, 13); + const riseStart = addDays(ovDay, 1); + const baselineStart = addDays(riseStart, -6); + [36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) => + logs.push(bbtDay(addDays(baselineStart, i), t)), + ); + logs.push(bbtDay(riseStart, 36.6)); + logs.push(bbtDay(addDays(riseStart, 1), 36.62)); + logs.push(bbtDay(addDays(riseStart, 2), 36.63)); + // cervix peak agreeing with the temp ovulation, confirmed by 3 closed days. + logs.push(cervixDay(ovDay, "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay(addDays(ovDay, 1), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(ovDay, 2), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(ovDay, 3), "LOW", "FIRM", "CLOSED")); + + const result = predictCycle( + cycles, + logs, + cervixProfile, + addDays(lastStart, 16), + ); + expect(result.ovulationConfirmed).toBe(true); + expect(result.method).toBe("BLENDED"); + expect(result.predictedOvulation).toBe(ovDay); + }); + it("predictCycle: a confirmed symptothermal ovulation overrides next-start and tightens the band", () => { const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); // 3 lengths, last start 2024-03-25 const lastStart = cycles[cycles.length - 1].startDate; diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 87a58fb52..a4bda6804 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -21,6 +21,7 @@ export interface CycleBackupSection { typicalCycleLength: number | null; typicalPeriodLength: number | null; lutealPhaseLength: number | null; + secondarySymptom: string; predictionEnabled: boolean; rawChartMode: boolean; discreetNotifications: boolean; @@ -43,6 +44,9 @@ export interface CycleBackupSection { temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: string | null; @@ -89,6 +93,7 @@ export async function buildCycleBackupSection( typicalCycleLength: profile.typicalCycleLength, typicalPeriodLength: profile.typicalPeriodLength, lutealPhaseLength: profile.lutealPhaseLength, + secondarySymptom: profile.secondarySymptom, predictionEnabled: profile.predictionEnabled, rawChartMode: profile.rawChartMode, discreetNotifications: profile.discreetNotifications, @@ -112,6 +117,9 @@ export async function buildCycleBackupSection( temperatureExcluded: d.temperatureExcluded, ovulationTest: d.ovulationTest, cervicalMucus: d.cervicalMucus, + cervixPosition: d.cervixPosition, + cervixFirmness: d.cervixFirmness, + cervixOpening: d.cervixOpening, sexualActivity: d.sexualActivity, protectedSex: d.protectedSex, pregnancyTest: d.pregnancyTest, @@ -156,6 +164,10 @@ const CERVICAL_MUCUS = new Set([ "WATERY", "EGG_WHITE", ]); +const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]); +const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]); +const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]); +const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]); const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]); const CONTRACEPTIVES = new Set([ "NONE", @@ -221,6 +233,8 @@ export async function restoreCycleData( typicalCycleLength: p.typicalCycleLength ?? null, typicalPeriodLength: p.typicalPeriodLength ?? null, lutealPhaseLength: p.lutealPhaseLength ?? null, + secondarySymptom: (enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? + "MUCUS") as never, predictionEnabled: p.predictionEnabled ?? true, rawChartMode: p.rawChartMode ?? false, discreetNotifications: p.discreetNotifications ?? false, @@ -294,6 +308,15 @@ export async function restoreCycleData( temperatureExcluded: d.temperatureExcluded ?? false, ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, + cervixPosition: enumOrNull( + d.cervixPosition, + CERVIX_POSITIONS, + ) as never, + cervixFirmness: enumOrNull( + d.cervixFirmness, + CERVIX_FIRMNESSES, + ) as never, + cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS) as never, sexualActivity: d.sexualActivity ?? false, protectedSex: d.protectedSex ?? null, pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never, diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 9d70d599e..1113c506f 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -143,6 +143,9 @@ type ExistingRow = { temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: string | null; @@ -162,6 +165,9 @@ const EXISTING_SELECT = { temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, sexualActivity: true, protectedSex: true, pregnancyTest: true, @@ -323,6 +329,18 @@ async function writeDayLog(args: WriteArgs): Promise { entry.cervicalMucus !== undefined ? (entry.cervicalMucus ?? null) : (existing?.cervicalMucus ?? null); + const cervixPosition = + entry.cervixPosition !== undefined + ? (entry.cervixPosition ?? null) + : (existing?.cervixPosition ?? null); + const cervixFirmness = + entry.cervixFirmness !== undefined + ? (entry.cervixFirmness ?? null) + : (existing?.cervixFirmness ?? null); + const cervixOpening = + entry.cervixOpening !== undefined + ? (entry.cervixOpening ?? null) + : (existing?.cervixOpening ?? null); // Split the sensitive fields between plaintext columns and the envelope // depending on the flag. When encrypting, the plaintext columns are NULL @@ -347,6 +365,9 @@ async function writeDayLog(args: WriteArgs): Promise { temperatureExcluded, ovulationTest: ovulationTest as never, cervicalMucus: cervicalMucus as never, + cervixPosition: cervixPosition as never, + cervixFirmness: cervixFirmness as never, + cervixOpening: cervixOpening as never, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, pregnancyTest: sensitivePlaintext.pregnancyTest as never, @@ -367,6 +388,9 @@ async function writeDayLog(args: WriteArgs): Promise { existing.temperatureExcluded !== temperatureExcluded || (existing.ovulationTest ?? null) !== ovulationTest || (existing.cervicalMucus ?? null) !== cervicalMucus || + (existing.cervixPosition ?? null) !== cervixPosition || + (existing.cervixFirmness ?? null) !== cervixFirmness || + (existing.cervixOpening ?? null) !== cervixOpening || storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || storedSensitive.protectedSex !== resolvedSensitive.protectedSex || storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || @@ -406,6 +430,9 @@ async function writeDayLog(args: WriteArgs): Promise { temperatureExcluded: baseData.temperatureExcluded as never, ovulationTest: baseData.ovulationTest as never, cervicalMucus: baseData.cervicalMucus as never, + cervixPosition: baseData.cervixPosition as never, + cervixFirmness: baseData.cervixFirmness as never, + cervixOpening: baseData.cervixOpening as never, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, pregnancyTest: sensitivePlaintext.pregnancyTest as never, diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 7b43456a4..3908ac9fa 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -33,6 +33,10 @@ export interface CycleDayLogDTO { temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; + /** Cervix observation signs (symptothermal secondary indicator). */ + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: string | null; @@ -112,6 +116,9 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { temperatureExcluded: row.temperatureExcluded, ovulationTest: row.ovulationTest, cervicalMucus: row.cervicalMucus, + cervixPosition: row.cervixPosition, + cervixFirmness: row.cervixFirmness, + cervixOpening: row.cervixOpening, sexualActivity: hasEnvelope ? (enc.sexualActivity ?? false) : row.sexualActivity, @@ -206,6 +213,8 @@ export function toCyclePredictionDTO( export interface CycleProfileDTO { goal: string; cycleTrackingEnabled: boolean; + /** Symptothermal secondary symptom — MUCUS (default) or CERVIX. */ + secondarySymptom: string; rawChartMode: boolean; predictionEnabled: boolean; discreetNotifications: boolean; @@ -223,6 +232,7 @@ export function toCycleProfileDTO( return { goal: row.goal, cycleTrackingEnabled, + secondarySymptom: row.secondarySymptom, rawChartMode: row.rawChartMode, predictionEnabled: row.predictionEnabled, discreetNotifications: row.discreetNotifications, @@ -236,6 +246,7 @@ export function toCycleProfileDTO( type MenstrualCycleProfileRow = { goal: string; + secondarySymptom: string; rawChartMode: boolean; predictionEnabled: boolean; discreetNotifications: boolean; diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 5afc60d09..a6c3e1901 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -54,6 +54,9 @@ export type CalendarDayLogRow = Pick< | "temperatureExcluded" | "ovulationTest" | "cervicalMucus" + | "cervixPosition" + | "cervixFirmness" + | "cervixOpening" > & { hasSymptoms: boolean }; /** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ @@ -86,6 +89,9 @@ export function toDayLogInputs( | "temperatureExcluded" | "ovulationTest" | "cervicalMucus" + | "cervixPosition" + | "cervixFirmness" + | "cervixOpening" >[], ): DayLogInput[] { return logs.map((l) => ({ @@ -95,6 +101,9 @@ export function toDayLogInputs( temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, })); } @@ -107,6 +116,7 @@ export function toProfileInput(profile: CycleProfile): CycleProfileInput { lutealPhaseLength: profile.lutealPhaseLength, predictionEnabled: profile.predictionEnabled, rawChartMode: profile.rawChartMode, + secondarySymptom: profile.secondarySymptom, }; } diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 716f6b3fe..4569f3933 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -57,6 +57,7 @@ import { type CycleProfileInput, type DayLogInput, type NightlyTempInput, + type SecondarySymptom, type PredictionMethod, } from "./types"; @@ -427,16 +428,89 @@ export function detectMucusPeak( } /** - * §4.2 — symptothermal CONFIRMATION: ovulation is confirmed only when the - * temp-shift day and the mucus-peak agree within ±2 days. Returns the - * temp-derived ovulation day when confirmed, else null. + * §4.2(b') — sensiplan cervix peak (Höhepunkt), the CERVIX-secondary-symptom + * analogue of `detectMucusPeak`. The cervix is a fertile sign while it is HIGH, + * SOFT, or OPEN (any of the three Sensiplan cervix signs in its fertile state); + * it is infertile only when fully closed — LOW + FIRM + CLOSED. The peak day is + * the LAST fertile-cervix day FOLLOWED by at least 3 consecutive closed + * (infertile) observed days (the cervix-closure rule). Like the mucus peak it is + * only confirmable retrospectively, so a stray late fertile-cervix entry not yet + * followed by 3 closed days cannot move an already-confirmed peak. + * + * A cervix "day" counts as observed when at least one of the three signs is + * logged (an unlogged sign is treated as its fertile/uncertain state so a + * partial entry never fabricates a closure). Returns the confirmed peak day, or + * null when no fertile-cervix day has yet been followed by 3 closed days. + * + * Citation: cervix observation as a Sensiplan secondary sign — the cervix opens, + * softens and rises around ovulation and closes, firms and lowers afterward; + * evaluation follows the same "last fertile day + 3 confirming days" shape as + * mucus. Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Den + * Gebärmutterhals beobachten". + */ +export function detectCervixPeak( + dayLogs: readonly DayLogInput[], +): string | null { + // Observed cervix days only (at least one sign logged), oldest→newest. + const observed = dayLogs + .filter( + (l) => + l.cervixPosition != null || + l.cervixFirmness != null || + l.cervixOpening != null, + ) + .map((l) => ({ date: l.date, closed: isCervixClosed(l) })) + .sort((a, b) => dayDiff(a.date, b.date)); + + // Walk every fertile-cervix day; a candidate peak is confirmed iff the next 3 + // observed cervix days are all closed (infertile). Scan forward and keep the + // LATEST confirmed peak (a true later peak supersedes an earlier one). + let confirmedPeak: string | null = null; + for (let i = 0; i < observed.length; i++) { + if (observed[i].closed) continue; // only a fertile-cervix day can be a peak + const following = observed.slice(i + 1, i + 4); + if (following.length < 3) continue; // not yet evaluable + if (following.every((d) => d.closed)) confirmedPeak = observed[i].date; + } + return confirmedPeak; +} + +/** + * A cervix is in its infertile (closed) state only when ALL three signs read + * infertile: LOW + FIRM + CLOSED. Any fertile sign (HIGH / SOFT / OPEN) — or an + * unlogged sign, treated conservatively as not-yet-closed — keeps the day + * fertile, so a partial entry never fabricates a closure. + */ +function isCervixClosed(log: DayLogInput): boolean { + return ( + log.cervixPosition === "LOW" && + log.cervixFirmness === "FIRM" && + log.cervixOpening === "CLOSED" + ); +} + +/** + * §4.2 — symptothermal CONFIRMATION (the Sensiplan double-check): ovulation is + * confirmed only when the temperature primary sign AND the chosen SECONDARY sign + * both confirm and agree within ±2 days. The secondary sign is the cervical + * mucus peak by default, or the cervix peak when `secondarySymptom` is `CERVIX`. + * Returns the temp-derived ovulation day when confirmed, else null. + * + * A single sign never confirms — no temperature shift returns null regardless of + * the secondary sign, and no secondary peak returns null regardless of the + * temperature. This is the honesty principle: the double-check requires two + * indicators, never one. */ export function confirmSymptothermal( dayLogs: readonly DayLogInput[], + secondarySymptom: SecondarySymptom = "MUCUS", ): string | null { const shift = detectTempShift(dayLogs, TEMP_SHIFT_C_MANUAL); if (!shift) return null; - const peak = detectMucusPeak(dayLogs); + const peak = + secondarySymptom === "CERVIX" + ? detectCervixPeak(dayLogs) + : detectMucusPeak(dayLogs); if (!peak) return null; if ( Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS @@ -671,7 +745,10 @@ export function predictCycle( dayDiff(date, windowStart) >= 0 && dayDiff(today, date) >= 0; const currentDayLogs = dayLogs.filter((l) => isInCurrentWindow(l.date)); const currentNights = nights.filter((n) => isInCurrentWindow(n.date)); - const symptoOvulation = confirmSymptothermal(currentDayLogs); + const symptoOvulation = confirmSymptothermal( + currentDayLogs, + profile.secondarySymptom, + ); const trendOvulation = symptoOvulation ? null : detectTemperatureTrend(currentNights); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 062cdc5fd..29c86a7dd 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -34,6 +34,22 @@ export type CervicalMucus = | "WATERY" | "EGG_WHITE"; +/** + * Which symptothermal SECONDARY sign pairs with the temperature primary sign + * for the Sensiplan double-check. `MUCUS` (cervical-mucus quality) is the + * default; `CERVIX` swaps in the cervix observation. + */ +export type SecondarySymptom = "MUCUS" | "CERVIX"; + +/** Cervix vertical position — fertile = HIGH, infertile = LOW. */ +export type CervixPosition = "LOW" | "HIGH"; + +/** Cervix firmness — fertile = SOFT, infertile = FIRM. */ +export type CervixFirmness = "FIRM" | "SOFT"; + +/** Cervix os / opening — fertile = OPEN, infertile = CLOSED. */ +export type CervixOpening = "CLOSED" | "OPEN"; + export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL"; export type PredictionMethod = @@ -215,6 +231,15 @@ export interface DayLogInput { temperatureExcluded?: boolean | null; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; + /** + * Cervix observation — the three Sensiplan cervix signs. Used as the + * symptothermal SECONDARY indicator when the profile's `secondarySymptom` + * is `CERVIX`; ignored on the default (mucus) path. All optional for + * back-compat; an unobserved sign is `null`/absent. + */ + cervixPosition?: CervixPosition | null; + cervixFirmness?: CervixFirmness | null; + cervixOpening?: CervixOpening | null; } /** @@ -240,6 +265,13 @@ export interface CycleProfileInput { predictionEnabled: boolean; /** Read-Your-Body mode: suppress all interpretation. */ rawChartMode: boolean; + /** + * Which symptothermal SECONDARY sign the double-check uses — `MUCUS` + * (default) or `CERVIX`. Optional for back-compat; the engine treats an + * absent value as `MUCUS`, so existing callers keep the mucus path + * unchanged. + */ + secondarySymptom?: SecondarySymptom; } /* ------------------------------------------------------------------ */ diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index fe6ef6391..b9880a8d6 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -11,6 +11,10 @@ import { flowLevelEnum, ovulationTestEnum, cervicalMucusEnum, + secondarySymptomEnum, + cervixPositionEnum, + cervixFirmnessEnum, + cervixOpeningEnum, homeTestResultEnum, cycleTrackingGoalEnum, cycleDayLogInputSchema, @@ -104,6 +108,9 @@ const cycleDayLogDto = z temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), + cervixPosition: cervixPositionEnum.nullable(), + cervixFirmness: cervixFirmnessEnum.nullable(), + cervixOpening: cervixOpeningEnum.nullable(), sexualActivity: z.boolean(), protectedSex: z.boolean().nullable(), pregnancyTest: homeTestResultEnum.nullable(), @@ -179,12 +186,16 @@ const cycleCalendarDayDto = z.object({ temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), + cervixPosition: cervixPositionEnum.nullable(), + cervixFirmness: cervixFirmnessEnum.nullable(), + cervixOpening: cervixOpeningEnum.nullable(), }); const cycleProfileDto = z .object({ goal: cycleTrackingGoalEnum, cycleTrackingEnabled: z.boolean(), + secondarySymptom: secondarySymptomEnum, rawChartMode: z.boolean(), predictionEnabled: z.boolean(), discreetNotifications: z.boolean(), diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 283c5c306..f3c2923aa 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -144,6 +144,9 @@ const cycleDayLogSchema = z temperatureExcluded: z.boolean().optional(), ovulationTest: z.string().nullable().optional(), cervicalMucus: z.string().nullable().optional(), + cervixPosition: z.string().nullable().optional(), + cervixFirmness: z.string().nullable().optional(), + cervixOpening: z.string().nullable().optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: z.string().nullable().optional(), @@ -166,6 +169,7 @@ const cycleProfileSchema = z typicalCycleLength: z.number().int().nullable().optional(), typicalPeriodLength: z.number().int().nullable().optional(), lutealPhaseLength: z.number().int().nullable().optional(), + secondarySymptom: z.string().nullable().optional(), predictionEnabled: z.boolean().optional(), rawChartMode: z.boolean().optional(), discreetNotifications: z.boolean().optional(), diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index c19ac0bb4..ced6da11d 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -51,6 +51,14 @@ export const cervicalMucusEnum = z.enum([ "EGG_WHITE", ]); +/** Symptothermal secondary-symptom choice (mirrors the Prisma enum). */ +export const secondarySymptomEnum = z.enum(["MUCUS", "CERVIX"]); + +/** The three Sensiplan cervix signs (mirror the Prisma enums). */ +export const cervixPositionEnum = z.enum(["LOW", "HIGH"]); +export const cervixFirmnessEnum = z.enum(["FIRM", "SOFT"]); +export const cervixOpeningEnum = z.enum(["CLOSED", "OPEN"]); + export const homeTestResultEnum = z.enum([ "NEGATIVE", "POSITIVE", @@ -108,6 +116,11 @@ export const cycleDayLogInputSchema = z.object({ temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.optional(), cervicalMucus: cervicalMucusEnum.optional(), + // Cervix observation — the symptothermal secondary indicator when the + // profile's secondarySymptom is CERVIX. + cervixPosition: cervixPositionEnum.optional(), + cervixFirmness: cervixFirmnessEnum.optional(), + cervixOpening: cervixOpeningEnum.optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: homeTestResultEnum.optional(), @@ -138,6 +151,9 @@ export const cycleDayLogPatchSchema = z.object({ temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.nullable().optional(), cervicalMucus: cervicalMucusEnum.nullable().optional(), + cervixPosition: cervixPositionEnum.nullable().optional(), + cervixFirmness: cervixFirmnessEnum.nullable().optional(), + cervixOpening: cervixOpeningEnum.nullable().optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: homeTestResultEnum.nullable().optional(), @@ -192,6 +208,9 @@ export const cyclePrefsSchema = z .object({ enabled: z.boolean().optional(), goal: cycleTrackingGoalEnum.optional(), + // Symptothermal secondary symptom — advanced cycle setting (MUCUS default + // | CERVIX). Drives which second sign the double-check uses. + secondarySymptom: secondarySymptomEnum.optional(), rawChartMode: z.boolean().optional(), predictionEnabled: z.boolean().optional(), discreetNotifications: z.boolean().optional(),