fix(cycle): reopen a re-tapped start that has nothing after it

The earlier rule could leave a one-day cycle in front of a real start
when day two of a period was logged before day one. Tapping "My period
started this day" on the real first day folds the later start in, but
the re-tapped row kept the end date and length its vanished successor
had given it, so the repaired cycle stayed closed after one day.

A start with no cycle after it is open, so opening one now clears a
stale end and length. The repair of such a history is covered end to
end: the broken state is seeded as the old rule left it, the one tap
is made through the period route, and the result is a single open
cycle that owns every day of the period.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:11:14 +02:00
1 parent e0b547a430
commit b77dbea6b1
2 files changed
+92

No files matched your search

+11
View File
@@ -183,6 +183,17 @@ export async function openCycleAt(
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.
@@ -371,6 +371,87 @@ describe("a bleeding day opens a cycle", () => {
expect(day.cycleId).toBe(may.id);
});
it("repairs a one-day cycle the old rule left, with one tap on the real first day", async () => {
// The state the previous rule produced when day two of a period was
// logged before day one: a cycle one day long in front of the real one.
// Seeded directly, because the current code no longer creates it.
const prisma = getPrismaClient();
const may = await prisma.menstrualCycle.create({
data: {
userId: USER_ID,
startDate: "2026-05-04",
endDate: "2026-05-31",
lengthDays: 28,
},
});
const stub = await prisma.menstrualCycle.create({
data: {
userId: USER_ID,
startDate: "2026-06-01",
endDate: "2026-06-01",
lengthDays: 1,
},
});
const real = await prisma.menstrualCycle.create({
data: { userId: USER_ID, startDate: "2026-06-02" },
});
await prisma.cycleDayLog.createMany({
data: [
{
userId: USER_ID,
date: "2026-06-01",
flow: "HEAVY",
cycleId: stub.id,
},
{
userId: USER_ID,
date: "2026-06-02",
flow: "MEDIUM",
cycleId: real.id,
},
{
userId: USER_ID,
date: "2026-06-06",
flow: "LIGHT",
cycleId: real.id,
},
],
});
// What the release note tells the person to do.
const { POST } = await import("@/app/api/cycle/period/route");
const res = await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-06-01",
loggedAt: new Date().toISOString(),
}),
);
expect(res.status).toBe(200);
const rows = await prisma.menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { startDate: "asc" },
select: { id: true, startDate: true, endDate: true, lengthDays: true },
});
expect(rows.map(({ id: _id, ...r }) => r)).toEqual([
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
{ startDate: "2026-06-01", endDate: null, lengthDays: null },
]);
expect(rows[0].id).toBe(may.id);
const june = rows[1].id;
const days = await prisma.cycleDayLog.findMany({
where: { userId: USER_ID, deletedAt: null },
orderBy: { date: "asc" },
select: { date: true, cycleId: true },
});
expect(days).toEqual([
{ date: "2026-06-01", cycleId: june },
{ date: "2026-06-02", cycleId: june },
{ date: "2026-06-06", cycleId: june },
]);
});
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.