mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
15bece9849
commit
824dcbb01a
9 files changed
+358
-49
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user