From c5a471f7fa7b70bc80ec8d846dc42d0134b4518a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:58:53 +0200 Subject: [PATCH 1/9] fix(cycle): keep counting an open cycle past the predicted period start The engine ended the open cycle's phase window at the predicted next start, so the day after a missed forecast carried no phase at all and the verdict resolver read that as today falling outside every known cycle window. An ordinary three-days-late period therefore reported INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words insufficient data, while the reminder cron was pushing a period-confirm prompt for the same day. OVERDUE was only reachable when the engine itself forecast a cycle longer than the grace window. The open cycle now runs to the predicted start or through today, whichever is later, and stops the moment the next start is logged. The count is bounded on the far side where it already was: past the typical length plus its grace window the verdict degrades to OVERDUE and says how late the period is. The extension pins the open cycle's ovulation anchor to the predicted length. Left to the span, an unconfirmed ovulation is back-calculated from the window's own length, so a window that grows with the delay would walk the estimate and every phase boundary drawn from it. - buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's today; the insights route and the coach snapshot pass it - verdict round-trip covers the predicted start day, three days past it, past the grace window, and the case where a new start closes the run --- .../__tests__/verdict-round-trip.test.ts | 125 ++++++++++++++++++ src/app/api/cycle/insights/route.ts | 1 + src/lib/ai/coach/cycle-snapshot.ts | 2 + src/lib/cycle/engine-adapter.ts | 59 ++++++++- 4 files changed, 182 insertions(+), 5 deletions(-) diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts index eb19019b7..4e10ce533 100644 --- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -103,6 +103,16 @@ const LONG_CYCLE_STARTS = [ "2026-04-30", ]; +/** + * Four period starts exactly 28 days apart — the textbook regular record. The + * last start is open, so where "today" sits relative to `lastStart + 28` + * decides what the verdict may claim. Built from a supplied last start so a + * test can place today a chosen number of days past the predicted one. + */ +function regularStarts(lastStart: string): string[] { + return [-84, -56, -28, 0].map((offset) => addDays(lastStart, offset)); +} + function cycleRows(starts: readonly string[]) { return starts.map((startDate, i) => ({ id: `cyc-${i}`, @@ -249,6 +259,7 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { cycleRows(LONG_CYCLE_STARTS) as never, prediction?.nextPeriodStart ?? null, resolveLuteal(profile() as never), + "2026-05-20", ); expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle); expect(verdict.phase).toBe(fromEngine.phase); @@ -276,6 +287,120 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected)); }); + /** + * D-list edge 1 — the verdict at and after the predicted start day, driven + * through the real route. + * + * A regular 28-day record whose period has not arrived is the ORDINARY way + * to be late, and it is the case the ring was silent about: the engine ends + * the open cycle's phase window at the predicted start, so the day after it + * carried no phase, and the verdict resolver read "no phase today" as "no + * data at all". The person saw an empty ring and the words insufficient data + * on the third day of a late period. `OVERDUE` was only ever reachable when + * the engine itself forecast a cycle longer than the grace window. + */ + describe("a period that has not arrived", () => { + /** Place today N days past `lastStart + 28`, the predicted next start. */ + function overdueBy(days: number): { today: string; lastStart: string } { + const today = "2026-06-11"; + return { today, lastStart: addDays(today, -(28 + days)) }; + } + + function seedRegular(lastStart: string): void { + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows(regularStarts(lastStart)) as never, + ); + } + + it("keeps counting the open cycle past the predicted start", async () => { + const { today, lastStart } = overdueBy(3); + seedRegular(lastStart); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const data = body.data as Record; + const verdict = data.verdict as Record; + const prediction = data.prediction as { nextPeriodStart: string } | null; + + // The predicted start really is behind us — otherwise this asserts nothing. + expect(prediction?.nextPeriodStart).toBe(addDays(today, -3)); + + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(32); + expect(verdict.phase).toBe("LUTEAL"); + expect(verdict.cycleStartDate).toBe(lastStart); + // There is no "until" once the predicted start is behind us. + expect(verdict.daysUntilNext).toBeNull(); + }); + + it("says OVERDUE, with the day count, once the grace window is spent", async () => { + // Typical 28 + 14 days of grace = 42. Day 44 is past it. + const { today, lastStart } = overdueBy(15); + seedRegular(lastStart); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("OVERDUE"); + expect(verdict.dayOfCycle).toBeNull(); + expect(verdict.phase).toBeNull(); + expect(verdict.overdueDays).toBe(16); + expect(verdict.cycleStartDate).toBe(lastStart); + expect(verdict.spans).toHaveLength(4); + }); + + it("holds the count on the predicted start day itself", async () => { + const { today, lastStart } = overdueBy(0); + seedRegular(lastStart); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + // Day 29 of a 28-day cycle: the period is due today and has not been + // logged, which is a cycle day, not an absence of data. + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(29); + expect(verdict.daysUntilNext).toBe(0); + }); + + it("stops extending the open window once the next period is logged", async () => { + // The same record with one more start logged four days ago: the earlier + // cycle closes at that start and today is day 5 of the new one. + const { today, lastStart } = overdueBy(3); + const withNewStart = [...regularStarts(lastStart), addDays(today, -4)]; + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows(withNewStart) as never, + ); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(5); + expect(verdict.cycleStartDate).toBe(addDays(today, -4)); + }); + }); + it("says INSUFFICIENT_DATA when nothing has been logged", async () => { vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(getSession).mockResolvedValue(session("UTC") as never); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index ba23e7992..d23d5c1da 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -176,6 +176,7 @@ export const GET = apiHandler(async () => { profile.lutealPhaseLength ?? LUTEAL_DEFAULT, from, today, + today, ); const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index b3ee48a32..c4f0b4cad 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -242,6 +242,7 @@ export async function buildCycleSnapshotBlock( cycles, prediction?.nextPeriodStart ?? null, lutealLength, + today, ); // Next predicted event — period range + confidence + method, fertile window @@ -280,6 +281,7 @@ export async function buildCycleSnapshotBlock( lutealLength, from, today, + today, ); const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ type: m.type, diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index a6c3e1901..b2add008d 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -18,6 +18,8 @@ import { type CyclePhase, type PhaseCycle, resolveLuteal, + clampLuteal, + POPULATION_DEFAULT_CYCLE, } from "@/lib/cycle"; import type { CycleProfile, @@ -129,9 +131,30 @@ function periodLengthOf( } /** - * Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's - * span ends where the next begins; the latest open cycle runs to the - * predicted next-period start). Used to label every calendar day's phase. + * Build a contiguous `PhaseCycle[]` from the observed cycles: each closed + * cycle's span ends where the next begins, and the latest open cycle runs to + * the predicted next-period start — or, when that day has come and gone with + * no period logged, on through `today`. + * + * That trailing extension is the difference between a late period and no + * record at all. A cycle stays open until the next one starts, and a forecast + * is a forecast: the day after a missed prediction is still a day of the cycle + * the person is in. Ending the span at the predicted start instead made every + * day past it carry no phase, which the verdict resolver could only read as + * "today falls outside every known cycle window" — so an ordinary three-days- + * late period reported INSUFFICIENT_DATA, and the honest OVERDUE state was + * unreachable unless the engine itself forecast a cycle longer than the grace + * window. The count is bounded on the other side: `resolveCycleVerdict` stops + * claiming a cycle day once the run passes the profile's typical length plus + * its grace window. + * + * The extension deliberately does NOT move the ovulation estimate. Left to + * `phaseForDay`, an ovulation with no confirmed date is back-calculated from + * the span's own length, so a window that grows a day per day of delay would + * walk the estimate — and every FOLLICULAR/OVULATORY/LUTEAL boundary already + * drawn for that cycle — forward with it. The open cycle therefore carries an + * explicit anchor derived from the PREDICTED length, which is the same date + * `phaseForDay` computes when the span ends where the forecast said it would. */ export function buildPhaseCycles( cycles: readonly Pick< @@ -140,18 +163,37 @@ export function buildPhaseCycles( >[], predictedNextStart: string | null, lutealLength: number, + /** Today in the USER's timezone. The open cycle runs at least this far. */ + today: string, ): PhaseCycle[] { const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate)); const out: PhaseCycle[] = []; for (let i = 0; i < sorted.length; i++) { const c = sorted[i]; const next = sorted[i + 1]; + if (next) { + out.push({ + startDate: c.startDate, + nextStart: next.startDate, + ovulationDate: c.ovulationDate, + periodLength: periodLengthOf(c), + lutealLength, + }); + continue; + } + + // The open cycle. `phaseForDay` treats `nextStart` as exclusive, so the + // window has to reach the day AFTER today for today to carry a phase. + const forecastEnd = + predictedNextStart ?? addDays(c.startDate, POPULATION_DEFAULT_CYCLE); + const throughToday = addDays(today, 1); const nextStart = - next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28); + dayDiff(throughToday, forecastEnd) > 0 ? throughToday : forecastEnd; out.push({ startDate: c.startDate, nextStart, - ovulationDate: c.ovulationDate, + ovulationDate: + c.ovulationDate ?? addDays(forecastEnd, -clampLuteal(lutealLength)), periodLength: periodLengthOf(c), lutealLength, }); @@ -186,11 +228,14 @@ export function phaseForDate( >[], predictedNextStart: string | null, lutealLength: number, + /** Today in the USER's timezone — how far a still-open cycle runs. */ + today: string, ): { phase: CyclePhase | null; dayOfCycle: number | null } { const phaseCycles = buildPhaseCycles( cycles, predictedNextStart, lutealLength, + today, ); for (const pc of phaseCycles) { const r = phaseForDay(date, pc); @@ -219,11 +264,14 @@ export function buildPhaseDayMap( lutealLength: number, from: string, to: string, + /** Today in the USER's timezone — how far a still-open cycle runs. */ + today: string, ): Map { const phaseCycles = buildPhaseCycles( cycles, predictedNextStart, lutealLength, + today, ); const out = new Map(); const span = dayDiff(to, from); @@ -304,6 +352,7 @@ export function buildCalendar( cycles, prediction?.nextPeriodStart ?? null, lutealLength, + today, ); // Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength). From 6db2f6f9ae7acfab63ae4721b54a686b7c9f5313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:03:10 +0200 Subject: [PATCH 2/9] fix(cycle): make removing a period start restore the neighbour it moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a cycle closes the one before it: the start write stamps the previous cycle's endDate and lengthDays, and a back-filled start takes its own end from its successor. Nothing undid any of that. Deleting the start left the previous cycle as a closed torso holding an end date from a cycle that no longer existed, and a record whose last cycle is closed has no open cycle to forecast from — the prediction went away and there was no way to bring it back from the app. Both removal paths now re-derive the boundary from the cycles that remain: a cycle ends the day before the next one begins, and with no next one it is open again. That is a re-derivation rather than an undo log, so it is correct for a history that came from an import or a restore, and running it twice changes nothing the second time. Deleting the day-log that opened a cycle now removes that cycle too. The one-tap start writes both rows and the log sheet's delete is the only way the web offers to take it back; leaving the cycle standing was how the broken state survived the undo. - soft-delete and re-anchor run in one transaction - the audit row names the neighbour's moved boundary, the only part of the write the tombstone cannot reconstruct - round trip through the real routes: photograph the cycle rows, log the start, remove it either way, compare field by field — including that the forecast comes back --- src/app/api/cycle/cycles/[id]/route.ts | 44 ++- src/app/api/cycle/day-logs/[id]/route.ts | 56 +++- src/lib/cycle/cycle-boundaries.ts | 85 +++++ .../cycle-start-round-trip.test.ts | 296 ++++++++++++++++++ 4 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 src/lib/cycle/cycle-boundaries.ts create mode 100644 tests/integration/cycle-start-round-trip.test.ts diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts index 642d5a81c..4b8900d00 100644 --- a/src/app/api/cycle/cycles/[id]/route.ts +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -2,6 +2,12 @@ * `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle * (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a * tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated. + * + * The delete also hands the span back to the preceding cycle. Opening a cycle + * closes the one before it, so removing it without re-deriving that boundary + * left the previous cycle standing as a closed torso with an end date taken + * from a cycle that no longer exists — and a record whose last cycle is closed + * has no open cycle to forecast from. */ import { NextRequest } from "next/server"; @@ -9,8 +15,10 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; 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"; type RouteParams = { params: Promise<{ id: string }> }; @@ -28,21 +36,46 @@ export const DELETE = apiHandler( const existing = await prisma.menstrualCycle.findUnique({ where: { id }, - select: { id: true, userId: true }, + select: { id: true, userId: true, startDate: true }, }); if (!existing || existing.userId !== user.id) { return apiError("Cycle not found", 404); } - await prisma.menstrualCycle.update({ - where: { id }, - data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + // 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); }); await auditLog("cycle.cycle.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { cycleId: id }, + // The tombstone carries the deleted cycle's own columns, so the only + // unrecoverable part of this write is what it moved on the NEIGHBOUR. + details: { + cycleId: id, + startDate: existing.startDate, + ...(reanchored + ? { + reanchoredCycleId: reanchored.cycleId, + ...overwriteDetails({ + before: { + endDate: reanchored.endDateBefore, + lengthDays: reanchored.lengthDaysBefore, + }, + after: { + endDate: reanchored.endDateAfter, + lengthDays: reanchored.lengthDaysAfter, + }, + }), + } + : {}), + }, }); annotate({ @@ -51,6 +84,7 @@ export const DELETE = apiHandler( entity_type: "menstrual_cycle", entity_id: id, }, + meta: { reanchored_prior: reanchored !== null }, }); return new Response(null, { status: 204 }); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 7cd6fb3b8..7b88e0479 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -23,6 +23,7 @@ import { safeJson, } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries"; import { encrypt, decrypt } from "@/lib/crypto"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; @@ -202,7 +203,7 @@ export const DELETE = apiHandler( const existing = await prisma.cycleDayLog.findUnique({ where: { id }, - select: { id: true, userId: true }, + select: { id: true, userId: true, date: true }, }); if (!existing || existing.userId !== user.id) { return apiError("Day-log not found", 404); @@ -211,15 +212,56 @@ export const DELETE = apiHandler( // Soft-delete: leave the row in place so the `/api/sync/changes` // delta feed surfaces it as a tombstone. A re-delete re-bumps // `syncVersion` harmlessly (idempotent). - await prisma.cycleDayLog.update({ - where: { id }, - data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + // + // A day that OPENS a cycle takes that cycle with it. The one-tap "started + // my period" writes both rows, and this delete is the only way the web + // offers to take it back — leaving the cycle behind meant the previous one + // stayed closed against a start whose last trace had just been removed, and + // the forecast never came back. The cycle boundary is then re-derived for + // the preceding cycle, the same repair the cycle delete does. + const removed = await prisma.$transaction(async (db) => { + await db.cycleDayLog.update({ + 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), + }; }); await auditLog("cycle.day-log.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { dayLogId: id }, + details: { + dayLogId: id, + date: existing.date, + ...(removed.cycleId ? { removedCycleId: removed.cycleId } : {}), + ...(removed.reanchored + ? { + reanchoredCycleId: removed.reanchored.cycleId, + ...overwriteDetails({ + before: { + endDate: removed.reanchored.endDateBefore, + lengthDays: removed.reanchored.lengthDaysBefore, + }, + after: { + endDate: removed.reanchored.endDateAfter, + lengthDays: removed.reanchored.lengthDaysAfter, + }, + }), + } + : {}), + }, }); annotate({ @@ -228,6 +270,10 @@ export const DELETE = apiHandler( entity_type: "cycle_day_log", entity_id: id, }, + meta: { + removed_cycle: removed.cycleId !== null, + reanchored_prior: removed.reanchored !== null, + }, }); return new Response(null, { status: 204 }); diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts new file mode 100644 index 000000000..9d9e7f84a --- /dev/null +++ b/src/lib/cycle/cycle-boundaries.ts @@ -0,0 +1,85 @@ +/** + * 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 { addDays, dayDiff } from "./day-math"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +/** The Prisma client or an interactive-transaction handle. */ +type CycleDb = PrismaClient | Prisma.TransactionClient; + +/** 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 { + 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, + }; +} diff --git a/tests/integration/cycle-start-round-trip.test.ts b/tests/integration/cycle-start-round-trip.test.ts new file mode 100644 index 000000000..8595df6f1 --- /dev/null +++ b/tests/integration/cycle-start-round-trip.test.ts @@ -0,0 +1,296 @@ +/** + * Logging a period start and taking it back has to leave the record where it + * found it. + * + * `action:"start"` does not only insert a cycle. It CLOSES the previous open + * one — stamps its `endDate` and `lengthDays` from the new start — and, when + * the start is back-filled between two existing cycles, re-anchors the one + * after it too. Nothing undid any of that. Deleting the start left the + * previous cycle standing as a closed torso, and a record whose last cycle is + * closed has no open cycle to forecast from, so the prediction disappeared and + * did not come back. There is no way to repair that from the app. + * + * So this is a round trip, not a delete test: it photographs every cycle row + * before the start is logged, logs it, removes it, and asserts the rows are + * back to the photograph field by field. Two removal paths reach a start — + * deleting the cycle, and deleting the day-log that opened it from the log + * sheet, which is the only one the web actually offers. + */ +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-round-trip-integration-32-bytes-min-1234567890"; +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-cycle-round-trip"; + +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 { + 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-round-trip", + email: "cycle-round-trip@example.test", + gender: "FEMALE", + }, + }); +}); + +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, + }); +} + +/** The cycle boundary state, which is exactly what a start write moves. */ +interface CycleShape { + startDate: string; + endDate: string | null; + lengthDays: number | null; + periodEndDate: string | null; + deletedAt: boolean; +} + +async function cycleShapes(): Promise { + const rows = await getPrismaClient().menstrualCycle.findMany({ + where: { userId: USER_ID }, + orderBy: { startDate: "asc" }, + select: { + startDate: true, + endDate: true, + lengthDays: true, + periodEndDate: true, + deletedAt: true, + }, + }); + return rows.map((r) => ({ + startDate: r.startDate, + endDate: r.endDate, + lengthDays: r.lengthDays, + periodEndDate: r.periodEndDate, + deletedAt: r.deletedAt !== null, + })); +} + +/** Only the cycles a read would see — a tombstone is not part of the record. */ +async function liveShapes(): Promise { + return (await cycleShapes()).filter((c) => !c.deletedAt); +} + +async function startPeriod(date: string): Promise { + const { POST } = await import("@/app/api/cycle/period/route"); + return POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date, + loggedAt: `${date}T08:00:00.000Z`, + }), + ); +} + +/** The three months of history the maintainer's record carried. */ +async function seedHistory(): Promise { + await startPeriod("2026-05-04"); + await startPeriod("2026-06-01"); + await startPeriod("2026-07-01"); +} + +describe("logging a period start and taking it back", () => { + it("re-opens the previous cycle when the start's own cycle is deleted", async () => { + await loginAs(USER_ID); + await seedHistory(); + const before = await liveShapes(); + // The July cycle is open — that is the state the record has to return to. + expect(before.at(-1)).toEqual({ + startDate: "2026-07-01", + endDate: null, + lengthDays: null, + periodEndDate: null, + deletedAt: false, + }); + + await startPeriod("2026-08-07"); + const during = await liveShapes(); + expect(during).toHaveLength(4); + // The July cycle really was closed by the new start, so the delete below + // has something to undo. + expect(during.at(-2)).toMatchObject({ + startDate: "2026-07-01", + endDate: "2026-08-06", + lengthDays: 37, + }); + + const added = await getPrismaClient().menstrualCycle.findFirstOrThrow({ + where: { userId: USER_ID, startDate: "2026-08-07" }, + select: { id: true }, + }); + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + const res = await DELETE( + jsonRequest(`/api/cycle/cycles/${added.id}`, "DELETE"), + { params: Promise.resolve({ id: added.id }) }, + ); + expect(res.status).toBe(204); + + expect(await liveShapes()).toEqual(before); + }); + + it("re-opens the previous cycle when the start's day-log is deleted", async () => { + // The path the web actually offers: the log sheet's delete button removes + // the day-log, and the cycle that one tap opened has to go with it. + await loginAs(USER_ID); + await seedHistory(); + const before = await liveShapes(); + + await startPeriod("2026-08-07"); + expect(await liveShapes()).toHaveLength(4); + + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-08-07" }, + 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("restores the boundary a back-filled start took from BOTH neighbours", async () => { + // A start inserted between two existing cycles closes the one before it + // and takes its own end from the one after. Removing it has to hand the + // whole span back to the earlier cycle. + await loginAs(USER_ID); + await seedHistory(); + await startPeriod("2026-08-07"); + const before = await liveShapes(); + + await startPeriod("2026-06-15"); + const during = await liveShapes(); + expect(during).toHaveLength(5); + expect(during[1]).toMatchObject({ + startDate: "2026-06-01", + endDate: "2026-06-14", + lengthDays: 14, + }); + expect(during[2]).toMatchObject({ + startDate: "2026-06-15", + endDate: "2026-06-30", + lengthDays: 16, + }); + + const inserted = await getPrismaClient().menstrualCycle.findFirstOrThrow({ + where: { userId: USER_ID, startDate: "2026-06-15" }, + select: { id: true }, + }); + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + await DELETE(jsonRequest(`/api/cycle/cycles/${inserted.id}`, "DELETE"), { + params: Promise.resolve({ id: inserted.id }), + }); + + 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. + await loginAs(USER_ID); + await seedHistory(); + const before = await liveShapes(); + + const { POST } = await import("@/app/api/cycle/day-logs/route"); + await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-07-14", + flow: "SPOTTING", + loggedAt: "2026-07-14T08:00:00.000Z", + }), + ); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-07-14" }, + select: { id: true }, + }); + const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); + await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), { + params: Promise.resolve({ id: dayLog.id }), + }); + + expect(await liveShapes()).toEqual(before); + }); + + it("gives the forecast back", async () => { + // The symptom the round trip exists for: after the start is taken back the + // engine must forecast again, from the same open cycle as before. + await loginAs(USER_ID); + await seedHistory(); + + const { GET } = await import("@/app/api/cycle/calendar/route"); + const readForecast = async (): Promise => { + const res = await GET( + new NextRequest("http://localhost/api/cycle/calendar"), + ); + const body = JSON.parse(await res.text()); + return body.data?.prediction?.nextPeriodStart ?? null; + }; + + const before = await readForecast(); + expect(before).not.toBeNull(); + + await startPeriod("2026-08-07"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-08-07" }, + select: { id: true }, + }); + const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); + await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), { + params: Promise.resolve({ id: dayLog.id }), + }); + + expect(await readForecast()).toBe(before); + }); +}); From ed274de0a01479d98b216a5fdafd34db2eded3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:05:50 +0200 Subject: [PATCH 3/9] fix(cycle): keep the cycle day when the phase band is withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cold-start gate suppresses the calendar's phase band until three cycles are observed, because a population-framed phase word off one or two of them is not earned. The verdict read the day count off those same labels, so the gate took the count with it: a record holding three logged period starts resolved INSUFFICIENT_DATA and rendered an empty ring under an offer to log a first period. The count is not the claim the gate is about. It is the number of days since a start the person entered themselves. The verdict now takes that start from the cycle rows and falls back to counting from it whenever today carries no phase, with the same overdue ceiling and no phase claim. INSUFFICIENT_DATA is left for the case that really is one: no period start on record at or before today. The sheet's day header was already written for this state and could never reach it — it renders a bare day number when no phase is trustworthy. - verdict round trip covers the still-learning day count and its overdue side through the real route - unit tests pin the anchor path, the absent-from-grid path, and that a future-dated start is not a cycle anyone is in --- .../__tests__/verdict-round-trip.test.ts | 66 +++++++++++++++++++ src/app/api/cycle/calendar/route.ts | 6 ++ src/lib/cycle/__tests__/verdict.test.ts | 54 +++++++++++++++ src/lib/cycle/verdict.ts | 64 +++++++++++++++++- 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts index 4e10ce533..259329ac8 100644 --- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -401,6 +401,72 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { }); }); + /** + * The cold-start gate suppresses the PHASE band while the engine is still + * learning — a population-framed phase word off one or two cycles is not + * earned. The day count is not a guess: it is the number of days since a + * start the person logged themselves. Reading it off the phase labels meant + * the gate took both, so a record with three logged periods rendered an + * empty ring and offered to log a first period. + */ + describe("while the engine is still learning", () => { + it("still counts the cycle day off the logged start", async () => { + const today = "2026-06-11"; + const lastStart = addDays(today, -10); + // Three starts → two observed lengths → under the three-cycle gate. + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows([ + addDays(lastStart, -56), + addDays(lastStart, -28), + lastStart, + ]) as never, + ); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const data = body.data as Record; + // The gate really is on, so the phase below is suppressed rather than absent. + expect(data.stillLearning).toBe(true); + + const verdict = data.verdict as Record; + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(11); + expect(verdict.cycleStartDate).toBe(lastStart); + // No phase claim — that part of the gate stands. + expect(verdict.phase).toBeNull(); + // The ring still draws a full wheel rather than an empty circle. + expect(verdict.spans).toHaveLength(4); + }); + + it("still says how late a period is", async () => { + const today = "2026-06-11"; + const lastStart = addDays(today, -50); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows([ + addDays(lastStart, -56), + addDays(lastStart, -28), + lastStart, + ]) as never, + ); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + // Day 51 against a stated 28-day typical plus 14 days of grace. + expect(verdict.state).toBe("OVERDUE"); + expect(verdict.overdueDays).toBe(23); + expect(verdict.cycleStartDate).toBe(lastStart); + }); + }); + it("says INSUFFICIENT_DATA when nothing has been logged", async () => { vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(getSession).mockResolvedValue(session("UTC") as never); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index f5f4d1f51..2c69eb869 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -195,6 +195,12 @@ export const GET = apiHandler(async (request: NextRequest) => { nextPeriodStart: predictionDto?.nextPeriodStart ?? null, fertileWindowStart: predictionDto?.fertileWindowStart ?? null, fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null, + // The latest start the person logged, read off the cycle rows rather than + // the grid. The grid's phase band is withheld while the engine is still + // learning, and the day count must not go with it — `cycles` is ordered by + // startDate ascending, so the last one at or before today is the anchor. + lastPeriodStart: + cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null, }); annotate({ diff --git a/src/lib/cycle/__tests__/verdict.test.ts b/src/lib/cycle/__tests__/verdict.test.ts index a7be69428..117c61efc 100644 --- a/src/lib/cycle/__tests__/verdict.test.ts +++ b/src/lib/cycle/__tests__/verdict.test.ts @@ -163,6 +163,60 @@ describe("resolveCycleVerdict — what the record supports", () => { expect(v.cycleLength).toBe(8); }); + it("counts off the logged start when the grid withholds its phase labels", () => { + // What the calendar hands over while the engine is still learning: a grid + // with every phase suppressed. The count is not the suppressed claim. + const days = [day("2026-06-01", null), day("2026-06-11", null)]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-11", + profile: { typicalCycleLength: 28 }, + lastPeriodStart: "2026-06-01", + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(11); + expect(v.cycleStartDate).toBe("2026-06-01"); + expect(v.phase).toBeNull(); + expect(v.spans).toHaveLength(4); + }); + + it("says how late a period is with no phase labels to read", () => { + const days = [day("2026-06-11", null)]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-11", + profile: { typicalCycleLength: 28 }, + lastPeriodStart: "2026-04-22", + }); + expect(v.state).toBe("OVERDUE"); + expect(v.dayOfCycle).toBeNull(); + expect(v.overdueDays).toBe(23); + expect(v.cycleStartDate).toBe("2026-04-22"); + }); + + it("counts off the logged start when today is absent from the grid", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-09", + lastPeriodStart: "2026-06-01", + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(9); + }); + + it("keeps INSUFFICIENT_DATA when the anchor is ahead of today", () => { + // A start dated in the future is not a cycle the person is in. + const days = [day("2026-06-02", null)]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-02", + lastPeriodStart: "2026-06-20", + }); + expect(v.state).toBe("INSUFFICIENT_DATA"); + expect(v.cycleStartDate).toBeNull(); + }); + it("draws the canonical four arcs for a low-data tracker", () => { // A two-day first-ever run: the observed-share math would let MENSTRUAL // fill nearly the whole circle, so the ring falls back to the profile's diff --git a/src/lib/cycle/verdict.ts b/src/lib/cycle/verdict.ts index 5fb5ecadd..ae7a83c91 100644 --- a/src/lib/cycle/verdict.ts +++ b/src/lib/cycle/verdict.ts @@ -116,6 +116,21 @@ export interface ResolveCycleVerdictInput { /** Already goal- and cold-start-gated fertile window bounds. */ fertileWindowStart?: string | null; fertileWindowEnd?: string | null; + /** + * The latest LOGGED period start at or before today, or null when the record + * holds none. Read straight off the cycle rows, never off the grid. + * + * The grid's phase labels are gated: the calendar suppresses the whole phase + * band while the engine is still learning (fewer than three observed cycles), + * because a population-framed phase word off one or two cycles is not earned. + * The day count is not that kind of claim — it is the number of days since a + * start the person entered themselves — but deriving it from the labelled run + * meant the gate took both, and a record with three logged periods rendered an + * empty ring under an offer to log a first period. This anchor keeps the count + * and the honest overdue judgement available when the labels are withheld; + * the phase stays null, which is the part the gate is actually about. + */ + lastPeriodStart?: string | null; } /** Below this many labelled days in the current cycle run, the ring would @@ -234,6 +249,41 @@ function resolveFertileWindow( }; } +/** + * The verdict for a record whose grid carries no phase for today but whose + * cycle rows carry a logged period start: count from that start, and apply the + * same overdue ceiling. No phase is claimed — that is exactly the claim the + * missing labels were withholding. Null when no start precedes today, which is + * the one case that really is INSUFFICIENT_DATA. + */ +function verdictFromLoggedStart( + input: ResolveCycleVerdictInput, + fertileWindow: CycleVerdict["fertileWindow"], + daysUntilNext: number | null, +): CycleVerdict | null { + const { today, profile, lastPeriodStart } = input; + if (!lastPeriodStart) return null; + const elapsed = dayDiff(today, lastPeriodStart); + if (elapsed < 0) return null; + + const dayOfCycle = elapsed + 1; + const typicalLength = typicalCycleLength(profile); + const ideal = idealizedSpans(profile); + const overdue = dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS; + + return { + state: overdue ? "OVERDUE" : "IN_CYCLE", + dayOfCycle: overdue ? null : dayOfCycle, + cycleLength: ideal.cycleLength, + phase: null, + spans: ideal.spans, + cycleStartDate: lastPeriodStart, + overdueDays: overdue ? dayOfCycle - typicalLength : null, + daysUntilNext, + fertileWindow, + }; +} + /** * Resolve everything a client needs to render the cycle ring and its caption. * The only place in the product that decides any of it. @@ -259,12 +309,22 @@ export function resolveCycleVerdict( fertileWindow, }; - if (!todayDay || todayDay.phase == null) return nothingToSay; + if (!todayDay || todayDay.phase == null) { + return ( + verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ?? + nothingToSay + ); + } // Sort dates ascending and find today's index. const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); const todayIdx = sorted.findIndex((d) => d.date === today); - if (todayIdx < 0) return nothingToSay; + if (todayIdx < 0) { + return ( + verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ?? + nothingToSay + ); + } const startIdx = findCycleStartIndex(sorted, todayIdx); const cycleStartDate = sorted[startIdx].date; From bd6089734de08c501f8b2a0c25684f4a1bb0f85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:20:29 +0200 Subject: [PATCH 4/9] fix(cycle): let the month grid read the month it is showing The page reads one window anchored on today, ninety days back and half a year forward, and the month grid navigates freely past it. A month outside the window came back with no days: the cells still rendered (they come from the month, not the read) and still opened the log sheet, and the write behind it still landed, because the period route is built for back-dating and re-anchors the neighbouring cycles for it. What did not happen is anything visible. Saving an April flow in August closed the sheet onto a month the read never carried, so the entry looked like it had not been made, and entering it again did not help. The grid now reports the month it moved to and gets its own bounded read for it. The anchored window is untouched, because every other surface on the page means today and the route caps a span at 400 days, so a union with a month two years back would simply be refused. A write also says so now. Saving, deleting and both period boundaries raise a confirmation; the sheet's inline strip already named a failure. Between the invisible month and the silent success there was no way to tell a save that worked from one that never happened. The sheet header carries the date it is writing to, and the one-tap boundaries drop the word today when the day is not today. It always wrote to the selected date; saying otherwise made a correction read as a mistake. The gear beside the log button is gone. It was a second door to the tab directly below it and no other page carries one in that corner. - calendar days carry isCycleStart, so the delete dialog can say that removing this day removes the period start with it - window arithmetic is a pure module with its own tests, including the 400-day span the anchor cannot be widened past - integration: a day 111 back is written, is absent from the anchored read, and comes back complete on a window over its month - new strings in all six languages --- docs/api/openapi.yaml | 4 + messages/de.json | 15 ++- messages/en.json | 15 ++- messages/es.json | 15 ++- messages/fr.json | 15 ++- messages/it.json | 15 ++- messages/pl.json | 15 ++- .../cycle/__tests__/bbt-chart.test.tsx | 1 + .../cycle/__tests__/calendar-window.test.ts | 93 +++++++++++++++ .../cycle/__tests__/cycle-calendar.test.tsx | 1 + src/components/cycle/calendar-window.ts | 58 ++++++++++ src/components/cycle/cycle-calendar.tsx | 20 +++- src/components/cycle/cycle-view.tsx | 79 +++++++------ src/components/cycle/log-day-sheet.tsx | 61 ++++++++-- src/components/cycle/types.ts | 2 + src/components/cycle/use-cycle.ts | 45 +++++++- src/lib/cycle/engine-adapter.ts | 10 ++ src/lib/openapi/routes/cycle.ts | 4 + tests/integration/cycle-routes.test.ts | 107 ++++++++++++++++++ 19 files changed, 508 insertions(+), 67 deletions(-) create mode 100644 src/components/cycle/__tests__/calendar-window.test.ts create mode 100644 src/components/cycle/calendar-window.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eddfd2b8c..92583d744 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16789,6 +16789,9 @@ components: type: boolean isPeriodLogged: type: boolean + isCycleStart: + type: boolean + description: Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it. flow: anyOf: - type: string @@ -16856,6 +16859,7 @@ components: - isFertileWindow - isPredictedOvulation - isPeriodLogged + - isCycleStart - flow - hasSymptoms - confidence diff --git a/messages/de.json b/messages/de.json index f3cf1a8af..46b5c16c3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7709,7 +7709,7 @@ }, "sheet": { "title": "Tag erfassen", - "description": "Erfasse Stärke, Symptome und Notizen von heute.", + "description": "Stärke, Symptome und Notizen für diesen Tag.", "flow": "Stärke", "symptoms": "Symptome", "temperature": "Basaltemperatur", @@ -7744,7 +7744,10 @@ "intimacySection": "Intimität & Verhütung", "testsSection": "Tests", "phaseDayHeader": "Tag {day} · {phase}", - "bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen." + "bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen.", + "titleForDate": "Eintrag für {date}", + "descriptionToday": "Heute. Stärke, Symptome und Notizen.", + "deleteBodyCycleStart": "Dieser Tag eröffnet einen Zyklus. Beim Löschen geht der Periodenbeginn mit, sodass der vorherige Zyklus wie zuvor weiterläuft. Alles hier Erfasste geht ebenfalls: Stärke, Temperatur, Symptome und deine Notiz. Es gibt keinen Weg zurück." }, "settings": { "title": "Zyklus-Einstellungen", @@ -7821,7 +7824,13 @@ "intercourseLabel": "Was erfasst Geschlechtsverkehr?", "protected": "Ob beim Verkehr verhütet wurde.", "protectedLabel": "Was bedeutet geschützt?" - } + }, + "saveSuccess": "Eintrag gespeichert.", + "deleteSuccess": "Eintrag gelöscht.", + "periodStartSaved": "Periodenbeginn gespeichert.", + "periodEndSaved": "Periodenende gespeichert.", + "startedPeriodOnDate": "Periode hat an diesem Tag begonnen", + "endedPeriodOnDate": "Periode an diesem Tag beendet" }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", diff --git a/messages/en.json b/messages/en.json index 9969e7de4..5ca03bf0f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7709,7 +7709,7 @@ }, "sheet": { "title": "Log your day", - "description": "Capture today's flow, symptoms, and notes.", + "description": "Flow, symptoms and notes for this day.", "flow": "Flow", "symptoms": "Symptoms", "temperature": "Basal body temperature", @@ -7744,7 +7744,10 @@ "intimacySection": "Intimacy & contraception", "testsSection": "Tests", "phaseDayHeader": "Day {day} · {phase}", - "bbtHint": "Take it first thing in the morning, before getting up." + "bbtHint": "Take it first thing in the morning, before getting up.", + "titleForDate": "Log for {date}", + "descriptionToday": "Today. Flow, symptoms and notes.", + "deleteBodyCycleStart": "This day opens a cycle. Deleting it takes the period start with it, so the previous cycle runs on as before. Everything logged here goes too: flow, temperature, symptoms and your note. There is no way to bring it back." }, "testResult": { "POSITIVE": "Positive", @@ -7821,7 +7824,13 @@ "intercourseLabel": "What does intercourse track?", "protected": "Whether contraception was used during intercourse.", "protectedLabel": "What does protected mean?" - } + }, + "saveSuccess": "Entry saved.", + "deleteSuccess": "Entry deleted.", + "periodStartSaved": "Period start saved.", + "periodEndSaved": "End of period saved.", + "startedPeriodOnDate": "My period started this day", + "endedPeriodOnDate": "My period ended this day" }, "cycleReminders": { "periodSoonTitle": "Period likely soon", diff --git a/messages/es.json b/messages/es.json index a800a3727..af9f0d2ba 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7709,7 +7709,7 @@ }, "sheet": { "title": "Registrar tu día", - "description": "Captura el flujo, los síntomas y las notas de hoy.", + "description": "Flujo, síntomas y notas de este día.", "flow": "Flujo", "symptoms": "Síntomas", "temperature": "Temperatura basal", @@ -7744,7 +7744,10 @@ "intimacySection": "Intimidad y anticoncepción", "testsSection": "Pruebas", "phaseDayHeader": "Día {day} · {phase}", - "bbtHint": "Mídela a primera hora de la mañana, antes de levantarte." + "bbtHint": "Mídela a primera hora de la mañana, antes de levantarte.", + "titleForDate": "Registro del {date}", + "descriptionToday": "Hoy. Flujo, síntomas y notas.", + "deleteBodyCycleStart": "Este día abre un ciclo. Al eliminarlo se va con él el inicio del periodo, de modo que el ciclo anterior continúa como antes. También se va todo lo registrado aquí: flujo, temperatura, síntomas y tu nota. No hay forma de recuperarlo." }, "settings": { "title": "Ajustes del ciclo", @@ -7821,7 +7824,13 @@ "intercourseLabel": "¿Qué registra relaciones?", "protected": "Si se usó anticoncepción durante las relaciones.", "protectedLabel": "¿Qué significa protegido?" - } + }, + "saveSuccess": "Entrada guardada.", + "deleteSuccess": "Entrada eliminada.", + "periodStartSaved": "Inicio del periodo guardado.", + "periodEndSaved": "Fin del periodo guardado.", + "startedPeriodOnDate": "Mi periodo empezó este día", + "endedPeriodOnDate": "Mi periodo terminó este día" }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", diff --git a/messages/fr.json b/messages/fr.json index b14279d03..52b0d5fd7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7709,7 +7709,7 @@ }, "sheet": { "title": "Enregistrer ta journée", - "description": "Saisis le flux, les symptômes et les notes du jour.", + "description": "Flux, symptômes et notes de cette journée.", "flow": "Flux", "symptoms": "Symptômes", "temperature": "Température basale", @@ -7744,7 +7744,10 @@ "intimacySection": "Intimité et contraception", "testsSection": "Tests", "phaseDayHeader": "Jour {day} · {phase}", - "bbtHint": "Prends-la dès le matin, avant de te lever." + "bbtHint": "Prends-la dès le matin, avant de te lever.", + "titleForDate": "Saisie du {date}", + "descriptionToday": "Aujourd'hui. Flux, symptômes et notes.", + "deleteBodyCycleStart": "Cette journée ouvre un cycle. La supprimer emporte le début des règles, si bien que le cycle précédent se poursuit comme avant. Tout ce qui est saisi ici disparaît aussi : flux, température, symptômes et ta note. Rien ne peut être récupéré." }, "settings": { "title": "Réglages du cycle", @@ -7821,7 +7824,13 @@ "intercourseLabel": "Que suit le rapport ?", "protected": "Si une contraception a été utilisée pendant le rapport.", "protectedLabel": "Que signifie protégé ?" - } + }, + "saveSuccess": "Entrée enregistrée.", + "deleteSuccess": "Entrée supprimée.", + "periodStartSaved": "Début des règles enregistré.", + "periodEndSaved": "Fin des règles enregistrée.", + "startedPeriodOnDate": "Mes règles ont commencé ce jour-là", + "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là" }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", diff --git a/messages/it.json b/messages/it.json index 72a8faeb1..39489237a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7709,7 +7709,7 @@ }, "sheet": { "title": "Registra la tua giornata", - "description": "Annota flusso, sintomi e note di oggi.", + "description": "Flusso, sintomi e note di questo giorno.", "flow": "Flusso", "symptoms": "Sintomi", "temperature": "Temperatura basale", @@ -7744,7 +7744,10 @@ "intimacySection": "Intimità e contraccezione", "testsSection": "Test", "phaseDayHeader": "Giorno {day} · {phase}", - "bbtHint": "Misurala appena sveglia, prima di alzarti." + "bbtHint": "Misurala appena sveglia, prima di alzarti.", + "titleForDate": "Registrazione del {date}", + "descriptionToday": "Oggi. Flusso, sintomi e note.", + "deleteBodyCycleStart": "Questo giorno apre un ciclo. Eliminandolo sparisce anche l'inizio delle mestruazioni, così il ciclo precedente prosegue come prima. Se ne va pure tutto ciò che è annotato qui: flusso, temperatura, sintomi e la tua nota. Non c'è modo di recuperarlo." }, "settings": { "title": "Impostazioni del ciclo", @@ -7821,7 +7824,13 @@ "intercourseLabel": "Cosa registra i rapporti?", "protected": "Se durante il rapporto è stata usata la contraccezione.", "protectedLabel": "Cosa significa protetto?" - } + }, + "saveSuccess": "Voce salvata.", + "deleteSuccess": "Voce eliminata.", + "periodStartSaved": "Inizio delle mestruazioni salvato.", + "periodEndSaved": "Fine delle mestruazioni salvata.", + "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno", + "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno" }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", diff --git a/messages/pl.json b/messages/pl.json index ddd1b58b4..2b7c3e606 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7709,7 +7709,7 @@ }, "sheet": { "title": "Zapisz swój dzień", - "description": "Zapisz dzisiejsze krwawienie, objawy i notatki.", + "description": "Krwawienie, objawy i notatki z tego dnia.", "flow": "Krwawienie", "symptoms": "Objawy", "temperature": "Podstawowa temperatura ciała", @@ -7744,7 +7744,10 @@ "intimacySection": "Intymność i antykoncepcja", "testsSection": "Testy", "phaseDayHeader": "Dzień {day} · {phase}", - "bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem." + "bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem.", + "titleForDate": "Wpis z {date}", + "descriptionToday": "Dzisiaj. Krwawienie, objawy i notatki.", + "deleteBodyCycleStart": "Ten dzień otwiera cykl. Usunięcie go kasuje razem z nim początek miesiączki, więc poprzedni cykl biegnie dalej jak wcześniej. Znika też wszystko, co tu zapisano: krwawienie, temperatura, objawy i twoja notatka. Nie da się tego cofnąć." }, "settings": { "title": "Ustawienia cyklu", @@ -7821,7 +7824,13 @@ "intercourseLabel": "Co śledzi stosunek?", "protected": "Czy podczas stosunku zastosowano antykoncepcję.", "protectedLabel": "Co oznacza zabezpieczony?" - } + }, + "saveSuccess": "Wpis zapisany.", + "deleteSuccess": "Wpis usunięty.", + "periodStartSaved": "Początek miesiączki zapisany.", + "periodEndSaved": "Koniec miesiączki zapisany.", + "startedPeriodOnDate": "Miesiączka zaczęła się tego dnia", + "endedPeriodOnDate": "Miesiączka skończyła się tego dnia" }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index 296831157..36437b9f7 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -19,6 +19,7 @@ function day(date: string, overrides: Partial = {}): CalendarDay { isFertileWindow: false, isPredictedOvulation: false, isPeriodLogged: false, + isCycleStart: false, flow: null, hasSymptoms: false, confidence: 1, diff --git a/src/components/cycle/__tests__/calendar-window.test.ts b/src/components/cycle/__tests__/calendar-window.test.ts new file mode 100644 index 000000000..79dfd4f2e --- /dev/null +++ b/src/components/cycle/__tests__/calendar-window.test.ts @@ -0,0 +1,93 @@ +/** + * The window arithmetic behind the month grid's own read. + * + * The bug it exists for is a save that appeared not to happen: the page read a + * fixed ninety-days-back window, an April cell in August fell outside it, and + * the write landed with nothing on screen to show for it. The month bounds and + * the "is this month already covered" question are the whole of the decision, + * so they are pinned here rather than left inside a component no test can drive. + */ +import { describe, it, expect } from "vitest"; + +import { monthBounds, monthOf, monthWindowOutside } from "../calendar-window"; + +describe("monthBounds", () => { + it("spans a 30-day month", () => { + expect(monthBounds("2026-04")).toEqual({ + from: "2026-04-01", + to: "2026-04-30", + }); + }); + + it("spans a 31-day month", () => { + expect(monthBounds("2026-08")).toEqual({ + from: "2026-08-01", + to: "2026-08-31", + }); + }); + + it("spans February in a common year and in a leap year", () => { + expect(monthBounds("2026-02").to).toBe("2026-02-28"); + expect(monthBounds("2028-02").to).toBe("2028-02-29"); + }); + + it("zero-pads so a day key compares as a date", () => { + expect(monthBounds("2026-01")).toEqual({ + from: "2026-01-01", + to: "2026-01-31", + }); + expect(monthBounds("2026-09").from < monthBounds("2026-10").from).toBe( + true, + ); + }); +}); + +describe("monthWindowOutside", () => { + // The page's anchored read on 2026-08-07: 90 days back, 180 forward. + const anchor = { from: "2026-05-09", to: "2027-02-03" }; + + it("reads nothing more for the month the anchor already carries", () => { + expect(monthWindowOutside("2026-08", anchor)).toBeNull(); + expect(monthWindowOutside("2026-06", anchor)).toBeNull(); + }); + + it("asks for the month the maintainer could not back-fill into", () => { + expect(monthWindowOutside("2026-04", anchor)).toEqual({ + from: "2026-04-01", + to: "2026-04-30", + }); + }); + + it("asks for a month that only overlaps the anchor's edge", () => { + // May 2026 starts before 2026-05-09, so half of it is missing. + expect(monthWindowOutside("2026-05", anchor)).toEqual({ + from: "2026-05-01", + to: "2026-05-31", + }); + }); + + it("asks for a month past the forward edge too", () => { + expect(monthWindowOutside("2027-03", anchor)).toEqual({ + from: "2027-03-01", + to: "2027-03-31", + }); + }); + + it("stays a bounded read however far back the grid navigates", () => { + // The anchor cannot simply be widened: the calendar route caps a span at + // 400 days, and a union with a month two years back blows through it. + const far = monthWindowOutside("2024-01", anchor); + expect(far).toEqual({ from: "2024-01-01", to: "2024-01-31" }); + const span = + (Date.parse(`${far!.to}T12:00:00Z`) - + Date.parse(`${far!.from}T12:00:00Z`)) / + 86_400_000; + expect(span).toBeLessThan(31); + }); +}); + +describe("monthOf", () => { + it("takes the month a day key belongs to", () => { + expect(monthOf("2026-04-18")).toBe("2026-04"); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index e24b107cd..f57f782f2 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -19,6 +19,7 @@ function dayBase(date: string): CalendarDay { isFertileWindow: false, isPredictedOvulation: false, isPeriodLogged: false, + isCycleStart: false, flow: null, hasSymptoms: false, confidence: 1, diff --git a/src/components/cycle/calendar-window.ts b/src/components/cycle/calendar-window.ts new file mode 100644 index 000000000..346ed8ee7 --- /dev/null +++ b/src/components/cycle/calendar-window.ts @@ -0,0 +1,58 @@ +/** + * The date range the /cycle page reads, and what a month outside it needs. + * + * The page reads one anchored window — ninety days back, half a year forward — + * and that window drives the ring, the forecast panel and the temperature + * chart, all of which are about today. The month grid is not: it navigates + * freely, and a month the anchored read does not cover came back with no days + * at all. The cells still rendered (they come from the month, not the read) and + * still opened the log sheet, and the write behind it still landed — the period + * route is built for back-dating and re-anchors the neighbouring cycles for it. + * What did not happen is anything visible. Save an April flow in August and the + * sheet closes onto a month the read never carried, so the entry appears not to + * have been made, and entering it again does not help. + * + * So the month grid gets its own read when it steps outside the anchor. A month + * is a bounded thing to fetch, the anchored window stays exactly as it was for + * every surface that means "today", and the two grids merge by date. + * + * Pure and dateless — the caller supplies both the month and the window. + */ + +/** An inclusive `YYYY-MM-DD` range, the shape the calendar read takes. */ +export interface CalendarWindow { + from: string; + to: string; +} + +/** Zero-padded `YYYY-MM-DD`. */ +function ymd(year: number, month: number, day: number): string { + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/** First and last day of a `YYYY-MM` month. */ +export function monthBounds(month: string): CalendarWindow { + const [year, monthIndex] = month.split("-").map(Number); + // Day 0 of the following month is the last day of this one, leap years and all. + const lastDay = new Date(year, monthIndex, 0).getDate(); + return { from: ymd(year, monthIndex, 1), to: ymd(year, monthIndex, lastDay) }; +} + +/** + * The extra window a visible month needs, or null when `anchor` already covers + * it and the page should read nothing more. Day keys are zero-padded, so a + * plain string compare is a date compare. + */ +export function monthWindowOutside( + month: string, + anchor: CalendarWindow, +): CalendarWindow | null { + const bounds = monthBounds(month); + if (bounds.from >= anchor.from && bounds.to <= anchor.to) return null; + return bounds; +} + +/** The `YYYY-MM` a `YYYY-MM-DD` day key belongs to. */ +export function monthOf(date: string): string { + return date.slice(0, 7); +} diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index d72c2fd38..e9fc3acfb 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -76,6 +76,13 @@ export interface CycleCalendarProps { * rather than opening a sheet whose every button the server would refuse. */ onSelectDay?: (date: string) => void; + /** + * The month the grid moved to (`YYYY-MM`). The page's anchored read covers + * a fixed span around today, so a month outside it arrives with no days — + * the caller uses this to fetch that month rather than render empty cells + * over entries that exist. + */ + onVisibleMonthChange?: (month: string) => void; className?: string; } @@ -84,6 +91,7 @@ export function CycleCalendar({ today, confirmedOvulation, onSelectDay, + onVisibleMonthChange, className, }: CycleCalendarProps) { const { t, locale } = useTranslations(); @@ -125,7 +133,17 @@ export function CycleCalendar({ }); function shiftMonth(delta: number) { - setMonthAnchor((a) => new Date(a.getFullYear(), a.getMonth() + delta, 1)); + const next = new Date( + monthAnchor.getFullYear(), + monthAnchor.getMonth() + delta, + 1, + ); + setMonthAnchor(next); + // The page reads a window anchored on today, so a month outside it has no + // days until somebody asks for them. + onVisibleMonthChange?.( + `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`, + ); } return ( diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 65adf14de..ddccf448f 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -3,13 +3,7 @@ import { useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; -import { - Loader2, - Plus, - RefreshCw, - SlidersHorizontal, - Sparkles, -} from "lucide-react"; +import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -29,6 +23,7 @@ import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab"; import { CycleSymptomPatterns } from "./cycle-symptom-patterns"; import { CycleSettings } from "./cycle-settings"; import { PHASE_HUE } from "./phase-tokens"; +import { monthOf, monthWindowOutside } from "./calendar-window"; import { localYmd, useCycleCalendar, @@ -110,9 +105,7 @@ export function CycleView() { : "calendar"; }, [searchParams]); - // Controlled so the header "cycle settings" wrench (parity with the - // labs/medications/illness modules, which expose a customize glyph) can jump - // to the settings tab. Initialised from the `?tab=` deep-link, matching the + // Controlled tabs, initialised from the `?tab=` deep-link and matching the // prior uncontrolled `defaultValue` behaviour (the initial param is honoured; // a later param change does not force-switch a tab the user is reading). const [tab, setTab] = useState(initialTab); @@ -126,6 +119,29 @@ export function CycleView() { const profileQuery = useCycleProfile(); const insights = useCycleInsights(); + // The month grid navigates freely; the anchored read above does not follow + // it, because every other surface on this page (the ring, the forecast panel, + // the temperature chart) is about today and the route caps a span at 400 + // days. A month outside the anchor therefore gets its own bounded read, and + // the two grids merge by date. Without it a back-dated entry landed on the + // server and had nothing on screen to land in. + const [visibleMonth, setVisibleMonth] = useState(() => monthOf(today)); + const monthWindow = useMemo( + () => monthWindowOutside(visibleMonth, { from, to }), + [visibleMonth, from, to], + ); + const monthCalendar = useCycleCalendar( + monthWindow?.from ?? from, + monthWindow?.to ?? to, + monthWindow !== null, + ); + // Month days last: on the edge months the two reads overlap and the grid + // keys by date, so the later entry wins and both carry the same answer. + const calendarDays = useMemo( + () => [...(calendar.data?.days ?? []), ...(monthCalendar.data?.days ?? [])], + [calendar.data?.days, monthCalendar.data?.days], + ); + const [sheetOpen, setSheetOpen] = useState(false); const [selectedDate, setSelectedDate] = useState(today); @@ -167,30 +183,18 @@ export function CycleView() { title={{t("cycle.title")}} description={t("cycle.subtitle")} actions={ - <> - {canManage && ( - <> - - - - )} - + // One action. The settings glyph that used to sit beside it was a + // second door to the tab directly below, and no other page in the + // app carries a gear in that corner. + canManage ? ( + + ) : null } /> @@ -365,7 +369,7 @@ export function CycleView() { ) : null} )} @@ -458,6 +463,10 @@ export function CycleView() { onOpenChange={setSheetOpen} date={selectedDate} today={today} + startsCycle={ + calendarDays.find((d) => d.date === selectedDate)?.isCycleStart ?? + false + } activePeriod={verdict?.phase === "MENSTRUAL"} phase={verdict?.phase ?? null} dayOfCycle={verdict?.dayOfCycle ?? null} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index b7732e010..ebb633514 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -45,6 +45,8 @@ import { import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { useTranslations } from "@/lib/i18n/context"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import type { Locale } from "@/lib/i18n/config"; import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; import { FieldInfo } from "./field-info"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; @@ -321,6 +323,11 @@ export interface LogDaySheetProps { date: string; /** Today (YYYY-MM-DD). */ today: string; + /** + * Whether a logged cycle opens on `date`. Deleting the day-log of a cycle + * start removes that start too, so the confirm dialog has to say so. + */ + startsCycle?: boolean; /** * Whether a period is currently open (today is in the MENSTRUAL phase) — gates * the one-tap "end period" affordance so it never shows when no period is in @@ -463,10 +470,28 @@ function CervixRow({ ); } +/** + * The day the sheet is writing to, spelled out in the reader's language. + * + * The sheet used to be titled "Log your day" whichever cell opened it, so a + * back-dated entry gave no clue which date it would land on and "Log today" + * gave no clue that any other date was possible. The weekday is included + * deliberately: it is how people recognise a date they are recalling. + */ +export function formatSheetDate(date: string, locale: Locale): string { + const [year, month, day] = date.split("-").map(Number); + return new Date(year, month - 1, day).toLocaleDateString( + resolveIntlLocale(locale), + { weekday: "long", day: "numeric", month: "long", year: "numeric" }, + ); +} + export function LogDaySheet({ open, onOpenChange, date, + today, + startsCycle = false, activePeriod = false, phase = null, dayOfCycle = null, @@ -475,7 +500,7 @@ export function LogDaySheet({ rawChartMode = false, cyclesObserved = 0, }: LogDaySheetProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const logDay = useLogDay(); const patchDay = usePatchDayLog(); const startPeriod = useStartPeriod(); @@ -682,8 +707,20 @@ export function LogDaySheet({ + {t("cycle.sheet.titleForDate", { + date: formatSheetDate(date, locale), + })} + + } + description={ + date === today + ? t("cycle.sheet.descriptionToday") + : t("cycle.sheet.description") + } contentWidth="lg" footer={ <> @@ -696,7 +733,11 @@ export function LogDaySheet({ label={t("cycle.sheet.delete")} ariaLabel={t("cycle.sheet.delete")} title={t("cycle.sheet.deleteTitle")} - body={t("cycle.sheet.deleteBody")} + body={ + startsCycle + ? t("cycle.sheet.deleteBodyCycleStart") + : t("cycle.sheet.deleteBody") + } confirmLabel={t("cycle.sheet.deleteConfirm")} disabled={busy && !deleteDay.isPending} pending={deleteDay.isPending} @@ -732,7 +773,9 @@ export function LogDaySheet({ {/* One-tap period boundaries — available on any selected date so a forgotten day-1 can be corrected retroactively (M3). "End period" - shows only while a period is actually open (M2). */} + shows only while a period is actually open (M2). The labels drop the + word "today" on a back-dated day: they always wrote to the selected + date, and saying otherwise is how a correction reads as a mistake. */}
{activePeriod ? ( ) : null}
diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index ed3b0ce97..983b9d5a3 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -29,6 +29,8 @@ export interface CalendarDay { isFertileWindow: boolean; isPredictedOvulation: boolean; isPeriodLogged: boolean; + /** Whether a logged cycle opens on this day. */ + isCycleStart: boolean; flow: string | null; hasSymptoms: boolean; confidence: number; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 323a7ee19..d21060e30 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -93,9 +93,16 @@ export function useCycleDayLog(date: string | null) { }); } -export function useCycleCalendar(from: string, to: string) { +/** + * The calendar read for one window. `enabled` exists for the month grid's own + * read: the page's anchored window covers today, and a month the grid + * navigates outside it needs a second, bounded read rather than a widened + * anchor the route's span cap would refuse. + */ +export function useCycleCalendar(from: string, to: string, enabled = true) { return useQuery({ queryKey: queryKeys.cycleCalendar(from, to), + enabled, queryFn: () => apiGet(`/api/cycle/calendar?from=${from}&to=${to}`), staleTime: 60_000, @@ -220,15 +227,28 @@ function idempotencyKey(): string { return crypto.randomUUID(); } +/** + * Capture one day. + * + * The success toast is not decoration. A day-log write is silent by nature — + * the sheet closes and the grid repaints — and for a back-dated entry the grid + * had nothing to repaint, so a save that worked and a save that never happened + * looked identical. A write says so now, either way: this names the success and + * the sheet's inline strip names the failure. + */ export function useLogDay() { const qc = useQueryClient(); + const { t } = useTranslations(); return useMutation({ mutationFn: async (input: CycleDayLogInput) => { return apiPost("/api/cycle/day-logs", input, { headers: { "Idempotency-Key": idempotencyKey() }, }); }, - onSuccess: () => invalidateKeys(qc, cycleDependentKeys), + onSuccess: () => { + void invalidateKeys(qc, cycleDependentKeys); + toast.success(t("cycle.saveSuccess")); + }, }); } @@ -260,6 +280,7 @@ export interface CycleDayLogPatch { /** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */ export function usePatchDayLog() { const qc = useQueryClient(); + const { t } = useTranslations(); return useMutation({ mutationFn: async ({ id, @@ -270,7 +291,10 @@ export function usePatchDayLog() { }) => { return apiPatch(`/api/cycle/day-logs/${id}`, patch); }, - onSuccess: () => invalidateKeys(qc, cycleDependentKeys), + onSuccess: () => { + void invalidateKeys(qc, cycleDependentKeys); + toast.success(t("cycle.saveSuccess")); + }, }); } @@ -285,7 +309,10 @@ export function useStartPeriod() { loggedAt: new Date().toISOString(), }); }, - onSuccess: () => invalidateKeys(qc, cycleDependentKeys), + onSuccess: () => { + void invalidateKeys(qc, cycleDependentKeys); + toast.success(t("cycle.periodStartSaved")); + }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. onError: () => toast.error(t("cycle.saveError")), @@ -304,7 +331,10 @@ export function useEndPeriod() { loggedAt: new Date().toISOString(), }); }, - onSuccess: () => invalidateKeys(qc, cycleDependentKeys), + onSuccess: () => { + void invalidateKeys(qc, cycleDependentKeys); + toast.success(t("cycle.periodEndSaved")); + }, onError: () => toast.error(t("cycle.saveError")), }); } @@ -317,7 +347,10 @@ export function useDeleteDayLog() { mutationFn: async (id: string) => { await apiDelete(`/api/cycle/day-logs/${id}`); }, - onSuccess: () => invalidateKeys(qc, cycleDependentKeys), + onSuccess: () => { + void invalidateKeys(qc, cycleDependentKeys); + toast.success(t("cycle.deleteSuccess")); + }, onError: () => toast.error(t("cycle.deleteError")), }); } diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index b2add008d..cd7ddf227 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -34,6 +34,13 @@ export interface CalendarDayDTO { isFertileWindow: boolean; isPredictedOvulation: boolean; isPeriodLogged: boolean; + /** + * Whether a logged cycle opens on this day. The client uses it to say what a + * delete would take: removing the day-log of a cycle start removes the start + * too, and a confirm dialog that does not name that is asking about the + * wrong thing. + */ + isCycleStart: boolean; flow: string | null; hasSymptoms: boolean; confidence: number; @@ -368,6 +375,8 @@ export function buildCalendar( const logByDate = new Map(); for (const l of dayLogs) logByDate.set(l.date, l); + const cycleStartDates = new Set(cycles.map((c) => c.startDate)); + const days: CalendarDayDTO[] = []; const span = dayDiff(to, from); for (let i = 0; i <= span; i++) { @@ -405,6 +414,7 @@ export function buildCalendar( isFertileWindow, isPredictedOvulation, isPeriodLogged: log?.flow != null && log.flow !== "NONE", + isCycleStart: cycleStartDates.has(date), flow: log?.flow ?? null, hasSymptoms: log?.hasSymptoms ?? false, confidence: prediction?.confidence ?? 0, diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 3fe139830..cb80b6e62 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -181,6 +181,10 @@ const cycleCalendarDayDto = z.object({ isFertileWindow: z.boolean(), isPredictedOvulation: z.boolean(), isPeriodLogged: z.boolean(), + isCycleStart: z.boolean().meta({ + description: + "Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.", + }), flow: flowLevelEnum.nullable(), hasSymptoms: z.boolean(), confidence: z.number(), diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 26f755aae..8dc05e92b 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -636,3 +636,110 @@ describe("sync/changes — cycle domains", () => { expect(cycleTombs[0].id).toBe(cycle.id); }); }); + +describe("back-filling an entry months into the past", () => { + /** `YYYY-MM-DD`, `days` from today, in the process zone the route resolves. */ + function dayKey(offset: number): string { + const d = new Date(); + d.setDate(d.getDate() + offset); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; + } + + // Far enough back to fall outside the page's anchored ninety-day read, the + // way an April entry does in August. Dates are relative so the case cannot + // slide out of the window it is about as the calendar moves on. + const BACK_DATED = dayKey(-111); + + it("writes the day and hands it back on a window that covers it", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + + const dayLogs = await import("@/app/api/cycle/day-logs/route"); + const written = await dayLogs.POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: BACK_DATED, + flow: "HEAVY", + loggedAt: `${BACK_DATED}T08:00:00.000Z`, + }), + ); + // The route accepts a past date: only the future is refused. + expect(written.status).toBe(201); + + // The row is really there, not just an accepted response. + const row = await prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: FEMALE_USER_ID, date: BACK_DATED, deletedAt: null }, + }); + expect(row.flow).toBe("HEAVY"); + + const { GET } = await import("@/app/api/cycle/calendar/route"); + // The page's own window. The entry exists and this read cannot show it, + // which is the whole of what the client saw as "nothing was saved". + const anchored = await GET( + new NextRequest( + `http://localhost/api/cycle/calendar?from=${dayKey(-90)}&to=${dayKey(180)}`, + ), + ); + const anchoredDays = (await anchored.json()).data.days as { + date: string; + }[]; + expect(anchoredDays.some((d) => d.date === BACK_DATED)).toBe(false); + + // A window over the month the grid is showing carries it, flow and all. + const month = BACK_DATED.slice(0, 7); + const lastDay = new Date( + Number(month.slice(0, 4)), + Number(month.slice(5, 7)), + 0, + ).getDate(); + const scoped = await GET( + new NextRequest( + `http://localhost/api/cycle/calendar?from=${month}-01&to=${month}-${lastDay}`, + ), + ); + expect(scoped.status).toBe(200); + const scopedDays = (await scoped.json()).data.days as { + date: string; + flow: string | null; + isPeriodLogged: boolean; + }[]; + const hit = scopedDays.find((d) => d.date === BACK_DATED); + expect(hit).toBeDefined(); + expect(hit?.flow).toBe("HEAVY"); + expect(hit?.isPeriodLogged).toBe(true); + }); + + it("opens a back-dated cycle and marks the day as its start", async () => { + await loginAs(FEMALE_USER_ID); + const period = await import("@/app/api/cycle/period/route"); + const res = await period.POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date: BACK_DATED, + loggedAt: `${BACK_DATED}T08:00:00.000Z`, + }), + ); + expect(res.status).toBe(200); + + const month = BACK_DATED.slice(0, 7); + const lastDay = new Date( + Number(month.slice(0, 4)), + Number(month.slice(5, 7)), + 0, + ).getDate(); + const { GET } = await import("@/app/api/cycle/calendar/route"); + const scoped = await GET( + new NextRequest( + `http://localhost/api/cycle/calendar?from=${month}-01&to=${month}-${lastDay}`, + ), + ); + const days = (await scoped.json()).data.days as { + date: string; + isCycleStart: boolean; + }[]; + // The flag the log sheet reads to say what a delete would take. + expect(days.find((d) => d.date === BACK_DATED)?.isCycleStart).toBe(true); + expect(days.filter((d) => d.isCycleStart)).toHaveLength(1); + }); +}); From 3596720905d03ba44fce31776eef729b77f309b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:50:23 +0200 Subject: [PATCH 5/9] 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 --- src/app/api/cycle/day-logs/[id]/route.ts | 66 +++-- src/app/api/cycle/period/route.ts | 98 ++----- .../cycle/__tests__/cycle-boundaries.test.ts | 67 +++++ src/lib/cycle/__tests__/day-log-write.test.ts | 49 +++- src/lib/cycle/cycle-boundaries.ts | 216 +++++++++++++- src/lib/cycle/day-log-write.ts | 24 +- .../cycle-flow-opens-cycle.test.ts | 275 ++++++++++++++++++ 7 files changed, 684 insertions(+), 111 deletions(-) create mode 100644 src/lib/cycle/__tests__/cycle-boundaries.test.ts create mode 100644 tests/integration/cycle-flow-opens-cycle.test.ts diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 7b88e0479..af4db03b8 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -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"; +} diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 8806cdc65..2223447d0 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -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 { // v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles, @@ -97,73 +91,15 @@ async function postPeriod(request: NextRequest): Promise { // 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 { 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 { moved: { periodEndDateBefore: current.periodEndDate, periodEndDateAfter: date, - } satisfies MovedAnchors, + } satisfies PeriodMovedAnchors, }; }); diff --git a/src/lib/cycle/__tests__/cycle-boundaries.test.ts b/src/lib/cycle/__tests__/cycle-boundaries.test.ts new file mode 100644 index 000000000..58ff9f654 --- /dev/null +++ b/src/lib/cycle/__tests__/cycle-boundaries.test.ts @@ -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); + }); +}); diff --git a/src/lib/cycle/__tests__/day-log-write.test.ts b/src/lib/cycle/__tests__/day-log-write.test.ts index 8fdd8119e..3a2a49c23 100644 --- a/src/lib/cycle/__tests__/day-log-write.test.ts +++ b/src/lib/cycle/__tests__/day-log-write.test.ts @@ -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 = { + 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; + upsert: ReturnType; + update: ReturnType; + }; + menstrualCycle: { + findFirst: ReturnType; + upsert: ReturnType; + update: ReturnType; + }; + cycleSymptom: { findMany: ReturnType }; + cycleSymptomLink: { + deleteMany: ReturnType; + createMany: ReturnType; + }; + }; +}); vi.mock("@/lib/db", () => ({ prisma: db })); vi.mock("@/lib/cycle/profile", () => ({ @@ -70,6 +99,8 @@ function existingRow(overrides: Record = {}) { 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", () => { diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts index 9d9e7f84a..6c81cc0b0 100644 --- a/src/lib/cycle/cycle-boundaries.ts +++ b/src/lib/cycle/cycle-boundaries.ts @@ -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 { + // 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; diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 8d447daf9..31a78d93f 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -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 { ? (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 { 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 { tz, source, externalId: entry.externalId ?? null, - cycleId, + cycleId: attributedCycleId, flow, intermenstrualBleeding, basalBodyTempC, diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts new file mode 100644 index 000000000..5e81fda56 --- /dev/null +++ b/tests/integration/cycle-flow-opens-cycle.test.ts @@ -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 { + 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, +): Promise { + 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 { + 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)); + }); +}); From 30cf1da1d379016e1466caa5eaf14cff247c9010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:52:59 +0200 Subject: [PATCH 6/9] chore(cycle): add a read-only report for records the module stopped reading Two defects put a record into a state that looks, from the app, like no period was ever logged. Both are fixed going forward; neither repairs the rows already made, and neither is visible from the surface it broke. A closed torso is a live cycle carrying an end date with no live cycle after it. A cycle is closed by its successor, so closed with no successor means the successor is gone, and a record whose last cycle is closed has no open cycle for the engine to forecast from. The signature is exact and needs no audit trail. A period with no cycle row is bleeding that only ever existed as a day-log, from the flow chips, the bulk drain or an import. The engine reads cycle rows, so the record holds periods and reports none. The report imports the bleeding-day rule from the write path rather than restating it, so it and the app cannot drift on what counts as a period start. Checked against seeded records: it names the torso, names the three flow-only starts while passing over a day two and a spotting day, and says nothing about a healthy record. Nothing is written. --- scripts/diagnose-cycle-state.ts | 222 ++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 scripts/diagnose-cycle-state.ts diff --git a/scripts/diagnose-cycle-state.ts b/scripts/diagnose-cycle-state.ts new file mode 100644 index 000000000..85856e998 --- /dev/null +++ b/scripts/diagnose-cycle-state.ts @@ -0,0 +1,222 @@ +/** + * Read-only diagnostic for cycle records the module has stopped reading. + * + * Two defects put records into states that look, from the app, like no periods + * were ever logged. Both are fixed going forward; neither repairs the rows it + * already made, and neither is visible from the surface it broke. + * + * The first is a closed torso. Logging a period start closes the cycle before + * it, and removing that start used to leave the closure standing. The previous + * cycle keeps an end date that a cycle which no longer exists gave it, and a + * record whose last cycle is closed has no open cycle for the engine to + * forecast from, so the prediction disappears and does not come back. The + * signature is exact and needs no audit trail: a live cycle carrying an end + * date with no live cycle after it. A cycle is closed by its successor, so + * closed with no successor means the successor is gone. + * + * The second is a period that only ever existed as flow. Cycle rows used to be + * written by the one-tap period boundary alone, so bleeding entered through the + * log sheet's chips, the bulk drain or an Apple Health import produced day-logs + * and no cycle at all. The engine reads cycle rows, so the record holds periods + * and reports none. + * + * The bleeding-day rule below is imported from the write path rather than + * restated, so this report and the app cannot drift on what counts as a period + * start. + * + * Nothing is written. The output is a per-record inventory plus the two flags, + * so the state can be read before anything is decided about it. + * + * Run (inside the app container, which has DATABASE_URL): + * pnpm dlx tsx scripts/diagnose-cycle-state.ts + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { bleedOpensCycle } from "@/lib/cycle/cycle-boundaries"; +import { dayDiff } from "@/lib/cycle/day-math"; + +/** Below this many observed cycles the app withholds its phase band. */ +const STILL_LEARNING_BELOW = 3; + +interface CycleRow { + id: string; + startDate: string; + endDate: string | null; + lengthDays: number | null; + periodEndDate: string | null; + deletedAt: Date | null; + isPredicted: boolean; +} + +interface DayRow { + date: string; + flow: string | null; + intermenstrualBleeding: boolean; +} + +function describeCycle(c: CycleRow): string { + const state = c.deletedAt + ? `deleted ${c.deletedAt.toISOString().slice(0, 10)}` + : c.endDate + ? `closed ${c.endDate}` + : "open"; + const length = c.lengthDays === null ? "-" : `${c.lengthDays}d`; + const period = c.periodEndDate ? ` periodEnd=${c.periodEndDate}` : ""; + const predicted = c.isPredicted ? " PREDICTED" : ""; + return `${c.startDate} ${state.padEnd(20)} length=${length.padEnd(5)}${period}${predicted}`; +} + +async function main(): Promise { + // Every record that has ever touched the module, from either side: a cycle + // row, or a day-log. A record with day-logs and no cycles is precisely one + // of the cases this exists to find, so it cannot be found by cycles alone. + const [cycleOwners, dayOwners] = await Promise.all([ + prisma.menstrualCycle.findMany({ + distinct: ["userId"], + select: { userId: true }, + }), + prisma.cycleDayLog.findMany({ + distinct: ["userId"], + select: { userId: true }, + }), + ]); + const userIds = Array.from( + new Set([ + ...cycleOwners.map((r) => r.userId), + ...dayOwners.map((r) => r.userId), + ]), + ).sort(); + + if (userIds.length === 0) { + console.log("No cycle records on this instance. Nothing to report."); + return; + } + + let torsoTotal = 0; + let flowOnlyTotal = 0; + let staleBoundaryTotal = 0; + + for (const userId of userIds) { + const [cycles, days] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId }, + orderBy: { startDate: "asc" }, + select: { + id: true, + startDate: true, + endDate: true, + lengthDays: true, + periodEndDate: true, + deletedAt: true, + isPredicted: true, + }, + }) as Promise, + prisma.cycleDayLog.findMany({ + where: { userId, deletedAt: null }, + orderBy: { date: "asc" }, + select: { date: true, flow: true, intermenstrualBleeding: true }, + }) as Promise, + ]); + + const live = cycles.filter((c) => c.deletedAt === null); + const liveStarts = live.map((c) => c.startDate); + const startSet = new Set(liveStarts); + + console.log(`record ${userId}`); + console.log( + ` ${cycles.length} cycle row(s), ${live.length} live, ` + + `${days.length} day-log(s)`, + ); + for (const c of cycles) console.log(` ${describeCycle(c)}`); + + // A closed cycle with nothing after it: closed by a start that is gone. + const torsos = live.filter( + (c, i) => c.endDate !== null && i === live.length - 1, + ); + // A closed cycle whose boundary does not match the successor it has. + const stale = live.filter((c, i) => { + const next = live[i + 1]; + if (!next) return false; + const expectedLength = dayDiff(next.startDate, c.startDate); + return c.endDate === null || c.lengthDays !== expectedLength; + }); + + // Bleeding days the write path would treat as a period start today, on a + // date carrying no live cycle. Walked forward so each decision sees the + // starts that already exist plus the ones this pass would infer, exactly + // as a replay of the writes would. + const inferred: string[] = [...liveStarts].sort(); + const flowOnly: string[] = []; + for (const d of days) { + if (startSet.has(d.date)) continue; + const prior = inferred.filter((s) => s <= d.date).at(-1) ?? null; + if ( + bleedOpensCycle( + d.flow as never, + d.intermenstrualBleeding, + prior, + d.date, + ) + ) { + flowOnly.push(d.date); + inferred.push(d.date); + inferred.sort(); + } + } + + if (torsos.length > 0) { + torsoTotal += torsos.length; + console.log( + ` CLOSED TORSO: ${torsos + .map((c) => `${c.startDate} (ends ${c.endDate})`) + .join(", ")}`, + ); + console.log( + " The last cycle is closed and has no successor, so the engine has " + + "no open cycle to forecast from. Re-opening it means clearing its " + + "endDate and lengthDays.", + ); + } + if (stale.length > 0) { + staleBoundaryTotal += stale.length; + console.log( + ` STALE BOUNDARY: ${stale.map((c) => c.startDate).join(", ")}`, + ); + console.log( + " The stored end or length disagrees with the next cycle's start.", + ); + } + if (flowOnly.length > 0) { + flowOnlyTotal += flowOnly.length; + console.log(` PERIOD WITH NO CYCLE ROW: ${flowOnly.join(", ")}`); + console.log( + " Bleeding was logged on these days and no cycle opens on them. " + + "Re-saving each day through the log sheet opens it now.", + ); + } + if (live.length > 0 && live.length - 1 < STILL_LEARNING_BELOW) { + console.log( + ` Only ${live.length - 1} observed cycle length(s): under the ` + + `${STILL_LEARNING_BELOW}-cycle gate, so the phase band stays withheld.`, + ); + } + console.log(""); + } + + console.log( + `${userIds.length} record(s) inspected. ` + + `${torsoTotal} closed torso(s), ${staleBoundaryTotal} stale boundary(ies), ` + + `${flowOnlyTotal} logged period(s) with no cycle row.`, + ); + console.log("Nothing was changed."); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => { + void prisma.$disconnect(); + }); From 47d41a13f45357d214d00b12c9eb0ddc0e7b81e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:53:55 +0200 Subject: [PATCH 7/9] docs(cycle): say why the fertile window is seven days, not six The constants make ovulation minus five through ovulation plus one, and the comment above them claimed a six-day window. Wilcox's six days are the five before ovulation plus the day itself, measured against a KNOWN ovulation day. Ours is estimated, usually by back-calculating a luteal length from a median cycle, so a window drawn tight to the estimate loses its last day whenever the estimate lands early, and the ovum stays viable for most of a day after release besides. The direction is decided by who reads the window. It is surfaced to people trying to conceive and to people avoiding it, and for the second group a window one day short is a window that misses. The constants keep the conservative reading and the comment now states it, with the note that changing either one is a coordination matter because the iOS client reimplements the same spec. The width is pinned by a test rather than left to be re-read off the numbers the code already uses. --- src/lib/cycle/__tests__/prediction.test.ts | 31 ++++++++++++++++++++++ src/lib/cycle/types.ts | 21 ++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 4e97d3963..08b994f4a 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { addDays } from "../day-math"; +import { FERTILE_POST, FERTILE_PRE } from "../types"; import { clampLuteal, confirmSymptothermal, @@ -1091,3 +1092,33 @@ describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)", expect(goalGated.predictedOvulation).toBeNull(); }); }); + +describe("cycle/prediction — the fertile window's width (A10)", () => { + it("spans seven days, ending the day after the ovulation estimate", () => { + // The comment on the constants used to say six days and the constants made + // seven. The width is now a decision with a reason written at the constant, + // so it is pinned here rather than left to be re-read off the same numbers + // the code uses: five days before the estimate, the estimate, and one after. + const cycles = cyclesFromGaps("2026-01-01", [28, 28, 28]); + const p = predictCycle(cycles, [], BASE_PROFILE, "2026-03-20", []); + + expect(p.predictedOvulation).not.toBeNull(); + expect(p.fertileWindowStart).toBe(addDays(p.predictedOvulation!, -5)); + expect(p.fertileWindowEnd).toBe(addDays(p.predictedOvulation!, 1)); + + const span = + (Date.parse(`${p.fertileWindowEnd}T12:00:00Z`) - + Date.parse(`${p.fertileWindowStart}T12:00:00Z`)) / + 86_400_000; + // Inclusive of both ends. + expect(span + 1).toBe(7); + }); + + it("keeps the extra day on the side that costs least to be wrong about", () => { + // The trailing day is estimator slack plus ovum viability. Dropping it + // would shorten the window for somebody avoiding pregnancy, which is the + // direction that misses. + expect(FERTILE_PRE).toBe(5); + expect(FERTILE_POST).toBe(1); + }); +}); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 16eada87f..33983bc14 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -103,7 +103,26 @@ export const LUTEAL_DEFAULT = 14; export const LUTEAL_MIN = 10; export const LUTEAL_MAX = 16; -/** §4 — six-day fertile window: ovulation − 5 … ovulation + 1. */ +/** + * §4 — the fertile window: ovulation − 5 … ovulation + 1, seven days. + * + * Wilcox's six-day window is the five days BEFORE ovulation plus the day + * itself, and the trailing day here is a deliberate departure from it. The + * six-day figure is measured against a KNOWN ovulation day; ours is estimated, + * usually by back-calculating a luteal length from a median cycle, and an + * estimate that lands a day early would put the last fertile day outside a + * window drawn tight to it. The ovum also stays viable for the better part of a + * day after release, so the extra day is not only estimator slack. + * + * The asymmetry of the cost decides the direction. This window is surfaced to + * people trying to conceive and to people avoiding it, and for the second group + * a window one day too short is a window that misses. The constants are the + * conservative reading; the comment used to claim six days and describe seven, + * which is the only part of this that was wrong. + * + * Changing either constant changes what the iOS client reimplements from the + * same spec, so it needs a coordination note, not just an edit here. + */ export const FERTILE_PRE = 5; export const FERTILE_POST = 1; From 8e3834f38d033b006ba2af8f6f634fd5639aa752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:57:21 +0200 Subject: [PATCH 8/9] fix(cycle): count the cycle day from the logged start, not the grid window The day count was the length of the labelled run inside the calendar grid, so the same day read as day 11 over a month and day 23 over three: the run was cut off at the window's edge rather than at the cycle's start. That was a latent contract question while every caller asked for the same ninety days, and it stopped being latent when the month grid began reading its own month, which is thirty days by construction. The verdict now anchors on the period start the record holds and falls back to walking the grid only when no caller supplied one, so the ring's arcs stay grid-derived (they are about what can be drawn) while the day is about what the person logged. The overdue ceiling then measures the real elapsed count too. Covers D-list edge 15, both shapes: a window that clips the run and a window that excludes the cycle start altogether. --- .../__tests__/verdict-round-trip.test.ts | 73 +++++++++++++++++++ src/lib/cycle/verdict.ts | 18 ++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts index 259329ac8..08f125662 100644 --- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -467,6 +467,79 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { }); }); + /** + * D-list edge 15 — the day count under a narrowed `from` window. + * + * The count used to be the length of the labelled run inside the grid, so a + * caller that asked for a shorter span got a smaller number for the same day: + * the run was cut off at the window's edge, not at the cycle's start. That was + * a latent contract question while every caller asked for the same ninety + * days. It stopped being latent when the month grid started reading its own + * month, which is a thirty-day window by construction. + */ + describe("under a narrowed window", () => { + async function callWindow( + from: string, + to: string, + ): Promise> { + const res = await GET( + new NextRequest( + `http://localhost/api/cycle/calendar?from=${from}&to=${to}`, + ), + ); + const body = JSON.parse(await res.text()); + return (body.data as Record).verdict as Record< + string, + unknown + >; + } + + it("counts from the logged start, not from the window's edge", async () => { + const today = "2026-06-11"; + const lastStart = "2026-05-20"; // day 23 of the open cycle + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows([ + addDays(lastStart, -84), + addDays(lastStart, -56), + addDays(lastStart, -28), + lastStart, + ]) as never, + ); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const wide = await callWindow(addDays(today, -90), addDays(today, 180)); + expect(wide.dayOfCycle).toBe(23); + + // The month the grid would be showing: the cycle start is inside it, but + // the window begins after several earlier cycles. + const narrow = await callWindow("2026-06-01", "2026-06-30"); + expect(narrow.dayOfCycle).toBe(23); + expect(narrow.cycleStartDate).toBe(lastStart); + }); + + it("counts correctly when the window excludes the start entirely", async () => { + const today = "2026-06-25"; + const lastStart = "2026-05-20"; + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows([ + addDays(lastStart, -56), + addDays(lastStart, -28), + lastStart, + ]) as never, + ); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + // June only: the cycle opened in May, outside the window entirely. + const narrow = await callWindow("2026-06-01", "2026-06-30"); + expect(narrow.cycleStartDate).toBe(lastStart); + expect(narrow.dayOfCycle).toBe(37); + }); + }); + it("says INSUFFICIENT_DATA when nothing has been logged", async () => { vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(getSession).mockResolvedValue(session("UTC") as never); diff --git a/src/lib/cycle/verdict.ts b/src/lib/cycle/verdict.ts index ae7a83c91..5ee273556 100644 --- a/src/lib/cycle/verdict.ts +++ b/src/lib/cycle/verdict.ts @@ -327,9 +327,21 @@ export function resolveCycleVerdict( } const startIdx = findCycleStartIndex(sorted, todayIdx); - const cycleStartDate = sorted[startIdx].date; - const run = sorted.slice(startIdx, todayIdx + 1); - const dayOfCycle = run.length; + + // The day count comes from the logged start when the caller supplied one, + // and only falls back to the length of the labelled run when it did not. + // The run is bounded by the grid, so counting rows made the answer depend on + // how much calendar the caller happened to ask for: the same day reads as + // day 11 over a month and day 23 over three. That was latent while every + // caller asked for the same ninety days, and stopped being latent when the + // month grid started reading its own month. The arcs below stay grid-derived + // — they are about what the ring can draw, not about what day it is. + const anchoredStart = + input.lastPeriodStart && dayDiff(today, input.lastPeriodStart) >= 0 + ? input.lastPeriodStart + : sorted[startIdx].date; + const cycleStartDate = anchoredStart; + const dayOfCycle = dayDiff(today, anchoredStart) + 1; // Honest ceiling: once the run overshoots the typical length + grace, the // count reflects the engine's open-ended cycle window, not an observed From 85240e96b1a1b56eaff278862e9001b2aebf3d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 16:14:28 +0200 Subject: [PATCH 9/9] fix(cycle): route the new save signals through the shared affordances Two repo-wide guards caught the additions rather than a reviewer, which is what they are for. The success toasts reached for sonner directly. The success affordance belongs to the outcome module, which binds it to the success key and nothing else, so a surface cannot raise a green toast for a write that wrote nothing. They go through toastWrittenOutcome now. The sheet's date header formatted a day key in the browser's zone. A cycle day key is a calendar day, not an instant, so it is anchored at noon UTC and rendered in UTC, the same anchor the domain's day arithmetic uses. Otherwise a reader east or west of the browser zone could see the sheet name a different day than the one it writes to. --- src/components/cycle/log-day-sheet.tsx | 15 ++++++++++++--- src/components/cycle/use-cycle.ts | 11 ++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index ebb633514..815b77df7 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -479,10 +479,19 @@ function CervixRow({ * deliberately: it is how people recognise a date they are recalling. */ export function formatSheetDate(date: string, locale: Locale): string { - const [year, month, day] = date.split("-").map(Number); - return new Date(year, month - 1, day).toLocaleDateString( + // A cycle day key is a calendar day, not an instant, so it is anchored at + // noon UTC and rendered in UTC — the same anchor the domain's day arithmetic + // uses. Formatting it in the browser's zone would let a reader east or west + // of it see the sheet name a different day than the one it writes to. + return new Date(`${date}T12:00:00Z`).toLocaleDateString( resolveIntlLocale(locale), - { weekday: "long", day: "numeric", month: "long", year: "numeric" }, + { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }, ); } diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d21060e30..0e8b4fd22 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -20,6 +20,7 @@ import { apiPost, } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { cycleDependentKeys, @@ -247,7 +248,7 @@ export function useLogDay() { }, onSuccess: () => { void invalidateKeys(qc, cycleDependentKeys); - toast.success(t("cycle.saveSuccess")); + toastWrittenOutcome("success", t("cycle.saveSuccess")); }, }); } @@ -293,7 +294,7 @@ export function usePatchDayLog() { }, onSuccess: () => { void invalidateKeys(qc, cycleDependentKeys); - toast.success(t("cycle.saveSuccess")); + toastWrittenOutcome("success", t("cycle.saveSuccess")); }, }); } @@ -311,7 +312,7 @@ export function useStartPeriod() { }, onSuccess: () => { void invalidateKeys(qc, cycleDependentKeys); - toast.success(t("cycle.periodStartSaved")); + toastWrittenOutcome("success", t("cycle.periodStartSaved")); }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. @@ -333,7 +334,7 @@ export function useEndPeriod() { }, onSuccess: () => { void invalidateKeys(qc, cycleDependentKeys); - toast.success(t("cycle.periodEndSaved")); + toastWrittenOutcome("success", t("cycle.periodEndSaved")); }, onError: () => toast.error(t("cycle.saveError")), }); @@ -349,7 +350,7 @@ export function useDeleteDayLog() { }, onSuccess: () => { void invalidateKeys(qc, cycleDependentKeys); - toast.success(t("cycle.deleteSuccess")); + toastWrittenOutcome("success", t("cycle.deleteSuccess")); }, onError: () => toast.error(t("cycle.deleteError")), });