From 24dd8a28e3427e7de6f52802ca685e16dab6901f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 7 Nov 2026 01:04:13 +0100 Subject: [PATCH] fix(cycle): keep the wearable repair from reopening a deleted cycle A cycle the person deleted, or whose first day she deleted or cleared, left its wearable days without a cycle when nothing lay before them, and the boot-time repair took them for the pre-fix backlog and reopened it on every deploy. A day within the hard minimum cycle length after a removed start is no longer a candidate. The repair also skips accounts with the cycle module off, as the syncs do, and their rows no longer make a boot enqueue the job. --- src/lib/jobs/wearable-cycle-repair.ts | 50 +++++++++++- .../integration/wearable-cycle-repair.test.ts | 77 +++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/lib/jobs/wearable-cycle-repair.ts b/src/lib/jobs/wearable-cycle-repair.ts index e45570928..6c002636c 100644 --- a/src/lib/jobs/wearable-cycle-repair.ts +++ b/src/lib/jobs/wearable-cycle-repair.ts @@ -19,6 +19,18 @@ * pass converges: a second run finds nothing, and a boot with nothing to * repair costs one query. * + * It repairs, and never re-decides what a person took back. A cycle she + * deleted (or whose first day she deleted or cleared) stays as a tombstone + * with no `absorbedIntoId`; its wearable days are left without a cycle when + * nothing lies before them, which looks exactly like the backlog. A day that + * lies within the hard minimum cycle length after such a removed start is + * therefore never a candidate: inside that span it can only be the period she + * removed, and reopening it on the next boot would undo her delete. A start + * folded into another one is not a removal and does not count. + * + * Accounts with the cycle module off are left alone, as the syncs leave them + * (`isCycleAvailableForUser`), and their rows do not make a boot enqueue. + * * Only cycles and the `cycleId` link move. No day-log's own content is * touched, a person's own rows included. A Garmin period whose cycle this pass * opened and that is over (a later cycle has started) has its last day stamped @@ -30,6 +42,7 @@ import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { activeAccountSql } from "@/lib/account-deletion/active-account"; import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries"; import { addDays } from "@/lib/cycle/day-math"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { stampSubordinatePeriodEnd } from "@/lib/cycle/subordinate-period-days"; import { HARD_CYCLE_MIN, PERIOD_MAX } from "@/lib/cycle/types"; import { prisma as defaultPrisma } from "@/lib/db"; @@ -57,7 +70,8 @@ interface CandidateRow { * The SQL half of the rule. `limit` turns it into the boot's existence check. * The second arm mirrors `ensureCycleForBleedingDay`: with no cycle on or * before the day, it opens one unless a later start lies within the hard - * minimum cycle length without being the same period. + * minimum cycle length without being the same period. The last arm keeps + * out every day a removed start still speaks for (see the file comment). */ function candidateQuery(limit: number | null): Prisma.Sql { return Prisma.sql` @@ -82,6 +96,14 @@ function candidateQuery(limit: number | null): Prisma.Sql { AND c."start_date" < to_char(d."date"::date + ${HARD_CYCLE_MIN}::int, 'YYYY-MM-DD') ) ) + AND NOT EXISTS ( + SELECT 1 FROM "menstrual_cycles" r + WHERE r."user_id" = d."user_id" + AND r."deleted_at" IS NOT NULL + AND r."absorbed_into_id" IS NULL + AND r."start_date" <= d."date" + AND r."start_date" > to_char(d."date"::date - ${HARD_CYCLE_MIN}::int, 'YYYY-MM-DD') + ) AND ${activeAccountSql('d."user_id"')} ORDER BY d."user_id", d."date" ${limit === null ? Prisma.empty : Prisma.sql`LIMIT ${limit}`} @@ -97,7 +119,7 @@ export interface WearableCycleRepairResult { attributed: number; /** Garmin period ends stamped on an opened cycle. */ periodEnds: number; - /** Accounts touched. */ + /** Accounts repaired (the cycle module on). */ accounts: number; } @@ -207,9 +229,11 @@ export async function repairWearableCycleDays( opened: 0, attributed: 0, periodEnds: 0, - accounts: byUser.size, + accounts: 0, }; for (const [userId, list] of byUser) { + if (!(await isCycleAvailableForUser(userId))) continue; + result.accounts += 1; await repairAccount(prisma, userId, list, result); } return result; @@ -247,9 +271,24 @@ export async function handleWearableCycleRepair( }); } +/** + * Whether any account with candidates has the cycle module on. Candidates + * come in account order, so the first account that may be repaired ends it. + */ +async function anyRepairableAccount(prisma: PrismaClient): Promise { + const rows = await prisma.$queryRaw(candidateQuery(null)); + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.userId)) continue; + seen.add(row.userId); + if (await isCycleAvailableForUser(row.userId)) return true; + } + return false; +} + /** * Boot discovery: one existence query, and the one job only when there is - * something to repair. + * something to repair for an account that tracks its cycle. */ export async function enqueueBootTimeWearableCycleRepair( prisma: PrismaClient = defaultPrisma, @@ -259,6 +298,9 @@ export async function enqueueBootTimeWearableCycleRepair( try { const any = await prisma.$queryRaw(candidateQuery(1)); if (any.length === 0) return { enqueued: false, error: null }; + if (!(await anyRepairableAccount(prisma))) { + return { enqueued: false, error: null }; + } const id = await boss.send( WEARABLE_CYCLE_REPAIR_QUEUE, {}, diff --git a/tests/integration/wearable-cycle-repair.test.ts b/tests/integration/wearable-cycle-repair.test.ts index b9d95b54a..47ed8e5e7 100644 --- a/tests/integration/wearable-cycle-repair.test.ts +++ b/tests/integration/wearable-cycle-repair.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ }), })); +import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries"; import { enqueueBootTimeWearableCycleRepair, repairWearableCycleDays, @@ -32,6 +33,7 @@ import { const GARMIN_USER = "user-wearable-repair-garmin"; const OURA_USER = "user-wearable-repair-oura"; const BLOCKED_USER = "user-wearable-repair-blocked"; +const OFF_USER = "user-wearable-repair-off"; const DAY_MS = 86_400_000; const addDays = (day: string, n: number) => @@ -197,4 +199,79 @@ describe("wearable cycle repair", () => { }), ).toEqual(daysBefore); }); + + it("never reopens a cycle the person deleted", async () => { + const prisma = getPrismaClient(); + await repairWearableCycleDays(); + // The cycle delete: the first cycle goes, and with nothing before it + // its Garmin days are left without a cycle. + await prisma.$transaction((db) => + removeCycleStartedOn(db, GARMIN_USER, "2026-07-01"), + ); + const orphaned = await prisma.cycleDayLog.count({ + where: { userId: GARMIN_USER, cycleId: null, deletedAt: null }, + }); + expect(orphaned).toBe(5); + + sends.length = 0; + expect(await enqueueBootTimeWearableCycleRepair()).toEqual({ + enqueued: false, + error: null, + }); + expect(await repairWearableCycleDays()).toMatchObject({ + scanned: 0, + opened: 0, + }); + expect((await liveCycles(GARMIN_USER)).map((c) => c.startDate)).toEqual([ + "2026-07-29", + "2026-08-26", + ]); + }); + + it("does not move the start to the next day when the first day is deleted", async () => { + const prisma = getPrismaClient(); + await repairWearableCycleDays(); + // The day-log delete: the opening day goes, and its cycle with it. + const first = await prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: GARMIN_USER, date: "2026-07-01" }, + }); + await prisma.$transaction(async (db) => { + await db.cycleDayLog.update({ + where: { id: first.id }, + data: { deletedAt: new Date() }, + }); + return removeCycleStartedOn(db, GARMIN_USER, "2026-07-01"); + }); + + await repairWearableCycleDays(); + expect((await liveCycles(GARMIN_USER)).map((c) => c.startDate)).toEqual([ + "2026-07-29", + "2026-08-26", + ]); + }); + + it("leaves an account with cycle tracking off alone", async () => { + const prisma = getPrismaClient(); + await seedUser(OFF_USER); + await prisma.cycleProfile.create({ + data: { userId: OFF_USER, cycleTrackingEnabled: false }, + }); + await orphanDays(OFF_USER, "GARMIN", ["2026-07-01", "2026-07-02"]); + + const result = await repairWearableCycleDays(); + expect(result.accounts).toBe(2); + expect(await liveCycles(OFF_USER)).toEqual([]); + expect( + await prisma.cycleDayLog.count({ + where: { userId: OFF_USER, cycleId: null }, + }), + ).toBe(2); + + // Its rows are no work while the module is off: the boot stays quiet. + sends.length = 0; + expect(await enqueueBootTimeWearableCycleRepair()).toEqual({ + enqueued: false, + error: null, + }); + }); });