feat(cycle): exclude disturbed temperatures from the symptothermal engine

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 15:04:25 +02:00
1 parent cd55c60fae
commit 4bfe9f11d7
27 files changed
+199 -7

No files matched your search

+12
View File
@@ -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
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
@@ -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;
+7
View File
@@ -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.
+2
View File
@@ -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,
+1
View File
@@ -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 } } } },
@@ -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);
});
});
+27 -1
View File
@@ -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<string>("");
const [bbtDisturbed, setBbtDisturbed] = useState(false);
const [opk, setOpk] = useState<OvulationTest | null>(null);
const [mucus, setMucus] = useState<CervicalMucus | null>(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 && (
<div className="mt-3 flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm">
{t("cycle.sheet.temperatureDisturbed")}
</span>
<span className="text-muted-foreground text-xs">
{t("cycle.sheet.temperatureDisturbedHint")}
</span>
</div>
<Switch
checked={bbtDisturbed}
onCheckedChange={setBbtDisturbed}
aria-label={t("cycle.sheet.temperatureDisturbed")}
/>
</div>
)}
</Field>
{/* Ovulation test */}
+2
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -176,6 +176,7 @@ export async function buildCycleSnapshotBlock(
date: true,
flow: true,
basalBodyTempC: true,
temperatureExcluded: true,
ovulationTest: true,
cervicalMucus: true,
},
@@ -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(),
@@ -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,
+3
View File
@@ -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,
+9
View File
@@ -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<DayLogWriteResult> {
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<DayLogWriteResult> {
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<DayLogWriteResult> {
(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<DayLogWriteResult> {
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,
+3
View File
@@ -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
+16 -2
View File
@@ -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,
});
+6 -3
View File
@@ -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));
+9
View File
@@ -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;
}
+2
View File
@@ -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(),
});
+1
View File
@@ -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(),
+4
View File
@@ -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(),