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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:30:39 +02:00
1 parent f28153c832
commit e822154936
5 files changed
+155 -17

No files matched your search

@@ -0,0 +1,8 @@
-- A period start logged a few days before an existing start folds that start
-- into itself (the same period, entered out of order), soft-deleting it.
-- Nothing recorded which start was folded, so taking the earlier start back
-- could not bring the later one back: a mis-tapped "started" wiped the real
-- start and reopened the cycle before it. The folded row now names the start
-- that absorbed it, and removing that start restores it.
ALTER TABLE "menstrual_cycles"
ADD COLUMN IF NOT EXISTS "absorbed_into_id" TEXT;
+8 -3
View File
@@ -7113,9 +7113,14 @@ model MenstrualCycle {
/// Per-row IANA timezone the date strings are anchored to (NULL =
/// legacy Europe/Berlin), mirroring MoodEntry.tz.
tz String?
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
tz String?
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
/// The start that folded this one in (a start logged a few days earlier is
/// the same period entered out of order). Set only on the soft-deleted,
/// folded row; removing the absorbing start restores every row naming it
/// (`removeCycleStartedOn`). Cleared when the row is live again.
absorbedIntoId String? @map("absorbed_into_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+11 -8
View File
@@ -19,7 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { apiError, getClientIp } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
type RouteParams = { params: Promise<{ id: string }> };
@@ -45,13 +45,13 @@ export const DELETE = apiHandler(
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
// half-applied delete would read the row being removed as still live.
const reanchored = await prisma.$transaction(async (db) => {
await db.menstrualCycle.update({
where: { id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
return reanchorAfterRemovedStart(db, user.id, existing.startDate);
});
// The shared removal also gives back a start this one folded in and
// hands the removed cycle's days back. `null` is a cycle already
// tombstoned: nothing left to move.
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.startDate),
);
const reanchored = removed?.reanchored ?? null;
await auditLog("cycle.cycle.delete", {
userId: user.id,
@@ -61,6 +61,9 @@ export const DELETE = apiHandler(
details: {
cycleId: id,
startDate: existing.startDate,
...(removed && removed.restoredCycleIds.length > 0
? { restoredCycleIds: removed.restoredCycleIds }
: {}),
...(reanchored
? {
reanchoredCycleId: reanchored.cycleId,
+68 -6
View File
@@ -114,10 +114,6 @@ export async function openCycleAt(
if (absorbed) {
moved.absorbedCycleId = absorbed.id;
moved.absorbedStartDate = absorbed.startDate;
await db.menstrualCycle.update({
where: { id: absorbed.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
}
// Close the prior open cycle: its end is the day before this start.
@@ -160,12 +156,26 @@ export async function openCycleAt(
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 } },
@@ -342,13 +352,21 @@ export async function ensureCycleForBleedingDay(
* 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 } | null> {
): Promise<{
cycleId: string;
reanchored: ReanchoredCycle | null;
restoredCycleIds: string[];
} | null> {
const opened = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: date },
select: { id: true },
@@ -359,6 +377,23 @@ export async function removeCycleStartedOn(
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({
@@ -370,7 +405,34 @@ export async function removeCycleStartedOn(
where: { userId, cycleId: opened.id },
data: { cycleId: prior?.id ?? null, syncVersion: { increment: 1 } },
});
return { cycleId: opened.id, reanchored };
// 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. */
@@ -236,6 +236,66 @@ describe("logging a period start and taking it back", () => {
expect(await liveShapes()).toEqual(before);
});
it("gives back the start a mis-tapped earlier start folded in", async () => {
// A start a few days after another is the same period entered late, so
// the earlier tap folds it in. When the earlier tap was the mistake,
// taking it back has to bring the real start back too, not reopen the
// cycle before it.
await loginAs(USER_ID);
await seedHistory();
await startPeriod("2026-08-10");
const before = await liveShapes();
const realStart = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-08-10" },
select: { id: true },
});
await startPeriod("2026-08-05");
const during = await liveShapes();
expect(during.map((c) => c.startDate)).not.toContain("2026-08-10");
expect(during.at(-1)?.startDate).toBe("2026-08-05");
const misTap = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-08-05" },
select: { id: true },
});
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
const res = await DELETE(
jsonRequest(`/api/cycle/cycles/${misTap.id}`, "DELETE"),
{ params: Promise.resolve({ id: misTap.id }) },
);
expect(res.status).toBe(204);
expect(await liveShapes()).toEqual(before);
// The real start's own day is its own again.
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-08-10" },
select: { cycleId: true },
});
expect(dayLog.cycleId).toBe(realStart.id);
});
it("gives back the folded start when the mis-tap's day-log is deleted", async () => {
await loginAs(USER_ID);
await seedHistory();
await startPeriod("2026-08-10");
const before = await liveShapes();
await startPeriod("2026-08-05");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-08-05" },
select: { id: true },
});
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await DELETE(
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(204);
expect(await liveShapes()).toEqual(before);
});
it("leaves a day-log that opened no cycle alone", async () => {
// A plain entry on a day that is not a cycle start must not take a cycle
// with it when it is deleted.