mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
1 parent
b3fea2ef59
commit
1f4ba5939d
1000 files changed
+12130
-8385
No files matched your search
@@ -80,8 +80,10 @@ describe("create schema", () => {
|
||||
|
||||
it("rejects a categoryKey other than custom", () => {
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "X", categoryKey: "physical" })
|
||||
.success,
|
||||
createCustomSymptomSchema.safeParse({
|
||||
label: "X",
|
||||
categoryKey: "physical",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addDays, dayDiff, isOnOrBefore, isWithin, parseDayMs, roundHalf } from "../day-math";
|
||||
import {
|
||||
addDays,
|
||||
dayDiff,
|
||||
isOnOrBefore,
|
||||
isWithin,
|
||||
parseDayMs,
|
||||
roundHalf,
|
||||
} from "../day-math";
|
||||
|
||||
/**
|
||||
* The day-math is the shared substrate iOS re-implements bit-for-bit. These
|
||||
|
||||
@@ -25,7 +25,10 @@ function profile(overrides: Partial<CycleProfile> = {}): CycleProfile {
|
||||
} as CycleProfile;
|
||||
}
|
||||
|
||||
function cycle(startDate: string, overrides: Partial<MenstrualCycle> = {}): MenstrualCycle {
|
||||
function cycle(
|
||||
startDate: string,
|
||||
overrides: Partial<MenstrualCycle> = {},
|
||||
): MenstrualCycle {
|
||||
return {
|
||||
id: `c-${startDate}`,
|
||||
userId: "u1",
|
||||
@@ -48,14 +51,33 @@ function cycle(startDate: string, overrides: Partial<MenstrualCycle> = {}): Mens
|
||||
describe("buildCalendar", () => {
|
||||
// Three ~28-day cycles → a real prediction.
|
||||
const cycles = [
|
||||
cycle("2026-01-01", { endDate: "2026-01-28", lengthDays: 28, periodEndDate: "2026-01-05" }),
|
||||
cycle("2026-01-29", { endDate: "2026-02-25", lengthDays: 28, periodEndDate: "2026-02-02" }),
|
||||
cycle("2026-01-01", {
|
||||
endDate: "2026-01-28",
|
||||
lengthDays: 28,
|
||||
periodEndDate: "2026-01-05",
|
||||
}),
|
||||
cycle("2026-01-29", {
|
||||
endDate: "2026-02-25",
|
||||
lengthDays: 28,
|
||||
periodEndDate: "2026-02-02",
|
||||
}),
|
||||
cycle("2026-02-26", { periodEndDate: "2026-03-02" }),
|
||||
];
|
||||
|
||||
it("labels logged-flow days and emits a forecast for ≥2 cycles", () => {
|
||||
const dayLogs: CalendarDayLogRow[] = [
|
||||
{ date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, cervixPosition: null, cervixFirmness: null, cervixOpening: null, hasSymptoms: true },
|
||||
{
|
||||
date: "2026-02-26",
|
||||
flow: "MEDIUM",
|
||||
basalBodyTempC: null,
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
hasSymptoms: true,
|
||||
},
|
||||
];
|
||||
const { prediction, days } = buildCalendar(
|
||||
profile(),
|
||||
@@ -99,9 +121,18 @@ describe("buildCalendar", () => {
|
||||
it("surfaces the fertile window for the conception goal once past cold start", () => {
|
||||
// Four starts → three completed cycles → not still-learning.
|
||||
const fourCycles = [
|
||||
cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }),
|
||||
cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }),
|
||||
cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }),
|
||||
cycle("2026-01-01", {
|
||||
endDate: "2026-01-29",
|
||||
periodEndDate: "2026-01-05",
|
||||
}),
|
||||
cycle("2026-01-29", {
|
||||
endDate: "2026-02-26",
|
||||
periodEndDate: "2026-02-02",
|
||||
}),
|
||||
cycle("2026-02-26", {
|
||||
endDate: "2026-03-26",
|
||||
periodEndDate: "2026-03-02",
|
||||
}),
|
||||
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
|
||||
];
|
||||
const { days } = buildCalendar(
|
||||
@@ -163,9 +194,18 @@ describe("buildCalendar", () => {
|
||||
it("emits normal fertile/ovulation/phase output once ≥3 cycles are observed", () => {
|
||||
// Four ~28-day starts → three COMPLETED cycle lengths → cyclesObserved=3.
|
||||
const fourCycles = [
|
||||
cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }),
|
||||
cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }),
|
||||
cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }),
|
||||
cycle("2026-01-01", {
|
||||
endDate: "2026-01-29",
|
||||
periodEndDate: "2026-01-05",
|
||||
}),
|
||||
cycle("2026-01-29", {
|
||||
endDate: "2026-02-26",
|
||||
periodEndDate: "2026-02-02",
|
||||
}),
|
||||
cycle("2026-02-26", {
|
||||
endDate: "2026-03-26",
|
||||
periodEndDate: "2026-03-02",
|
||||
}),
|
||||
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
|
||||
];
|
||||
const { prediction, stillLearning, days } = buildCalendar(
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const isModuleEnabledMock = vi.fn<(userId: string, key: string) => Promise<boolean>>();
|
||||
const isModuleEnabledMock =
|
||||
vi.fn<(userId: string, key: string) => Promise<boolean>>();
|
||||
const getOrCreateCycleProfileMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
@@ -50,7 +51,9 @@ describe("isCycleEnabled", () => {
|
||||
|
||||
it("gates on UPPERCASE FEMALE only (server enum spelling)", () => {
|
||||
// Lowercase / other spellings never enable by derivation.
|
||||
expect(isCycleEnabled("female", { cycleTrackingEnabled: null })).toBe(false);
|
||||
expect(isCycleEnabled("female", { cycleTrackingEnabled: null })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCycleEnabled("OTHER", { cycleTrackingEnabled: null })).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ describe("cycle/healthkit-mapping — identifier ownership", () => {
|
||||
});
|
||||
|
||||
it("does NOT own unrelated identifiers", () => {
|
||||
expect(isCycleHkIdentifier("HKQuantityTypeIdentifierStepCount")).toBe(false);
|
||||
expect(isCycleHkIdentifier("HKQuantityTypeIdentifierStepCount")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +85,10 @@ describe("cycle/healthkit-mapping — ovulation + cervical mucus + tests", () =>
|
||||
HK_OVULATION_TEST,
|
||||
"HKCategoryValueOvulationTestResultLuteinizingHormoneSurge",
|
||||
),
|
||||
).toEqual({ kind: "day-log", fields: { ovulationTest: "POSITIVE_LH_SURGE" } });
|
||||
).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { ovulationTest: "POSITIVE_LH_SURGE" },
|
||||
});
|
||||
expect(mapHkCycleSample(HK_OVULATION_TEST, "4")).toEqual({
|
||||
kind: "day-log",
|
||||
fields: { ovulationTest: "ESTROGEN_SURGE" },
|
||||
@@ -141,10 +146,7 @@ describe("cycle/healthkit-mapping — sexual activity + protection", () => {
|
||||
describe("cycle/healthkit-mapping — contraceptive routes to day-log + profile", () => {
|
||||
it("records the method AND carries a profile nudge", () => {
|
||||
expect(
|
||||
mapHkCycleSample(
|
||||
HK_CONTRACEPTIVE,
|
||||
"HKCategoryValueContraceptiveOral",
|
||||
),
|
||||
mapHkCycleSample(HK_CONTRACEPTIVE, "HKCategoryValueContraceptiveOral"),
|
||||
).toEqual({
|
||||
kind: "day-log+profile",
|
||||
fields: { contraceptive: "ORAL" },
|
||||
@@ -164,9 +166,9 @@ describe("cycle/healthkit-mapping — symptoms", () => {
|
||||
});
|
||||
|
||||
it("skips an explicit not-present symptom sample", () => {
|
||||
expect(
|
||||
mapHkCycleSample("HKCategoryTypeIdentifierHeadache", "1"),
|
||||
).toEqual({ kind: "skip" });
|
||||
expect(mapHkCycleSample("HKCategoryTypeIdentifierHeadache", "1")).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
expect(hkSymptomIsPresent("1")).toBe(false);
|
||||
expect(hkSymptomIsPresent("HKCategoryValueSeverityNotPresent")).toBe(false);
|
||||
expect(hkSymptomIsPresent("3")).toBe(true);
|
||||
|
||||
@@ -16,11 +16,7 @@ import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
import type { CyclePhase } from "../types";
|
||||
|
||||
/** Build a measurement row on a given YYYY-MM-DD at noon UTC. */
|
||||
function m(
|
||||
type: string,
|
||||
day: string,
|
||||
value: number,
|
||||
): CrossMetricMeasurement {
|
||||
function m(type: string, day: string, value: number): CrossMetricMeasurement {
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
@@ -55,7 +51,9 @@ function fixture(opts: {
|
||||
const k = dayKey(dayIdx++);
|
||||
phaseByDay.set(k, "LUTEAL");
|
||||
// tiny deterministic jitter so the group has non-zero variance
|
||||
measurements.push(m("RESTING_HEART_RATE", k, opts.lutealRhr + (i % 3) * 0.5));
|
||||
measurements.push(
|
||||
m("RESTING_HEART_RATE", k, opts.lutealRhr + (i % 3) * 0.5),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < opts.n; i++) {
|
||||
const k = dayKey(dayIdx++);
|
||||
@@ -130,7 +128,11 @@ describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", ()
|
||||
});
|
||||
|
||||
it("surfaces a luteal-vs-follicular MOOD contrast (injected synthetic channel)", () => {
|
||||
const { phaseByDay, measurements } = contrastFor(MOOD_CHANNEL_KEY, 4.4, 2.6);
|
||||
const { phaseByDay, measurements } = contrastFor(
|
||||
MOOD_CHANNEL_KEY,
|
||||
4.4,
|
||||
2.6,
|
||||
);
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const mood = rows.find((r) => r.metricKey === "mood");
|
||||
expect(mood).toBeDefined();
|
||||
@@ -193,7 +195,9 @@ describe("computePhaseMetricCrosstab", () => {
|
||||
});
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
// No real separation → nothing clears p < 0.05.
|
||||
expect(rows.find((r) => r.metricKey === "restingHeartRate")).toBeUndefined();
|
||||
expect(
|
||||
rows.find((r) => r.metricKey === "restingHeartRate"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores MENSTRUAL / OVULATORY days (only the two contrast phases count)", () => {
|
||||
@@ -298,7 +302,12 @@ describe("discoverPhaseCorrelations", () => {
|
||||
const phaseByDay = new Map<string, CyclePhase>();
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
const base = new Date(Date.UTC(2026, 0, 1));
|
||||
const phases: CyclePhase[] = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"];
|
||||
const phases: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(base);
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
@@ -326,13 +335,19 @@ describe("selectHeadlinePhaseRow", () => {
|
||||
});
|
||||
|
||||
it("prefers resting heart rate", () => {
|
||||
const rows = [row("weight"), row("restingHeartRate"), row("heartRateVariability")];
|
||||
const rows = [
|
||||
row("weight"),
|
||||
row("restingHeartRate"),
|
||||
row("heartRateVariability"),
|
||||
];
|
||||
expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("restingHeartRate");
|
||||
});
|
||||
|
||||
it("falls back to HRV when RHR is absent", () => {
|
||||
const rows = [row("weight"), row("heartRateVariability")];
|
||||
expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("heartRateVariability");
|
||||
expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe(
|
||||
"heartRateVariability",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the strongest remaining row", () => {
|
||||
|
||||
@@ -108,8 +108,16 @@ describe("cycle/phase — phaseSeries", () => {
|
||||
it("emits a categorical value per day across the span", () => {
|
||||
const series = phaseSeries("2024-01-01", "2024-01-28", CYCLE);
|
||||
expect(series).toHaveLength(28);
|
||||
expect(series[0]).toEqual({ date: "2024-01-01", phase: "MENSTRUAL", dayOfCycle: 1 });
|
||||
expect(series[27]).toEqual({ date: "2024-01-28", phase: "LUTEAL", dayOfCycle: 28 });
|
||||
expect(series[0]).toEqual({
|
||||
date: "2024-01-01",
|
||||
phase: "MENSTRUAL",
|
||||
dayOfCycle: 1,
|
||||
});
|
||||
expect(series[27]).toEqual({
|
||||
date: "2024-01-28",
|
||||
phase: "LUTEAL",
|
||||
dayOfCycle: 28,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks out-of-cycle days null at the span edges", () => {
|
||||
@@ -131,6 +139,8 @@ describe("cycle/phase — phaseSeries", () => {
|
||||
};
|
||||
expect(phaseForDay("2024-03-14", dstCycle).dayOfCycle).toBe(14);
|
||||
expect(phaseForDay("2024-03-14", dstCycle).phase).toBe("OVULATORY");
|
||||
expect(phaseForDay(addDays("2024-03-01", 27), dstCycle).dayOfCycle).toBe(28);
|
||||
expect(phaseForDay(addDays("2024-03-01", 27), dstCycle).dayOfCycle).toBe(
|
||||
28,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,10 @@ describe("cycle/prediction — robust statistics", () => {
|
||||
|
||||
describe("cycle/prediction — cycle-length estimator (§1, §5)", () => {
|
||||
it("regular 28-day user: median 28, MAD 0 → sigma floor 1.0", () => {
|
||||
const est = estimateCycleLength([28, 28, 28, 28, 28, 28], "TRYING_TO_CONCEIVE");
|
||||
const est = estimateCycleLength(
|
||||
[28, 28, 28, 28, 28, 28],
|
||||
"TRYING_TO_CONCEIVE",
|
||||
);
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
expect(est.sigma).toBe(1.0); // SIGMA_FLOOR (MAD = 0)
|
||||
expect(est.cyclesObserved).toBe(6);
|
||||
@@ -74,14 +77,20 @@ describe("cycle/prediction — cycle-length estimator (§1, §5)", () => {
|
||||
|
||||
it("irregular user: high MAD widens sigma", () => {
|
||||
// lengths 24,28,32,26,30,28 → median 28, deviations 4,0,4,2,2,0 → MAD 2
|
||||
const est = estimateCycleLength([24, 28, 32, 26, 30, 28], "TRYING_TO_CONCEIVE");
|
||||
const est = estimateCycleLength(
|
||||
[24, 28, 32, 26, 30, 28],
|
||||
"TRYING_TO_CONCEIVE",
|
||||
);
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
expect(est.sigma).toBeCloseTo(1.4826 * 2, 6); // 2.9652
|
||||
});
|
||||
|
||||
it("excludes a 3-MAD outlier from the point estimate but counts it against confidence", () => {
|
||||
// 28,28,29,28,28 + one wild 60-day (missed-log: >= 1.75*28 = 49).
|
||||
const est = estimateCycleLength([28, 28, 29, 28, 28, 60], "TRYING_TO_CONCEIVE");
|
||||
const est = estimateCycleLength(
|
||||
[28, 28, 29, 28, 28, 60],
|
||||
"TRYING_TO_CONCEIVE",
|
||||
);
|
||||
// 60 excluded → median over kept = 28, kept count = 5.
|
||||
expect(est.lengthRounded).toBe(28);
|
||||
expect(est.cyclesObserved).toBe(5);
|
||||
@@ -122,7 +131,13 @@ describe("cycle/prediction — period-length estimator (§2)", () => {
|
||||
};
|
||||
|
||||
function flowDay(date: string, flow: DayLogInput["flow"]): DayLogInput {
|
||||
return { date, flow, basalBodyTempC: null, ovulationTest: null, cervicalMucus: null };
|
||||
return {
|
||||
date,
|
||||
flow,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("counts a contiguous 5-day bleeding run", () => {
|
||||
@@ -149,7 +164,9 @@ describe("cycle/prediction — period-length estimator (§2)", () => {
|
||||
});
|
||||
|
||||
it("returns 0 when startDate itself has no bleeding", () => {
|
||||
expect(observedPeriodLength("2024-01-01", [flowDay("2024-01-02", "MEDIUM")])).toBe(0);
|
||||
expect(
|
||||
observedPeriodLength("2024-01-01", [flowDay("2024-01-02", "MEDIUM")]),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("estimatePeriodLength prefers explicit periodEndDate", () => {
|
||||
@@ -286,7 +303,12 @@ describe("cycle/prediction — predictCycle calendar path (§1–§3)", () => {
|
||||
cervicalMucus: null,
|
||||
});
|
||||
}
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 7));
|
||||
const result = predictCycle(
|
||||
cycles,
|
||||
logs,
|
||||
BASE_PROFILE,
|
||||
addDays(lastStart, 7),
|
||||
);
|
||||
// density 1 → logSparsity 0 → penalty 1 → halfWidth round(1*1*1)=1.
|
||||
expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -1));
|
||||
// cAdherence = 0.4 + 0.6*1 = 1.0 → confidence 1*1*1.0 = 0.98 (clamped from 1.0).
|
||||
@@ -296,8 +318,18 @@ describe("cycle/prediction — predictCycle calendar path (§1–§3)", () => {
|
||||
});
|
||||
|
||||
describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
|
||||
function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput {
|
||||
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
|
||||
function bbtDay(
|
||||
date: string,
|
||||
t: number,
|
||||
mucus: DayLogInput["cervicalMucus"] = null,
|
||||
): DayLogInput {
|
||||
return {
|
||||
date,
|
||||
flow: null,
|
||||
basalBodyTempC: t,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: mucus,
|
||||
};
|
||||
}
|
||||
|
||||
function cervixDay(
|
||||
@@ -529,7 +561,10 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
|
||||
logs.push(bbtDay(addDays(start, 8), 36.62));
|
||||
// temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06,
|
||||
// confirmed by 3 drier days after it (Sensiplan post-peak count).
|
||||
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
|
||||
const mucusDay = (
|
||||
date: string,
|
||||
m: DayLogInput["cervicalMucus"],
|
||||
): DayLogInput => ({
|
||||
date,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
@@ -670,7 +705,10 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
|
||||
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
|
||||
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
|
||||
// mucus peak agreeing with the temp ovulation, confirmed by 3 drier days.
|
||||
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
|
||||
const mucusDay = (
|
||||
date: string,
|
||||
m: DayLogInput["cervicalMucus"],
|
||||
): DayLogInput => ({
|
||||
date,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
@@ -682,7 +720,12 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
|
||||
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
|
||||
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
|
||||
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
|
||||
const result = predictCycle(
|
||||
cycles,
|
||||
logs,
|
||||
BASE_PROFILE,
|
||||
addDays(lastStart, 16),
|
||||
);
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.method).toBe("BLENDED");
|
||||
expect(result.predictedOvulation).toBe(ovDay);
|
||||
@@ -768,7 +811,10 @@ describe("cycle/prediction — Marquette LH/OPK ovulation anchor (§4, C2)", ()
|
||||
logs.push(bbt(riseStart, 36.6));
|
||||
logs.push(bbt(addDays(riseStart, 1), 36.62));
|
||||
logs.push(bbt(addDays(riseStart, 2), 36.63));
|
||||
const mucus = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
|
||||
const mucus = (
|
||||
date: string,
|
||||
m: DayLogInput["cervicalMucus"],
|
||||
): DayLogInput => ({
|
||||
date,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
@@ -782,7 +828,12 @@ describe("cycle/prediction — Marquette LH/OPK ovulation anchor (§4, C2)", ()
|
||||
// A contradicting LH surge far from the temp shift must NOT override it.
|
||||
logs.push(lhDay(addDays(ovDay, 5)));
|
||||
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 20));
|
||||
const result = predictCycle(
|
||||
cycles,
|
||||
logs,
|
||||
BASE_PROFILE,
|
||||
addDays(lastStart, 20),
|
||||
);
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.predictedOvulation).toBe(ovDay); // symptothermal wins
|
||||
});
|
||||
@@ -793,7 +844,8 @@ describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)
|
||||
const start = "2024-02-01";
|
||||
const nights: NightlyTempInput[] = [];
|
||||
// 6 trailing nights at 36.30 → mean 36.30.
|
||||
for (let i = 0; i < 6; i++) nights.push({ date: addDays(start, i), valueC: 36.3 });
|
||||
for (let i = 0; i < 6; i++)
|
||||
nights.push({ date: addDays(start, i), valueC: 36.3 });
|
||||
// 4-night window: 3 elevated (>= 36.45) + 1 not.
|
||||
nights.push({ date: addDays(start, 6), valueC: 36.46 });
|
||||
nights.push({ date: addDays(start, 7), valueC: 36.47 });
|
||||
@@ -810,13 +862,20 @@ describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
const start = addDays(lastStart, 5);
|
||||
const nights: NightlyTempInput[] = [];
|
||||
for (let i = 0; i < 6; i++) nights.push({ date: addDays(start, i), valueC: 36.3 });
|
||||
for (let i = 0; i < 6; i++)
|
||||
nights.push({ date: addDays(start, i), valueC: 36.3 });
|
||||
nights.push({ date: addDays(start, 6), valueC: 36.46 });
|
||||
nights.push({ date: addDays(start, 7), valueC: 36.47 });
|
||||
nights.push({ date: addDays(start, 8), valueC: 36.48 });
|
||||
nights.push({ date: addDays(start, 9), valueC: 36.49 });
|
||||
|
||||
const result = predictCycle(cycles, [], BASE_PROFILE, addDays(lastStart, 20), nights);
|
||||
const result = predictCycle(
|
||||
cycles,
|
||||
[],
|
||||
BASE_PROFILE,
|
||||
addDays(lastStart, 20),
|
||||
nights,
|
||||
);
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.method).toBe("BLENDED");
|
||||
expect(result.predictedOvulation).toBe(addDays(start, 5));
|
||||
@@ -824,8 +883,18 @@ describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)
|
||||
});
|
||||
|
||||
describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
|
||||
function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput {
|
||||
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
|
||||
function bbtDay(
|
||||
date: string,
|
||||
t: number,
|
||||
mucus: DayLogInput["cervicalMucus"] = null,
|
||||
): DayLogInput {
|
||||
return {
|
||||
date,
|
||||
flow: null,
|
||||
basalBodyTempC: t,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: mucus,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a confirmable symptothermal shift (temp + agreeing mucus) on `ovDay`. */
|
||||
@@ -839,7 +908,10 @@ describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
|
||||
logs.push(bbtDay(riseStart, 36.6));
|
||||
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
|
||||
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
|
||||
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
|
||||
const mucusDay = (
|
||||
date: string,
|
||||
m: DayLogInput["cervicalMucus"],
|
||||
): DayLogInput => ({
|
||||
date,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
@@ -882,9 +954,17 @@ describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
|
||||
|
||||
const staleOv = addDays("2024-01-01", 13); // months ago
|
||||
const currentOv = addDays(lastStart, 13); // current cycle
|
||||
const logs = [...symptothermalAround(staleOv), ...symptothermalAround(currentOv)];
|
||||
const logs = [
|
||||
...symptothermalAround(staleOv),
|
||||
...symptothermalAround(currentOv),
|
||||
];
|
||||
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
|
||||
const result = predictCycle(
|
||||
cycles,
|
||||
logs,
|
||||
BASE_PROFILE,
|
||||
addDays(lastStart, 16),
|
||||
);
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.predictedOvulation).toBe(currentOv);
|
||||
expect(result.nextPeriodStart).toBe(addDays(currentOv, 14));
|
||||
@@ -932,10 +1012,15 @@ describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", (
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
// Profile asks for luteal 8 → engine clamps to 10.
|
||||
const profile: CycleProfileInput = { ...BASE_PROFILE, lutealPhaseLength: 8 };
|
||||
const profile: CycleProfileInput = {
|
||||
...BASE_PROFILE,
|
||||
lutealPhaseLength: 8,
|
||||
};
|
||||
const result = predictCycle(cycles, [], profile, addDays(lastStart, 5));
|
||||
// predictedOvulation = nextStart − clampedLuteal(10).
|
||||
expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10));
|
||||
expect(result.predictedOvulation).toBe(
|
||||
addDays(result.nextPeriodStart, -10),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -308,10 +308,7 @@ export async function restoreCycleData(
|
||||
temperatureExcluded: d.temperatureExcluded ?? false,
|
||||
ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never,
|
||||
cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never,
|
||||
cervixPosition: enumOrNull(
|
||||
d.cervixPosition,
|
||||
CERVIX_POSITIONS,
|
||||
) as never,
|
||||
cervixPosition: enumOrNull(d.cervixPosition, CERVIX_POSITIONS) as never,
|
||||
cervixFirmness: enumOrNull(
|
||||
d.cervixFirmness,
|
||||
CERVIX_FIRMNESSES,
|
||||
|
||||
@@ -206,11 +206,17 @@ export function toCyclePredictionDTO(
|
||||
// a fertile window the rest of the app refuses to show. `ovulationConfirmed`
|
||||
// stays goal-gated only: a confirmed shift is observed data, not a prior.
|
||||
fertileWindowStart:
|
||||
goalAllowsFertile && !result.stillLearning ? result.fertileWindowStart : null,
|
||||
goalAllowsFertile && !result.stillLearning
|
||||
? result.fertileWindowStart
|
||||
: null,
|
||||
fertileWindowEnd:
|
||||
goalAllowsFertile && !result.stillLearning ? result.fertileWindowEnd : null,
|
||||
goalAllowsFertile && !result.stillLearning
|
||||
? result.fertileWindowEnd
|
||||
: null,
|
||||
predictedOvulation:
|
||||
goalAllowsFertile && !result.stillLearning ? result.predictedOvulation : null,
|
||||
goalAllowsFertile && !result.stillLearning
|
||||
? result.predictedOvulation
|
||||
: null,
|
||||
ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false,
|
||||
confidence: result.confidence,
|
||||
cyclesObserved: result.cyclesObserved,
|
||||
|
||||
Reference in new issue
Block a user