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] 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(); + });