From 6c5146942b0b4bad660c7584d46a93394f0fee8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 7 Nov 2026 01:04:12 +0100 Subject: [PATCH] fix(cycle): stamp a wearable period end only on a cycle it opened Garmin's period end was written on any cycle starting on Garmin's start date, a cycle the person opened herself included, even when her own days ran past it. The stamp now requires the cycle's first day to be the wearable's own live row. --- src/lib/cycle/subordinate-period-days.ts | 11 ++- src/lib/garmin/cycle-sync.ts | 9 +- src/lib/jobs/wearable-cycle-repair.ts | 5 +- .../garmin-period-end-stamp.test.ts | 95 +++++++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/integration/garmin-period-end-stamp.test.ts diff --git a/src/lib/cycle/subordinate-period-days.ts b/src/lib/cycle/subordinate-period-days.ts index 569451520..aaa796e81 100644 --- a/src/lib/cycle/subordinate-period-days.ts +++ b/src/lib/cycle/subordinate-period-days.ts @@ -91,11 +91,15 @@ export async function createSubordinatePeriodDays( /** * Stamp the last day of a finished period on the cycle that opens at - * `start`, when that cycle has no period end yet. A person's own end, or one - * an earlier pass stamped, stays. Returns whether a stamp was written. + * `start`, when that cycle has no period end yet and the wearable opened it: + * its first day is the wearable's own live row. A cycle the person opened + * herself on that date carries her account of the period (her days, her end + * or none yet), and a wearable never writes over it. An end already set, by + * her or an earlier pass, stays. Returns whether a stamp was written. */ export async function stampSubordinatePeriodEnd( userId: string, + source: SubordinateCycleSource, start: string, lastDay: string, ): Promise { @@ -106,6 +110,9 @@ export async function stampSubordinatePeriodEnd( startDate: start, deletedAt: null, periodEndDate: null, + dayLogs: { + some: { userId, date: start, source, deletedAt: null }, + }, }, data: { periodEndDate: lastDay, syncVersion: { increment: 1 } }, }); diff --git a/src/lib/garmin/cycle-sync.ts b/src/lib/garmin/cycle-sync.ts index a826847e0..369413a4c 100644 --- a/src/lib/garmin/cycle-sync.ts +++ b/src/lib/garmin/cycle-sync.ts @@ -7,7 +7,7 @@ * The write goes through `createSubordinatePeriodDays`, which attributes each * created day to its cycle and opens the cycle a first bleeding day starts, * as every other cycle writer does. A finished period also stamps its last - * day on that cycle, when the cycle has no period end yet. + * day on that cycle, when Garmin opened it and it has no period end yet. * * `GET /periodichealth-service/menstrualcycle/summary/{date}` describes the * cycle a date falls in: its start and its period length. A cycle Garmin @@ -98,7 +98,12 @@ export async function writeGarminPeriod( period.days, ); if (period.ended) { - await stampSubordinatePeriodEnd(userId, period.start, period.days.at(-1)!); + await stampSubordinatePeriodEnd( + userId, + "GARMIN", + period.start, + period.days.at(-1)!, + ); } return created; } diff --git a/src/lib/jobs/wearable-cycle-repair.ts b/src/lib/jobs/wearable-cycle-repair.ts index f4ed25108..e45570928 100644 --- a/src/lib/jobs/wearable-cycle-repair.ts +++ b/src/lib/jobs/wearable-cycle-repair.ts @@ -182,7 +182,10 @@ async function repairAccount( // An open cycle may still be bleeding; the hourly sync stamps its end. if (!cycle?.endDate) continue; const end = await garminRunEnd(prisma, userId, start); - if (end && (await stampSubordinatePeriodEnd(userId, start, end))) { + if ( + end && + (await stampSubordinatePeriodEnd(userId, "GARMIN", start, end)) + ) { result.periodEnds += 1; } } diff --git a/tests/integration/garmin-period-end-stamp.test.ts b/tests/integration/garmin-period-end-stamp.test.ts new file mode 100644 index 000000000..989be94fd --- /dev/null +++ b/tests/integration/garmin-period-end-stamp.test.ts @@ -0,0 +1,95 @@ +/** + * Garmin's period end lands only on a cycle Garmin's own day opened + * (real Postgres). + * + * The hourly Garmin leg stamps the last day of a finished period on the cycle + * that starts on Garmin's start date. A cycle the person opened herself on + * that date carries her own account of the period: her days, her end or none + * yet. Garmin fills only the days no row covers, and it does not write her + * cycle's period end either. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { writeGarminPeriod } from "@/lib/garmin/cycle-sync"; + +const GARMIN_OPENED = "user-garmin-stamp-own"; +const PERSON_OPENED = "user-garmin-stamp-person"; + +const START = "2026-09-01"; +const DAYS = ["2026-09-01", "2026-09-02", "2026-09-03", "2026-09-04"]; + +async function seedUser(id: string) { + await getPrismaClient().user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "UTC", + gender: "FEMALE", + }, + }); +} + +async function cycleAt(userId: string) { + return getPrismaClient().menstrualCycle.findFirstOrThrow({ + where: { userId, startDate: START, deletedAt: null }, + select: { id: true, periodEndDate: true }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await seedUser(GARMIN_OPENED); + await seedUser(PERSON_OPENED); +}); + +describe("Garmin period end", () => { + it("stamps the end on the cycle Garmin's first day opened", async () => { + await writeGarminPeriod(GARMIN_OPENED, { + start: START, + days: DAYS, + ended: true, + }); + expect(await cycleAt(GARMIN_OPENED)).toMatchObject({ + periodEndDate: "2026-09-04", + }); + }); + + it("leaves the period end of a cycle the person opened to her", async () => { + const prisma = getPrismaClient(); + // She tapped her start: the cycle and her own first day. + const cycle = await prisma.menstrualCycle.create({ + data: { userId: PERSON_OPENED, startDate: START, tz: "UTC" }, + }); + await prisma.cycleDayLog.create({ + data: { + userId: PERSON_OPENED, + date: START, + tz: "UTC", + source: "MANUAL", + flow: "MEDIUM", + cycleId: cycle.id, + }, + }); + + // Every hourly tick after the period is over writes the same thing. + for (let tick = 0; tick < 2; tick++) { + await writeGarminPeriod(PERSON_OPENED, { + start: START, + days: DAYS, + ended: true, + }); + } + expect(await cycleAt(PERSON_OPENED)).toMatchObject({ + id: cycle.id, + periodEndDate: null, + }); + // Garmin still filled the days she did not log. + expect( + await prisma.cycleDayLog.count({ + where: { userId: PERSON_OPENED, source: "GARMIN", cycleId: cycle.id }, + }), + ).toBe(3); + }); +});