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,
};
});
@@ -0,0 +1,67 @@
/**
* 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);
});
});
+40 -9
View File
@@ -11,15 +11,44 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
cycleDayLog: {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
},
cycleSymptom: { findMany: vi.fn() },
cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
}));
const db = vi.hoisted(() => {
const client: Record<string, unknown> = {
cycleDayLog: {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
},
// The write path asks whether the logged day opens a cycle. These cases
// are about the encryption columns, so the fake answers "no cycles on
// record" and lets the boundary work run for real against itself — the
// inference itself is pinned by its own unit + integration cases.
menstrualCycle: {
findFirst: vi.fn(async () => null),
upsert: vi.fn(async () => ({ id: "cyc-1", endDate: null })),
update: vi.fn(),
},
cycleSymptom: { findMany: vi.fn() },
cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
};
client.$transaction = async (fn: (tx: unknown) => unknown) => fn(client);
return client as {
cycleDayLog: {
findUnique: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
menstrualCycle: {
findFirst: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
cycleSymptom: { findMany: ReturnType<typeof vi.fn> };
cycleSymptomLink: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
};
});
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/cycle/profile", () => ({
@@ -70,6 +99,8 @@ function existingRow(overrides: Record<string, unknown> = {}) {
beforeEach(() => {
vi.clearAllMocks();
db.cycleDayLog.upsert.mockResolvedValue({ id: "cdl-1" });
db.menstrualCycle.findFirst.mockResolvedValue(null);
db.menstrualCycle.upsert.mockResolvedValue({ id: "cyc-1", endDate: null });
});
describe("upsertCycleDayLog — C-1 undecryptable note preservation", () => {
+215 -1
View File
@@ -19,12 +19,226 @@
* it to the surviving neighbour restores exactly the state the start write
* moved — and applying it twice changes nothing the second time.
*/
import { prisma } from "@/lib/db";
import { addDays, dayDiff } from "./day-math";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { HARD_CYCLE_MIN } from "./types";
import type {
FlowLevel,
Prisma,
PrismaClient,
} from "@/generated/prisma/client";
/** The Prisma client or an interactive-transaction handle. */
type CycleDb = PrismaClient | Prisma.TransactionClient;
/**
* The boundary columns an opening write moved on the cycles around it, so the
* audit row can name them. They are the only unrecoverable part of the write:
* the opened cycle is a row, the re-anchoring is an overwrite of two columns
* on somebody else's cycle history.
*/
export 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;
}
/**
* Open a cycle at `date`, closing the one before it and taking the opened
* cycle's own end from the one after it when the start is back-filled between
* two existing cycles. Without that second half an inserted cycle keeps a null
* endDate even though its successor's start is right there, and every history
* surface reads a stale open cycle.
*
* Idempotent on `(userId, startDate)`: a re-tap on the same day, or a flow
* entry on a day the one-tap boundary already anchored, updates the existing
* row rather than adding a second one.
*
* Callers run this inside a transaction — the close-prior and open-new pair
* must not interleave with a concurrent write, or two taps double-close a
* prior cycle or compute a length against a stale read.
*/
export async function openCycleAt(
db: CycleDb,
userId: string,
date: string,
tz: string | null,
): Promise<{ cycleId: string; moved: MovedAnchors }> {
const moved: MovedAnchors = {};
// Close the prior open cycle: its end is the day before this start.
const prior = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true, startDate: true, endDate: true, lengthDays: true },
});
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 },
},
});
}
const cycle = await db.menstrualCycle.upsert({
where: { userId_startDate: { userId, startDate: date } },
create: { userId, startDate: date, tz, isPredicted: false },
update: {
deletedAt: null,
isPredicted: false,
syncVersion: { increment: 1 },
},
});
// Re-anchor the FOLLOWING neighbour's side of the boundary.
const next = await db.menstrualCycle.findFirst({
where: { userId, 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, moved };
}
/**
* Whether a logged day's bleeding is the first day of a period, and therefore
* opens a cycle.
*
* Cycle rows used to come only from the one-tap period boundary. Every other
* way of recording bleeding — the log sheet's flow chips, the bulk drain, an
* Apple Health export of menstrual-flow samples — wrote a day-log and stopped
* there, and the engine reads cycle rows. A person could log every period they
* had and the module would still hold that none had happened.
*
* The rule is deliberately conservative, because a false cycle start corrupts
* every length the engine derives afterwards:
*
* - SPOTTING does not open a cycle. It is as often the tail of a period or
* its herald as its first day, and the user has an explicit chip for a
* real flow when it is one.
* - Bleeding flagged as between periods does not open a cycle. That flag
* exists to say exactly this.
* - A bleeding day within the hard physiological minimum cycle length of an
* existing cycle's start is part of that cycle, not a new one. This is
* what keeps day three of a period — or a day two somebody forgot and
* entered later — from starting a cycle of its own, and it needs no
* contiguity check to do it.
*
* The cost is that a genuinely short cycle (under the hard minimum) is not
* inferred from flow alone. The one-tap start still opens one on any date, so
* the case has an answer; guessing it from flow does not.
*/
export function bleedOpensCycle(
flow: FlowLevel | null,
intermenstrualBleeding: boolean,
priorCycleStart: string | null,
date: string,
): boolean {
if (flow === null || flow === "NONE" || flow === "SPOTTING") return false;
if (intermenstrualBleeding) return false;
if (priorCycleStart === null) return true;
return dayDiff(date, priorCycleStart) >= HARD_CYCLE_MIN;
}
/**
* Open a cycle for a bleeding day when `bleedOpensCycle` says it starts one.
* Returns the opened cycle's id, or null when the day changes nothing.
*
* Runs its own transaction so the boundary work is atomic even when the caller
* is a plain day-log write with no transaction of its own.
*/
export async function ensureCycleForBleedingDay(
userId: string,
date: string,
tz: string | null,
day: { flow: FlowLevel | null; intermenstrualBleeding: boolean },
): Promise<string | null> {
// Cheap pre-check before a transaction: most logged days are not a first
// bleeding day, and the flow grade alone rules most of them out.
if (
day.flow === null ||
day.flow === "NONE" ||
day.flow === "SPOTTING" ||
day.intermenstrualBleeding
) {
return null;
}
return prisma.$transaction(async (db) => {
const prior = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { lte: date } },
orderBy: { startDate: "desc" },
select: { startDate: true },
});
if (
!bleedOpensCycle(
day.flow,
day.intermenstrualBleeding,
prior?.startDate ?? null,
date,
)
) {
return null;
}
const { cycleId } = await openCycleAt(db, userId, date, tz);
return cycleId;
});
}
/**
* Remove the cycle that opens on `date`, if there is one, and hand its span
* back to the cycle before it. The inverse of `openCycleAt`, and the shared
* body behind every way of taking a period start back: deleting the day-log
* that opened it, clearing that day's flow, and deleting the cycle itself.
*
* Returns null when no live cycle starts on that date.
*/
export async function removeCycleStartedOn(
db: CycleDb,
userId: string,
date: string,
): Promise<{ cycleId: string; reanchored: ReanchoredCycle | null } | null> {
const opened = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: date },
select: { id: true },
});
if (!opened) return null;
await db.menstrualCycle.update({
where: { id: opened.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
return {
cycleId: opened.id,
reanchored: await reanchorAfterRemovedStart(db, userId, date),
};
}
/** The boundary columns the re-derivation moved, for the audit row. */
export interface ReanchoredCycle {
cycleId: string;
+22 -2
View File
@@ -31,6 +31,7 @@
import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries";
import type { CycleDayLogInput } from "@/lib/validations/cycle";
import type {
CervicalMucus,
@@ -387,6 +388,25 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
? (entry.cervixOpening ?? null)
: (existing?.cervixOpening ?? null);
// A first bleeding day opens the cycle it belongs to. Cycle rows used to
// come only from the one-tap period boundary, so a person who logged every
// period as flow had a module that held none had ever happened — no lengths,
// no forecast, an empty chart. This is the one shared seam behind the single
// capture, the bulk drain, the Apple Health fold and the boundary itself, so
// all four infer a start the same way (the boundary's own write lands on a
// date that already has a cycle, which the detector declines). The day is
// then attributed to the cycle it just opened rather than the one before it.
const openedCycleId = await ensureCycleForBleedingDay(
userId,
entry.date,
tz,
{
flow,
intermenstrualBleeding,
},
);
const attributedCycleId = openedCycleId ?? cycleId;
// Was any of the five sensitive fields actually supplied in THIS update?
// Used to decide whether an undecryptable existing envelope may be preserved
// verbatim (an unrelated-field edit) or must be rewritten (the user is
@@ -479,7 +499,7 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
const updateData: Prisma.CycleDayLogUncheckedUpdateInput = {
...baseData,
...(entry.externalId ? { date: entry.date } : {}),
...(cycleId !== null ? { cycleId } : {}),
...(attributedCycleId !== null ? { cycleId: attributedCycleId } : {}),
deletedAt: null,
// Only bump syncVersion when a field actually changed — an unchanged
// re-post must be a true no-op on the /api/sync/changes feed so other
@@ -498,7 +518,7 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
tz,
source,
externalId: entry.externalId ?? null,
cycleId,
cycleId: attributedCycleId,
flow,
intermenstrualBleeding,
basalBodyTempC,
@@ -0,0 +1,275 @@
/**
* A period logged as flow has to count as a period.
*
* `MenstrualCycle` rows were only ever written by the one-tap period
* boundary. Everything else that records bleeding — the log sheet's flow
* chips, the bulk drain, an Apple Health export full of menstrual-flow
* samples — wrote a `CycleDayLog` and nothing more, and the engine reads
* cycle rows. So a person could log every period they had and the module
* still held that none had ever 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.
*
* The rule the fix applies is deliberately conservative, and these cases are
* as much about what must NOT open a cycle as what must. Spotting does not
* (it is as often the tail or the herald of a period as its first day), an
* entry flagged as bleeding between periods does not, and a bleeding day
* inside an existing cycle's plausible span does not — otherwise day three of
* a period, or a forgotten day two, would each start a cycle of their own.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-cycle-flow-opens-integration-32-bytes-min-1234567890";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-cycle-flow";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function loginAs(userId: string): Promise<void> {
cookieJar.clear();
headerJar.clear();
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "cycle-flow",
email: "cycle-flow@example.test",
gender: "FEMALE",
},
});
await loginAs(USER_ID);
});
function jsonRequest(
path: string,
method: string,
body?: unknown,
): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
/** Log one day through the real capture route. */
async function logDay(
date: string,
fields: Record<string, unknown>,
): Promise<Response> {
const { POST } = await import("@/app/api/cycle/day-logs/route");
return POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date,
loggedAt: `${date}T08:00:00.000Z`,
...fields,
}),
);
}
/** The live cycle starts, oldest first. */
async function cycleStarts(): Promise<string[]> {
const rows = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
return rows.map((r) => r.startDate);
}
describe("a bleeding day opens a cycle", () => {
it("turns the first flow entry into a cycle the engine can see", async () => {
const res = await logDay("2026-05-04", { flow: "HEAVY" });
expect(res.status).toBe(201);
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("does not open a second cycle for the rest of the same period", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-05-05", { flow: "MEDIUM" });
// Day three with day two missing: the gap must not read as a new period.
await logDay("2026-05-07", { flow: "LIGHT" });
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("opens the next cycle when the next period arrives", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
await logDay("2026-06-29", { flow: "MEDIUM" });
expect(await cycleStarts()).toEqual([
"2026-05-04",
"2026-06-01",
"2026-06-29",
]);
});
it("leaves spotting alone", async () => {
await logDay("2026-05-04", { flow: "SPOTTING" });
expect(await cycleStarts()).toEqual([]);
});
it("leaves bleeding flagged as between periods alone", async () => {
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-05-20", {
flow: "MEDIUM",
intermenstrualBleeding: true,
});
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("leaves a day with no flow alone", async () => {
await logDay("2026-05-04", { basalBodyTempC: 36.4 });
expect(await cycleStarts()).toEqual([]);
});
it("re-anchors the neighbours of a back-dated bleed", async () => {
// The same boundary work the one-tap start does: the inserted cycle takes
// its end from the cycle after it, and the one before it closes.
await logDay("2026-06-01", { flow: "HEAVY" });
await logDay("2026-07-01", { flow: "HEAVY" });
// Remembered later, and earlier than both.
await logDay("2026-05-04", { flow: "HEAVY" });
const rows = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { startDate: true, endDate: true, lengthDays: true },
});
expect(rows).toEqual([
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
{ startDate: "2026-06-01", endDate: "2026-06-30", lengthDays: 30 },
{ startDate: "2026-07-01", endDate: null, lengthDays: null },
]);
});
it("does not duplicate the cycle the one-tap start already opened", async () => {
const { POST } = await import("@/app/api/cycle/period/route");
await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-05-04",
loggedAt: "2026-05-04T08:00:00.000Z",
}),
);
// The boundary write logs MEDIUM for the day; re-saving it richer must not
// open a second cycle on the same date.
await logDay("2026-05-04", { flow: "HEAVY" });
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("opens a cycle when an edit turns a day into a bleeding day", async () => {
await logDay("2026-05-04", { basalBodyTempC: 36.4 });
expect(await cycleStarts()).toEqual([]);
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-05-04" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await PATCH(
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", {
flow: "HEAVY",
}),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(200);
expect(await cycleStarts()).toEqual(["2026-05-04"]);
});
it("removes the cycle again when an edit clears the flow", async () => {
// The inverse of the case above. A cycle that can be created by an edit
// and not undone by one leaves the record holding a period that nothing on
// the day says happened.
await logDay("2026-05-04", { flow: "HEAVY" });
await logDay("2026-06-01", { flow: "HEAVY" });
expect(await cycleStarts()).toEqual(["2026-05-04", "2026-06-01"]);
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-06-01" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
await PATCH(
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: null }),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(await cycleStarts()).toEqual(["2026-05-04"]);
// And the May cycle is open again, not left closed against a start that
// no longer exists.
const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
select: { endDate: true, lengthDays: true },
});
expect(may).toEqual({ endDate: null, lengthDays: null });
});
it("gives the record a forecast and a cycle day it never had", async () => {
// What the person actually sees. Four periods 28 days apart, logged as
// flow and nothing else, and today inside the last one.
const today = new Date();
const dayKey = (offset: number): string => {
const d = new Date(today);
d.setDate(d.getDate() + offset);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
};
for (const offset of [-86, -58, -30, -2]) {
await logDay(dayKey(offset), { flow: "HEAVY" });
}
const { GET } = await import("@/app/api/cycle/calendar/route");
const res = await GET(
new NextRequest("http://localhost/api/cycle/calendar"),
);
const body = JSON.parse(await res.text());
expect(body.data.profile.cyclesObserved).toBe(3);
expect(body.data.prediction).not.toBeNull();
expect(body.data.verdict.state).toBe("IN_CYCLE");
expect(body.data.verdict.dayOfCycle).toBe(3);
expect(body.data.verdict.cycleStartDate).toBe(dayKey(-2));
});
});