From 4bfe9f11d7490c878d1d6aa99e6188a866364df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:04:25 +0200 Subject: [PATCH] feat(cycle): exclude disturbed temperatures from the symptothermal engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fever, illness, alcohol or a late/short-sleep reading can fake or mask a temperature rise. Add a per-day `temperatureExcluded` flag on CycleDayLog (migration 0156): when set, the engine drops the reading from the cover-line baseline AND the shift evaluation, so a spike neither fabricates nor masks a rise (Sensiplan: the cover line is drawn over the last six unbracketed values). Wired end to end — schema + Prisma, the engine's `detectTempShift` filter, the Zod input/patch schemas, the day-log upsert (partial merge + change detection), the read DTO, the calendar/insights/coach selects, the backup round-trip, and the OpenAPI contract (a new boolean on the day-log and calendar-day DTOs for the iOS client). The log-day sheet gains a "mark reading as disturbed" toggle that appears once a temperature is entered, translated across all six locales. Citation: de.wikipedia "Symptothermale Methode"; myNFP "Temperaturkurve auswerten" — Störfaktor bracketing. --- docs/api/openapi.yaml | 12 +++++ messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../migration.sql | 10 ++++ prisma/schema.prisma | 7 +++ src/app/api/cycle/calendar/route.ts | 2 + src/app/api/cycle/insights/route.ts | 1 + .../cycle/__tests__/log-day-sheet.test.ts | 24 ++++++++++ src/components/cycle/log-day-sheet.tsx | 28 ++++++++++- src/components/cycle/types.ts | 2 + src/components/cycle/use-cycle.ts | 1 + src/lib/ai/coach/cycle-snapshot.ts | 1 + .../cycle/__tests__/engine-adapter.test.ts | 2 +- src/lib/cycle/__tests__/prediction.test.ts | 46 +++++++++++++++++++ src/lib/cycle/backup.ts | 3 ++ src/lib/cycle/day-log-write.ts | 9 ++++ src/lib/cycle/dto.ts | 3 ++ src/lib/cycle/engine-adapter.ts | 18 +++++++- src/lib/cycle/prediction.ts | 9 ++-- src/lib/cycle/types.ts | 9 ++++ src/lib/openapi/routes/cycle.ts | 2 + src/lib/validations/backup.ts | 1 + src/lib/validations/cycle.ts | 4 ++ 27 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c5d57b9ca..1b8f0c632 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -117,6 +117,8 @@ paths: type: number minimum: 30 maximum: 45 + temperatureExcluded: + type: boolean ovulationTest: type: string enum: @@ -252,6 +254,8 @@ paths: minimum: 30 maximum: 45 - type: "null" + temperatureExcluded: + type: boolean ovulationTest: anyOf: - type: string @@ -412,6 +416,8 @@ paths: type: number minimum: 30 maximum: 45 + temperatureExcluded: + type: boolean ovulationTest: type: string enum: @@ -6240,6 +6246,8 @@ components: anyOf: - type: number - type: "null" + temperatureExcluded: + type: boolean ovulationTest: anyOf: - type: string @@ -6333,6 +6341,7 @@ components: - flow - intermenstrualBleeding - basalBodyTempC + - temperatureExcluded - ovulationTest - cervicalMucus - sexualActivity @@ -6647,6 +6656,8 @@ components: anyOf: - type: number - type: "null" + temperatureExcluded: + type: boolean ovulationTest: anyOf: - type: string @@ -6677,6 +6688,7 @@ components: - hasSymptoms - confidence - basalBodyTempC + - temperatureExcluded - ovulationTest - cervicalMucus additionalProperties: false diff --git a/messages/de.json b/messages/de.json index 4090d9ad3..86bdf4591 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5882,6 +5882,8 @@ "symptoms": "Symptome", "temperature": "Basaltemperatur", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Messwert als gestört markieren", + "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.", "ovulationTest": "Ovulationstest", "mucus": "Zervixschleim", "intercourse": "Geschlechtsverkehr", diff --git a/messages/en.json b/messages/en.json index 22b529a5b..3ee745a08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5882,6 +5882,8 @@ "symptoms": "Symptoms", "temperature": "Basal body temperature", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Mark reading as disturbed", + "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.", "ovulationTest": "Ovulation test", "mucus": "Cervical mucus", "intercourse": "Intercourse", diff --git a/messages/es.json b/messages/es.json index c9aa4fbdf..6ff2de793 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5882,6 +5882,8 @@ "symptoms": "Síntomas", "temperature": "Temperatura basal", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Marcar la medición como alterada", + "temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.", "ovulationTest": "Test de ovulación", "mucus": "Moco cervical", "intercourse": "Relaciones", diff --git a/messages/fr.json b/messages/fr.json index 739e1af82..1ac723285 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5882,6 +5882,8 @@ "symptoms": "Symptômes", "temperature": "Température basale", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Marquer la mesure comme perturbée", + "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.", "ovulationTest": "Test d'ovulation", "mucus": "Glaire cervicale", "intercourse": "Rapports", diff --git a/messages/it.json b/messages/it.json index 183042f2e..b9f2be8b7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5882,6 +5882,8 @@ "symptoms": "Sintomi", "temperature": "Temperatura basale", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Segna la misurazione come disturbata", + "temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.", "ovulationTest": "Test di ovulazione", "mucus": "Muco cervicale", "intercourse": "Rapporti", diff --git a/messages/pl.json b/messages/pl.json index ed11af429..e60e33682 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5882,6 +5882,8 @@ "symptoms": "Objawy", "temperature": "Podstawowa temperatura ciała", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Oznacz pomiar jako zakłócony", + "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.", "ovulationTest": "Test owulacyjny", "mucus": "Śluz szyjkowy", "intercourse": "Współżycie", diff --git a/prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql b/prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql new file mode 100644 index 000000000..5556992de --- /dev/null +++ b/prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql @@ -0,0 +1,10 @@ +-- v1.17.0 — disturbed-temperature exclusion for the symptothermal engine. +-- +-- A fever / illness / alcohol / late or short-sleep BBT reading can fake or +-- mask a temperature rise. `temperature_excluded = true` marks a day's basal +-- body temperature as a disturbed value: the Sensiplan cover-line baseline and +-- the shift evaluation both skip it, so a spike neither fabricates nor masks a +-- rise (the cover line is drawn over the last six *unbracketed* values). +-- Additive: every existing row backfills `false`. + +ALTER TABLE "cycle_day_logs" ADD COLUMN "temperature_excluded" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a1ebb4dd..8214510e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3749,6 +3749,13 @@ model CycleDayLog { /// symptothermal confirmation. BBT also lands in Measurement /// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy. basalBodyTempC Float? @map("basal_body_temp_c") + /// Whether this day's BBT is a disturbed/unreliable reading (fever, + /// illness, alcohol, a late or short-sleep measurement) the user marked + /// excluded. When true the symptothermal engine drops the reading from the + /// cover-line baseline AND the shift evaluation, so a spike neither + /// fabricates nor masks a temperature rise (Sensiplan: the cover line is + /// drawn over the last six *unbracketed* values). + temperatureExcluded Boolean @default(false) @map("temperature_excluded") /// Ovulation predictor-kit (OPK) reading. ovulationTest OvulationTest? @map("ovulation_test") /// Cervical-mucus quality. diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 40b1b6f1c..cd03590f9 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -99,6 +99,7 @@ export const GET = apiHandler(async (request: NextRequest) => { date: true, flow: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, _count: { select: { symptomLinks: true } }, @@ -125,6 +126,7 @@ export const GET = apiHandler(async (request: NextRequest) => { date: l.date, flow: l.flow, basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, hasSymptoms: l._count.symptomLinks > 0, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 9ce67b0e9..0bff32a89 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -90,6 +90,7 @@ export const GET = apiHandler(async () => { date: true, flow: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, symptomLinks: { select: { symptom: { select: { key: true } } } }, diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index a7a130f29..00e6a5933 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -11,6 +11,7 @@ function blank(): DayLogFormState { flow: null, intermenstrual: false, bbt: "", + bbtDisturbed: false, opk: null, mucus: null, intercourse: false, @@ -90,4 +91,27 @@ describe("buildDayLogInput (new row → omit empties)", () => { expect(input.flow).toBe("LIGHT"); expect(input.basalBodyTempC).toBe(36.5); }); + + it("carries the disturbed flag alongside a BBT reading", () => { + const input = buildDayLogInput( + { ...blank(), bbt: "36.5", bbtDisturbed: true }, + "2026-06-06", + ); + expect(input.basalBodyTempC).toBe(36.5); + expect(input.temperatureExcluded).toBe(true); + const patch = buildDayLogPatch({ ...blank(), bbt: "36.5", bbtDisturbed: true }); + expect(patch.temperatureExcluded).toBe(true); + }); + + it("never marks an absent BBT reading disturbed", () => { + // A disturbed flag with no temperature is meaningless — it must reset. + const patch = buildDayLogPatch({ ...blank(), bbt: "", bbtDisturbed: true }); + expect(patch.basalBodyTempC).toBeNull(); + expect(patch.temperatureExcluded).toBe(false); + const input = buildDayLogInput( + { ...blank(), bbt: "", bbtDisturbed: true }, + "2026-06-06", + ); + expect("temperatureExcluded" in input).toBe(false); + }); }); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index c80170167..b21c4173d 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -154,6 +154,8 @@ export interface DayLogFormState { intermenstrual: boolean; /** Raw text from the BBT input; "" / non-finite resolves to null. */ bbt: string; + /** Whether the BBT reading is marked disturbed (fever/late) — engine skips it. */ + bbtDisturbed: boolean; opk: OvulationTest | null; mucus: CervicalMucus | null; intercourse: boolean; @@ -189,6 +191,8 @@ export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch { flow: s.flow ?? null, intermenstrualBleeding: s.intermenstrual, basalBodyTempC: resolveBbt(s.bbt), + // Only meaningful with a BBT present; a cleared reading resets the flag. + temperatureExcluded: resolveBbt(s.bbt) != null ? s.bbtDisturbed : false, ovulationTest: s.opk ?? null, cervicalMucus: s.mucus ?? null, sexualActivity: s.intercourse, @@ -214,7 +218,9 @@ export function buildDayLogInput( source: "MANUAL", ...(s.flow ? { flow: s.flow } : {}), intermenstrualBleeding: s.intermenstrual, - ...(bbtVal != null ? { basalBodyTempC: bbtVal } : {}), + ...(bbtVal != null + ? { basalBodyTempC: bbtVal, temperatureExcluded: s.bbtDisturbed } + : {}), ...(s.opk ? { ovulationTest: s.opk } : {}), ...(s.mucus ? { cervicalMucus: s.mucus } : {}), sexualActivity: s.intercourse, @@ -292,6 +298,7 @@ export function LogDaySheet({ new Map(), ); const [bbt, setBbt] = useState(""); + const [bbtDisturbed, setBbtDisturbed] = useState(false); const [opk, setOpk] = useState(null); const [mucus, setMucus] = useState(null); const [intercourse, setIntercourse] = useState(false); @@ -324,6 +331,7 @@ export function LogDaySheet({ setIntermenstrual(dto?.intermenstrualBleeding ?? false); setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity]))); setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : ""); + setBbtDisturbed(dto?.temperatureExcluded ?? false); setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); setIntercourse(dto?.sexualActivity ?? false); @@ -359,6 +367,7 @@ export function LogDaySheet({ flow, intermenstrual, bbt, + bbtDisturbed, opk, mucus, intercourse, @@ -601,6 +610,23 @@ export function LogDaySheet({ className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" aria-label={t("cycle.sheet.temperature")} /> + {resolveBbt(bbt) != null && ( +
+
+ + {t("cycle.sheet.temperatureDisturbed")} + + + {t("cycle.sheet.temperatureDisturbedHint")} + +
+ +
+ )} {/* Ovulation test */} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index be791ffe5..064495b48 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -138,6 +138,7 @@ export interface CycleDayLogInput { flow?: FlowLevel; intermenstrualBleeding?: boolean; basalBodyTempC?: number; + temperatureExcluded?: boolean; ovulationTest?: OvulationTest; cervicalMucus?: CervicalMucus; sexualActivity?: boolean; @@ -159,6 +160,7 @@ export interface CycleDayLogDTO { flow: FlowLevel | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + temperatureExcluded: boolean; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; sexualActivity: boolean; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index dc5d94a48..1f7fca8ee 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -237,6 +237,7 @@ export interface CycleDayLogPatch { flow?: FlowLevel | null; intermenstrualBleeding?: boolean; basalBodyTempC?: number | null; + temperatureExcluded?: boolean; ovulationTest?: OvulationTest | null; cervicalMucus?: CervicalMucus | null; sexualActivity?: boolean; diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index c3c3e24f2..91369c3f3 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -176,6 +176,7 @@ export async function buildCycleSnapshotBlock( date: true, flow: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, }, diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 6e2db3080..4630c2088 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, ovulationTest: null, cervicalMucus: null, hasSymptoms: true }, + { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: 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 47768d805..01d6e1d34 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -389,6 +389,52 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9)); }); + it("detectTempShift: an excluded (disturbed) baseline spike no longer masks a true rise", () => { + const start = "2024-01-01"; + // 6 baseline days; the LAST (immediately before the rise) is a fever spike, + // so it sits in the cover-line window. Unexcluded it raises the cover line + // and contaminates detection; excluded it is dropped and the remaining lows + // form the real (lower) cover line. + const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 37.5]; + const extraLow = 36.39; // a 6th valid low so exclusion still leaves 6 + const rise = [36.55, 36.6, 36.62]; + const build = (excludeFever: boolean): DayLogInput[] => { + const logs: DayLogInput[] = []; + logs.push(bbtDay(addDays(start, 0), extraLow)); + baseline.forEach((t, i) => { + const day = bbtDay(addDays(start, 1 + i), t); + if (i === 5 && excludeFever) day.temperatureExcluded = true; + logs.push(day); + }); + rise.forEach((t, i) => logs.push(bbtDay(addDays(start, 7 + i), t))); + return logs; + }; + // Fever NOT excluded: the 37.5 spike contaminates detection — it does NOT + // yield the correct shift (ovulation = day before the real rise). + const corrupted = detectTempShift(build(false), 0.2); + expect(corrupted?.ovulationDate).not.toBe(addDays(start, 6)); + // Fever excluded: cover line falls to the real baseline max and the genuine + // rise confirms with ovulation the day before it (2024-01-07). + const shift = detectTempShift(build(true), 0.2); + expect(shift?.rule).toBe(0); + expect(shift?.ovulationDate).toBe(addDays(start, 6)); + }); + + it("detectTempShift: an excluded fever spike inside the rise no longer fabricates a shift", () => { + const start = "2024-01-01"; + const logs: DayLogInput[] = []; + [36.4, 36.4, 36.4, 36.4, 36.4, 36.4].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + const fever = bbtDay(addDays(start, 6), 38.0); + fever.temperatureExcluded = true; + logs.push(fever); + logs.push(bbtDay(addDays(start, 7), 36.41)); + logs.push(bbtDay(addDays(start, 8), 36.4)); + // The excluded fever is dropped; the remaining readings show no rise. + expect(detectTempShift(logs, 0.2)).toBeNull(); + }); + it("detectMucusPeak: confirms the last best-quality day followed by 3 drier days", () => { const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({ date, diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 34ef32e7d..87a58fb52 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -40,6 +40,7 @@ export interface CycleBackupSection { flow: string | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; sexualActivity: boolean; @@ -108,6 +109,7 @@ export async function buildCycleBackupSection( flow: d.flow, intermenstrualBleeding: d.intermenstrualBleeding, basalBodyTempC: d.basalBodyTempC, + temperatureExcluded: d.temperatureExcluded, ovulationTest: d.ovulationTest, cervicalMucus: d.cervicalMucus, sexualActivity: d.sexualActivity, @@ -289,6 +291,7 @@ export async function restoreCycleData( flow: enumOrNull(d.flow, FLOW_LEVELS) as never, intermenstrualBleeding: d.intermenstrualBleeding ?? false, basalBodyTempC: d.basalBodyTempC ?? null, + temperatureExcluded: d.temperatureExcluded ?? false, ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, sexualActivity: d.sexualActivity ?? false, diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 16b9c2234..9d70d599e 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -140,6 +140,7 @@ type ExistingRow = { flow: string | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; sexualActivity: boolean; @@ -158,6 +159,7 @@ const EXISTING_SELECT = { flow: true, intermenstrualBleeding: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, sexualActivity: true, @@ -309,6 +311,10 @@ async function writeDayLog(args: WriteArgs): Promise { entry.basalBodyTempC !== undefined ? (entry.basalBodyTempC ?? null) : (existing?.basalBodyTempC ?? null); + const temperatureExcluded = + entry.temperatureExcluded !== undefined + ? entry.temperatureExcluded + : (existing?.temperatureExcluded ?? false); const ovulationTest = entry.ovulationTest !== undefined ? (entry.ovulationTest ?? null) @@ -338,6 +344,7 @@ async function writeDayLog(args: WriteArgs): Promise { flow: flow as never, intermenstrualBleeding, basalBodyTempC, + temperatureExcluded, ovulationTest: ovulationTest as never, cervicalMucus: cervicalMucus as never, sexualActivity: sensitivePlaintext.sexualActivity, @@ -357,6 +364,7 @@ async function writeDayLog(args: WriteArgs): Promise { (existing.flow ?? null) !== flow || existing.intermenstrualBleeding !== intermenstrualBleeding || (existing.basalBodyTempC ?? null) !== basalBodyTempC || + existing.temperatureExcluded !== temperatureExcluded || (existing.ovulationTest ?? null) !== ovulationTest || (existing.cervicalMucus ?? null) !== cervicalMucus || storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || @@ -395,6 +403,7 @@ async function writeDayLog(args: WriteArgs): Promise { flow: baseData.flow as never, intermenstrualBleeding: baseData.intermenstrualBleeding as never, basalBodyTempC: baseData.basalBodyTempC as never, + temperatureExcluded: baseData.temperatureExcluded as never, ovulationTest: baseData.ovulationTest as never, cervicalMucus: baseData.cervicalMucus as never, sexualActivity: sensitivePlaintext.sexualActivity, diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index b9a3ca7b5..7b43456a4 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -29,6 +29,8 @@ export interface CycleDayLogDTO { flow: string | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + /** Whether the day's BBT is marked disturbed (excluded from the engine). */ + temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; sexualActivity: boolean; @@ -107,6 +109,7 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { flow: row.flow, intermenstrualBleeding: row.intermenstrualBleeding, basalBodyTempC: row.basalBodyTempC, + temperatureExcluded: row.temperatureExcluded, ovulationTest: row.ovulationTest, cervicalMucus: row.cervicalMucus, sexualActivity: hasEnvelope diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 20c11f2d2..5afc60d09 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -37,6 +37,8 @@ export interface CalendarDayDTO { confidence: number; /** Logged basal body temperature (°C), or null. Feeds the web BBT chart. */ basalBodyTempC: number | null; + /** Whether the day's BBT is marked disturbed (excluded from the engine). */ + temperatureExcluded: boolean; /** Logged ovulation-test result, or null. */ ovulationTest: string | null; /** Logged cervical-mucus quality, or null. */ @@ -46,7 +48,12 @@ export interface CalendarDayDTO { /** Rows the calendar needs from each day-log. */ export type CalendarDayLogRow = Pick< CycleDayLog, - "date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus" + | "date" + | "flow" + | "basalBodyTempC" + | "temperatureExcluded" + | "ovulationTest" + | "cervicalMucus" > & { hasSymptoms: boolean }; /** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ @@ -73,13 +80,19 @@ export function toCycleInputs( export function toDayLogInputs( logs: readonly Pick< CycleDayLog, - "date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus" + | "date" + | "flow" + | "basalBodyTempC" + | "temperatureExcluded" + | "ovulationTest" + | "cervicalMucus" >[], ): DayLogInput[] { return logs.map((l) => ({ date: l.date, flow: l.flow, basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, })); @@ -337,6 +350,7 @@ export function buildCalendar( hasSymptoms: log?.hasSymptoms ?? false, confidence: prediction?.confidence ?? 0, basalBodyTempC: log?.basalBodyTempC ?? null, + temperatureExcluded: log?.temperatureExcluded ?? false, ovulationTest: log?.ovulationTest ?? null, cervicalMucus: log?.cervicalMucus ?? null, }); diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index abc908b32..735b65af8 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -272,8 +272,11 @@ interface TempShiftResult { * published Ausnahmeregeln (exception rules). * * Cover line (Hüllkurve) = the highest of the 6 measured low values immediately - * preceding the rise. Ovulation = the last low day before the rise (the day - * BEFORE the first elevated reading). + * preceding the rise. Excluded (disturbed) readings are dropped before the cover + * line and the rise are evaluated, so a fever / late reading neither raises the + * cover line nor masks a true shift (Sensiplan: the line is drawn over the last + * six *unbracketed* values). Ovulation = the last low day before the rise (the + * day BEFORE the first elevated reading). * * Rules (Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Temperatur­kurve * auswerten"; Generation-Pille "Die wichtigsten NFP-Regeln"): @@ -291,7 +294,7 @@ export function detectTempShift( thresholdC: number, ): TempShiftResult | null { const temps = dayLogs - .filter((l) => l.basalBodyTempC != null) + .filter((l) => l.basalBodyTempC != null && l.temperatureExcluded !== true) .map((l) => ({ date: l.date, t: l.basalBodyTempC as number })) .sort((a, b) => dayDiff(a.date, b.date)); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 425cd6e53..993bfb4ad 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -192,6 +192,15 @@ export interface DayLogInput { flow: FlowLevel | null; /** Basal body temperature in °C, already rounded to 2 dp before input. */ basalBodyTempC: number | null; + /** + * Whether this day's BBT is a disturbed/unreliable reading (fever, illness, + * alcohol, a late or short-sleep measurement) the user marked excluded. When + * true the engine drops the reading from the cover-line baseline AND the shift + * evaluation, so a spike neither fabricates nor masks a temperature rise + * (Sensiplan: the cover line is drawn over the last six *unbracketed* values). + * Optional for back-compat; treated as `false` when absent. + */ + temperatureExcluded?: boolean | null; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; } diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index a2663c885..fe6ef6391 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -101,6 +101,7 @@ const cycleDayLogDto = z flow: flowLevelEnum.nullable(), intermenstrualBleeding: z.boolean(), basalBodyTempC: z.number().nullable(), + temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), sexualActivity: z.boolean(), @@ -175,6 +176,7 @@ const cycleCalendarDayDto = z.object({ // so the web BBT chart renders from the calendar read (the values are already // loaded server-side for the symptothermal layer; no extra query). basalBodyTempC: z.number().nullable(), + temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), }); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ea483d352..283c5c306 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -141,6 +141,7 @@ const cycleDayLogSchema = z flow: z.string().nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().nullable().optional(), + temperatureExcluded: z.boolean().optional(), ovulationTest: z.string().nullable().optional(), cervicalMucus: z.string().nullable().optional(), sexualActivity: z.boolean().optional(), diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index 6b3353cc1..c19ac0bb4 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -103,6 +103,9 @@ export const cycleDayLogInputSchema = z.object({ flow: flowLevelEnum.optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().min(30).max(45).optional(), + // Marks the day's BBT as disturbed (fever / illness / late reading): the + // symptothermal engine drops it from the cover line + shift evaluation. + temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.optional(), cervicalMucus: cervicalMucusEnum.optional(), sexualActivity: z.boolean().optional(), @@ -132,6 +135,7 @@ export const cycleDayLogPatchSchema = z.object({ flow: flowLevelEnum.nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().min(30).max(45).nullable().optional(), + temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.nullable().optional(), cervicalMucus: cervicalMucusEnum.nullable().optional(), sexualActivity: z.boolean().optional(),