Files
HealthLog/src/lib/cycle/__tests__/cycle-boundaries.test.ts
T
Marc-André Bombeck 3596720905 fix(cycle): let a period logged as flow count as a period
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.

A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.

The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.

An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.

- the period route's start arm and the flow inference now share one
  boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
  about what must NOT open a cycle
2026-08-07 15:50:23 +02:00

68 lines
2.6 KiB
TypeScript

/**
* The rule that decides whether a logged bleeding day starts a cycle.
*
* It is the one piece of the flow-to-cycle inference that is a judgement
* rather than bookkeeping, and a wrong yes is worse than a wrong no: a false
* cycle start corrupts every length the engine derives after it, while a
* missed one leaves the person exactly where they were before, with the
* one-tap start still available on any date.
*/
import { describe, it, expect } from "vitest";
import { bleedOpensCycle } from "../cycle-boundaries";
import { HARD_CYCLE_MIN } from "../types";
import { addDays } from "../day-math";
const DAY = "2026-06-01";
describe("bleedOpensCycle", () => {
it("opens the first cycle a record ever has", () => {
expect(bleedOpensCycle("MEDIUM", false, null, DAY)).toBe(true);
expect(bleedOpensCycle("HEAVY", false, null, DAY)).toBe(true);
expect(bleedOpensCycle("LIGHT", false, null, DAY)).toBe(true);
});
it("declines a day that records no bleeding", () => {
expect(bleedOpensCycle(null, false, null, DAY)).toBe(false);
expect(bleedOpensCycle("NONE", false, null, DAY)).toBe(false);
});
it("declines spotting", () => {
// As often the tail or the herald of a period as its first day, and the
// person has a chip for a real flow when it is one.
expect(bleedOpensCycle("SPOTTING", false, null, DAY)).toBe(false);
});
it("declines bleeding the person flagged as between periods", () => {
expect(bleedOpensCycle("HEAVY", true, null, DAY)).toBe(false);
});
it("declines a bleeding day inside the current cycle's plausible span", () => {
// Day two, day three, and a day somebody forgot and entered late: all of
// them are this period, not a new one.
for (const offset of [1, 2, 4, HARD_CYCLE_MIN - 1]) {
expect(bleedOpensCycle("HEAVY", false, addDays(DAY, -offset), DAY)).toBe(
false,
);
}
});
it("declines a bleeding day on a date a cycle already starts", () => {
// What the one-tap boundary's own day-log write hits.
expect(bleedOpensCycle("MEDIUM", false, DAY, DAY)).toBe(false);
});
it("opens the next cycle at the hard physiological minimum and beyond", () => {
expect(
bleedOpensCycle("MEDIUM", false, addDays(DAY, -HARD_CYCLE_MIN), DAY),
).toBe(true);
expect(bleedOpensCycle("MEDIUM", false, addDays(DAY, -28), DAY)).toBe(true);
});
it("declines when the nearest cycle start is AFTER the day", () => {
// Never happens through the resolver (it queries `startDate <= date`), but
// a negative gap must not read as a large one.
expect(bleedOpensCycle("HEAVY", false, addDays(DAY, 10), DAY)).toBe(false);
});
});