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

No files matched your search

+9 -2
View File
@@ -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<boolean> {
@@ -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 } },
});
+7 -2
View File
@@ -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;
}
+4 -1
View File
@@ -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;
}
}
@@ -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);
});
});