perf(cycle): scope the reminder cron to the in-window cohort and harden the period write

Push the cycle-reminder cron's gating into the query instead of loading
every CyclePrediction row each tick: filter on the user's
cycleTrackingEnabled / predictionEnabled relation and restrict to rows
whose nextPeriodStart falls inside a tz-generous [today-grace, today+lead]
string window. The per-tick cost now scales with the in-window cohort, not
the whole table; the exact per-user-timezone window still runs in JS.

Make the period-boundary shortcut transactional and idempotent: wrap the
close-prior + open-new (or stamp-current) cycle mutations in a single
$transaction so two concurrent taps cannot double-close a prior cycle or
compute a length against a stale read, and add withIdempotency so an exact
key replay is a no-op. The boundary day-log now ranks the incoming flow
against any existing same-day entry and never downgrades a richer flow.

Decode sync cursors additively: a live beta v1 token keeps its
measurement / mood / intake watermarks and simply fresh-scans the two
domains added in v2 (cycleDays, cycles) rather than forcing a full
re-init. Correct the stale "no live consumer" comment.

Thread the user's timezone through the Coach cycle snapshot so "today",
day-of-cycle and days-until-period derive from the user's zone — matching
the calendar and insights routes — instead of wall-clock Berlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 12:17:07 +02:00
1 parent 15bece9849
commit 824dcbb01a
9 files changed
+358 -49

No files matched your search

