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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-07 01:04:13 +01:00
1 parent 6c5146942b
commit 24dd8a28e3
2 files changed
+123 -4

No files matched your search

+46 -4
View File
@@ -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<boolean> {
const rows = await prisma.$queryRaw<CandidateRow[]>(candidateQuery(null));
const seen = new Set<string>();
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<CandidateRow[]>(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,
{},
@@ -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,
});
});
});