feat(cycle): choose cervix as the symptothermal secondary symptom

Let a user pick the Sensiplan double-check's second indicator — cervical
mucus (default) or cervix observation — mirroring the published NFP
standard where the temperature primary sign pairs with one chosen
secondary sign.

- `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which
  second sign the engine confirms against. Casual users keep temperature
  + mucus untouched; the choice lives behind the advanced cycle settings.
- `CycleDayLog` gains the three standard cervix signs — position
  (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable
  columns (migration 0157), wired through Zod, the day-log upsert, the
  DTO, the sync feed, backup/restore and OpenAPI. Additive and
  back-compatible: existing profiles backfill MUCUS, existing day-logs
  keep NULL cervix signs, iOS inherits the fields.
- The engine's `confirmSymptothermal` takes the secondary-symptom choice;
  on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix
  day followed by three closed days — the cervix-closure rule) instead of
  the mucus peak. The double-check still requires BOTH the temperature
  shift AND the secondary peak: a single cervix sign never confirms, and
  the still-learning gate and conservative posture are unchanged.

Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula &
Frank-Herrmann standard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 15:23:26 +02:00
1 parent 2c225ce094
commit 609d248252
18 files changed
+561 -6

No files matched your search

+110
View File
@@ -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
@@ -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";
+57
View File
@@ -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.
+3
View File
@@ -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,
+6
View File
@@ -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,
}));
+3
View File
@@ -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 } } } },
},
}),
+5
View File
@@ -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({
@@ -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(),
+128
View File
@@ -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;
+23
View File
@@ -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,
+27
View File
@@ -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<DayLogWriteResult> {
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<DayLogWriteResult> {
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<DayLogWriteResult> {
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<DayLogWriteResult> {
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,
+11
View File
@@ -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;
+10
View File
@@ -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,
};
}
+82 -5
View File
@@ -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);
+32
View File
@@ -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;
}
/* ------------------------------------------------------------------ */
+11
View File
@@ -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(),
+4
View File
@@ -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(),
+19
View File
@@ -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(),