diff --git a/prisma/migrations/0345_cycle_absorbed_start/migration.sql b/prisma/migrations/0345_cycle_absorbed_start/migration.sql new file mode 100644 index 000000000..96abb90f7 --- /dev/null +++ b/prisma/migrations/0345_cycle_absorbed_start/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66075b211..c6c619041 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts index 4e8483cc2..1a163c633 100644 --- a/src/app/api/cycle/cycles/[id]/route.ts +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -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, diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts index 9626d1239..660c5ed73 100644 --- a/src/lib/cycle/cycle-boundaries.ts +++ b/src/lib/cycle/cycle-boundaries.ts @@ -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. */ diff --git a/tests/integration/cycle-start-round-trip.test.ts b/tests/integration/cycle-start-round-trip.test.ts index 8595df6f1..bbb458a1e 100644 --- a/tests/integration/cycle-start-round-trip.test.ts +++ b/tests/integration/cycle-start-round-trip.test.ts @@ -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.