mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user