mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 "Temperaturkurve
|
||||
* 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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user