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

@@ -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;