From 3596720905d03ba44fce31776eef729b77f309b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:50:23 +0200 Subject: [PATCH] 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 --- src/app/api/cycle/day-logs/[id]/route.ts | 66 +++-- src/app/api/cycle/period/route.ts | 98 ++----- .../cycle/__tests__/cycle-boundaries.test.ts | 67 +++++ src/lib/cycle/__tests__/day-log-write.test.ts | 49 +++- src/lib/cycle/cycle-boundaries.ts | 216 +++++++++++++- src/lib/cycle/day-log-write.ts | 24 +- .../cycle-flow-opens-cycle.test.ts | 275 ++++++++++++++++++ 7 files changed, 684 insertions(+), 111 deletions(-) create mode 100644 src/lib/cycle/__tests__/cycle-boundaries.test.ts create mode 100644 tests/integration/cycle-flow-opens-cycle.test.ts diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 7b88e0479..af4db03b8 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -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"; +} diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 8806cdc65..2223447d0 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -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 { // v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles, @@ -97,73 +91,15 @@ async function postPeriod(request: NextRequest): Promise { // 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 { 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 { moved: { periodEndDateBefore: current.periodEndDate, periodEndDateAfter: date, - } satisfies MovedAnchors, + } satisfies PeriodMovedAnchors, }; }); diff --git a/src/lib/cycle/__tests__/cycle-boundaries.test.ts b/src/lib/cycle/__tests__/cycle-boundaries.test.ts new file mode 100644 index 000000000..58ff9f654 --- /dev/null +++ b/src/lib/cycle/__tests__/cycle-boundaries.test.ts @@ -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); + }); +}); diff --git a/src/lib/cycle/__tests__/day-log-write.test.ts b/src/lib/cycle/__tests__/day-log-write.test.ts index 8fdd8119e..3a2a49c23 100644 --- a/src/lib/cycle/__tests__/day-log-write.test.ts +++ b/src/lib/cycle/__tests__/day-log-write.test.ts @@ -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 = { + 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; + upsert: ReturnType; + update: ReturnType; + }; + menstrualCycle: { + findFirst: ReturnType; + upsert: ReturnType; + update: ReturnType; + }; + cycleSymptom: { findMany: ReturnType }; + cycleSymptomLink: { + deleteMany: ReturnType; + createMany: ReturnType; + }; + }; +}); vi.mock("@/lib/db", () => ({ prisma: db })); vi.mock("@/lib/cycle/profile", () => ({ @@ -70,6 +99,8 @@ function existingRow(overrides: Record = {}) { 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", () => { diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts index 9d9e7f84a..6c81cc0b0 100644 --- a/src/lib/cycle/cycle-boundaries.ts +++ b/src/lib/cycle/cycle-boundaries.ts @@ -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 { + // 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; diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 8d447daf9..31a78d93f 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -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 { ? (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 { 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 { tz, source, externalId: entry.externalId ?? null, - cycleId, + cycleId: attributedCycleId, flow, intermenstrualBleeding, basalBodyTempC, diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts new file mode 100644 index 000000000..5e81fda56 --- /dev/null +++ b/tests/integration/cycle-flow-opens-cycle.test.ts @@ -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 { + 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, +): Promise { + 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 { + 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)); + }); +});