feat(cycle): name the start a period start folds in and the starts a removal gives back

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:16:11 +02:00
1 parent 74fa4e10b6
commit b562587c45
18 files changed
+481 -49

No files matched your search

+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "Periodenbeginn gespeichert",
"periodEndSaved": "Periodenende gespeichert",
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
"endedPeriodOnDate": "Periode an diesem Tag beendet"
"endedPeriodOnDate": "Periode an diesem Tag beendet",
"startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.",
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da."
},
"cycleReminders": {
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "Period start saved",
"periodEndSaved": "End of period saved",
"startedPeriodOnDate": "My period started this day",
"endedPeriodOnDate": "My period ended this day"
"endedPeriodOnDate": "My period ended this day",
"startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.",
"startRestored": "Saved. The start on {dates} is back."
},
"cycleReminders": {
"periodSoonTitle": "Period likely soon",
+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "Inicio del periodo guardado",
"periodEndSaved": "Fin del periodo guardado",
"startedPeriodOnDate": "Mi periodo empezó este día",
"endedPeriodOnDate": "Mi periodo terminó este día"
"endedPeriodOnDate": "Mi periodo terminó este día",
"startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.",
"startRestored": "Guardado. El inicio del {dates} vuelve a estar."
},
"cycleReminders": {
"periodSoonTitle": "El periodo llegará pronto",
+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "Début des règles enregistré",
"periodEndSaved": "Fin des règles enregistrée",
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là"
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là",
"startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.",
"startRestored": "Enregistré. Le début du {dates} est de retour."
},
"cycleReminders": {
"periodSoonTitle": "Règles probablement bientôt",
+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "Inizio delle mestruazioni salvato",
"periodEndSaved": "Fine delle mestruazioni salvata",
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno",
"startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.",
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente."
},
"cycleReminders": {
"periodSoonTitle": "Ciclo probabilmente in arrivo",
+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "생리 시작을 저장했어요",
"periodEndSaved": "생리 종료를 저장했어요",
"startedPeriodOnDate": "이 날 생리를 시작했어요",
"endedPeriodOnDate": "이 날 생리가 끝났어요"
"endedPeriodOnDate": "이 날 생리가 끝났어요",
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
},
"cycleReminders": {
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
+3 -1
View File
@@ -9235,7 +9235,9 @@
"periodStartSaved": "Początek miesiączki zapisany",
"periodEndSaved": "Koniec miesiączki zapisany",
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia"
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia",
"startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.",
"startRestored": "Zapisano. Początek z {dates} jest z powrotem."
},
"cycleReminders": {
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
+10 -2
View File
@@ -2,6 +2,9 @@
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
* With `Prefer: return=representation` (v1.43, RFC 7240) the answer is 200
* with what the delete changed — the removed cycle and the starts it gave
* back — so a client can say "the start on 17 Sep is back".
*
* The delete also hands the span back to the preceding cycle. Opening a cycle
* closes the one before it, so removing it without re-deriving that boundary
@@ -18,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { apiError, getClientIp } from "@/lib/api-response";
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
@@ -44,7 +48,7 @@ export const DELETE = apiHandler(
}
// Already a tombstone: the delete is done, and nothing is left to move.
if (existing.deletedAt !== null) {
return new Response(null, { status: 204 });
return cycleDeleteOutcome(request, null, []);
}
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
@@ -99,6 +103,10 @@ export const DELETE = apiHandler(
meta: { reanchored_prior: reanchored !== null },
});
return new Response(null, { status: 204 });
return cycleDeleteOutcome(
request,
removed?.cycleId ?? null,
removed?.restored ?? [],
);
},
);
+13 -3
View File
@@ -3,7 +3,8 @@
* (ios-contract §2.A).
* `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F):
* set `deletedAt` + bump `syncVersion`, emit a tombstone on the next
* sync page. 204. Idempotent.
* sync page. 204. Idempotent. With `Prefer: return=representation` 200
* with the removed cycle and the starts given back (v1.43).
*
* Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by
* another user 404s).
@@ -34,6 +35,7 @@ import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
type RouteParams = { params: Promise<{ id: string }> };
@@ -176,11 +178,13 @@ export const PATCH = apiHandler(
row,
);
let removedCycleId: string | null = null;
let restored: Array<{ cycleId: string; startDate: string }> = [];
if (!openedCycleId && !stillBleeds(row)) {
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.date),
);
removedCycleId = removed?.cycleId ?? null;
restored = removed?.restored ?? [];
}
await auditLog("cycle.day-log.update", {
@@ -221,7 +225,9 @@ export const PATCH = apiHandler(
},
});
return apiSuccess(toCycleDayLogDTO(row));
// v1.43 (ios #116 item 9) — the starts this edit gave back by clearing
// the flow on a start day; empty when it removed none.
return apiSuccess({ ...toCycleDayLogDTO(row), restored });
},
);
@@ -303,7 +309,11 @@ export const DELETE = apiHandler(
},
});
return new Response(null, { status: 204 });
return cycleDeleteOutcome(
request,
removed?.cycleId ?? null,
removed?.restored ?? [],
);
},
);
+13 -2
View File
@@ -7,7 +7,8 @@
* and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"`
* stamps the current cycle's `periodEndDate` and writes the boundary
* day-log. Convenience over §2.A; returns
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`.
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO, absorbed }`, where
* `absorbed` names a later start this one folded in (v1.43, null otherwise).
*/
import { NextRequest } from "next/server";
@@ -221,12 +222,22 @@ async function postPeriod(request: NextRequest): Promise<Response> {
return apiSuccess({
cycle: toMenstrualCycleDTO(cycleRow),
dayLog: toCycleDayLogDTO(dayLogRow),
// v1.43 (ios #116 item 9) — a later start inside the same period that
// this start folded in, so the client can say which date stopped being a
// start. Null when nothing was folded; present on every `start`.
absorbed:
txResult.moved.absorbedCycleId && txResult.moved.absorbedStartDate
? {
cycleId: txResult.moved.absorbedCycleId,
startDate: txResult.moved.absorbedStartDate,
}
: null,
});
}
/** `end` with no preceding cycle is a no-op the client should not hit. */
function apiSuccessNoCycle(): Response {
return apiSuccess({ cycle: null, dayLog: null });
return apiSuccess({ cycle: null, dayLog: null, absorbed: null });
}
/**
+59 -9
View File
@@ -23,6 +23,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import {
cycleDependentKeys,
@@ -318,10 +319,33 @@ export interface CycleDayLogPatch {
note?: string | null;
}
/**
* A period start the server folded in or gave back (v1.43). The server names
* it; the toast only says it, so the person learns which date moved without
* comparing the calendar before and after.
*/
export interface CycleStartRef {
cycleId: string;
startDate: string;
}
/** The toast after a write that may have given starts back. */
function useRestoredStartsMessage() {
const { t } = useTranslations();
const formatDay = useCalendarDate();
return (fallback: string, restored: readonly CycleStartRef[] | undefined) =>
restored && restored.length > 0
? t("cycle.startRestored", {
dates: restored.map((r) => formatDay(r.startDate)).join(", "),
})
: fallback;
}
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
export function usePatchDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async ({
id,
@@ -330,11 +354,17 @@ export function usePatchDayLog() {
id: string;
patch: CycleDayLogPatch;
}) => {
return apiPatch<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
return apiPatch<CycleDayLogDTO & { restored?: CycleStartRef[] }>(
`/api/cycle/day-logs/${id}`,
patch,
);
},
onSuccess: () => {
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.saveSuccess"));
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.saveSuccess"), data?.restored),
);
},
});
}
@@ -342,17 +372,26 @@ export function usePatchDayLog() {
export function useStartPeriod() {
const qc = useQueryClient();
const { t } = useTranslations();
const formatDay = useCalendarDate();
return useMutation({
mutationFn: async (date: string) => {
return apiPost<unknown>("/api/cycle/period", {
return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", {
action: "start",
date,
loggedAt: new Date().toISOString(),
});
},
onSuccess: () => {
onSuccess: (data, date) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.periodStartSaved"));
toastWrittenOutcome(
"success",
data?.absorbed
? t("cycle.startAbsorbed", {
absorbed: formatDay(data.absorbed.startDate),
date: formatDay(date),
})
: t("cycle.periodStartSaved"),
);
},
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
@@ -386,13 +425,24 @@ export function useEndPeriod() {
export function useDeleteDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/cycle/day-logs/${id}`);
// Ask for what the delete changed: removing a start day can give back a
// start it had folded in, and the toast names it. A server that does
// not know the preference answers 204, read as nothing given back.
return apiDelete<{ restored?: CycleStartRef[] } | null>(
`/api/cycle/day-logs/${id}`,
undefined,
{ headers: { Prefer: "return=representation" } },
);
},
onSuccess: () => {
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.deleteSuccess"), data?.restored),
);
},
onError: (failure) =>
showErrorToast(t("cycle.deleteError"), { cause: failure }),
+6
View File
@@ -366,6 +366,8 @@ export async function removeCycleStartedOn(
cycleId: string;
reanchored: ReanchoredCycle | null;
restoredCycleIds: string[];
/** The same starts with their dates, for the answer to the client. */
restored: Array<{ cycleId: string; startDate: string }>;
} | null> {
const opened = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: date },
@@ -432,6 +434,10 @@ export async function removeCycleStartedOn(
cycleId: opened.id,
reanchored,
restoredCycleIds: restored.map((row) => row.id),
restored: restored.map((row) => ({
cycleId: row.id,
startDate: row.startDate,
})),
};
}
+30
View File
@@ -0,0 +1,30 @@
/**
* The answer to a delete that can take a period start with it (v1.43, ios
* #116 item 9).
*
* Without a preference the delete answers 204, exactly as before, which is
* what every client predating this relies on. With `Prefer:
* return=representation` (RFC 7240) it answers 200 with the cycle it removed
* and the starts that removal gave back — a start folded into the removed one
* stands again — and says so in `Preference-Applied`.
*/
import { apiSuccess } from "@/lib/api-response";
import { hasPreference, RETURN_REPRESENTATION } from "@/lib/http/prefer";
export interface CycleRestoredStart {
cycleId: string;
startDate: string;
}
export function cycleDeleteOutcome(
request: Request,
removedCycleId: string | null,
restored: readonly CycleRestoredStart[],
): Response {
if (!hasPreference(request, RETURN_REPRESENTATION)) {
return new Response(null, { status: 204 });
}
const response = apiSuccess({ removedCycleId, restored: [...restored] });
response.headers.set("Preference-Applied", RETURN_REPRESENTATION);
return response;
}
+2 -8
View File
@@ -4,6 +4,7 @@
* response the synchronous route has always given.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { hasPreference } from "@/lib/http/prefer";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import type { AiRunFailure } from "./store";
@@ -20,14 +21,7 @@ import {
* is what a client that has not adopted runs relies on.
*/
export function prefersRespondAsync(request: Request): boolean {
const header = request.headers.get("prefer");
if (!header) return false;
return header
.split(",")
.some(
(preference) =>
preference.split(";")[0]?.trim().toLowerCase() === "respond-async",
);
return hasPreference(request, "respond-async");
}
/** The 202 for a queued run, with the poll address and the applied preference. */
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { hasPreference, RETURN_REPRESENTATION } from "../prefer";
function req(prefer?: string): Request {
return new Request("http://localhost/x", {
headers: prefer === undefined ? {} : { prefer },
});
}
describe("hasPreference", () => {
it("is false without the header", () => {
expect(hasPreference(req(), RETURN_REPRESENTATION)).toBe(false);
});
it("finds a valued preference among several, with parameters and case", () => {
expect(
hasPreference(
req("respond-async, Return=Representation; foo=bar"),
RETURN_REPRESENTATION,
),
).toBe(true);
expect(
hasPreference(req("return = representation"), "return=representation"),
).toBe(true);
});
it("does not take a different value of the same preference", () => {
expect(hasPreference(req("return=minimal"), RETURN_REPRESENTATION)).toBe(
false,
);
});
it("reads a bare token", () => {
expect(hasPreference(req("respond-async; wait=10"), "respond-async")).toBe(
true,
);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* RFC 7240 `Prefer` header reading. A preference is the token before its
* first `;` (parameters follow it), several preferences are comma-separated,
* and a token without `=` stands alone (`respond-async`) while one with `=`
* carries a value (`return=representation`). Compared case-insensitively.
*/
export function hasPreference(request: Request, preference: string): boolean {
const header = request.headers.get("prefer");
if (!header) return false;
const wanted = preference.toLowerCase();
return header.split(",").some(
(entry) =>
entry
.split(";")[0]
?.trim()
.toLowerCase()
.replace(/\s*=\s*/, "=") === wanted,
);
}
/** `Prefer: return=representation`: answer a write with what it changed. */
export const RETURN_REPRESENTATION = "return=representation";
+32 -18
View File
@@ -333,6 +333,30 @@ const cycleDeleteOutcomeEnvelope = dataEnvelope(
"CycleDeleteOutcomeEnvelope",
);
/** `Prefer: return=representation` on the two deletes that can remove a start. */
const preferReturnRepresentationParameter = {
name: "Prefer",
in: "header" as const,
required: false,
schema: { type: "string" as const, example: "return=representation" },
description:
"v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before.",
};
/** The 200 a delete answers with when the request preferred a representation. */
const cycleDeleteOutcomeResponse = {
description:
"v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
headers: z.object({
"Preference-Applied": z.literal("return=representation").meta({
description: "The route honoured `Prefer: return=representation`.",
}),
}),
};
const cyclePeriodResponse = z.object({
cycle: menstrualCycleDto.nullable(),
dayLog: cycleDayLogDto.nullable(),
@@ -340,7 +364,7 @@ const cyclePeriodResponse = z.object({
.nullable()
.optional()
.describe(
'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.',
'v1.43 — a later start within the period length that this start folded in (it stops being a start of its own), or null. Present on every `action: "start"` answer; null on `end`.',
),
});
@@ -787,7 +811,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
.array(cycleRestoredStart)
.optional()
.describe(
"v1.43 — starts given back because this edit cleared the flow on a start day.",
"v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. Absent on older servers.",
),
}),
),
@@ -808,16 +832,11 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Soft-delete a cycle day-log (v1.15.0)",
description:
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [preferReturnRepresentationParameter],
responses: {
"200": {
description:
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
},
"200": cycleDeleteOutcomeResponse,
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Day-log not found / not owned.",
@@ -970,16 +989,11 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Soft-delete a menstrual cycle (v1.15.0)",
description:
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [preferReturnRepresentationParameter],
responses: {
"200": {
description:
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
},
"200": cycleDeleteOutcomeResponse,
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Cycle not found / not owned.",
@@ -0,0 +1,234 @@
/**
* The cycle writes name the starts they fold in and give back (ios #116
* item 9).
*
* A start logged a few days before an existing one is the same period entered
* in the other order, so the later start is folded in. Taking the earlier one
* back gives the later one back. Both used to reach only the audit row; the
* client could refetch the grid but could not say which date moved. These
* pin the answers: `absorbed` on the start, `restored` on the PATCH that
* clears a start day's flow, and the opt-in 200 on the two deletes, with the
* 204 unchanged for a client that does not ask.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-cycle-absorbed-integration-32-bytes-min-1234567890";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-cycle-absorbed";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "cycle-absorbed",
email: "cycle-absorbed@example.test",
gender: "FEMALE",
},
});
const session = await getPrismaClient().session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
function request(
path: string,
method: string,
body?: unknown,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json", ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
async function startPeriod(date: string): Promise<{
cycle: { id: string; startDate: string } | null;
absorbed: { cycleId: string; startDate: string } | null;
}> {
const { POST } = await import("@/app/api/cycle/period/route");
const res = await POST(
request("/api/cycle/period", "POST", {
action: "start",
date,
loggedAt: `${date}T08:00:00.000Z`,
}),
);
expect(res.status).toBe(200);
return (await res.json()).data;
}
async function cycleIdOn(date: string): Promise<string> {
const row = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: date },
select: { id: true },
});
return row.id;
}
const PREFER = { prefer: "return=representation" };
describe("absorbed and restored cycle starts", () => {
it("names the later start a start folds in, and null when it folds none", async () => {
const first = await startPeriod("2026-09-17");
expect(first.absorbed).toBeNull();
const later = await cycleIdOn("2026-09-17");
const earlier = await startPeriod("2026-09-14");
expect(earlier.absorbed).toEqual({
cycleId: later,
startDate: "2026-09-17",
});
});
it("gives the folded start back on a cycle delete with Prefer, as 200", async () => {
await startPeriod("2026-09-17");
const later = await cycleIdOn("2026-09-17");
await startPeriod("2026-09-14");
const earlier = await cycleIdOn("2026-09-14");
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
const res = await DELETE(
request(`/api/cycle/cycles/${earlier}`, "DELETE", undefined, PREFER),
{ params: Promise.resolve({ id: earlier }) },
);
expect(res.status).toBe(200);
expect(res.headers.get("preference-applied")).toBe("return=representation");
expect((await res.json()).data).toEqual({
removedCycleId: earlier,
restored: [{ cycleId: later, startDate: "2026-09-17" }],
});
// The calendar shows 17 Sep again.
const live = await getPrismaClient().menstrualCycle.findMany({
where: { userId: USER_ID, deletedAt: null },
select: { startDate: true },
});
expect(live.map((c) => c.startDate)).toEqual(["2026-09-17"]);
});
it("keeps answering 204 without a body when the request does not ask", async () => {
await startPeriod("2026-09-17");
await startPeriod("2026-09-14");
const earlier = await cycleIdOn("2026-09-14");
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
const res = await DELETE(
request(`/api/cycle/cycles/${earlier}`, "DELETE"),
{ params: Promise.resolve({ id: earlier }) },
);
expect(res.status).toBe(204);
expect(res.headers.get("preference-applied")).toBeNull();
expect(await res.text()).toBe("");
});
it("answers a repeated delete with nothing removed and nothing restored", async () => {
await startPeriod("2026-09-17");
const id = await cycleIdOn("2026-09-17");
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
await DELETE(request(`/api/cycle/cycles/${id}`, "DELETE"), {
params: Promise.resolve({ id }),
});
const again = await DELETE(
request(`/api/cycle/cycles/${id}`, "DELETE", undefined, PREFER),
{ params: Promise.resolve({ id }) },
);
expect(again.status).toBe(200);
expect((await again.json()).data).toEqual({
removedCycleId: null,
restored: [],
});
});
it("gives the folded start back on a day-log delete with Prefer", async () => {
await startPeriod("2026-09-17");
const later = await cycleIdOn("2026-09-17");
await startPeriod("2026-09-14");
const earlier = await cycleIdOn("2026-09-14");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-09-14" },
select: { id: true },
});
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await DELETE(
request(`/api/cycle/day-logs/${dayLog.id}`, "DELETE", undefined, {
prefer: "handling=lenient, return=representation",
}),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
removedCycleId: earlier,
restored: [{ cycleId: later, startDate: "2026-09-17" }],
});
});
it("names the start given back when an edit clears the flow on a start day", async () => {
await startPeriod("2026-09-17");
const later = await cycleIdOn("2026-09-17");
await startPeriod("2026-09-14");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-09-14" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await PATCH(
request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "NONE" }),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(200);
const data = (await res.json()).data;
expect(data.restored).toEqual([
{ cycleId: later, startDate: "2026-09-17" },
]);
expect(data.id).toBe(dayLog.id);
});
it("answers an edit that removes no start with an empty list", async () => {
await startPeriod("2026-09-17");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-09-17" },
select: { id: true },
});
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await PATCH(
request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "HEAVY" }),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect((await res.json()).data.restored).toEqual([]);
});
});