mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
2c225ce094
commit
609d248252
18 files changed
+561
-6
No files matched your search
@@ -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";
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 } } } },
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user