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
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 15:50:23 +02:00
1 parent bd6089734d
commit 3596720905
7 files changed
+684 -111

No files matched your search

+48 -18
View File
@@ -23,7 +23,11 @@ import {
safeJson,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
import {
ensureCycleForBleedingDay,
removeCycleStartedOn,
} from "@/lib/cycle/cycle-boundaries";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
@@ -158,6 +162,26 @@ export const PATCH = apiHandler(
include: dayLogSymptomInclude,
});
// An edit that turns a day into a first bleeding day opens the cycle it
// starts, and an edit that takes the bleeding back off it removes the
// cycle that day opened. Both directions go through the same shared
// boundary work the capture route and the one-tap start use — a cycle that
// could be created here but not undone here is how the record ends up
// holding a period nothing on the day says happened.
const openedCycleId = await ensureCycleForBleedingDay(
user.id,
existing.date,
user.timezone ?? DEFAULT_TIMEZONE,
row,
);
let removedCycleId: string | null = null;
if (!openedCycleId && !stillBleeds(row)) {
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.date),
);
removedCycleId = removed?.cycleId ?? null;
}
await auditLog("cycle.day-log.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -167,6 +191,8 @@ export const PATCH = apiHandler(
details: {
dayLogId: id,
date: existing.date,
...(openedCycleId ? { openedCycleId } : {}),
...(removedCycleId ? { removedCycleId } : {}),
...overwriteDetails({
before: {},
after: {},
@@ -184,6 +210,10 @@ export const PATCH = apiHandler(
entity_type: "cycle_day_log",
entity_id: id,
},
meta: {
opened_cycle: openedCycleId !== null,
removed_cycle: removedCycleId !== null,
},
});
return apiSuccess(toCycleDayLogDTO(row));
@@ -224,19 +254,7 @@ export const DELETE = apiHandler(
where: { id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const openedCycle = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: existing.date },
select: { id: true },
});
if (!openedCycle) return { cycleId: null, reanchored: null };
await db.menstrualCycle.update({
where: { id: openedCycle.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
return {
cycleId: openedCycle.id,
reanchored: await reanchorAfterRemovedStart(db, user.id, existing.date),
};
return removeCycleStartedOn(db, user.id, existing.date);
});
await auditLog("cycle.day-log.delete", {
@@ -245,8 +263,8 @@ export const DELETE = apiHandler(
details: {
dayLogId: id,
date: existing.date,
...(removed.cycleId ? { removedCycleId: removed.cycleId } : {}),
...(removed.reanchored
...(removed ? { removedCycleId: removed.cycleId } : {}),
...(removed?.reanchored
? {
reanchoredCycleId: removed.reanchored.cycleId,
...overwriteDetails({
@@ -271,8 +289,8 @@ export const DELETE = apiHandler(
entity_id: id,
},
meta: {
removed_cycle: removed.cycleId !== null,
reanchored_prior: removed.reanchored !== null,
removed_cycle: removed !== null,
reanchored_prior: removed?.reanchored != null,
},
});
@@ -327,3 +345,15 @@ function readStoredSensitive(row: {
contraceptive: row.contraceptive,
};
}
/**
* Whether the edited day still records a period. A cleared or downgraded flow,
* or one re-flagged as bleeding between periods, no longer anchors a cycle.
*/
function stillBleeds(row: {
flow: string | null;
intermenstrualBleeding: boolean;
}): boolean {
if (row.intermenstrualBleeding) return false;
return row.flow !== null && row.flow !== "NONE" && row.flow !== "SPOTTING";
}
+17 -81
View File
@@ -37,26 +37,20 @@ import {
dayLogSymptomInclude,
} from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
/**
* v1.37.0 — the anchors a boundary write moves, carried out of the
* transaction so the audit row can name them (C4).
* transaction so the audit row can name them (C4). The `start` arm's own
* anchors live with the shared boundary helper; the `end` arm adds the one
* column it overwrites.
*/
interface MovedAnchors {
priorCycleId?: string;
/** Day keys, the shape this domain stores its dates in. */
priorEndDateBefore?: string | null;
priorEndDateAfter?: string | null;
priorLengthBefore?: number | null;
priorLengthAfter?: number | null;
openedEndDateBefore?: string | null;
openedEndDateAfter?: string | null;
type PeriodMovedAnchors = MovedAnchors & {
periodEndDateBefore?: string | null;
periodEndDateAfter?: string | null;
}
};
async function postPeriod(request: NextRequest): Promise<Response> {
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
@@ -97,73 +91,15 @@ async function postPeriod(request: NextRequest): Promise<Response> {
// exact-key replay; the transaction guards the interleaving case.
const txResult = await prisma.$transaction(async (db) => {
if (action === "start") {
// Close the prior open cycle (its end is the day before this start)
// and record its observed length.
const prior = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
// v1.37.0 — `endDate` and `lengthDays` are read for the audit row:
// this write overwrites both on the prior cycle and they are the
// dates C4 exists to name.
select: {
id: true,
startDate: true,
endDate: true,
lengthDays: true,
},
});
const moved: MovedAnchors = {};
if (prior) {
moved.priorCycleId = prior.id;
moved.priorEndDateBefore = prior.endDate;
moved.priorEndDateAfter = addDays(date, -1);
moved.priorLengthBefore = prior.lengthDays;
moved.priorLengthAfter = dayDiff(date, prior.startDate);
await db.menstrualCycle.update({
where: { id: prior.id },
data: {
endDate: addDays(date, -1),
lengthDays: dayDiff(date, prior.startDate),
syncVersion: { increment: 1 },
},
});
}
// Upsert the new cycle on the `(userId, startDate)` unique so a
// re-tap on the same day is idempotent.
const cycle = await db.menstrualCycle.upsert({
where: { userId_startDate: { userId: user.id, startDate: date } },
create: { userId: user.id, startDate: date, tz, isPredicted: false },
update: {
deletedAt: null,
isPredicted: false,
syncVersion: { increment: 1 },
},
});
// Re-anchor the FOLLOWING neighbour: when this start is back-filled
// between two existing cycles, the new cycle's end + length are already
// known from its successor's start. Without this the inserted cycle keeps
// endDate/lengthDays = null even though the next start exists, and the
// iOS mirror + history surfaces read a stale open cycle (QA M-1).
const next = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { gt: date } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
if (next) {
moved.openedEndDateBefore = cycle.endDate;
moved.openedEndDateAfter = addDays(next.startDate, -1);
await db.menstrualCycle.update({
where: { id: cycle.id },
data: {
endDate: addDays(next.startDate, -1),
lengthDays: dayDiff(next.startDate, date),
syncVersion: { increment: 1 },
},
});
}
return { cycleId: cycle.id as string | null, moved };
// Close the prior open cycle, open this one, and re-anchor the following
// neighbour when the start is back-filled between two existing cycles.
// The same helper runs when a first bleeding day is captured as flow, so
// a period start means one thing however it was entered.
const { cycleId, moved } = await openCycleAt(db, user.id, date, tz);
return {
cycleId: cycleId as string | null,
moved: moved as PeriodMovedAnchors,
};
}
// `end`: stamp the current cycle's periodEndDate.
@@ -174,7 +110,7 @@ async function postPeriod(request: NextRequest): Promise<Response> {
select: { id: true, periodEndDate: true },
});
if (!current) {
return { cycleId: null, moved: {} satisfies MovedAnchors };
return { cycleId: null, moved: {} satisfies PeriodMovedAnchors };
}
await db.menstrualCycle.update({
where: { id: current.id },
@@ -185,7 +121,7 @@ async function postPeriod(request: NextRequest): Promise<Response> {
moved: {
periodEndDateBefore: current.periodEndDate,
periodEndDateAfter: date,
} satisfies MovedAnchors,
} satisfies PeriodMovedAnchors,
};
});