Files
HealthLog/src/lib/cycle/cycle-boundaries.ts
T
Marc-André Bombeck e822154936 fix(cycle): give back a start that a mis-tapped earlier start folded in
A start logged within one period before an existing start folds that start
into itself and soft-deletes it, since the two are one period entered out of
order. Nothing recorded which start was folded, so taking the earlier start
back could not bring the later one back: a mis-tap on the 5th with a real
start on the 10th, then deleted, left the 10th gone and the cycle before it
reopened.

Migration 0345 adds menstrual_cycles.absorbed_into_id. The folded row names
the start that absorbed it; removing that start restores it, re-derives its
own end and hands its days back to it. The column is needed because a
soft-deleted row alone cannot tell a folded start from one the person
deleted, and the audit trail is not state a removal can read. Deleting a
cycle directly now runs the same shared removal as deleting its day-log.
2026-09-24 12:30:39 +02:00

496 lines
18 KiB
TypeScript

/**
* Cycle boundary re-derivation — the inverse of what a period-start write does
* to its neighbours.
*
* `POST /api/cycle/period` with `action:"start"` is not an insert. It closes
* the previous open cycle (stamps `endDate` and `lengthDays` from the new
* start) and, when the start is back-filled between two existing cycles, takes
* the inserted cycle's own end from its successor. Removing that start left
* every one of those overwrites standing: the previous cycle kept the end date
* a cycle that no longer exists gave it, and a record whose last cycle is
* closed has no open cycle for the engine to forecast from. The prediction
* disappeared and there was no way to bring it back from the app.
*
* The repair is a re-derivation rather than an undo log, which is why it is
* safe to run on a record whose history came from an import or a restore
* instead of from the one-tap boundary: a cycle ends the day before the next
* one begins, and when there is no next one it is open. That statement is true
* of every cycle in the table regardless of how the row got there, so applying
* 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 { HARD_CYCLE_MIN, PERIOD_MAX } 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;
/**
* A cycle that started a few days AFTER the opened date and was folded into
* it: the period began earlier than first recorded, not twice.
*/
absorbedCycleId?: string;
absorbedStartDate?: string;
}
/**
* Whether a cycle starting at `laterStart` is the same period as one starting
* at `date`, just recorded late: it begins within a maximum-length period
* after `date`. Two starts that close together are one period entered out of
* order, never two cycles, and keeping both leaves a one- or two-day cycle
* that corrupts every length after it.
*/
export function isSamePeriodStartedEarlier(
date: string,
laterStart: string,
): boolean {
const gap = dayDiff(laterStart, date);
return gap > 0 && gap < PERIOD_MAX;
}
/**
* 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 = {};
// A start recorded a few days after this date is the same period, entered
// in the other order (day two first, day one remembered later). Fold it
// into the start being opened instead of leaving a cycle a day or two long
// in front of it. What that row knew beyond its start carries over below.
const following = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: date } },
orderBy: { startDate: "asc" },
select: {
id: true,
startDate: true,
periodEndDate: true,
ovulationDate: true,
ovulationConfirmed: true,
},
});
const absorbed =
following && isSamePeriodStartedEarlier(date, following.startDate)
? following
: null;
if (absorbed) {
moved.absorbedCycleId = absorbed.id;
moved.absorbedStartDate = absorbed.startDate;
}
// 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 carried = absorbed
? {
...(absorbed.periodEndDate
? { periodEndDate: absorbed.periodEndDate }
: {}),
...(absorbed.ovulationDate
? {
ovulationDate: absorbed.ovulationDate,
ovulationConfirmed: absorbed.ovulationConfirmed,
}
: {}),
}
: {};
const cycle = await db.menstrualCycle.upsert({
where: { userId_startDate: { userId, startDate: date } },
create: { userId, startDate: date, tz, isPredicted: false, ...carried },
update: {
deletedAt: null,
absorbedIntoId: null,
isPredicted: false,
...carried,
syncVersion: { increment: 1 },
},
});
// The folded start is soft-deleted and names the start that absorbed it,
// so taking this start back (a mis-tap) can give it back.
if (absorbed) {
await db.menstrualCycle.update({
where: { id: absorbed.id },
data: {
deletedAt: new Date(),
absorbedIntoId: cycle.id,
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 },
},
});
} else if (cycle.endDate != null || cycle.lengthDays != null) {
// With nothing after it the cycle is open. A re-tapped start whose row
// still carries an end from a successor that no longer exists (the
// one-day cycles an earlier rule left in front of a real start, once
// that start is folded in) would otherwise stay closed on a stale date.
moved.openedEndDateBefore = cycle.endDate;
moved.openedEndDateAfter = null;
await db.menstrualCycle.update({
where: { id: cycle.id },
data: { endDate: null, lengthDays: null, syncVersion: { increment: 1 } },
});
}
// The days between this start and the next one belong to this cycle now.
// A start back-filled into the middle of an existing cycle used to leave
// them attributed to the cycle before it.
await attributeDaysToCycle(
db,
userId,
cycle.id,
date,
next?.startDate ?? null,
);
return { cycleId: cycle.id, moved };
}
/**
* Point every live day-log in `[from, until)` at `cycleId` (open-ended when
* `until` is null), bumping `syncVersion` only on rows whose attribution
* actually changes so the sync feed does not re-pull untouched days.
*/
async function attributeDaysToCycle(
db: CycleDb,
userId: string,
cycleId: string | null,
from: string,
until: string | null,
): Promise<void> {
await db.cycleDayLog.updateMany({
where: {
userId,
deletedAt: null,
date: { gte: from, ...(until ? { lt: until } : {}) },
...(cycleId === null
? { cycleId: { not: null } }
: { OR: [{ cycleId: null }, { cycleId: { not: cycleId } }] }),
},
data: { cycleId, syncVersion: { increment: 1 } },
});
}
/**
* 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;
}
// The same judgement looking forward. A logged start a few days later is
// this period entered out of order, and `openCycleAt` folds it in. One
// further out but still inside the hard minimum is neither the same
// period nor a cycle of its own, so nothing is inferred from the flow.
const following = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: date } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
if (
following &&
dayDiff(following.startDate, date) < HARD_CYCLE_MIN &&
!isSamePeriodStartedEarlier(date, following.startDate)
) {
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.
*
* A start this one folded in when it was opened (a later start within one
* period, see `openCycleAt`) comes back: the earlier start was the mistake,
* so the later one was the real first day all along.
*
* 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;
restoredCycleIds: string[];
} | 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 } },
});
// Give back the starts it folded in, before any boundary is re-derived, so
// the cycle before this one closes against them rather than reopening.
const restored = await db.menstrualCycle.findMany({
where: { userId, absorbedIntoId: opened.id, deletedAt: { not: null } },
orderBy: { startDate: "asc" },
select: { id: true, startDate: true },
});
for (const row of restored) {
await db.menstrualCycle.update({
where: { id: row.id },
data: {
deletedAt: null,
absorbedIntoId: null,
syncVersion: { increment: 1 },
},
});
}
const reanchored = await reanchorAfterRemovedStart(db, userId, date);
// The removed cycle's days go back to the cycle before it (or to none).
const prior = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true },
});
await db.cycleDayLog.updateMany({
where: { userId, cycleId: opened.id },
data: { cycleId: prior?.id ?? null, syncVersion: { increment: 1 } },
});
// A restored start owns its span again, and its own end is re-derived from
// whatever was logged after it while it was folded.
for (const row of restored) {
const next = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: row.startDate } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
await db.menstrualCycle.update({
where: { id: row.id },
data: {
endDate: next ? addDays(next.startDate, -1) : null,
lengthDays: next ? dayDiff(next.startDate, row.startDate) : null,
},
});
await attributeDaysToCycle(
db,
userId,
row.id,
row.startDate,
next?.startDate ?? null,
);
}
return {
cycleId: opened.id,
reanchored,
restoredCycleIds: restored.map((row) => row.id),
};
}
/** The boundary columns the re-derivation moved, for the audit row. */
export interface ReanchoredCycle {
cycleId: string;
endDateBefore: string | null;
endDateAfter: string | null;
lengthDaysBefore: number | null;
lengthDaysAfter: number | null;
}
/**
* Re-derive the boundary of the cycle that precedes `removedStartDate` from
* the cycles that remain. Call it AFTER the cycle at that date is soft-deleted
* (the queries below read live rows only, so a still-live row at the removed
* start would re-derive against itself and change nothing).
*
* Returns the columns it moved, or null when there is no preceding cycle or
* the preceding cycle already holds the derived boundary — an idempotent
* re-run must not bump `syncVersion` and make every device re-pull a row that
* did not change.
*/
export async function reanchorAfterRemovedStart(
db: CycleDb,
userId: string,
removedStartDate: string,
): Promise<ReanchoredCycle | null> {
const prior = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { lt: removedStartDate } },
orderBy: { startDate: "desc" },
select: { id: true, startDate: true, endDate: true, lengthDays: true },
});
if (!prior) return null;
const next = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: removedStartDate } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
// A cycle ends the day before the next one starts; with no next one it is
// open again, which is the state the removed start took away from it.
const endDate = next ? addDays(next.startDate, -1) : null;
const lengthDays = next ? dayDiff(next.startDate, prior.startDate) : null;
if (prior.endDate === endDate && prior.lengthDays === lengthDays) return null;
await db.menstrualCycle.update({
where: { id: prior.id },
data: { endDate, lengthDays, syncVersion: { increment: 1 } },
});
return {
cycleId: prior.id,
endDateBefore: prior.endDate,
endDateAfter: endDate,
lengthDaysBefore: prior.lengthDays,
lengthDaysAfter: lengthDays,
};
}