+85 -33
View File
@@ -22,8 +22,10 @@ import {
safeJson,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { withIdempotency } from "@/lib/idempotency";
import { cyclePeriodSchema } from "@/lib/validations/cycle";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import type { FlowLevel } from "@/lib/cycle/types";
import {
toCycleDayLogDTO,
toMenstrualCycleDTO,
@@ -32,7 +34,9 @@ import {
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
async function postPeriod(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
@@ -55,59 +59,85 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { action, date, externalId, loggedAt } = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
let cycleId: string;
// The close-prior + open-new (start) or stamp-current (end) mutation set
// is run inside a single transaction so two concurrent taps can never
// double-close a prior cycle or compute the new cycle's length against a
// stale read. `withIdempotency` on the route additionally collapses an
// exact-key replay; the transaction guards the interleaving case.
const txResult = await prisma.$transaction(async (db) => {
if (action === "start") {
// Close the prior open cycle (its end is the day before this start)
// and record its observed length.
const prior = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true, startDate: true },
});
if (prior) {
await db.menstrualCycle.update({
where: { id: prior.id },
data: {
endDate: addDays(date, -1),
lengthDays: dayDiff(date, prior.startDate),
syncVersion: { increment: 1 },
},
});
}
if (action === "start") {
// Close the prior open cycle (its end is the day before this start)
// and record its observed length.
const prior = await prisma.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true, startDate: true },
});
if (prior) {
await prisma.menstrualCycle.update({
where: { id: prior.id },
data: {
endDate: addDays(date, -1),
lengthDays: dayDiff(date, prior.startDate),
// Upsert the new cycle on the `(userId, startDate)` unique so a
// re-tap on the same day is idempotent.
const cycle = await db.menstrualCycle.upsert({
where: { userId_startDate: { userId: user.id, startDate: date } },
create: { userId: user.id, startDate: date, tz, isPredicted: false },
update: {
deletedAt: null,
isPredicted: false,
syncVersion: { increment: 1 },
},
});
return { cycleId: cycle.id as string | null };
}
// Upsert the new cycle on the `(userId, startDate)` unique so a
// re-tap on the same day is idempotent.
const cycle = await prisma.menstrualCycle.upsert({
where: { userId_startDate: { userId: user.id, startDate: date } },
create: { userId: user.id, startDate: date, tz, isPredicted: false },
update: { deletedAt: null, isPredicted: false, syncVersion: { increment: 1 } },
});
cycleId = cycle.id;
} else {
// `end`: stamp the current cycle's periodEndDate.
const current = await prisma.menstrualCycle.findFirst({
const current = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
orderBy: { startDate: "desc" },
select: { id: true },
});
if (!current) {
return apiSuccessNoCycle();
return { cycleId: null };
}
await prisma.menstrualCycle.update({
await db.menstrualCycle.update({
where: { id: current.id },
data: { periodEndDate: date, syncVersion: { increment: 1 } },
});
cycleId = current.id;
return { cycleId: current.id as string | null };
});
if (txResult.cycleId === null) {
return apiSuccessNoCycle();
}
const cycleId: string = txResult.cycleId;
// Boundary flow. Start → MEDIUM opens the bleed; end → SPOTTING tail.
// Never downgrade a richer flow already logged for the same day (e.g. a
// manual HEAVY entry must survive a later one-tap "start") — only set the
// boundary flow when it ranks at or above what's stored.
const boundaryFlow: FlowLevel = action === "start" ? "MEDIUM" : "SPOTTING";
const existingDay = await prisma.cycleDayLog.findFirst({
where: { userId: user.id, date, deletedAt: null },
select: { flow: true },
});
const flow =
flowRank(boundaryFlow) >= flowRank(existingDay?.flow ?? null)
? boundaryFlow
: undefined;
// Boundary day-log. Start → MEDIUM flow opens the bleed; end → SPOTTING
// tail (the last logged bleeding day).
await upsertCycleDayLog(
user.id,
{
date,
flow: action === "start" ? "MEDIUM" : "SPOTTING",
...(flow !== undefined ? { flow } : {}),
loggedAt,
source: "MANUAL",
...(externalId ? { externalId } : {}),
@@ -143,9 +173,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
cycle: toMenstrualCycleDTO(cycleRow),
dayLog: toCycleDayLogDTO(dayLogRow),
});
});
}
/** `end` with no preceding cycle is a no-op the client should not hit. */
function apiSuccessNoCycle(): Response {
return apiSuccess({ cycle: null, dayLog: null });
}
/**
* Ordinal rank of a flow level (NONE = 0 … HEAVY = 5; a missing flow ranks
* below NONE). Used to keep the one-tap boundary flow from downgrading a
* richer same-day entry.
*/
function flowRank(flow: FlowLevel | string | null): number {
switch (flow) {
case "NONE":
return 1;
case "SPOTTING":
return 2;
case "LIGHT":
return 3;
case "MEDIUM":
return 4;
case "HEAVY":
return 5;
default:
return 0;
}
}
@@ -139,6 +139,32 @@ describe("buildCycleSnapshotBlock", () => {
).toBe(true);
});
it("derives 'today' from the threaded user timezone, not wall-clock", async () => {
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
// NOW is 2026-03-22T12:00Z. In Berlin (UTC+1) the local day is the
// 22nd → day-of-cycle 21 (cycle started 2026-03-02). In Kiritimati
// (UTC+14) the local day has already rolled to the 23rd → day 22.
const berlin = await buildCycleSnapshotBlock(
"u1",
"FEMALE",
NOW,
"Europe/Berlin",
);
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
const kiritimati = await buildCycleSnapshotBlock(
"u1",
"FEMALE",
NOW,
"Pacific/Kiritimati",
);
expect(berlin!.dayOfCycle).toBe(21);
expect(kiritimati!.dayOfCycle).toBe(22);
});
it("includes the headline phase insight when a contrast clears FDR", async () => {
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
+6 -1
View File
@@ -126,6 +126,7 @@ export async function buildCycleSnapshotBlock(
userId: string,
gender: string | null | undefined,
now: Date = new Date(),
timezone: string | null | undefined = DEFAULT_TIMEZONE,
): Promise<CycleSnapshotBlock | null> {
// Test environments may mock only part of prisma — bow out silently if the
// cycle models are absent (matches "no cycle data" production behaviour).
@@ -154,7 +155,11 @@ export async function buildCycleSnapshotBlock(
rawChartMode: profile.rawChartMode,
};
const tz = DEFAULT_TIMEZONE;
// Derive "today" from the user's timezone — matches how the calendar
// (calendar/route.ts) and insights routes resolve it, so the Coach's
// "you are on day N / period in M days" never disagrees with the calendar
// near local midnight.
const tz = timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(now, tz);
const from = addDays(today, -WINDOW_DAYS);
+1 -1
View File
@@ -812,7 +812,7 @@ async function buildCoachSnapshotImpl(
prefsRow?.cycleProfile ?? null,
);
const cycleBlockPromise = cycleEnabled
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now)
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
const [
@@ -462,3 +462,47 @@ describe("runCycleReminderTick", () => {
expect(summary.dispatchedPeriodSoon).toBe(1); // u2 still processed
});
});
describe("runCycleReminderTick — windowing query", () => {
it("pushes the cohort gate into the findMany where-clause", async () => {
const findMany = vi.fn().mockResolvedValue([]);
const prisma = {
cyclePrediction: { findMany },
pushAttempt: { findFirst: vi.fn(async () => null) },
menstrualCycle: { findMany: vi.fn(async () => []) },
};
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runCycleReminderTick(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
AT_0900_BERLIN,
{
dispatch:
dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification,
},
);
expect(findMany).toHaveBeenCalledTimes(1);
const arg = findMany.mock.calls[0][0] as unknown as {
where: {
nextPeriodStart: { gte: string; lte: string };
user: {
cycleProfile: {
cycleTrackingEnabled: boolean;
NOT: { predictionEnabled: boolean };
};
};
};
};
// Tracking + prediction gate is in the query, not JS.
expect(arg.where.user.cycleProfile.cycleTrackingEnabled).toBe(true);
expect(arg.where.user.cycleProfile.NOT.predictionEnabled).toBe(false);
// The window is a YYYY-MM-DD string range straddling "now". At
// 2026-05-17 it spans [today-grace-1 .. today+lead+1] in UTC.
expect(arg.where.nextPeriodStart.gte).toBe("2026-05-13");
expect(arg.where.nextPeriodStart.lte).toBe("2026-05-20");
expect(arg.where.nextPeriodStart.gte < arg.where.nextPeriodStart.lte).toBe(
true,
);
});
});
+46 -4
View File
@@ -3,8 +3,10 @@
*
* Mirrors `mood-reminder.ts` and the medication-reminder cron: a cron tick
* fires every 15 minutes, and when it lands inside the user's reminder hour
* in their local timezone this module scans every cycle-enabled user with a
* cached `CyclePrediction`, decides who is in-window, and dispatches at most
* in their local timezone this module loads only the in-window cohort —
* cycle-tracking-enabled users whose cached `CyclePrediction.nextPeriodStart`
* falls inside the reminder window — decides who is in-window, and dispatches
* at most
* one push per event per local day through the existing
* APNs → Telegram → ntfy → Web-Push cascade (`dispatchNotification`), which
* records every attempt in `push_attempts`.
@@ -81,6 +83,20 @@ export interface CycleReminderSummary {
failed: number;
}
/**
* `YYYY-MM-DD` for `now + offsetDays` in UTC. Used only to bound the
* `nextPeriodStart` query window generously across timezones; the exact
* per-user-local window is applied by `evaluateCycleReminder`.
*/
function utcDateString(now: Date, offsetDays: number): string {
const d = new Date(now.getTime() + offsetDays * 86_400_000);
return `${d.getUTCFullYear().toString().padStart(4, "0")}-${(
d.getUTCMonth() + 1
)
.toString()
.padStart(2, "0")}-${d.getUTCDate().toString().padStart(2, "0")}`;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
@@ -244,9 +260,35 @@ export async function runCycleReminderTick(
const dispatchImpl = options.dispatch ?? dispatchNotification;
const summary = emptySummary();
// Only users with a cached forecast can be in-window; the join also
// narrows to the cohort that has logged enough to predict.
// Push the gating into the query so the per-tick cost scales with the
// in-window cohort, not the whole `CyclePrediction` table. Three filters
// run server-side:
//
// 1. the user's `cycleProfile` must have `cycleTrackingEnabled` (an
// account that never opted in can never receive a cycle push), and
// `predictionEnabled` must not be explicitly off,
// 2. `nextPeriodStart` (a `YYYY-MM-DD` string) must fall inside the
// reminder window [today − grace, today + lead]. The string range is
// computed against a tz-generous span (±1 day past the exact
// lead/grace) so a user whose local date differs from UTC by a day is
// never excluded at the query layer — `evaluateCycleReminder` still
// applies the exact per-user-timezone window below.
//
// The pure-string `gte`/`lte` range is correct because `YYYY-MM-DD`
// ordering is lexicographic-equals-chronological.
const windowFloor = utcDateString(now, -(PERIOD_CONFIRM_GRACE_DAYS + 1));
const windowCeil = utcDateString(now, PERIOD_SOON_LEAD_DAYS + 1);
const predictions = await prisma.cyclePrediction.findMany({
where: {
nextPeriodStart: { gte: windowFloor, lte: windowCeil },
user: {
cycleProfile: {
cycleTrackingEnabled: true,
NOT: { predictionEnabled: false },
},
},
},
select: {
userId: true,
nextPeriodStart: true,
+32 -3
View File
@@ -42,10 +42,13 @@ describe("sync cursor codec", () => {
expect(decodeCursor("")).toBeNull();
// valid base64url but not the expected JSON shape
expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull();
// wrong version — v1.15.0 bumped CURSOR_VERSION to 2, so a v1 token
// (the pre-cycle format) now decodes to null → clean re-init.
// unsupported version (v3 is not yet a thing) → clean re-init.
expect(
decodeCursor(Buffer.from('{"v":1,"d":{}}').toString("base64url")),
decodeCursor(Buffer.from('{"v":3,"d":{}}').toString("base64url")),
).toBeNull();
// a non-numeric version is garbage too.
expect(
decodeCursor(Buffer.from('{"v":"1","d":{}}').toString("base64url")),
).toBeNull();
// missing the `d` envelope
expect(
@@ -53,6 +56,32 @@ describe("sync cursor codec", () => {
).toBeNull();
});
it("decodes a live v1 beta token additively — keeps known domains, fresh-scans the two new ones", () => {
// A pre-cycle v1 cursor only ever carried measurements/mood/intakes.
// It must NOT force a full re-init now that v2 added cycleDays/cycles;
// the known watermarks survive and the new domains decode as absent
// (a fresh scan of just those two).
const v1Token = Buffer.from(
JSON.stringify({
v: 1,
d: {
measurements: { u: 1_717_000_000_000, i: "clx0meas" },
mood: { u: 1_717_000_500_000, i: "clx0mood" },
intakes: { u: 1_717_000_900_000, i: "clx0intk" },
},
}),
).toString("base64url");
const decoded = decodeCursor(v1Token);
expect(decoded).toEqual({
measurements: { updatedAtMs: 1_717_000_000_000, id: "clx0meas" },
mood: { updatedAtMs: 1_717_000_500_000, id: "clx0mood" },
intakes: { updatedAtMs: 1_717_000_900_000, id: "clx0intk" },
});
// cycleDays + cycles absent → caller fresh-scans only those domains.
expect(decoded?.cycleDays).toBeUndefined();
expect(decoded?.cycles).toBeUndefined();
});
it("decodes a current-version empty envelope to a fresh-scan map", () => {
expect(
decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")),
+21 -7
View File
@@ -14,9 +14,16 @@
* token back verbatim and never parses it (iOS-coord §7.6), so the
* server is free to change this format without a client release. The
* current encoding is base64url-of-JSON; nothing depends on that choice.
* A device on the old single-domain token simply decodes to null (the
* shape no longer matches) and re-inits cleanly — acceptable because no
* live consumer cursors exist yet.
*
* Version compatibility is additive, not breaking. The iOS client has
* been in public beta since v1.5 and the delta feed shipped in v1.7.0, so
* live v1 cursors DO exist in the field. A v1 token (which only carried
* the measurements / mood / intakes domains) decodes normally: its known
* domain watermarks are preserved and the two domains added in v2
* (`cycleDays`, `cycles`) are simply absent → a fresh scan of just those
* two domains on the next sync, with no re-download of the measurement /
* mood / intake history. Only a genuinely malformed / unparseable token
* re-inits from scratch.
*/
/** The sync domains the feed serves. */
@@ -50,11 +57,16 @@ export interface DomainWatermark {
export type SyncCursor = Partial<Record<SyncDomain, DomainWatermark>>;
// v1.15.0 — bumped to 2 when the `cycleDays` + `cycles` domains were
// added. An in-flight v1 token decodes to null (wrong version) → clean
// re-init, which is acceptable because the cursor is opaque and the
// client re-syncs from scratch.
// added. New tokens are stamped v2; decoding accepts both v1 and v2
// envelopes (see `decodeCursor`) so live beta v1 cursors keep their
// measurement / mood / intake watermarks and only fresh-scan the two new
// domains rather than re-initialising the whole feed.
const CURSOR_VERSION = 2;
/** Versions whose envelope shape `decodeCursor` accepts. v1 lacked the
* `cycleDays`/`cycles` domains; they decode as absent (fresh scan). */
const SUPPORTED_CURSOR_VERSIONS: readonly number[] = [1, 2];
/** Encode a per-domain keyset map into an opaque token. */
export function encodeCursor(cursor: SyncCursor): string {
const d: Record<string, { u: number; i: string }> = {};
@@ -87,10 +99,12 @@ export function decodeCursor(token: string): SyncCursor | null {
try {
const json = Buffer.from(token, "base64url").toString("utf-8");
const parsed = JSON.parse(json) as unknown;
const version = (parsed as { v?: unknown }).v;
if (
typeof parsed !== "object" ||
parsed === null ||
(parsed as { v?: unknown }).v !== CURSOR_VERSION ||
typeof version !== "number" ||
!SUPPORTED_CURSOR_VERSIONS.includes(version) ||
typeof (parsed as { d?: unknown }).d !== "object" ||
(parsed as { d: unknown }).d === null
) {
+97
View File
@@ -233,6 +233,103 @@ describe("cycle period shortcut", () => {
expect(json.data.cycle.startDate).toBe("2026-03-10");
expect(json.data.dayLog.flow).toBe("MEDIUM");
});
it("closes the prior cycle atomically when a new start is inserted", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/cycle/period/route");
await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-04-01",
loggedAt: "2026-04-01T08:00:00.000Z",
}),
);
await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-04-29",
loggedAt: "2026-04-29T08:00:00.000Z",
}),
);
const prior = await prisma.menstrualCycle.findFirstOrThrow({
where: { userId: FEMALE_USER_ID, startDate: "2026-04-01" },
});
// Close-prior + open-new ran as one unit: the prior cycle is stamped.
expect(prior.endDate).toBe("2026-04-28");
expect(prior.lengthDays).toBe(28);
});
it("replays the same idempotency key without a second mutation", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/cycle/period/route");
const key = `period-${Date.now()}`;
const first = await POST(
jsonRequest(
"/api/cycle/period",
"POST",
{
action: "start",
date: "2026-05-10",
loggedAt: "2026-05-10T08:00:00.000Z",
},
{ idempotencyKey: key },
),
);
expect(first.status).toBe(200);
const replay = await POST(
jsonRequest(
"/api/cycle/period",
"POST",
{
action: "start",
date: "2026-05-10",
loggedAt: "2026-05-10T08:00:00.000Z",
},
{ idempotencyKey: key },
),
);
expect(replay.status).toBe(200);
const cycles = await prisma.menstrualCycle.count({
where: { userId: FEMALE_USER_ID, startDate: "2026-05-10", deletedAt: null },
});
expect(cycles).toBe(1);
});
it("never downgrades a richer same-day flow on the boundary write", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const dayLogs = await import("@/app/api/cycle/day-logs/route");
const period = await import("@/app/api/cycle/period/route");
// A manual HEAVY entry exists for the day…
await dayLogs.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-06-01",
flow: "HEAVY",
loggedAt: "2026-06-01T07:00:00.000Z",
}),
);
// …a one-tap "start" (boundary flow MEDIUM) must not downgrade it.
await period.POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-06-01",
loggedAt: "2026-06-01T08:00:00.000Z",
}),
);
const row = await prisma.cycleDayLog.findFirstOrThrow({
where: { userId: FEMALE_USER_ID, date: "2026-06-01", deletedAt: null },
});
expect(row.flow).toBe("HEAVY");
});
});
describe("cycle calendar", () => {