mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(cycle): let a period logged as flow count as a period
MenstrualCycle rows were only ever written by the one-tap period boundary. Every other way of recording bleeding wrote a day-log and stopped there: the log sheet's flow chips, the bulk drain, an Apple Health export full of menstrual-flow samples. The engine reads cycle rows, so a person could log every period they had and the module still held that none had happened. No cycle length, no forecast, an empty chart, and an offer to log a first period. An Apple Health user who never found the one-tap button had the whole module dead. A first bleeding day now opens the cycle it belongs to, through one shared seam in the day-log write helper, so the single capture, the bulk drain and the Apple Health fold all infer a start the same way and the one-tap boundary's own write lands on a date that already has a cycle, which the rule declines. The rule is conservative, because a false start corrupts every length derived after it. Spotting does not open a cycle: it is as often the tail or the herald of a period as its first day. Bleeding flagged as between periods does not, since that flag exists to say so. Neither does a bleeding day within the hard physiological minimum of an existing start, which is what keeps day three of a period, or a forgotten day two, from starting a cycle of its own without needing a contiguity check. A genuinely short cycle is not inferred from flow alone; the one-tap start still opens one on any date. An edit is symmetric. Setting a flow opens the cycle; clearing it, or flagging it as between periods, removes the cycle that day opened and re-opens the one before it. A cycle that an edit can create and not undo leaves the record holding a period nothing on the day says happened. - the period route's start arm and the flow inference now share one boundary helper, so a start means the same thing however it is entered - back-dating re-anchors both neighbours, as the one-tap start does - integration: eleven cases through the real routes, of which five are about what must NOT open a cycle
This commit is contained in:
1 parent
bd6089734d
commit
3596720905
7 files changed
+684
-111
No files matched your search
@@ -23,7 +23,11 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
|
||||
import {
|
||||
ensureCycleForBleedingDay,
|
||||
removeCycleStartedOn,
|
||||
} from "@/lib/cycle/cycle-boundaries";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
|
||||
@@ -158,6 +162,26 @@ export const PATCH = apiHandler(
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
// An edit that turns a day into a first bleeding day opens the cycle it
|
||||
// starts, and an edit that takes the bleeding back off it removes the
|
||||
// cycle that day opened. Both directions go through the same shared
|
||||
// boundary work the capture route and the one-tap start use — a cycle that
|
||||
// could be created here but not undone here is how the record ends up
|
||||
// holding a period nothing on the day says happened.
|
||||
const openedCycleId = await ensureCycleForBleedingDay(
|
||||
user.id,
|
||||
existing.date,
|
||||
user.timezone ?? DEFAULT_TIMEZONE,
|
||||
row,
|
||||
);
|
||||
let removedCycleId: string | null = null;
|
||||
if (!openedCycleId && !stillBleeds(row)) {
|
||||
const removed = await prisma.$transaction((db) =>
|
||||
removeCycleStartedOn(db, user.id, existing.date),
|
||||
);
|
||||
removedCycleId = removed?.cycleId ?? null;
|
||||
}
|
||||
|
||||
await auditLog("cycle.day-log.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -167,6 +191,8 @@ export const PATCH = apiHandler(
|
||||
details: {
|
||||
dayLogId: id,
|
||||
date: existing.date,
|
||||
...(openedCycleId ? { openedCycleId } : {}),
|
||||
...(removedCycleId ? { removedCycleId } : {}),
|
||||
...overwriteDetails({
|
||||
before: {},
|
||||
after: {},
|
||||
@@ -184,6 +210,10 @@ export const PATCH = apiHandler(
|
||||
entity_type: "cycle_day_log",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
opened_cycle: openedCycleId !== null,
|
||||
removed_cycle: removedCycleId !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toCycleDayLogDTO(row));
|
||||
@@ -224,19 +254,7 @@ export const DELETE = apiHandler(
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
const openedCycle = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: existing.date },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!openedCycle) return { cycleId: null, reanchored: null };
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: openedCycle.id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
return {
|
||||
cycleId: openedCycle.id,
|
||||
reanchored: await reanchorAfterRemovedStart(db, user.id, existing.date),
|
||||
};
|
||||
return removeCycleStartedOn(db, user.id, existing.date);
|
||||
});
|
||||
|
||||
await auditLog("cycle.day-log.delete", {
|
||||
@@ -245,8 +263,8 @@ export const DELETE = apiHandler(
|
||||
details: {
|
||||
dayLogId: id,
|
||||
date: existing.date,
|
||||
...(removed.cycleId ? { removedCycleId: removed.cycleId } : {}),
|
||||
...(removed.reanchored
|
||||
...(removed ? { removedCycleId: removed.cycleId } : {}),
|
||||
...(removed?.reanchored
|
||||
? {
|
||||
reanchoredCycleId: removed.reanchored.cycleId,
|
||||
...overwriteDetails({
|
||||
@@ -271,8 +289,8 @@ export const DELETE = apiHandler(
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
removed_cycle: removed.cycleId !== null,
|
||||
reanchored_prior: removed.reanchored !== null,
|
||||
removed_cycle: removed !== null,
|
||||
reanchored_prior: removed?.reanchored != null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -327,3 +345,15 @@ function readStoredSensitive(row: {
|
||||
contraceptive: row.contraceptive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the edited day still records a period. A cleared or downgraded flow,
|
||||
* or one re-flagged as bleeding between periods, no longer anchors a cycle.
|
||||
*/
|
||||
function stillBleeds(row: {
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
}): boolean {
|
||||
if (row.intermenstrualBleeding) return false;
|
||||
return row.flow !== null && row.flow !== "NONE" && row.flow !== "SPOTTING";
|
||||
}
|
||||
@@ -37,26 +37,20 @@ import {
|
||||
dayLogSymptomInclude,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anchors a boundary write moves, carried out of the
|
||||
* transaction so the audit row can name them (C4).
|
||||
* transaction so the audit row can name them (C4). The `start` arm's own
|
||||
* anchors live with the shared boundary helper; the `end` arm adds the one
|
||||
* column it overwrites.
|
||||
*/
|
||||
interface MovedAnchors {
|
||||
priorCycleId?: string;
|
||||
/** Day keys, the shape this domain stores its dates in. */
|
||||
priorEndDateBefore?: string | null;
|
||||
priorEndDateAfter?: string | null;
|
||||
priorLengthBefore?: number | null;
|
||||
priorLengthAfter?: number | null;
|
||||
openedEndDateBefore?: string | null;
|
||||
openedEndDateAfter?: string | null;
|
||||
type PeriodMovedAnchors = MovedAnchors & {
|
||||
periodEndDateBefore?: string | null;
|
||||
periodEndDateAfter?: string | null;
|
||||
}
|
||||
};
|
||||
|
||||
async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
|
||||
@@ -97,73 +91,15 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
// 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" },
|
||||
// v1.37.0 — `endDate` and `lengthDays` are read for the audit row:
|
||||
// this write overwrites both on the prior cycle and they are the
|
||||
// dates C4 exists to name.
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
},
|
||||
});
|
||||
const moved: MovedAnchors = {};
|
||||
if (prior) {
|
||||
moved.priorCycleId = prior.id;
|
||||
moved.priorEndDateBefore = prior.endDate;
|
||||
moved.priorEndDateAfter = addDays(date, -1);
|
||||
moved.priorLengthBefore = prior.lengthDays;
|
||||
moved.priorLengthAfter = dayDiff(date, prior.startDate);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: {
|
||||
endDate: addDays(date, -1),
|
||||
lengthDays: dayDiff(date, prior.startDate),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-anchor the FOLLOWING neighbour: when this start is back-filled
|
||||
// between two existing cycles, the new cycle's end + length are already
|
||||
// known from its successor's start. Without this the inserted cycle keeps
|
||||
// endDate/lengthDays = null even though the next start exists, and the
|
||||
// iOS mirror + history surfaces read a stale open cycle (QA M-1).
|
||||
const next = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
moved.openedEndDateBefore = cycle.endDate;
|
||||
moved.openedEndDateAfter = addDays(next.startDate, -1);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
endDate: addDays(next.startDate, -1),
|
||||
lengthDays: dayDiff(next.startDate, date),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { cycleId: cycle.id as string | null, moved };
|
||||
// Close the prior open cycle, open this one, and re-anchor the following
|
||||
// neighbour when the start is back-filled between two existing cycles.
|
||||
// The same helper runs when a first bleeding day is captured as flow, so
|
||||
// a period start means one thing however it was entered.
|
||||
const { cycleId, moved } = await openCycleAt(db, user.id, date, tz);
|
||||
return {
|
||||
cycleId: cycleId as string | null,
|
||||
moved: moved as PeriodMovedAnchors,
|
||||
};
|
||||
}
|
||||
|
||||
// `end`: stamp the current cycle's periodEndDate.
|
||||
@@ -174,7 +110,7 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
select: { id: true, periodEndDate: true },
|
||||
});
|
||||
if (!current) {
|
||||
return { cycleId: null, moved: {} satisfies MovedAnchors };
|
||||
return { cycleId: null, moved: {} satisfies PeriodMovedAnchors };
|
||||
}
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: current.id },
|
||||
@@ -185,7 +121,7 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
moved: {
|
||||
periodEndDateBefore: current.periodEndDate,
|
||||
periodEndDateAfter: date,
|
||||
} satisfies MovedAnchors,
|
||||
} satisfies PeriodMovedAnchors,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user