mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
6c5146942b
commit
24dd8a28e3
2 files changed
+123
-4
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user