mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
bd6089734d
commit
3596720905
7 files changed
+684
-111
No files matched your search
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user