From 4e9e8649ecb2d57ad2438564ac1dca4c89ae7064 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:03:09 +0200
Subject: [PATCH 01/18] fix(medications): record the slot a backfilled dose
names
Marking a missed dose taken from the Verlauf ledger showed the row flip to
taken and then fall straight back to missed, with the entry nowhere on the day
it was made for.
The ledger posts the displayed slot's own anchor as `scheduledFor` and means
"record THIS dose". The route resolved the slot by window band from `takenAt`
instead, which answers a different question: which slot the recording moment
falls into. Backfilling an older slot matches no band, so the write fell
through to the standalone insert and landed as an ad-hoc row anchored at the
recording moment. The named slot stayed unserved, the optimistic row was
overwritten by the authoritative refetch, and the dose sat under today with no
sign of where the user had put it.
A named instant that is a real slot of this medication now decides the binding,
validated against the same band anchors the "diesem Slot zuordnen" pin uses, so
an arbitrary client instant still records ad-hoc. A slot that lies ahead of the
take is still refused by the existing forward guard, so a late-morning dose
cannot consume the evening slot. When the naming and the clock disagree the
binding rests on the user's choice, so the row stores it as such and the read
ledger anchors it there rather than dropping back to ad-hoc; an ordinary
on-time take resolves to the same anchor either way and is untouched.
The tests drive the shipped POST and the shipped dose-history GET, and assert
the persisted row and the ledger read back from it.
---
src/app/api/medications/[id]/intake/route.ts | 47 +++
.../intake-backfill-missed-slot.test.ts | 278 ++++++++++++++++++
2 files changed, 325 insertions(+)
create mode 100644 tests/integration/intake-backfill-missed-slot.test.ts
diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts
index 4a05ff73a..0164660c2 100644
--- a/src/app/api/medications/[id]/intake/route.ts
+++ b/src/app/api/medications/[id]/intake/route.ts
@@ -314,6 +314,53 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
// stale USER_PIN when this write converges onto a previously-pinned row.
attributionSource = "AUTO";
+
+ // The client can NAME the dose it is recording, and when it does, that
+ // answer outranks the band. The Verlauf ledger's "Genommen" posts the
+ // displayed slot's own anchor, and the medication card posts its
+ // display-due slot; both mean "record THIS dose". Band attribution answers
+ // a different question — which slot the take's clock time falls into — so
+ // backfilling a slot from an earlier day matched no band, fell through to
+ // the standalone insert, and left the named slot unserved: the entry
+ // reverted to missed on the next read and the dose the user recorded was
+ // filed on the wrong day.
+ //
+ // Only a REAL slot of this medication is honoured (the same band-anchor
+ // validation the "diesem Slot zuordnen" pin runs), so an arbitrary client
+ // instant still records ad-hoc rather than parking a live row on a
+ // fabricated anchor. And only a slot that does not sit ahead of the take,
+ // by the same forward guard the convergence probe below uses — a
+ // late-morning dose must never consume the evening slot.
+ if (
+ scheduledFor !== undefined &&
+ mayConvergeOntoSuppliedSlot({
+ skipped,
+ takenAt: resolvedTakenAt,
+ suppliedSlot: incomingScheduledFor,
+ })
+ ) {
+ const namedSlot = await resolveForcedSlotForWrite({
+ userId: user.id,
+ medicationId: id,
+ userTz: user.timezone,
+ slotInstant: incomingScheduledFor,
+ });
+ // Only when the naming actually decides something the clock did not.
+ // A dose taken inside its own window resolves to the same anchor either
+ // way and stays an AUTO binding, so an ordinary on-time take is
+ // unchanged. When the two disagree the binding rests on the user's
+ // choice, which is what USER_PIN records — and the read ledger binds a
+ // USER_PIN take by its stored anchor rather than by the clock, so a
+ // backfilled dose reads under the slot it was recorded for instead of
+ // falling back to ad-hoc.
+ if (
+ namedSlot !== null &&
+ namedSlot.getTime() !== canonicalSlot?.getTime()
+ ) {
+ canonicalSlot = namedSlot;
+ attributionSource = "USER_PIN";
+ }
+ }
}
// Idempotency check (explicit key or server-side dedup window)
diff --git a/tests/integration/intake-backfill-missed-slot.test.ts b/tests/integration/intake-backfill-missed-slot.test.ts
new file mode 100644
index 000000000..9df84f04f
--- /dev/null
+++ b/tests/integration/intake-backfill-missed-slot.test.ts
@@ -0,0 +1,278 @@
+/**
+ * Backfilling a missed dose from the Verlauf ledger must record THAT slot.
+ *
+ * The ledger's "Genommen" action posts the displayed slot's own anchor as
+ * `scheduledFor` with `takenAt = now`, meaning "record this dose". The route
+ * resolved the slot by window band from `takenAt` instead, so a backfill of a
+ * slot from an earlier day matched no band, fell through to the standalone
+ * insert, and landed as an ad-hoc row anchored at the recording moment. The
+ * named slot stayed unserved: the optimistic row flipped to taken, the
+ * authoritative refetch read the slot as missed again, and the entry the user
+ * had just made was gone from the day they made it for.
+ *
+ * Everything here goes through the shipped `POST` / `GET` exports so the real
+ * `apiHandler`, the real auth resolution and the real Prisma writes answer.
+ * The assertions are on the persisted row AND on the ledger the tab reads
+ * back — the pipe between them is where the entry disappeared.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest } from "next/server";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+import { localHmAsUtc } from "@/lib/tz/local-day";
+
+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),
+}));
+
+const TZ = "Europe/Berlin";
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+let counter = 0;
+
+async function makeUser() {
+ const suffix = `backfill-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `bf-${suffix}`,
+ email: `bf-${suffix}@example.test`,
+ role: "USER",
+ timezone: TZ,
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return session;
+}
+
+/**
+ * Slot hours placed so neither dose window can contain the moment the test
+ * runs. A band spans roughly [anchor − 1 h, anchor + 4 h]; +5 h and +12 h from
+ * the current local hour keep "now" outside both on every clock, so the
+ * assertions never depend on when the suite happens to run.
+ */
+function slotHours(): { a: number; b: number } {
+ const hour = Number(
+ new Intl.DateTimeFormat("en-GB", {
+ timeZone: TZ,
+ hour: "2-digit",
+ hour12: false,
+ }).format(new Date()),
+ );
+ return { a: (hour + 5) % 24, b: (hour + 12) % 24 };
+}
+
+async function seedTwiceDaily(userId: string, hours: { a: number; b: number }) {
+ const hh = (h: number) => `${String(h).padStart(2, "0")}:00`;
+ const med = await getPrismaClient().medication.create({
+ data: {
+ userId,
+ name: "Ramipril",
+ dose: "5mg",
+ active: true,
+ startsOn: new Date(Date.now() - 30 * DAY_MS),
+ // The ledger clamps its window floor to the medication's creation, so a
+ // medication minted at test time would have no yesterday to read back.
+ createdAt: new Date(Date.now() - 30 * DAY_MS),
+ schedules: {
+ create: {
+ windowStart: hh(hours.a),
+ windowEnd: hh(hours.b),
+ timesOfDay: [hh(hours.a), hh(hours.b)],
+ daysOfWeek: null,
+ scheduleType: "SCHEDULED",
+ },
+ },
+ },
+ });
+ return med.id;
+}
+
+type Handler = (
+ request: NextRequest,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ context: { params: Promise },
+) => Promise;
+
+async function postIntake(medicationId: string, body: unknown) {
+ const { POST } = await import("@/app/api/medications/[id]/intake/route");
+ return (POST as Handler)(
+ new NextRequest(`http://localhost/api/medications/${medicationId}/intake`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ id: medicationId }) },
+ );
+}
+
+async function readLedger(medicationId: string, from: Date, to: Date) {
+ const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
+ const search = new URLSearchParams({
+ from: from.toISOString(),
+ to: to.toISOString(),
+ });
+ const response = await (GET as Handler)(
+ new NextRequest(
+ `http://localhost/api/medications/${medicationId}/dose-history?${search}`,
+ { method: "GET" },
+ ),
+ { params: Promise.resolve({ id: medicationId }) },
+ );
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ data: {
+ rows: {
+ kind: string;
+ at: string;
+ status: string;
+ intake: { takenAt: string | null } | null;
+ }[];
+ };
+ };
+ return body.data.rows;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("POST /api/medications/[id]/intake — backfilling a missed slot", () => {
+ it("records the named slot from an earlier day, not an ad-hoc row at 'now'", async () => {
+ const prisma = getPrismaClient();
+ const user = await makeUser();
+ await signIn(user.id);
+ const hours = slotHours();
+ const medicationId = await seedTwiceDaily(user.id, hours);
+
+ // Yesterday's first slot: long past its overdue tail, so the ledger reads
+ // it as missed and the kebab offers "Genommen".
+ const missedSlot = localHmAsUtc(
+ new Date(Date.now() - DAY_MS),
+ TZ,
+ hours.a,
+ 0,
+ );
+
+ const response = await postIntake(medicationId, {
+ skipped: false,
+ scheduledFor: missedSlot.toISOString(),
+ takenAt: new Date().toISOString(),
+ });
+ expect(response.status).toBe(201);
+
+ // The row lands ON the slot the user marked.
+ const rows = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id, medicationId, deletedAt: null },
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.scheduledFor.getTime()).toBe(missedSlot.getTime());
+ expect(rows[0]?.takenAt).not.toBeNull();
+ expect(rows[0]?.skipped).toBe(false);
+ });
+
+ it("the ledger reads the backfilled slot as taken, not missed", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+ const hours = slotHours();
+ const medicationId = await seedTwiceDaily(user.id, hours);
+ const missedSlot = localHmAsUtc(
+ new Date(Date.now() - DAY_MS),
+ TZ,
+ hours.a,
+ 0,
+ );
+
+ await postIntake(medicationId, {
+ skipped: false,
+ scheduledFor: missedSlot.toISOString(),
+ takenAt: new Date().toISOString(),
+ });
+
+ const rows = await readLedger(
+ medicationId,
+ new Date(Date.now() - 3 * DAY_MS),
+ new Date(),
+ );
+ const slotRow = rows.find(
+ (r) => r.kind === "slot" && r.at === missedSlot.toISOString(),
+ );
+ expect(slotRow).toBeDefined();
+ expect(slotRow?.status).toMatch(/^taken_/);
+ expect(slotRow?.intake?.takenAt).not.toBeNull();
+ // And no orphan ad-hoc row at the recording moment.
+ expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0);
+ });
+
+ it("still refuses to fill a slot that lies ahead of the take", async () => {
+ const prisma = getPrismaClient();
+ const user = await makeUser();
+ await signIn(user.id);
+ const hours = slotHours();
+ const medicationId = await seedTwiceDaily(user.id, hours);
+
+ // Tomorrow's first slot — the "late-morning dose must not consume the
+ // evening slot" invariant, stated across a day boundary so it holds
+ // whatever the clock says when the suite runs.
+ const futureSlot = localHmAsUtc(
+ new Date(Date.now() + DAY_MS),
+ TZ,
+ hours.a,
+ 0,
+ );
+
+ const response = await postIntake(medicationId, {
+ skipped: false,
+ scheduledFor: futureSlot.toISOString(),
+ takenAt: new Date().toISOString(),
+ });
+ expect(response.status).toBe(201);
+
+ const onFutureSlot = await prisma.medicationIntakeEvent.findMany({
+ where: {
+ userId: user.id,
+ medicationId,
+ scheduledFor: futureSlot,
+ deletedAt: null,
+ },
+ });
+ expect(onFutureSlot).toHaveLength(0);
+
+ const all = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: user.id, medicationId, deletedAt: null },
+ });
+ expect(all).toHaveLength(1);
+ expect(all[0]?.scheduledFor.getTime()).toBeLessThan(futureSlot.getTime());
+ });
+});
From 4563bce5b3b3e9728c3c90a2dc1f752648d9c105 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:16:17 +0200
Subject: [PATCH 02/18] fix(insights): keep impossible readings out of the
personal band
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The baseline-drift card reported a pulse in the hundreds of millions against a
usual range in the tens of thousands. Neither figure is a heart rate, and both
came from the same place.
The application declares a plausibility domain per metric, and every
interactive, import, MCP and Telegram write validates against it. The
derivation layer did not ask. So a single stored value outside that domain — a
provider unit-decode slip, or a row from a writer that never had the gate — was
folded into the day mean, carried by the median into the band, and then held
against itself as "currently". The two numbers in the sentence were wrong by
different amounts, which is why the line read as nonsense rather than as an
exaggeration.
Both readers now refuse a value the application has already declared
impossible: the per-day means behind the band, and the latest-day mean the
deviation reads. A day left with no plausible reading contributes nothing
rather than a fabricated point, and the vital drops out of the comparison — the
coverage floor already knows how to report an absent day. The rollup branch
applies the same rule to a bucket mean, since the rollup writer averages
whatever is stored. Everything downstream of `readDayMeanSeries` inherits it,
which is every consumer of the usual range: the band strip, the drift card, the
changepoint detector, readiness, trajectory, the coach read.
The card also printed raw doubles. Each figure now renders at the metric's own
decimal precision through the shared formatter, so a mean reads as a number
rather than as a float and follows the reader's number convention.
Left open, and recorded for a separate pass: the provider sync writers do not
enforce the domain on the way in, so the impossible rows keep landing.
---
.../insights/health-status-card.tsx | 22 ++-
.../__tests__/baseline-plausibility.test.ts | 115 +++++++++++++
src/lib/insights/derived/baseline.ts | 69 ++++++--
.../insights/derived/coincident-deviation.ts | 28 ++-
src/lib/measurements/value-domain.ts | 30 ++++
.../health-status-implausible-values.test.ts | 160 ++++++++++++++++++
6 files changed, 399 insertions(+), 25 deletions(-)
create mode 100644 src/lib/insights/derived/__tests__/baseline-plausibility.test.ts
create mode 100644 tests/integration/health-status-implausible-values.test.ts
diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx
index 3d7161432..dea25694c 100644
--- a/src/components/insights/health-status-card.tsx
+++ b/src/components/insights/health-status-card.tsx
@@ -3,8 +3,9 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
+import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -44,6 +45,7 @@ export function HealthStatusCard({
className,
}: HealthStatusCardProps) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
@@ -60,6 +62,14 @@ export function HealthStatusCard({
return key ? t(key) : type;
};
+ // The engines hand over raw doubles. Interpolating one straight into the
+ // sentence prints whatever floating-point produced — a pulse mean reads
+ // "68.33333333333333" — and reads in the wrong number convention for every
+ // locale that does not use a dot. Render each figure at the metric's own
+ // decimal precision, through the same formatter the rest of the app uses.
+ const metricValue = (type: string, value: number): string =>
+ fmt.number(value, metricFractionDigits(type));
+
return (
diff --git a/src/lib/insights/derived/__tests__/baseline-plausibility.test.ts b/src/lib/insights/derived/__tests__/baseline-plausibility.test.ts
new file mode 100644
index 000000000..7a83f5b61
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/baseline-plausibility.test.ts
@@ -0,0 +1,115 @@
+/**
+ * A stored value the application declares impossible is not a measurement.
+ *
+ * The personal band ("your usual range") and the deviation line that reads
+ * against it are both means over stored `Measurement.value`. Neither end
+ * checked the value against the metric's own plausibility domain, so a single
+ * impossible pulse reading — the shape a provider unit-decode slip or a writer
+ * without the range gate produces — moved the day mean, the median carried it
+ * into the band, and the card said "your pulse is above your usual range,
+ * currently 111,287,531.01 instead of the usual 36,016.75".
+ *
+ * The numbers below are shaped like the real thing: a resting-pulse series in
+ * the 60s and 70s, one impossible sample dropped into it.
+ */
+import { describe, expect, it } from "vitest";
+
+import { buildBaselineBand, dayMeansFromRows } from "../baseline";
+import { latestDayMeanFromRows } from "../coincident-deviation";
+import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
+
+const TZ = "Europe/Berlin";
+
+/** A real-shaped resting-pulse day: four readings in the 60s/70s. */
+function pulseDay(day: string, values: number[]) {
+ return values.map((value, i) => ({
+ value,
+ measuredAt: new Date(`${day}T0${6 + i}:30:00.000Z`),
+ }));
+}
+
+describe("metric plausibility domain", () => {
+ it("admits a real pulse and refuses one no heart produces", () => {
+ expect(isPlausibleMetricValue("PULSE", 68)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", 20)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", 300)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", 111287531.01)).toBe(false);
+ expect(isPlausibleMetricValue("PULSE", 36016.75)).toBe(false);
+ expect(isPlausibleMetricValue("PULSE", 19)).toBe(false);
+ });
+
+ it("passes a metric with no declared range and refuses a non-finite value", () => {
+ expect(isPlausibleMetricValue("SOMETHING_UNDECLARED", 12345)).toBe(true);
+ expect(isPlausibleMetricValue("PULSE", Number.NaN)).toBe(false);
+ expect(isPlausibleMetricValue("PULSE", Number.POSITIVE_INFINITY)).toBe(
+ false,
+ );
+ });
+});
+
+describe("per-day means behind the personal band", () => {
+ it("keeps the day mean on the real readings when one sample is impossible", () => {
+ const rows = pulseDay("2026-08-01", [66, 70, 111287531.01, 68]);
+
+ const [point] = dayMeansFromRows(rows, "PULSE");
+
+ expect(point.day).toBe("2026-08-01");
+ expect(point.mean).toBeCloseTo(68, 6);
+ });
+
+ it("drops a day whose every reading is impossible rather than inventing one", () => {
+ const rows = [
+ ...pulseDay("2026-08-01", [66, 70, 68]),
+ ...pulseDay("2026-08-02", [36016.75, 111287531.01]),
+ ];
+
+ const points = dayMeansFromRows(rows, "PULSE");
+
+ expect(points.map((p) => p.day)).toEqual(["2026-08-01"]);
+ });
+
+ it("holds the band inside the metric's own domain across a poisoned month", () => {
+ const rows: { value: number; measuredAt: Date }[] = [];
+ for (let d = 1; d <= 28; d += 1) {
+ const day = `2026-08-${String(d).padStart(2, "0")}`;
+ // A steady resting pulse with a normal day-to-day wobble.
+ rows.push(...pulseDay(day, [64 + (d % 5), 70 + (d % 3), 67]));
+ }
+ // Three days the provider decoded wrong.
+ rows.push(...pulseDay("2026-08-05", [36016.75]));
+ rows.push(...pulseDay("2026-08-17", [111287531.01]));
+ rows.push(...pulseDay("2026-08-26", [98211.5]));
+
+ const band = buildBaselineBand(
+ dayMeansFromRows(rows, "PULSE").map((p) => p.mean),
+ "PULSE",
+ );
+
+ expect(band).not.toBeNull();
+ expect(band!.center).toBeGreaterThan(40);
+ expect(band!.center).toBeLessThan(120);
+ expect(band!.low).toBeGreaterThan(20);
+ expect(band!.high).toBeLessThan(300);
+ });
+});
+
+describe("the latest-day mean the deviation line reads", () => {
+ it("means only the real readings of the most recent day", () => {
+ const rows = [
+ ...pulseDay("2026-08-01", [66, 70, 68]),
+ ...pulseDay("2026-08-02", [72, 111287531.01, 74]),
+ ];
+
+ const latest = latestDayMeanFromRows(rows, "PULSE", TZ);
+
+ expect(latest).not.toBeNull();
+ expect(latest!.day).toBe("2026-08-02");
+ expect(latest!.value).toBeCloseTo(73, 6);
+ });
+
+ it("reports nothing when the latest day holds no plausible reading", () => {
+ const rows = pulseDay("2026-08-02", [36016.75, 111287531.01]);
+
+ expect(latestDayMeanFromRows(rows, "PULSE", TZ)).toBeNull();
+ });
+});
diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts
index 8ae06c775..881ae202d 100644
--- a/src/lib/insights/derived/baseline.ts
+++ b/src/lib/insights/derived/baseline.ts
@@ -32,7 +32,10 @@ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
-import { clampDerivedLowerBound } from "@/lib/measurements/value-domain";
+import {
+ clampDerivedLowerBound,
+ isPlausibleMetricValue,
+} from "@/lib/measurements/value-domain";
import type { ProfileSex } from "@/lib/profile/sex";
import {
probeRollupCoverage,
@@ -209,6 +212,39 @@ export function buildBaselineBand(
};
}
+/**
+ * Group raw readings into per-day means, keeping only the values that sit
+ * inside the metric's own plausibility domain.
+ *
+ * Pure, and exported because this is the arithmetic the band rests on: a
+ * single stored value the application itself declares impossible drags the
+ * day's mean with it, the median then carries it into the band, and the
+ * personal "usual range" reads in the tens of thousands. Filtering here rather
+ * than at the edge of the card keeps the number wrong in exactly one place —
+ * nowhere — instead of right on one surface and wrong on the next.
+ *
+ * A day whose every reading is implausible produces no point at all. That is
+ * the honest outcome: the day contributed no measurement, and an absent day is
+ * something the coverage floor already knows how to report.
+ */
+export function dayMeansFromRows(
+ rows: readonly { value: number; measuredAt: Date }[],
+ type: MeasurementType,
+): DayMeanPoint[] {
+ const byDay = new Map();
+ for (const row of rows) {
+ if (!isPlausibleMetricValue(type, row.value)) continue;
+ const day = row.measuredAt.toISOString().slice(0, 10);
+ const acc = byDay.get(day) ?? { sum: 0, count: 0 };
+ acc.sum += row.value;
+ acc.count += 1;
+ byDay.set(day, acc);
+ }
+ return [...byDay.entries()]
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ .map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
+}
+
// ── reads ────────────────────────────────────────────────────────────
/**
@@ -237,11 +273,19 @@ export async function readDayMeanSeries(
resolved.granularity === "DAY" &&
resolved.rows.length > 0
) {
- const points = resolved.rows.map((row) => ({
- day: row.bucketStart.toISOString().slice(0, 10),
- mean: row.mean,
- }));
- return { points, source: "DAY" };
+ // A bucket mean is only as good as the rows it averaged. The rollup
+ // writer aggregates whatever is stored, so a reading outside the
+ // metric's plausibility domain rides straight through into the bucket
+ // and from there into the band. Drop those buckets on the same rule the
+ // live read applies to its rows, so the two paths agree about what
+ // counts as a measurement.
+ const points = resolved.rows
+ .filter((row) => isPlausibleMetricValue(type, row.mean))
+ .map((row) => ({
+ day: row.bucketStart.toISOString().slice(0, 10),
+ mean: row.mean,
+ }));
+ if (points.length > 0) return { points, source: "DAY" };
}
// Coverage probe said "has buckets" but the window resolved to a
// coarser tier or zero DAY rows → fall through to the live read so
@@ -264,17 +308,10 @@ export async function readDayMeanSeries(
if (rows.length === 0) {
return { points: [], source: "none" };
}
- const byDay = new Map();
- for (const row of rows) {
- const day = row.measuredAt.toISOString().slice(0, 10);
- const acc = byDay.get(day) ?? { sum: 0, count: 0 };
- acc.sum += row.value;
- acc.count += 1;
- byDay.set(day, acc);
+ const points = dayMeansFromRows(rows, type);
+ if (points.length === 0) {
+ return { points: [], source: "none" };
}
- const points: DayMeanPoint[] = [...byDay.entries()]
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
- .map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
return { points, source: "live" };
}
diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts
index 8b1bfdc18..6217971ab 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -40,6 +40,7 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
+import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -154,15 +155,36 @@ async function readLatestDayMean(
take: MAX_LATEST_DAY_ROWS,
select: { value: true, measuredAt: true },
});
- if (rows.length === 0) return null;
+ return latestDayMeanFromRows(rows, type, tz);
+}
+
+/**
+ * The pure half of the read above: pick the most recent day present in the
+ * rows and mean that day's readings.
+ *
+ * Readings outside the metric's declared plausibility domain are not means
+ * material. This side of the comparison had no gate at all, so one impossible
+ * stored value became "your pulse today" and was then held against a band the
+ * same value had already inflated — the two numbers in the sentence were both
+ * wrong, and wrong by different amounts, which is why the line read as
+ * nonsense rather than as an exaggeration. A day left with no plausible
+ * reading contributes nothing and the vital drops out of the comparison.
+ */
+export function latestDayMeanFromRows(
+ rows: readonly { value: number; measuredAt: Date }[],
+ type: MeasurementType,
+ tz: string,
+): { value: number; day: string } | null {
+ const usable = rows.filter((r) => isPlausibleMetricValue(type, r.value));
+ if (usable.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering)
// so the "today" reading is always the genuine latest, then mean its rows.
let day = "";
- for (const r of rows) {
+ for (const r of usable) {
const d = userDayKey(r.measuredAt, tz);
if (d > day) day = d;
}
- const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day);
+ const sameDay = usable.filter((r) => userDayKey(r.measuredAt, tz) === day);
return {
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
day,
diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts
index 33e913f27..fdfad09c4 100644
--- a/src/lib/measurements/value-domain.ts
+++ b/src/lib/measurements/value-domain.ts
@@ -81,6 +81,36 @@ export function isNonNegativeMetric(type: string | null | undefined): boolean {
return range !== undefined && range.min >= 0;
}
+/**
+ * True when the value sits inside the metric's declared plausibility domain —
+ * the same `VALUE_RANGES` band every interactive, import, MCP and Telegram
+ * write path validates against before storing a reading.
+ *
+ * The derivation layer needs the question answered too, and for the opposite
+ * direction. A stored value outside the domain is one the application has
+ * already declared impossible, so it is not a reading: it is a provider glitch,
+ * a unit-decode slip, or a row from a writer that never had the gate. Folding
+ * it into a mean or a median makes the statistic say something the data never
+ * did — a personal pulse band in the tens of thousands, and a "your pulse is
+ * above your usual range" line built on a number no heart has ever produced.
+ * A statistic that quietly digests an impossible input is worse than one that
+ * declines to, because it looks like an answer.
+ *
+ * Unknown types and non-finite values answer `false`-safe in opposite ways on
+ * purpose: an absent range is an absent fact, so the value passes; a NaN is
+ * never an input to anything.
+ */
+export function isPlausibleMetricValue(
+ type: string | null | undefined,
+ value: number,
+): boolean {
+ if (!Number.isFinite(value)) return false;
+ if (!type) return true;
+ const range = VALUE_RANGES[type];
+ if (range === undefined) return true;
+ return value >= range.min && value <= range.max;
+}
+
/**
* True when EVERY named metric forbids negative values (and at least one was
* named). Used by the chart, which can paint several series in one axis: a
diff --git a/tests/integration/health-status-implausible-values.test.ts b/tests/integration/health-status-implausible-values.test.ts
new file mode 100644
index 000000000..fdf4d45e6
--- /dev/null
+++ b/tests/integration/health-status-implausible-values.test.ts
@@ -0,0 +1,160 @@
+/**
+ * The Insights baseline-drift read, driven as a route against real Postgres.
+ *
+ * The unit suite pins the arithmetic; this pins the pipe. A stored pulse value
+ * the application declares impossible used to reach the card twice over — once
+ * as "currently", once through the band it inflated — and the line read
+ * "your pulse is above your usual range, currently 111,287,531.01 instead of
+ * the usual 36,016.75". The shipped `GET` export answers here, so the readers
+ * really have to apply the domain rather than a test's copy of it.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest } from "next/server";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+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),
+}));
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+/** The two numbers off the report, kept verbatim so the case stays legible. */
+const IMPOSSIBLE_PULSE = 111287531.01;
+/** A raised but real pulse, so the card has a genuine deviation to report. */
+const REAL_RAISED_PULSE = 112;
+
+let counter = 0;
+
+async function makeUser() {
+ const suffix = `hs-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `hs-${suffix}`,
+ email: `hs-${suffix}@example.test`,
+ role: "USER",
+ timezone: "Europe/Berlin",
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+/** A fortnight of ordinary readings for one metric, one per day. */
+async function seedDaily(
+ userId: string,
+ type: "PULSE" | "WEIGHT",
+ valueFor: (dayIndex: number) => number,
+) {
+ const rows = [];
+ for (let d = 14; d >= 1; d -= 1) {
+ rows.push({
+ userId,
+ type,
+ value: valueFor(d),
+ unit: type === "PULSE" ? "bpm" : "kg",
+ measuredAt: new Date(Date.now() - d * DAY_MS),
+ source: "MANUAL" as const,
+ });
+ }
+ await getPrismaClient().measurement.createMany({ data: rows });
+}
+
+async function readHealthStatus() {
+ const { GET } = await import("@/app/api/insights/health-status/route");
+ const response = await (GET as (request: NextRequest) => Promise)(
+ new NextRequest("http://localhost/api/insights/health-status", {
+ method: "GET",
+ }),
+ );
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ data: {
+ present: boolean;
+ deviations: {
+ type: string;
+ value: number;
+ low: number;
+ high: number;
+ }[];
+ };
+ };
+ return body.data;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("GET /api/insights/health-status — implausible stored values", () => {
+ it("reports today's real pulse, not the impossible row beside it", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+ // Two banded vitals — the coincident engine needs a pair before it reports.
+ await seedDaily(user.id, "PULSE", (d) => 64 + (d % 5));
+ await seedDaily(user.id, "WEIGHT", (d) => 80 + (d % 3) * 0.2);
+ // Today: a genuinely raised pulse — so the card has something real to say —
+ // and beside it one impossible reading, the shape a provider unit-decode
+ // slip leaves behind.
+ await getPrismaClient().measurement.createMany({
+ data: [
+ {
+ userId: user.id,
+ type: "PULSE",
+ value: REAL_RAISED_PULSE,
+ unit: "bpm",
+ measuredAt: new Date(Date.now() - 120_000),
+ source: "MANUAL",
+ },
+ {
+ userId: user.id,
+ type: "PULSE",
+ value: IMPOSSIBLE_PULSE,
+ unit: "bpm",
+ measuredAt: new Date(Date.now() - 60_000),
+ source: "MANUAL",
+ },
+ ],
+ });
+
+ const data = await readHealthStatus();
+
+ const pulse = data.deviations.find((d) => d.type === "PULSE");
+ // The deviation has to be there — otherwise this asserts nothing.
+ expect(pulse).toBeDefined();
+ expect(pulse!.value).toBeCloseTo(REAL_RAISED_PULSE, 6);
+ expect(pulse!.high).toBeLessThan(300);
+ expect(pulse!.low).toBeGreaterThanOrEqual(20);
+ // And the impossible number itself never reaches the wire.
+ expect(JSON.stringify(data)).not.toContain(String(IMPOSSIBLE_PULSE));
+ });
+});
From dfd9b4679b4f24d4fd63eb6e08f366fc978d933c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:24:13 +0200
Subject: [PATCH 03/18] fix(insights): stop making claims about today out of
older readings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hero line said a pulse was well above its 30-day average today. Nothing
had measured a pulse for five days. The day-signals card flagged the same
reading, and the baseline-drift card said the value was above the usual range,
present tense, on a day that held no reading at all.
One class, and it sits in the data preparation rather than in any of the
sentences. Each surface was handed "the freshest stored value" with no notion
of when it was taken, so each of them assumed now. The age was reachable — the
aggregates carry `newestDaysAgo` inside their coverage object — but it read as
a statistics footnote rather than as the fact that governs the tense of every
sentence about the metric, and none of the three consulted it.
A reading is material for a claim about today when it comes from today or from
yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10
is the same reading it was half an hour earlier. Two is not, because by then a
whole day has passed with nothing in it and the sentence would be describing a
gap.
The rule now lives beside the data. The vital deviations carry the age of the
reading they were computed from, and the two deterministic consumers apply it:
the day-signals flag counts only current vitals toward its count, and the
baseline-drift card surfaces only current deviations. Stale vitals stay in the
list with their age, so the anatomy view still shows them and nothing silently
disappears; the dated level shifts were never today claims and are untouched.
For the narrated surfaces the same fact is stamped onto every metric block of
the Coach snapshot before anything reads it, so the age travels with the
numbers into the hero, the briefing, the Coach reply and the retrieval tools
instead of each of them guessing. A block narrated off a stale series also
lands in the wide event, which is where it should have been visible all along.
---
.../coincident-deviation-card.test.tsx | 1 +
src/lib/ai/coach/snapshot-freshness.ts | 87 +++++++++++
src/lib/ai/coach/snapshot.ts | 14 ++
.../insights/__tests__/health-status.test.ts | 2 +
.../__tests__/measurement-freshness.test.ts | 138 ++++++++++++++++++
.../insights/derived/coincident-deviation.ts | 31 +++-
src/lib/insights/health-status.ts | 11 +-
src/lib/insights/measurement-freshness.ts | 57 ++++++++
8 files changed, 339 insertions(+), 2 deletions(-)
create mode 100644 src/lib/ai/coach/snapshot-freshness.ts
create mode 100644 src/lib/insights/__tests__/measurement-freshness.test.ts
create mode 100644 src/lib/insights/measurement-freshness.ts
diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
index 17ed6ec87..7e123c875 100644
--- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx
+++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx
@@ -37,6 +37,7 @@ function deviation(
high: 62,
outside,
direction: outside ? "above" : "in",
+ daysAgo: 0,
};
}
diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts
new file mode 100644
index 000000000..670d365be
--- /dev/null
+++ b/src/lib/ai/coach/snapshot-freshness.ts
@@ -0,0 +1,87 @@
+/**
+ * Stamp every metric block in the Coach snapshot with the age of its freshest
+ * reading.
+ *
+ * The narrated surfaces — the dashboard hero line, the daily briefing, the
+ * Coach reply — are written from this snapshot. Each metric block carried its
+ * aggregates and a dated timeline, and nothing else: no statement of when the
+ * series actually ends. Reading it, the newest row looks like now, and the
+ * prose came out as "your pulse is well above your 30-day average today" on a
+ * day whose pulse had not been measured for the better part of a week.
+ *
+ * The age was already in the block, buried one level down in the coverage
+ * object as `newestDaysAgo`, where it read as a statistics footnote rather
+ * than as the fact that governs the tense of every sentence about the metric.
+ * This lifts it to the block's own surface and states the consequence
+ * directly, so the answer arrives with the data instead of depending on the
+ * reader working it out.
+ *
+ * Pure and in-place — the snapshot is a plain record by the time it gets here.
+ */
+import {
+ TODAY_CLAIM_MAX_AGE_DAYS,
+ isCurrentForTodayClaim,
+} from "@/lib/insights/measurement-freshness";
+
+/** When a metric block's series actually ends, and what may be said about it. */
+export interface SnapshotAsOf {
+ /** Whole days between the freshest reading and today. `0` is today. */
+ daysAgo: number;
+ /** True when the freshest reading is from today. */
+ isToday: boolean;
+ /**
+ * False when the freshest reading is too old to back a present-tense
+ * sentence. The block's numbers are still true — they are just true about
+ * the day they were taken on, and must be stated with that day.
+ */
+ currentForTodayClaims: boolean;
+}
+
+function readNewestDaysAgo(block: unknown): number | null {
+ if (typeof block !== "object" || block === null) return null;
+ const aggregate = (block as { aggregate?: unknown }).aggregate;
+ if (typeof aggregate !== "object" || aggregate === null) return null;
+ const coverage = (aggregate as { coverage?: unknown }).coverage;
+ if (typeof coverage !== "object" || coverage === null) return null;
+ const newestDaysAgo = (coverage as { newestDaysAgo?: unknown }).newestDaysAgo;
+ return typeof newestDaysAgo === "number" && Number.isFinite(newestDaysAgo)
+ ? newestDaysAgo
+ : null;
+}
+
+/**
+ * Build the `asOf` stamp for one reading age. Exported so the rule is testable
+ * without assembling a snapshot.
+ */
+export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf {
+ return {
+ daysAgo,
+ isToday: daysAgo === 0,
+ currentForTodayClaims: isCurrentForTodayClaim(daysAgo),
+ };
+}
+
+/**
+ * Attach `asOf` to every snapshot block whose aggregate reports a freshest
+ * reading. Blocks with no coverage (narrative memory, plans, the reference
+ * grounding table) are untouched — they carry no measurement to date.
+ *
+ * Returns the metric keys that were stamped stale, so the caller can annotate
+ * them: a briefing narrated off a week-old series is worth seeing in the wide
+ * event, not just in the reader's confusion.
+ */
+export function annotateSnapshotFreshness(
+ snapshot: Record,
+): string[] {
+ const stale: string[] = [];
+ for (const [key, block] of Object.entries(snapshot)) {
+ const daysAgo = readNewestDaysAgo(block);
+ if (daysAgo === null) continue;
+ const asOf = asOfFromDaysAgo(daysAgo);
+ (block as Record).asOf = asOf;
+ if (!asOf.currentForTodayClaims) stale.push(key);
+ }
+ return stale;
+}
+
+export { TODAY_CLAIM_MAX_AGE_DAYS };
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index ca34a5df1..1b4473dfc 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -29,6 +29,7 @@ import type { SleepStageRow } from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
+import { annotateSnapshotFreshness } from "./snapshot-freshness";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
@@ -1209,6 +1210,19 @@ async function buildCoachSnapshotImpl(
// side of the prompt.
const compactSnapshot = compactSections(snapshot);
+ // Date the end of every metric series before anything narrates it. Without
+ // this each block reads as if its newest number were taken just now, and the
+ // hero line said "today" about a metric last measured five days earlier. The
+ // stamp sits on the block itself so the age travels with the numbers into
+ // every surface the snapshot feeds — hero, briefing, Coach reply, tools.
+ const staleBlocks = annotateSnapshotFreshness(compactSnapshot);
+ if (staleBlocks.length > 0) {
+ annotate({
+ action: { name: "coach.snapshot.stale_blocks" },
+ meta: { blocks: staleBlocks.sort() },
+ });
+ }
+
// v1.7.0 — assembled-snapshot soft cap. Enabling every cluster at a
// long window can balloon the prompt; degrade progressively by
// reverse cluster priority until the serialised size fits
diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts
index f0750a2bc..a932d476b 100644
--- a/src/lib/insights/__tests__/health-status.test.ts
+++ b/src/lib/insights/__tests__/health-status.test.ts
@@ -7,6 +7,7 @@ import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
function vital(
outside: boolean,
direction: VitalDeviation["direction"],
+ daysAgo = 0,
): VitalDeviation {
return {
type: "RESTING_HEART_RATE",
@@ -16,6 +17,7 @@ function vital(
high: 65,
outside,
direction,
+ daysAgo,
};
}
diff --git a/src/lib/insights/__tests__/measurement-freshness.test.ts b/src/lib/insights/__tests__/measurement-freshness.test.ts
new file mode 100644
index 000000000..4d77ad0d8
--- /dev/null
+++ b/src/lib/insights/__tests__/measurement-freshness.test.ts
@@ -0,0 +1,138 @@
+/**
+ * A reading from five days ago must not produce a sentence about today.
+ *
+ * Two surfaces said it out loud — the day-signals card ("one of your vitals is
+ * outside its usual range today") and the baseline-drift card ("your pulse is
+ * above your usual range") — and the narrated hero line said it in prose. One
+ * class: the freshest stored reading reached each of them with no notion of
+ * when it was taken, so every one of them assumed now.
+ *
+ * These cover the shared rule and the two deterministic consumers. The
+ * narrated surfaces are covered by the snapshot stamp beside them.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ TODAY_CLAIM_MAX_AGE_DAYS,
+ dayKeyAgeInDays,
+ isCurrentForTodayClaim,
+} from "../measurement-freshness";
+import { summariseHealthStatus } from "../health-status";
+import { classifyDeviation } from "../derived/coincident-deviation";
+import {
+ annotateSnapshotFreshness,
+ asOfFromDaysAgo,
+} from "@/lib/ai/coach/snapshot-freshness";
+
+describe("the today-claim window", () => {
+ it("admits today and yesterday and refuses anything older", () => {
+ expect(isCurrentForTodayClaim(0)).toBe(true);
+ expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS)).toBe(true);
+ expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS + 1)).toBe(false);
+ expect(isCurrentForTodayClaim(5)).toBe(false);
+ });
+
+ it("treats an unknown age as not current", () => {
+ expect(isCurrentForTodayClaim(null)).toBe(false);
+ expect(isCurrentForTodayClaim(undefined)).toBe(false);
+ expect(isCurrentForTodayClaim(Number.NaN)).toBe(false);
+ expect(isCurrentForTodayClaim(Number.POSITIVE_INFINITY)).toBe(false);
+ expect(isCurrentForTodayClaim(-1)).toBe(false);
+ });
+
+ it("counts whole calendar days between two local day keys", () => {
+ expect(dayKeyAgeInDays("2026-08-07", "2026-08-07")).toBe(0);
+ expect(dayKeyAgeInDays("2026-08-06", "2026-08-07")).toBe(1);
+ expect(dayKeyAgeInDays("2026-08-02", "2026-08-07")).toBe(5);
+ // Across a month end and across a DST change in the reader's zone.
+ expect(dayKeyAgeInDays("2026-07-31", "2026-08-07")).toBe(7);
+ expect(dayKeyAgeInDays("2026-10-24", "2026-10-26")).toBe(2);
+ expect(dayKeyAgeInDays("not-a-day", "2026-08-07")).toBeNull();
+ });
+});
+
+describe("the baseline-drift card's deviations", () => {
+ /** A pulse well above its band, taken `daysAgo` days ago. */
+ const raisedPulse = (daysAgo: number) =>
+ classifyDeviation("PULSE", 96, 58, 74, 66, daysAgo);
+
+ it("carries a deviation measured today", () => {
+ const summary = summariseHealthStatus([raisedPulse(0)], []);
+
+ expect(summary.present).toBe(true);
+ expect(summary.deviations.map((d) => d.type)).toEqual(["PULSE"]);
+ });
+
+ it("says nothing about a vital last measured five days ago", () => {
+ const summary = summariseHealthStatus([raisedPulse(5)], []);
+
+ expect(summary.deviations).toEqual([]);
+ expect(summary.present).toBe(false);
+ });
+
+ it("keeps a dated level shift, which was never a today claim", () => {
+ const summary = summariseHealthStatus(
+ [raisedPulse(5)],
+ [
+ {
+ metric: "RESTING_HEART_RATE",
+ breakDate: "2026-07-20",
+ beforeMean: 54,
+ afterMean: 61,
+ direction: "up",
+ magnitude: 2.1,
+ },
+ ],
+ );
+
+ expect(summary.deviations).toEqual([]);
+ expect(summary.shifts).toHaveLength(1);
+ expect(summary.present).toBe(true);
+ });
+});
+
+describe("the Coach snapshot stamp", () => {
+ it("states the age and the consequence on the block itself", () => {
+ expect(asOfFromDaysAgo(0)).toEqual({
+ daysAgo: 0,
+ isToday: true,
+ currentForTodayClaims: true,
+ });
+ expect(asOfFromDaysAgo(5)).toEqual({
+ daysAgo: 5,
+ isToday: false,
+ currentForTodayClaims: false,
+ });
+ });
+
+ it("stamps every metric block and names the stale ones", () => {
+ const snapshot: Record = {
+ pulse: {
+ aggregate: { avg30: 66, coverage: { count: 40, newestDaysAgo: 5 } },
+ timeline: { recent: [] },
+ },
+ weight: {
+ aggregate: { latest: 81.4, coverage: { count: 30, newestDaysAgo: 0 } },
+ },
+ // No measurement to date — untouched.
+ memory: { headline: "steady month" },
+ scope: { sources: ["pulse", "weight"] },
+ };
+
+ const stale = annotateSnapshotFreshness(snapshot);
+
+ expect(stale).toEqual(["pulse"]);
+ expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({
+ daysAgo: 5,
+ isToday: false,
+ currentForTodayClaims: false,
+ });
+ expect((snapshot.weight as { asOf: unknown }).asOf).toEqual({
+ daysAgo: 0,
+ isToday: true,
+ currentForTodayClaims: true,
+ });
+ expect(snapshot.memory).toEqual({ headline: "steady month" });
+ expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] });
+ });
+});
diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts
index 6217971ab..152c4401f 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -41,6 +41,10 @@ import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
+import {
+ dayKeyAgeInDays,
+ isCurrentForTodayClaim,
+} from "@/lib/insights/measurement-freshness";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -70,6 +74,16 @@ export interface VitalDeviation {
outside: boolean;
/** "above" / "below" the band, or "in" when inside. */
direction: "above" | "below" | "in";
+ /**
+ * Whole days between the reading this standing was computed from and the
+ * caller's own local today. `0` is today, `1` yesterday.
+ *
+ * The value used to be presented with no age at all, so a vital last taken
+ * five days ago was narrated as "outside its range today" on a day that
+ * carried no reading. Consumers making a present-tense claim check this;
+ * `contributing` below already does.
+ */
+ daysAgo: number;
}
export interface CoincidentDeviationValue {
@@ -102,6 +116,7 @@ export function classifyDeviation(
low: number,
high: number,
center: number,
+ daysAgo: number = 0,
): VitalDeviation {
const above = value > high;
const below = value < low;
@@ -113,6 +128,7 @@ export function classifyDeviation(
high,
outside: above || below,
direction: above ? "above" : below ? "below" : "in",
+ daysAgo,
};
}
@@ -210,6 +226,9 @@ export async function computeCoincidentDeviation(
let latestDay = "";
let anyDaySource = false;
let maxHistoryDays = 0;
+ // The reader's own calendar day — the anchor every vital's age is measured
+ // against, so "today" means the day the reader is having.
+ const todayKey = userDayKey(now, tz);
// Fan the baseline engine across the supported vitals. Each baseline read
// shares the one coverage probe (no per-vital re-probe).
@@ -235,6 +254,7 @@ export async function computeCoincidentDeviation(
baseline.value.low,
baseline.value.high,
baseline.value.center,
+ dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY,
),
);
}
@@ -263,7 +283,16 @@ export async function computeCoincidentDeviation(
});
}
- const contributing = vitals.filter((v) => v.outside);
+ // Everything this flag says is about TODAY — the card is literally called
+ // "signals of the day" and every line it renders is present tense. A vital
+ // whose freshest reading is older than that cannot contribute to it: the
+ // reading describes the day it was taken on, and saying otherwise tells the
+ // reader something about a day on which nothing was measured. Stale vitals
+ // stay in `vitals` with their age, so the anatomy view still lists them and
+ // nothing silently disappears.
+ const contributing = vitals.filter(
+ (v) => v.outside && isCurrentForTodayClaim(v.daysAgo),
+ );
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts
index 419f7dbc1..a1dc31092 100644
--- a/src/lib/insights/health-status.ts
+++ b/src/lib/insights/health-status.ts
@@ -17,6 +17,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
+import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
/** One vital sitting outside its personal band today. */
export interface HealthStatusDeviation {
@@ -58,7 +59,15 @@ export function summariseHealthStatus(
shifts: readonly ChangepointSignal[],
): HealthStatusSummary {
const deviations: HealthStatusDeviation[] = vitals
- .filter((v) => v.outside && v.direction !== "in")
+ // Present tense, so present readings only. "Your pulse is above your usual
+ // range" describes now; a reading from last week describes last week, and
+ // the card carried no date to say which. A vital whose freshest reading is
+ // outside the claim window is left out rather than dressed as current —
+ // the changepoint shifts below are dated by construction and unaffected.
+ .filter(
+ (v) =>
+ v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo),
+ )
.map((v) => ({
type: v.type,
value: v.value,
diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts
new file mode 100644
index 000000000..54068b1ba
--- /dev/null
+++ b/src/lib/insights/measurement-freshness.ts
@@ -0,0 +1,57 @@
+/**
+ * How old a reading may be before "today" stops being true about it.
+ *
+ * Several surfaces make present-tense statements out of the freshest stored
+ * reading: the day-signals card ("one of your vitals is outside its usual
+ * range today"), the baseline-drift card ("your pulse is above your usual
+ * range"), and the narrated hero line on the dashboard. Every one of them was
+ * handed "the latest value" with no notion of when it was taken, so a reading
+ * from last week was narrated as this morning's — the reader is told something
+ * about a day on which nothing was measured at all.
+ *
+ * The answer belongs beside the data, not beside each sentence. A reading is
+ * material for a claim about today when it comes from today or from yesterday;
+ * anything older is history, and history is stated with its date.
+ *
+ * The window is deliberately one day rather than zero. A vital taken at 23:40
+ * and read at 00:10 is the same reading it was thirty minutes earlier, and a
+ * morning weigh-in narrated that evening is still current. Two days is not:
+ * by then a day has passed with no reading in it, and the sentence would be
+ * describing a gap.
+ */
+
+/** Whole days of age a reading may carry and still support a present claim. */
+export const TODAY_CLAIM_MAX_AGE_DAYS = 1;
+
+/**
+ * Whether a reading of this age may back a present-tense statement.
+ *
+ * An absent, negative or non-finite age answers `false`. A missing age is not
+ * a fresh one, and it is exactly the case that produced the wrong sentence:
+ * nothing knew how old the value was, so everything assumed it was new.
+ */
+export function isCurrentForTodayClaim(
+ daysAgo: number | null | undefined,
+): boolean {
+ return (
+ typeof daysAgo === "number" &&
+ Number.isFinite(daysAgo) &&
+ daysAgo >= 0 &&
+ daysAgo <= TODAY_CLAIM_MAX_AGE_DAYS
+ );
+}
+
+/**
+ * Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`).
+ *
+ * Both keys are already resolved in the reader's own timezone by the caller,
+ * so the arithmetic is plain calendar subtraction — parsing them as UTC
+ * midnights keeps it free of any second zone conversion. A malformed key
+ * answers `null` rather than a fabricated distance.
+ */
+export function dayKeyAgeInDays(then: string, today: string): number | null {
+ const a = Date.parse(`${then}T00:00:00.000Z`);
+ const b = Date.parse(`${today}T00:00:00.000Z`);
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
+ return Math.round((b - a) / 86_400_000);
+}
From d97e3c0976affdeb4d082d0a71788dd076ef7026 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:30:10 +0200
Subject: [PATCH 04/18] fix(measurements): store a hand-entered night in the
unit the field asks for
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.
Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.
Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.
The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.
The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.
`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
---
scripts/diagnose-manual-sleep-hours.ts | 103 ++++++++++++
.../measurements/measurement-form.tsx | 25 ++-
src/lib/measurements/entry-units.ts | 73 +++++++++
.../sleep-entry-hours-round-trip.test.ts | 153 ++++++++++++++++++
4 files changed, 351 insertions(+), 3 deletions(-)
create mode 100644 scripts/diagnose-manual-sleep-hours.ts
create mode 100644 src/lib/measurements/entry-units.ts
create mode 100644 tests/integration/sleep-entry-hours-round-trip.test.ts
diff --git a/scripts/diagnose-manual-sleep-hours.ts b/scripts/diagnose-manual-sleep-hours.ts
new file mode 100644
index 000000000..38a4b51a9
--- /dev/null
+++ b/scripts/diagnose-manual-sleep-hours.ts
@@ -0,0 +1,103 @@
+/**
+ * Read-only diagnostic for hand-entered sleep rows stored in the wrong unit.
+ *
+ * `SLEEP_DURATION` is stored in minutes. The manual entry field asked for
+ * hours and sent the typed number through unconverted, so a night entered as
+ * 7.5 was filed as seven and a half MINUTES. Nothing objected: a single sleep
+ * STAGE really can be that short, so the value sits inside the column's
+ * plausibility band and looks like an ordinary row.
+ *
+ * That ambiguity is why this reports and does not repair. A stage segment of
+ * eight minutes and a night typed as eight hours are the same number in the
+ * same column, and only the person who entered it knows which it was. What CAN
+ * be narrowed is the population: a MANUAL, whole-night row (no sleep stage) is
+ * one somebody typed into the field, and a typed night shorter than the
+ * longest plausible hours entry is almost certainly hours that were read as
+ * minutes.
+ *
+ * The output lists the candidates with their date, stored value and the night
+ * they would have been if the number was hours, so the maintainer can decide
+ * per row. Nothing is written.
+ *
+ * Run (inside the app container, which has DATABASE_URL):
+ * pnpm dlx tsx scripts/diagnose-manual-sleep-hours.ts
+ */
+import "dotenv/config";
+
+import { prisma } from "@/lib/db";
+import { MINUTES_PER_HOUR } from "@/lib/measurements/entry-units";
+
+/**
+ * Longest night anybody types into an hours field. A stored value at or below
+ * this reads as hours; above it, the number can only ever have been minutes.
+ */
+const MAX_PLAUSIBLE_HOURS = 24;
+
+async function main(): Promise {
+ const suspects = await prisma.measurement.findMany({
+ where: {
+ type: "SLEEP_DURATION",
+ source: "MANUAL",
+ deletedAt: null,
+ // Whole-night rows only — a staged row comes from a sync source, never
+ // from the entry field.
+ sleepStage: null,
+ value: { gt: 0, lte: MAX_PLAUSIBLE_HOURS },
+ },
+ orderBy: [{ userId: "asc" }, { measuredAt: "asc" }],
+ select: {
+ id: true,
+ userId: true,
+ value: true,
+ measuredAt: true,
+ createdAt: true,
+ },
+ });
+
+ if (suspects.length === 0) {
+ console.log(
+ "No hand-entered sleep rows in the hours-shaped band. Nothing to review.",
+ );
+ return;
+ }
+
+ const byUser = new Map();
+ for (const row of suspects) {
+ const bucket = byUser.get(row.userId);
+ if (bucket) bucket.push(row);
+ else byUser.set(row.userId, [row]);
+ }
+
+ console.log(
+ `${suspects.length} hand-entered sleep row(s) across ${byUser.size} record(s) ` +
+ `store a value of ${MAX_PLAUSIBLE_HOURS} or less.\n` +
+ `Each is either a very short recorded sleep, or hours that were read as minutes.\n`,
+ );
+
+ for (const [userId, rows] of byUser) {
+ console.log(`record ${userId} — ${rows.length} row(s)`);
+ for (const row of rows) {
+ const asHours = row.value * MINUTES_PER_HOUR;
+ console.log(
+ ` ${row.measuredAt.toISOString().slice(0, 10)} id=${row.id} ` +
+ `stored=${row.value} min if it was hours=${asHours} min ` +
+ `(${(asHours / MINUTES_PER_HOUR).toFixed(2)} h) ` +
+ `entered=${row.createdAt.toISOString().slice(0, 10)}`,
+ );
+ }
+ console.log("");
+ }
+
+ console.log(
+ "Nothing was changed. Correct a row from the record list, or delete and re-enter it.",
+ );
+}
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(() => {
+ void prisma.$disconnect();
+ });
diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx
index 11e814478..93d1da4f5 100644
--- a/src/components/measurements/measurement-form.tsx
+++ b/src/components/measurements/measurement-form.tsx
@@ -26,6 +26,10 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
+import {
+ entryValueToCanonical,
+ parseDecimalEntry,
+} from "@/lib/measurements/entry-units";
import {
invalidateKeys,
measurementDependentKeys,
@@ -343,7 +347,17 @@ export function MeasurementForm({
// identity path — `fromDisplay` returns the typed number unchanged, so
// the payload is byte-identical to before. Round only the converted
// imperial value so 210 lb stores as 95.25 kg, not 95.25439770….
- const typed = parseFloat(value);
+ //
+ // A second boundary sits beside it: a few types are stored in a unit
+ // the form does not ask for. Sleep is entered in hours and stored in
+ // minutes, and sending the typed number through unconverted filed a
+ // 7.5-hour night as seven and a half minutes — inside the column's
+ // plausibility band, so nothing objected.
+ const typed = parseDecimalEntry(value);
+ if (typed === null) {
+ setError(t("measurements.saveError"));
+ return;
+ }
let canonicalValue = typed;
if (unitDisplay.isTransformed(type)) {
const inverted = unitDisplay.fromDisplay(type, typed);
@@ -352,6 +366,7 @@ export function MeasurementForm({
? Math.round(inverted * 100) / 100
: inverted;
}
+ canonicalValue = entryValueToCanonical(type, canonicalValue);
await apiPost("/api/measurements", {
type,
value: canonicalValue,
@@ -533,9 +548,13 @@ export function MeasurementForm({
>
setValue(e.target.value)}
placeholder={
diff --git a/src/lib/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts
new file mode 100644
index 000000000..147886507
--- /dev/null
+++ b/src/lib/measurements/entry-units.ts
@@ -0,0 +1,73 @@
+/**
+ * Where the entry form asks for one unit and the column stores another.
+ *
+ * This is not the metric/imperial display transform in `display-transform.ts`:
+ * that one follows a preference and is the identity for anyone on metric. This
+ * one holds regardless of preference, because the storage unit was chosen for
+ * the sync sources rather than for the person typing.
+ *
+ * Sleep is the case. `SLEEP_DURATION` is stored in minutes — every provider
+ * delivers minutes, and a per-stage segment is only a few of them — while a
+ * person logging a night by hand thinks in hours and the field says so. The
+ * form sent the typed number straight through, so a night entered as 7.5 was
+ * stored as seven and a half MINUTES and read back on the list as eight
+ * minutes of sleep. Nothing rejected it: seven and a half minutes is inside
+ * the column's plausibility band, because a single sleep STAGE really can be
+ * that short.
+ *
+ * The storage unit stays minutes. The conversion belongs at the entry
+ * boundary, in one place, with the inverse beside it so a future editing
+ * surface cannot land on a different answer.
+ */
+
+export const MINUTES_PER_HOUR = 60;
+
+/**
+ * Entry unit → canonical unit multiplier, per measurement type. A type absent
+ * from this table enters in the unit it is stored in.
+ */
+const ENTRY_TO_CANONICAL_FACTOR: Readonly> = {
+ SLEEP_DURATION: MINUTES_PER_HOUR,
+};
+
+/** Whether the type's entry unit differs from the unit its column stores. */
+export function hasEntryUnitConversion(type: string): boolean {
+ return type in ENTRY_TO_CANONICAL_FACTOR;
+}
+
+/**
+ * Convert a value as typed in the entry form to the unit the column stores.
+ *
+ * The result is rounded to the nearest whole canonical unit for a converted
+ * type: 7.3 hours is 438 minutes, and floating-point multiplication would
+ * otherwise store 437.99999999999994 for it. An unconverted type is returned
+ * untouched, so no existing metric changes shape.
+ */
+export function entryValueToCanonical(type: string, entered: number): number {
+ const factor = ENTRY_TO_CANONICAL_FACTOR[type];
+ if (factor === undefined || !Number.isFinite(entered)) return entered;
+ return Math.round(entered * factor);
+}
+
+/** The inverse: a stored value expressed in the unit the entry form asks for. */
+export function canonicalValueToEntry(type: string, canonical: number): number {
+ const factor = ENTRY_TO_CANONICAL_FACTOR[type];
+ if (factor === undefined || !Number.isFinite(canonical)) return canonical;
+ return canonical / factor;
+}
+
+/**
+ * Read a number out of an entry field, accepting the decimal comma.
+ *
+ * Most of the application's readers are German, and "7,5" is how they write
+ * seven and a half. `parseFloat` reads that as 7 and discards the rest without
+ * complaint — the worst shape of wrong, since the entry looks accepted.
+ * Returns `null` for anything that is not a number, so the caller decides what
+ * an empty field means rather than inheriting a `NaN`.
+ */
+export function parseDecimalEntry(raw: string): number | null {
+ const trimmed = raw.trim().replace(",", ".");
+ if (trimmed === "") return null;
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) ? parsed : null;
+}
diff --git a/tests/integration/sleep-entry-hours-round-trip.test.ts b/tests/integration/sleep-entry-hours-round-trip.test.ts
new file mode 100644
index 000000000..9389eb742
--- /dev/null
+++ b/tests/integration/sleep-entry-hours-round-trip.test.ts
@@ -0,0 +1,153 @@
+/**
+ * A night typed as hours must come back as that many hours.
+ *
+ * The manual sleep field says hours and the column stores minutes, and the
+ * form sent the typed number through unchanged: a 7.5-hour night was filed as
+ * seven and a half MINUTES. Nothing rejected it, because a single sleep stage
+ * really can be that short, so the entry looked accepted and the list showed
+ * eight minutes of sleep.
+ *
+ * Both ends were already right on their own — the field's label and the list's
+ * duration formatter. The assembly between them was what dropped the factor,
+ * so this runs the whole of it: the form's conversion, the shipped POST, the
+ * stored row, the shipped GET, and the formatter the list paints with.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest } from "next/server";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+import {
+ entryValueToCanonical,
+ parseDecimalEntry,
+} from "@/lib/measurements/entry-units";
+import { formatDurationMinutes } from "@/lib/i18n/duration";
+
+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),
+}));
+
+let counter = 0;
+
+async function makeUser() {
+ const suffix = `sleep-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `sl-${suffix}`,
+ email: `sl-${suffix}@example.test`,
+ role: "USER",
+ timezone: "Europe/Berlin",
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+/** Exactly what the form's submit path does with what the reader typed. */
+function submitPathValue(type: string, typedInTheField: string): number {
+ const typed = parseDecimalEntry(typedInTheField);
+ expect(typed).not.toBeNull();
+ return entryValueToCanonical(type, typed!);
+}
+
+async function postMeasurement(body: unknown) {
+ const { POST } = await import("@/app/api/measurements/route");
+ return (POST as (request: NextRequest) => Promise)(
+ new NextRequest("http://localhost/api/measurements", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+/** The English catalogue's duration strings, as the list renders them. */
+const t = (key: string, params?: Record): string =>
+ key === "common.durationHoursMinutes"
+ ? `${params?.hours} h ${params?.minutes} min`
+ : `${params?.minutes} min`;
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("manual sleep entry — hours in, hours out", () => {
+ it.each([
+ ["8", 480, "8 h 0 min"],
+ ["7.5", 450, "7 h 30 min"],
+ // The decimal comma most of the readers actually type.
+ ["7,5", 450, "7 h 30 min"],
+ ["6,25", 375, "6 h 15 min"],
+ ])(
+ "a night typed as %s reads back as %s minutes (%s)",
+ async (typedInTheField, expectedMinutes, expectedLabel) => {
+ const user = await makeUser();
+ await signIn(user.id);
+ const measuredAt = new Date(Date.now() - 3_600_000).toISOString();
+
+ const response = await postMeasurement({
+ type: "SLEEP_DURATION",
+ value: submitPathValue("SLEEP_DURATION", typedInTheField),
+ measuredAt,
+ });
+ expect(response.status).toBe(201);
+
+ // The stored row, in the canonical unit.
+ const rows = await getPrismaClient().measurement.findMany({
+ where: { userId: user.id, type: "SLEEP_DURATION", deletedAt: null },
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.value).toBe(expectedMinutes);
+
+ // And what the reader is shown when the list paints that row.
+ expect(formatDurationMinutes(rows[0]!.value, t)).toBe(expectedLabel);
+ },
+ );
+
+ it("leaves a metric with no entry conversion exactly as typed", async () => {
+ const user = await makeUser();
+ await signIn(user.id);
+
+ const response = await postMeasurement({
+ type: "WEIGHT",
+ value: submitPathValue("WEIGHT", "81,4"),
+ measuredAt: new Date(Date.now() - 3_600_000).toISOString(),
+ });
+ expect(response.status).toBe(201);
+
+ const rows = await getPrismaClient().measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", deletedAt: null },
+ });
+ expect(rows[0]?.value).toBe(81.4);
+ });
+});
From 2f14379066c700a4350300ac49544dfe381d7e91 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:41:24 +0200
Subject: [PATCH 05/18] Carry the workout heart-rate curve to the end of the
session
`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.
Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.
The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
---
src/lib/workouts/__tests__/hr-series.test.ts | 71 +++++
src/lib/workouts/hr-series.ts | 50 +++-
.../workout-detail-hr-series.test.ts | 270 ++++++++++++++++++
3 files changed, 382 insertions(+), 9 deletions(-)
create mode 100644 tests/integration/workout-detail-hr-series.test.ts
diff --git a/src/lib/workouts/__tests__/hr-series.test.ts b/src/lib/workouts/__tests__/hr-series.test.ts
index 0beab9c57..6e09a7413 100644
--- a/src/lib/workouts/__tests__/hr-series.test.ts
+++ b/src/lib/workouts/__tests__/hr-series.test.ts
@@ -180,3 +180,74 @@ describe("buildWorkoutHrSeries", () => {
expect(findMany).not.toHaveBeenCalled();
});
});
+
+/**
+ * `durationSec` is a denormalised DURATION, and not every source reports
+ * the elapsed one: Strava sends `moving_time` while the row's `endedAt`
+ * is `startedAt + elapsed_time`. A ride with 20 minutes of stops
+ * therefore carries a `durationSec` 20 minutes shorter than its own
+ * wall-clock span, and folding the curve against it drops every reading
+ * after the moving-time mark — the tail of the session vanishes with no
+ * gap, no marker, nothing that reads as missing.
+ */
+describe("buildWorkoutHrSeries — sessions whose duration is not their span", () => {
+ const SPAN_SEC = 3600;
+ const MOVING_SEC = 2400;
+ const strava = {
+ userId: "u1",
+ startedAt: START,
+ endedAt: new Date(startMs + SPAN_SEC * 1000),
+ durationSec: MOVING_SEC,
+ now: new Date(startMs + SPAN_SEC * 1000 + 1000),
+ };
+
+ it("carries a stored series to the end of the session, not to the moving-time mark", async () => {
+ const stored = Array.from({ length: SPAN_SEC / 15 }, (_, i) =>
+ storedSample(i * 15, 120 + (i % 20)),
+ );
+ const result = await buildWorkoutHrSeries({
+ ...strava,
+ storedSamples: stored,
+ });
+ expect(result).not.toBeNull();
+ const lastTSec = result!.points[result!.points.length - 1].tSec;
+ // The final bucket sits within one bucket width of the session end.
+ expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - result!.bucketSec);
+ expect(lastTSec).toBeLessThan(SPAN_SEC);
+ });
+
+ it("carries a pulse-window series past the moving-time mark too", async () => {
+ const rows = Array.from({ length: SPAN_SEC / 20 }, (_, i) => ({
+ value: 125 + (i % 9),
+ measuredAt: new Date(startMs + i * 20_000),
+ externalId: null,
+ }));
+ findMany.mockResolvedValue(rows);
+ const result = await buildWorkoutHrSeries({
+ ...strava,
+ storedSamples: null,
+ });
+ expect(result).not.toBeNull();
+ expect(result!.source).toBe("pulse_window");
+ const lastTSec = result!.points[result!.points.length - 1].tSec;
+ expect(lastTSec).toBeGreaterThan(MOVING_SEC);
+ // The rows sit 20 s apart, so the final one can miss the very last
+ // bucket; landing within a sample interval of the end is the claim.
+ expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - 20 - result!.bucketSec);
+ });
+
+ it("keeps the duration as the floor when the row carries no usable span", async () => {
+ const stored = Array.from({ length: 40 }, (_, i) =>
+ storedSample(i * 15, 130),
+ );
+ const result = await buildWorkoutHrSeries({
+ ...strava,
+ // A row whose end is not after its start: the span is unusable, so
+ // the denormalised duration has to stay the fold window.
+ endedAt: START,
+ storedSamples: stored,
+ });
+ expect(result).not.toBeNull();
+ expect(result!.bucketSec).toBe(adaptiveBucketSec(MOVING_SEC));
+ });
+});
diff --git a/src/lib/workouts/hr-series.ts b/src/lib/workouts/hr-series.ts
index e1db75335..92f4e651c 100644
--- a/src/lib/workouts/hr-series.ts
+++ b/src/lib/workouts/hr-series.ts
@@ -82,27 +82,55 @@ function median(values: number[]): number {
: sorted[mid];
}
+/**
+ * The session's own length on the wall clock.
+ *
+ * `durationSec` is a denormalised DURATION, and not every source reports
+ * the elapsed one — Strava sends `moving_time` while the row's `endedAt`
+ * is `startedAt + elapsed_time` (`src/lib/strava/client.ts`). A ride with
+ * twenty minutes of stops therefore carries a `durationSec` twenty
+ * minutes shorter than its own span, and a curve folded against it ends
+ * at the moving-time mark with no gap and no marker: the tail of the
+ * session simply is not there. The curve covers the session, so the span
+ * is what it folds over.
+ *
+ * The duration stays the floor. A row whose `endedAt` is not after its
+ * `startedAt` (a manual entry saved with one timestamp, a provider that
+ * shipped an end it could not resolve) has no usable span, and every
+ * source that computes `endedAt` from the duration lands on the same
+ * number either way.
+ */
+export function sessionSpanSec(
+ startedAt: Date,
+ endedAt: Date,
+ durationSec: number,
+): number {
+ const floor = Math.max(0, durationSec);
+ const span = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
+ return Number.isFinite(span) && span > floor ? span : floor;
+}
+
/**
* Fold raw HR samples into session-grain buckets. Samples outside the
- * session window `[0, durationSec)` are dropped from the curve (they
- * only widen the DB query so a boundary reading still lands in bucket
- * 0 / the last bucket). Empty buckets stay as gaps — no interpolation.
+ * session window `[0, spanSec)` are dropped from the curve (they only
+ * widen the DB query so a boundary reading still lands in bucket 0 / the
+ * last bucket). Empty buckets stay as gaps — no interpolation.
* Pure; exported for unit testing.
*/
export function foldHrBuckets(
samples: readonly RawHrSample[],
startMs: number,
- durationSec: number,
+ spanSec: number,
bucketSec: number,
): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
- const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
+ const bucketCount = Math.max(1, Math.ceil(spanSec / bucketSec));
const acc = new Map<
number,
{ sum: number; min: number; max: number; n: number }
>();
for (const s of samples) {
const rel = (s.tMs - startMs) / 1000;
- if (rel < 0 || rel >= durationSec) continue;
+ if (rel < 0 || rel >= spanSec) continue;
const idx = Math.floor(rel / bucketSec);
const bucket = acc.get(idx);
if (bucket) {
@@ -171,7 +199,11 @@ export async function buildWorkoutHrSeries(
const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
const now = input.now ?? new Date();
const startMs = startedAt.getTime();
- const bucketSec = adaptiveBucketSec(durationSec);
+ // Fold over the session's span, not its duration — see `sessionSpanSec`.
+ // The bucket width tracks the span too, so a stop-heavy ride keeps the
+ // same on-screen point density as a continuous one of the same length.
+ const spanSec = sessionSpanSec(startedAt, endedAt, durationSec);
+ const bucketSec = adaptiveBucketSec(spanSec);
// 1. Stored series — the native, highest-fidelity path.
const stored = parseStoredSamples(storedSamples);
@@ -179,7 +211,7 @@ export async function buildWorkoutHrSeries(
const { points, medianDensity } = foldHrBuckets(
stored,
startMs,
- durationSec,
+ spanSec,
bucketSec,
);
if (points.length >= 2) {
@@ -222,7 +254,7 @@ export async function buildWorkoutHrSeries(
const { points, bucketCount, medianDensity } = foldHrBuckets(
raw,
startMs,
- durationSec,
+ spanSec,
bucketSec,
);
diff --git a/tests/integration/workout-detail-hr-series.test.ts b/tests/integration/workout-detail-hr-series.test.ts
new file mode 100644
index 000000000..fff801a84
--- /dev/null
+++ b/tests/integration/workout-detail-hr-series.test.ts
@@ -0,0 +1,270 @@
+/**
+ * Integration suite for the heart-rate curve on `GET /api/workouts/{id}`.
+ *
+ * The curve is the one part of the workout-detail envelope that is
+ * assembled from rows the workout does not own: when a session carries no
+ * stored sensor stream, the server reconstructs it from the account's raw
+ * `PULSE` measurements around the session. Two boundaries decide whether
+ * that reconstruction is honest, and neither is provable from a unit test
+ * with a mocked Prisma:
+ *
+ * - the WINDOW. A reading taken after the session ended is not part of
+ * the session. The query pads by five minutes so a boundary reading
+ * still lands in the first / last bucket, and the fold then clamps to
+ * the session itself — a pad reading must not extend the curve past
+ * the finish.
+ * - the OWNER. The measurement read is scoped to the calling account.
+ * Another account's pulse at the same wall-clock minute must not
+ * enter this curve.
+ *
+ * Both run through the real route against a real Postgres, and the
+ * fixture places a reading on the far side of each boundary with a bpm
+ * value no in-window reading has, so a leak is visible in the numbers
+ * rather than only in a count.
+ */
+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.ENCRYPTION_KEY =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+const TEST_USER_ID = "user-workout-hr-series";
+const OTHER_USER_ID = "user-workout-hr-series-other";
+
+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),
+}));
+
+const { GET } = await import("@/app/api/workouts/[id]/route");
+
+/** Session window: 07:00 → 07:30 on a fixed day. */
+const STARTED_AT = new Date("2026-05-15T07:00:00.000Z");
+const ENDED_AT = new Date("2026-05-15T07:30:00.000Z");
+const SPAN_SEC = 1800;
+
+/**
+ * In-window readings sit in this band; every out-of-window reading is
+ * placed outside it, so an assertion on the curve's bpm bounds names any
+ * leak directly.
+ */
+const IN_WINDOW_LOW = 120;
+const IN_WINDOW_HIGH = 168;
+/** After the finish but inside the query's five-minute pad. */
+const PAD_TAIL_BPM = 44;
+/** Well outside the padded window on either side. */
+const FAR_BEFORE_BPM = 201;
+const FAR_AFTER_BPM = 51;
+/** Another account's reading in the middle of this session. */
+const FOREIGN_BPM = 199;
+
+interface HrSeriesPoint {
+ tSec: number;
+ mean: number;
+ min: number;
+ max: number;
+}
+
+interface WorkoutDetailBody {
+ data: {
+ id: string;
+ hrSeries: {
+ source: string;
+ bucketSec: number;
+ envelope: boolean;
+ points: HrSeriesPoint[];
+ } | null;
+ } | null;
+ error: string | null;
+}
+
+async function createUser(id: string, username: string) {
+ await getPrismaClient().user.create({
+ data: { id, username, email: `${username}@example.test` },
+ });
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ await createUser(TEST_USER_ID, "workout-hr-series");
+ await createUser(OTHER_USER_ID, "workout-hr-series-other");
+ const session = await getPrismaClient().session.create({
+ data: {
+ userId: TEST_USER_ID,
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+});
+
+/**
+ * The session under test, with no stored sample stream — so the route has
+ * to reconstruct the curve from `PULSE` rows and the boundaries above are
+ * the ones that decide what it sees.
+ */
+async function createWorkout(): Promise {
+ const row = await getPrismaClient().workout.create({
+ data: {
+ userId: TEST_USER_ID,
+ sportType: "running",
+ startedAt: STARTED_AT,
+ endedAt: ENDED_AT,
+ durationSec: SPAN_SEC,
+ avgHeartRate: 144,
+ maxHeartRate: IN_WINDOW_HIGH,
+ source: "MANUAL",
+ },
+ select: { id: true },
+ });
+ return row.id;
+}
+
+function pulseRow(userId: string, offsetSec: number, value: number) {
+ return {
+ userId,
+ type: "PULSE" as const,
+ value,
+ unit: "bpm",
+ measuredAt: new Date(STARTED_AT.getTime() + offsetSec * 1000),
+ };
+}
+
+/**
+ * A dense in-window sweep plus one reading on the far side of each
+ * boundary. The sweep alternates across the in-window band so the fold
+ * has a real shape to carry.
+ */
+async function seedPulse() {
+ const rows = [] as ReturnType[];
+ for (let offset = 0; offset < SPAN_SEC; offset += 5) {
+ const swing = Math.round(
+ ((IN_WINDOW_HIGH - IN_WINDOW_LOW) / 2) *
+ (1 + Math.sin((offset / SPAN_SEC) * Math.PI * 4)),
+ );
+ rows.push(pulseRow(TEST_USER_ID, offset, IN_WINDOW_LOW + swing));
+ }
+ // Two minutes after the finish: inside the query's pad, outside the
+ // session. The query returns it; the curve must not.
+ rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 120, PAD_TAIL_BPM));
+ // Far outside the padded window on both sides.
+ rows.push(pulseRow(TEST_USER_ID, -20 * 60, FAR_BEFORE_BPM));
+ rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 15 * 60, FAR_AFTER_BPM));
+ // Another account, mid-session.
+ rows.push(pulseRow(OTHER_USER_ID, Math.floor(SPAN_SEC / 2), FOREIGN_BPM));
+ await getPrismaClient().measurement.createMany({ data: rows });
+}
+
+async function fetchDetail(id: string): Promise {
+ const request = new NextRequest(
+ `http://localhost/api/workouts/${id}?compact=1`,
+ );
+ const response = await GET(request, { params: Promise.resolve({ id }) });
+ return (await response.json()) as WorkoutDetailBody;
+}
+
+describe("GET /api/workouts/{id} — reconstructed heart-rate curve", () => {
+ it("reconstructs the curve from the account's pulse readings", async () => {
+ const id = await createWorkout();
+ await seedPulse();
+
+ const body = await fetchDetail(id);
+ expect(body.error).toBeNull();
+ const series = body.data?.hrSeries;
+ expect(series).not.toBeNull();
+ expect(series!.source).toBe("pulse_window");
+ expect(series!.points.length).toBeGreaterThan(2);
+ });
+
+ it("stops the curve at the finish — a reading inside the query pad is not part of the session", async () => {
+ const id = await createWorkout();
+ await seedPulse();
+
+ const series = (await fetchDetail(id)).data!.hrSeries!;
+ const lastTSec = series.points[series.points.length - 1].tSec;
+ expect(lastTSec).toBeLessThan(SPAN_SEC);
+ // The pad reading is the only source of a sub-100 bpm value in the
+ // fixture, so naming the value proves it stayed out rather than
+ // merely proving a count.
+ expect(series.points.map((p) => p.min)).not.toContain(PAD_TAIL_BPM);
+ expect(Math.min(...series.points.map((p) => p.min))).toBeGreaterThanOrEqual(
+ IN_WINDOW_LOW,
+ );
+ });
+
+ it("keeps readings from outside the window and from other accounts out of the curve", async () => {
+ const id = await createWorkout();
+ await seedPulse();
+
+ const series = (await fetchDetail(id)).data!.hrSeries!;
+ const maxima = series.points.map((p) => p.max);
+ expect(maxima).not.toContain(FAR_BEFORE_BPM);
+ expect(maxima).not.toContain(FOREIGN_BPM);
+ expect(series.points.map((p) => p.min)).not.toContain(FAR_AFTER_BPM);
+ expect(Math.max(...maxima)).toBeLessThanOrEqual(IN_WINDOW_HIGH);
+ });
+
+ it("serves no curve when the account's readings do not cover the session", async () => {
+ const id = await createWorkout();
+ // A handful of opportunistic readings in the first minute. Enough
+ // rows to clear the sample floor, nowhere near enough to describe a
+ // half-hour session — the route says nothing rather than drawing a
+ // curve out of a corner of it.
+ await getPrismaClient().measurement.createMany({
+ data: Array.from({ length: 12 }, (_, i) =>
+ pulseRow(TEST_USER_ID, i * 4, 130 + i),
+ ),
+ });
+
+ const body = await fetchDetail(id);
+ expect(body.error).toBeNull();
+ expect(body.data?.hrSeries).toBeNull();
+ });
+
+ it("does not reach the reconstruction at all for another account's workout", async () => {
+ const foreign = await getPrismaClient().workout.create({
+ data: {
+ userId: OTHER_USER_ID,
+ sportType: "running",
+ startedAt: STARTED_AT,
+ endedAt: ENDED_AT,
+ durationSec: SPAN_SEC,
+ source: "MANUAL",
+ },
+ select: { id: true },
+ });
+ await seedPulse();
+
+ const request = new NextRequest(
+ `http://localhost/api/workouts/${foreign.id}?compact=1`,
+ );
+ const response = await GET(request, {
+ params: Promise.resolve({ id: foreign.id }),
+ });
+ expect(response.status).toBe(404);
+ });
+});
From 0c99c27bfce625759e066b9d9ae3dae3111e0b26 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:50:03 +0200
Subject: [PATCH 06/18] Keep the workout heart-rate axis on the readings it
plots
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.
The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.
The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.
Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
---
messages/de.json | 4 +
messages/en.json | 4 +
messages/es.json | 4 +
messages/fr.json | 4 +
messages/it.json | 4 +
messages/pl.json | 4 +
.../__tests__/workout-hr-chart-data.test.ts | 114 ++++++++++++++
.../charts/workout-hr-chart-data.ts | 106 +++++++++++++
src/components/charts/workout-hr-chart.tsx | 145 ++++++++++--------
.../insights/workout-detail/hr-section.tsx | 3 +-
10 files changed, 328 insertions(+), 64 deletions(-)
create mode 100644 src/components/charts/__tests__/workout-hr-chart-data.test.ts
create mode 100644 src/components/charts/workout-hr-chart-data.ts
diff --git a/messages/de.json b/messages/de.json
index f3cf1a8af..f4414e635 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3110,6 +3110,10 @@
"notFound": "Workout nicht gefunden.",
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
"hrAxisMinutes": "Min.",
+ "hrAvgMarker": "Ø {bpm}",
+ "hrMaxMarker": "Max. {bpm}",
+ "hrRangeValue": "{low} bis {high} bpm",
+ "hrRangeLabel": "Spanne",
"sportAverageLabel": "Dein aktueller Durchschnitt:",
"zonesTitle": "Belastungszonen",
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
diff --git a/messages/en.json b/messages/en.json
index 9969e7de4..dfcbd366f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3110,6 +3110,10 @@
"notFound": "Workout not found.",
"hrFromPulse": "From your heart-rate data around this session",
"hrAxisMinutes": "min",
+ "hrAvgMarker": "Avg {bpm}",
+ "hrMaxMarker": "Peak {bpm}",
+ "hrRangeValue": "{low} to {high} bpm",
+ "hrRangeLabel": "Range",
"sportAverageLabel": "Your recent average:",
"zonesTitle": "Effort zones",
"zonesCaption": "How your effort was distributed across heart-rate zones.",
diff --git a/messages/es.json b/messages/es.json
index a800a3727..900b2fb4d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3110,6 +3110,10 @@
"notFound": "Entrenamiento no encontrado.",
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
"hrAxisMinutes": "min",
+ "hrAvgMarker": "Media {bpm}",
+ "hrMaxMarker": "Máx. {bpm}",
+ "hrRangeValue": "{low} a {high} bpm",
+ "hrRangeLabel": "Rango",
"sportAverageLabel": "Tu promedio reciente:",
"zonesTitle": "Zonas de esfuerzo",
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
diff --git a/messages/fr.json b/messages/fr.json
index b14279d03..d87e1247a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3110,6 +3110,10 @@
"notFound": "Entraînement introuvable.",
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
"hrAxisMinutes": "min",
+ "hrAvgMarker": "Moy. {bpm}",
+ "hrMaxMarker": "Max. {bpm}",
+ "hrRangeValue": "{low} à {high} bpm",
+ "hrRangeLabel": "Plage",
"sportAverageLabel": "Votre moyenne récente :",
"zonesTitle": "Zones d'effort",
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
diff --git a/messages/it.json b/messages/it.json
index 72a8faeb1..5cf1d6856 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3110,6 +3110,10 @@
"notFound": "Allenamento non trovato.",
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
"hrAxisMinutes": "min",
+ "hrAvgMarker": "Media {bpm}",
+ "hrMaxMarker": "Max {bpm}",
+ "hrRangeValue": "da {low} a {high} bpm",
+ "hrRangeLabel": "Intervallo",
"sportAverageLabel": "La tua media recente:",
"zonesTitle": "Zone di sforzo",
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
diff --git a/messages/pl.json b/messages/pl.json
index ddd1b58b4..3d3f2c64e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3110,6 +3110,10 @@
"notFound": "Nie znaleziono treningu.",
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
"hrAxisMinutes": "min",
+ "hrAvgMarker": "Śr. {bpm}",
+ "hrMaxMarker": "Maks. {bpm}",
+ "hrRangeValue": "{low} do {high} bpm",
+ "hrRangeLabel": "Zakres",
"sportAverageLabel": "Twoja ostatnia średnia:",
"zonesTitle": "Strefy wysiłku",
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
diff --git a/src/components/charts/__tests__/workout-hr-chart-data.test.ts b/src/components/charts/__tests__/workout-hr-chart-data.test.ts
new file mode 100644
index 000000000..a4911e729
--- /dev/null
+++ b/src/components/charts/__tests__/workout-hr-chart-data.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ buildWorkoutHrChartData,
+ workoutHrAxisDomain,
+ type WorkoutHrCurvePoint,
+} from "@/components/charts/workout-hr-chart-data";
+
+/**
+ * The workout heart-rate curve's data mapping. The assertions that matter
+ * are the two that decide whether the rendered curve tells the truth:
+ * every value the axis sees has to be a bpm reading (so the domain lands
+ * on the band the session was actually in), and a hole in the recording
+ * has to stay a hole.
+ */
+
+const BUCKET = 10;
+
+/** A dense stretch with a two-bucket hole at 20 s and 30 s. */
+const POINTS: WorkoutHrCurvePoint[] = [
+ { tSec: 0, mean: 128, min: 122, max: 134 },
+ { tSec: 10, mean: 141, min: 133, max: 149 },
+ { tSec: 40, mean: 166, min: 158, max: 174 },
+];
+
+describe("buildWorkoutHrChartData", () => {
+ it("lays the buckets on a complete elapsed grid", () => {
+ const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
+ expect(data.map((d) => d.tSec)).toEqual([0, 10, 20, 30, 40]);
+ });
+
+ it("keeps a hole in the recording as a hole", () => {
+ const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
+ expect(data[2]).toEqual({ tSec: 20, mean: null, band: null });
+ expect(data[3]).toEqual({ tSec: 30, mean: null, band: null });
+ });
+
+ it("carries the envelope as the bucket's own [min, max] pair", () => {
+ const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
+ expect(data[0].band).toEqual([122, 134]);
+ expect(data[4].band).toEqual([158, 174]);
+ });
+
+ /**
+ * The regression this mapping exists to prevent. The band used to ship
+ * as a stacked pair — a base at `min` plus a `max - min` delta — and a
+ * stack is measured from zero, so the axis domain collapsed to include
+ * 0 and the curve was drawn squashed against the top of the plot. Every
+ * number handed to the chart must be a bpm reading in the session's own
+ * range; a spread posing as a value is what broke it.
+ */
+ it("hands the axis nothing but bpm readings — no spread, no zero baseline", () => {
+ const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
+ const lowestReading = Math.min(...POINTS.map((p) => p.min));
+ const highestReading = Math.max(...POINTS.map((p) => p.max));
+
+ const values = data.flatMap((d) => [
+ ...(d.mean == null ? [] : [d.mean]),
+ ...(d.band ?? []),
+ ]);
+ expect(values.length).toBeGreaterThan(0);
+ for (const value of values) {
+ expect(value).toBeGreaterThanOrEqual(lowestReading);
+ expect(value).toBeLessThanOrEqual(highestReading);
+ }
+ });
+
+ it("drops the band when the server judged the buckets too sparse for one", () => {
+ const data = buildWorkoutHrChartData(POINTS, BUCKET, false);
+ expect(data.every((d) => d.band === null)).toBe(true);
+ expect(data[0].mean).toBe(128);
+ });
+
+ it("maps an empty or unusable series to no data at all", () => {
+ expect(buildWorkoutHrChartData([], BUCKET, true)).toEqual([]);
+ expect(buildWorkoutHrChartData(POINTS, 0, true)).toEqual([]);
+ });
+});
+
+describe("workoutHrAxisDomain", () => {
+ const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
+
+ it("covers the plotted readings with breathing room", () => {
+ expect(workoutHrAxisDomain(data)).toEqual([112, 184]);
+ });
+
+ /**
+ * A reference line outside the axis domain is discarded by recharts,
+ * not clamped — so a peak above the curve's own highest bucket mean
+ * would simply never be drawn. The reported peak is a single reading
+ * and the curve plots bucket means, so that is the common case, not
+ * the edge one.
+ */
+ it("stretches to hold a peak the bucketed curve never reaches", () => {
+ const domain = workoutHrAxisDomain(data, [145, 191]);
+ expect(domain).not.toBeNull();
+ expect(domain![1]).toBeGreaterThanOrEqual(191);
+ });
+
+ it("stretches downwards for an average below the curve too", () => {
+ const domain = workoutHrAxisDomain(data, [96, null]);
+ expect(domain![0]).toBeLessThanOrEqual(96);
+ });
+
+ it("ignores markers that are absent or not numbers", () => {
+ expect(workoutHrAxisDomain(data, [null, undefined, Number.NaN])).toEqual(
+ workoutHrAxisDomain(data),
+ );
+ });
+
+ it("leaves the axis alone when there is nothing plotted", () => {
+ expect(workoutHrAxisDomain([], [140])).toBeNull();
+ });
+});
diff --git a/src/components/charts/workout-hr-chart-data.ts b/src/components/charts/workout-hr-chart-data.ts
new file mode 100644
index 000000000..e61ea1ba0
--- /dev/null
+++ b/src/components/charts/workout-hr-chart-data.ts
@@ -0,0 +1,106 @@
+/**
+ * Data mapping for the per-workout heart-rate curve.
+ *
+ * Pure and separate from the chart component so the shape the axis reads
+ * can be asserted without mounting recharts — the y-axis domain is
+ * computed from these values, and getting them wrong flattens the curve
+ * rather than erroring.
+ */
+
+export interface WorkoutHrCurvePoint {
+ /** Elapsed seconds from the workout start (bucket left edge). */
+ tSec: number;
+ mean: number;
+ min: number;
+ max: number;
+}
+
+export interface WorkoutHrChartDatum {
+ tSec: number;
+ /** Bucket mean, or null where the recording has a hole. */
+ mean: number | null;
+ /**
+ * The min→max ribbon as an explicit `[low, high]` pair — recharts reads
+ * an array `dataKey` as a range area and takes its baseline from the
+ * first element.
+ *
+ * This deliberately is NOT a stacked pair of `min` and `max - min`.
+ * Stacked series are measured from zero: recharts derives the axis
+ * domain for a stack from the stacked offsets, whose baseline is 0
+ * (`getDomainOfStackGroups` over `stackOffsetNone`). A chart asking for
+ * `dataMin - 10` then resolves to −10 instead of to the bottom of the
+ * band, and a session spent between 120 and 170 bpm gets drawn on a
+ * −10…180 axis — the shape of the effort, which is the whole point of
+ * the curve, squashes into the top quarter. A range area keeps the
+ * domain on the bpm values that are actually in the data.
+ */
+ band: [number, number] | null;
+}
+
+/**
+ * Lay the bucket points onto a complete elapsed-time grid.
+ *
+ * A missing bucket becomes an explicit null rather than being skipped, so
+ * a hole in the recording breaks the line (`connectNulls` false) instead
+ * of being drawn across as if the reading had continued. Absence reads as
+ * absence.
+ *
+ * `envelope` is the server's density verdict: below its threshold the
+ * per-bucket min→max spread is noise rather than signal, and the chart
+ * draws the mean line alone.
+ */
+export function buildWorkoutHrChartData(
+ points: readonly WorkoutHrCurvePoint[],
+ bucketSec: number,
+ envelope: boolean,
+): WorkoutHrChartDatum[] {
+ if (points.length === 0 || bucketSec <= 0) return [];
+ const byT = new Map(points.map((p) => [p.tSec, p]));
+ const maxT = points[points.length - 1].tSec;
+ const grid: WorkoutHrChartDatum[] = [];
+ for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
+ const p = byT.get(tSec);
+ grid.push({
+ tSec,
+ mean: p ? p.mean : null,
+ band: p && envelope ? [p.min, p.max] : null,
+ });
+ }
+ return grid;
+}
+
+/** Breathing room above and below the plotted readings, in bpm. */
+const AXIS_PAD_BPM = 10;
+
+/**
+ * The bpm range the y-axis has to cover: every plotted reading plus every
+ * reference line drawn over them.
+ *
+ * The markers have to be in the domain or they are not drawn at all —
+ * recharts discards an out-of-range `ReferenceLine` rather than clamping
+ * it. The session's reported peak is routinely above the curve's own
+ * highest point, because the curve plots bucket means and a peak is a
+ * single reading, so a domain derived from the data alone drops the peak
+ * marker on exactly the sessions that have one. Silently: no warning, no
+ * gap, just a line that never appears.
+ *
+ * Returns null for an empty series, leaving the axis to recharts.
+ */
+export function workoutHrAxisDomain(
+ data: readonly WorkoutHrChartDatum[],
+ markers: readonly (number | null | undefined)[] = [],
+): [number, number] | null {
+ const values: number[] = [];
+ for (const datum of data) {
+ if (datum.mean != null) values.push(datum.mean);
+ if (datum.band) values.push(datum.band[0], datum.band[1]);
+ }
+ if (values.length === 0) return null;
+ for (const marker of markers) {
+ if (marker != null && Number.isFinite(marker)) values.push(marker);
+ }
+ return [
+ Math.floor(Math.min(...values) - AXIS_PAD_BPM),
+ Math.ceil(Math.max(...values) + AXIS_PAD_BPM),
+ ];
+}
diff --git a/src/components/charts/workout-hr-chart.tsx b/src/components/charts/workout-hr-chart.tsx
index 9a5fe387e..78731cee0 100644
--- a/src/components/charts/workout-hr-chart.tsx
+++ b/src/components/charts/workout-hr-chart.tsx
@@ -15,6 +15,11 @@ import {
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
+import {
+ buildWorkoutHrChartData,
+ workoutHrAxisDomain,
+ type WorkoutHrCurvePoint,
+} from "./workout-hr-chart-data";
/**
* Per-workout heart-rate curve. Rendered ONLY through the shared
@@ -22,20 +27,15 @@ import { useTranslations } from "@/lib/i18n/context";
* library stays a single shared chunk — never import this file directly
* at a call site.
*
- * `ComposedChart`: an `Area` for the min→max envelope (drawn only when
- * the series is dense enough to be honest), a `Line` for the bucket
- * mean, optional %HRmax zone bands behind them, and a dashed reference
- * line at the workout's average HR. Gaps stay gaps (`connectNulls`
+ * `ComposedChart`: a range `Area` for the min→max envelope (drawn only
+ * when the series is dense enough to be honest), a `Line` for the bucket
+ * mean, optional %HRmax zone bands behind them, and dashed reference
+ * lines at the session's average and peak. Gaps stay gaps (`connectNulls`
* false) — a hole in the recording never reads as an interpolated
* curve.
*/
-export interface WorkoutHrChartPoint {
- tSec: number;
- mean: number;
- min: number;
- max: number;
-}
+export type WorkoutHrChartPoint = WorkoutHrCurvePoint;
export interface WorkoutHrZoneBand {
zone: number;
@@ -50,6 +50,8 @@ export interface WorkoutHrChartProps {
envelope: boolean;
/** Workout average HR for the reference line. */
avgHr: number | null;
+ /** Workout peak HR for the reference line. */
+ maxHr?: number | null;
/** Optional %HRmax zone bands for the shaded background. */
zones?: WorkoutHrZoneBand[] | null;
}
@@ -71,33 +73,19 @@ export function WorkoutHrChart({
bucketSec,
envelope,
avgHr,
+ maxHr,
zones,
}: WorkoutHrChartProps) {
const { t } = useTranslations();
- // Build a full elapsed-time grid so a gap of missing buckets breaks
- // the line rather than interpolating across it.
- const data = useMemo(() => {
- if (points.length === 0) return [];
- const byT = new Map(points.map((p) => [p.tSec, p]));
- const maxT = points[points.length - 1].tSec;
- const grid: Array<{
- tSec: number;
- mean: number | null;
- lo: number | null;
- hi: number | null;
- }> = [];
- for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
- const p = byT.get(tSec);
- grid.push({
- tSec,
- mean: p ? p.mean : null,
- lo: p ? p.min : null,
- hi: p && envelope ? p.max - p.min : null,
- });
- }
- return grid;
- }, [points, bucketSec, envelope]);
+ const data = useMemo(
+ () => buildWorkoutHrChartData(points, bucketSec, envelope),
+ [points, bucketSec, envelope],
+ );
+ const yDomain = useMemo(
+ () => workoutHrAxisDomain(data, [avgHr, maxHr]),
+ [data, avgHr, maxHr],
+ );
return (
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}`
}
- formatter={(value, name) =>
- name === "mean"
- ? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")]
- : [null, null]
- }
+ formatter={(value, name) => {
+ if (name === "mean") {
+ return [
+ `${value} bpm`,
+ t("insights.workouts.detail.hrChartTitle"),
+ ];
+ }
+ // The envelope's own `[min, max]` pair, read back as the
+ // spread inside that bucket rather than as a second series.
+ if (name === "band" && Array.isArray(value)) {
+ return [
+ t("insights.workouts.detail.hrRangeValue", {
+ low: String(value[0]),
+ high: String(value[1]),
+ }),
+ t("insights.workouts.detail.hrRangeLabel"),
+ ];
+ }
+ return [null, null];
+ }}
/>
- {/* Envelope band: a transparent base at `lo` plus a stacked
- `hi` (= max − min) renders the min→max ribbon. Only present
- when `envelope` gated it in. */}
+ {/* Envelope band: one range `Area` over the bucket's own
+ `[min, max]` pair. Deliberately not a stack — a stacked pair
+ is measured from zero and drags the y-axis domain down with
+ it, squashing the curve (see `workout-hr-chart-data.ts`). */}
{envelope ? (
- <>
-
-
- >
+
) : null}
{avgHr != null ? (
+ ) : null}
+ {/* The session's peak, the second number the stats grid already
+ names. Drawn so the curve can be read against it rather than
+ leaving the reader to eyeball where the top was. */}
+ {maxHr != null ? (
+
) : null}
From cea330bf70aff9dc3dfb90e71c809aa744b16fce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:51:13 +0200
Subject: [PATCH 07/18] Say when a session has heart-rate figures but no
profile behind them
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.
Three states now. A curve when there is one; a named absence when the
session carries heart-rate figures without a profile; nothing at all when
nothing was ever measured, since there is no absence to report there and
an empty card would be a shell.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
.../__tests__/workout-detail.test.tsx | 23 ++++++++-
.../insights/workout-detail/hr-section.tsx | 51 ++++++++++++++-----
8 files changed, 66 insertions(+), 14 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index f4414e635..fd827a575 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3114,6 +3114,7 @@
"hrMaxMarker": "Max. {bpm}",
"hrRangeValue": "{low} bis {high} bpm",
"hrRangeLabel": "Spanne",
+ "hrNoProfile": "Für diese Einheit liegen keine einzelnen Pulswerte vor.",
"sportAverageLabel": "Dein aktueller Durchschnitt:",
"zonesTitle": "Belastungszonen",
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
diff --git a/messages/en.json b/messages/en.json
index dfcbd366f..c27ca78b6 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3114,6 +3114,7 @@
"hrMaxMarker": "Peak {bpm}",
"hrRangeValue": "{low} to {high} bpm",
"hrRangeLabel": "Range",
+ "hrNoProfile": "No reading-by-reading heart-rate data covers this session.",
"sportAverageLabel": "Your recent average:",
"zonesTitle": "Effort zones",
"zonesCaption": "How your effort was distributed across heart-rate zones.",
diff --git a/messages/es.json b/messages/es.json
index 900b2fb4d..a4c12b689 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3114,6 +3114,7 @@
"hrMaxMarker": "Máx. {bpm}",
"hrRangeValue": "{low} a {high} bpm",
"hrRangeLabel": "Rango",
+ "hrNoProfile": "No hay valores de pulso individuales que cubran esta sesión.",
"sportAverageLabel": "Tu promedio reciente:",
"zonesTitle": "Zonas de esfuerzo",
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
diff --git a/messages/fr.json b/messages/fr.json
index d87e1247a..e33fbc113 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3114,6 +3114,7 @@
"hrMaxMarker": "Max. {bpm}",
"hrRangeValue": "{low} à {high} bpm",
"hrRangeLabel": "Plage",
+ "hrNoProfile": "Aucune valeur de pouls détaillée ne couvre cette séance.",
"sportAverageLabel": "Votre moyenne récente :",
"zonesTitle": "Zones d'effort",
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
diff --git a/messages/it.json b/messages/it.json
index 5cf1d6856..6f61e22e6 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3114,6 +3114,7 @@
"hrMaxMarker": "Max {bpm}",
"hrRangeValue": "da {low} a {high} bpm",
"hrRangeLabel": "Intervallo",
+ "hrNoProfile": "Nessun valore di battito dettagliato copre questa sessione.",
"sportAverageLabel": "La tua media recente:",
"zonesTitle": "Zone di sforzo",
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
diff --git a/messages/pl.json b/messages/pl.json
index 3d3f2c64e..2b303a47f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3114,6 +3114,7 @@
"hrMaxMarker": "Maks. {bpm}",
"hrRangeValue": "{low} do {high} bpm",
"hrRangeLabel": "Zakres",
+ "hrNoProfile": "Brak pojedynczych pomiarów tętna dla tej sesji.",
"sportAverageLabel": "Twoja ostatnia średnia:",
"zonesTitle": "Strefy wysiłku",
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx
index aa6306131..a7916eaaa 100644
--- a/src/components/insights/__tests__/workout-detail.test.tsx
+++ b/src/components/insights/__tests__/workout-detail.test.tsx
@@ -207,8 +207,27 @@ describe("", () => {
});
describe("", () => {
- it("returns null without an HR series", () => {
- expect(render()).toBe("");
+ it("names the absence when the session has heart-rate figures but no profile", () => {
+ // The stats grid is showing an average and a peak for this session,
+ // so the reader can see heart rate WAS recorded. Rendering nothing
+ // here would read as "this page has no heart-rate section" rather
+ // than as "the shape behind those numbers is not on record".
+ const html = render();
+ expect(html).toContain('data-slot="workout-detail-hr-empty"');
+ expect(html).toContain("No reading-by-reading heart-rate data");
+ expect(html).not.toContain('data-slot="workout-hr-chart"');
+ });
+
+ it("returns null when the session carries no heart rate at all", () => {
+ // Nothing was measured, so there is no absence to report and an
+ // empty card would be a shell.
+ const noHr: WorkoutDetailPayload = {
+ ...FIXTURE,
+ avgHr: null,
+ maxHr: null,
+ minHr: null,
+ };
+ expect(render()).toBe("");
});
it("renders the card and a provenance chip for a pulse-window series", () => {
diff --git a/src/components/insights/workout-detail/hr-section.tsx b/src/components/insights/workout-detail/hr-section.tsx
index 7106ae280..37ac56b5b 100644
--- a/src/components/insights/workout-detail/hr-section.tsx
+++ b/src/components/insights/workout-detail/hr-section.tsx
@@ -37,15 +37,33 @@ export interface WorkoutDetailHrSectionProps {
* Heart-rate curve card — mean line + optional min→max envelope + %HRmax
* zone bands + average and peak reference lines. A muted provenance chip
* discloses when the curve was reconstructed from pulse data around the
- * session rather than the workout's own sensor stream. Returns `null`
- * when no series is available (hide, don't render empty).
+ * session rather than the workout's own sensor stream.
+ *
+ * Three states, and the middle one is the reason this is not a plain
+ * hide-when-empty section:
+ *
+ * - a curve, when the server resolved one;
+ * - a named absence, when the session HAS heart-rate figures (the
+ * stats grid is showing an average, a peak) but no reading-by-reading
+ * profile behind them. Silence there reads as "this page has no
+ * heart-rate section", which is wrong — the data exists, the shape of
+ * it does not, and saying so is the difference between an honest gap
+ * and an invisible one;
+ * - nothing at all, when the session carries no heart rate of any kind.
+ * There is no absence to report where nothing was ever measured, and
+ * an empty card would be a shell (page contract: hide, don't render
+ * empty).
*/
export function WorkoutDetailHrSection({
workout,
}: WorkoutDetailHrSectionProps) {
const { t } = useTranslations();
const series = workout.hrSeries;
- if (!series || series.points.length < 2) return null;
+ const hasCurve = Boolean(series && series.points.length >= 2);
+ const hasHeartRateAtAll =
+ workout.avgHr != null || workout.maxHr != null || workout.minHr != null;
+
+ if (!hasCurve && !hasHeartRateAtAll) return null;
return (
@@ -55,7 +73,7 @@ export function WorkoutDetailHrSection({
title={t("insights.workouts.detail.hrChartTitle")}
titleAs="h2"
/>
- {series.source === "pulse_window" ? (
+ {hasCurve && series!.source === "pulse_window" ? (
-
+ {hasCurve ? (
+
+ ) : (
+
+ {t("insights.workouts.detail.hrNoProfile")}
+
+ )}
);
From c5a471f7fa7b70bc80ec8d846dc42d0134b4518a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 14:58:53 +0200
Subject: [PATCH 08/18] fix(cycle): keep counting an open cycle past the
predicted period start
The engine ended the open cycle's phase window at the predicted next
start, so the day after a missed forecast carried no phase at all and
the verdict resolver read that as today falling outside every known
cycle window. An ordinary three-days-late period therefore reported
INSUFFICIENT_DATA: no cycle day, no phase, an empty ring and the words
insufficient data, while the reminder cron was pushing a period-confirm
prompt for the same day. OVERDUE was only reachable when the engine
itself forecast a cycle longer than the grace window.
The open cycle now runs to the predicted start or through today,
whichever is later, and stops the moment the next start is logged. The
count is bounded on the far side where it already was: past the typical
length plus its grace window the verdict degrades to OVERDUE and says
how late the period is.
The extension pins the open cycle's ovulation anchor to the predicted
length. Left to the span, an unconfirmed ovulation is back-calculated
from the window's own length, so a window that grows with the delay
would walk the estimate and every phase boundary drawn from it.
- buildPhaseCycles, phaseForDate and buildPhaseDayMap take the user's
today; the insights route and the coach snapshot pass it
- verdict round-trip covers the predicted start day, three days past it,
past the grace window, and the case where a new start closes the run
---
.../__tests__/verdict-round-trip.test.ts | 125 ++++++++++++++++++
src/app/api/cycle/insights/route.ts | 1 +
src/lib/ai/coach/cycle-snapshot.ts | 2 +
src/lib/cycle/engine-adapter.ts | 59 ++++++++-
4 files changed, 182 insertions(+), 5 deletions(-)
diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
index eb19019b7..4e10ce533 100644
--- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
+++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
@@ -103,6 +103,16 @@ const LONG_CYCLE_STARTS = [
"2026-04-30",
];
+/**
+ * Four period starts exactly 28 days apart — the textbook regular record. The
+ * last start is open, so where "today" sits relative to `lastStart + 28`
+ * decides what the verdict may claim. Built from a supplied last start so a
+ * test can place today a chosen number of days past the predicted one.
+ */
+function regularStarts(lastStart: string): string[] {
+ return [-84, -56, -28, 0].map((offset) => addDays(lastStart, offset));
+}
+
function cycleRows(starts: readonly string[]) {
return starts.map((startDate, i) => ({
id: `cyc-${i}`,
@@ -249,6 +259,7 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
cycleRows(LONG_CYCLE_STARTS) as never,
prediction?.nextPeriodStart ?? null,
resolveLuteal(profile() as never),
+ "2026-05-20",
);
expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle);
expect(verdict.phase).toBe(fromEngine.phase);
@@ -276,6 +287,120 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected));
});
+ /**
+ * D-list edge 1 — the verdict at and after the predicted start day, driven
+ * through the real route.
+ *
+ * A regular 28-day record whose period has not arrived is the ORDINARY way
+ * to be late, and it is the case the ring was silent about: the engine ends
+ * the open cycle's phase window at the predicted start, so the day after it
+ * carried no phase, and the verdict resolver read "no phase today" as "no
+ * data at all". The person saw an empty ring and the words insufficient data
+ * on the third day of a late period. `OVERDUE` was only ever reachable when
+ * the engine itself forecast a cycle longer than the grace window.
+ */
+ describe("a period that has not arrived", () => {
+ /** Place today N days past `lastStart + 28`, the predicted next start. */
+ function overdueBy(days: number): { today: string; lastStart: string } {
+ const today = "2026-06-11";
+ return { today, lastStart: addDays(today, -(28 + days)) };
+ }
+
+ function seedRegular(lastStart: string): void {
+ vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
+ cycleRows(regularStarts(lastStart)) as never,
+ );
+ }
+
+ it("keeps counting the open cycle past the predicted start", async () => {
+ const { today, lastStart } = overdueBy(3);
+ seedRegular(lastStart);
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const { body } = await callCalendar();
+ const data = body.data as Record;
+ const verdict = data.verdict as Record;
+ const prediction = data.prediction as { nextPeriodStart: string } | null;
+
+ // The predicted start really is behind us — otherwise this asserts nothing.
+ expect(prediction?.nextPeriodStart).toBe(addDays(today, -3));
+
+ expect(verdict.state).toBe("IN_CYCLE");
+ expect(verdict.dayOfCycle).toBe(32);
+ expect(verdict.phase).toBe("LUTEAL");
+ expect(verdict.cycleStartDate).toBe(lastStart);
+ // There is no "until" once the predicted start is behind us.
+ expect(verdict.daysUntilNext).toBeNull();
+ });
+
+ it("says OVERDUE, with the day count, once the grace window is spent", async () => {
+ // Typical 28 + 14 days of grace = 42. Day 44 is past it.
+ const { today, lastStart } = overdueBy(15);
+ seedRegular(lastStart);
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const { body } = await callCalendar();
+ const verdict = (body.data as Record).verdict as Record<
+ string,
+ unknown
+ >;
+
+ expect(verdict.state).toBe("OVERDUE");
+ expect(verdict.dayOfCycle).toBeNull();
+ expect(verdict.phase).toBeNull();
+ expect(verdict.overdueDays).toBe(16);
+ expect(verdict.cycleStartDate).toBe(lastStart);
+ expect(verdict.spans).toHaveLength(4);
+ });
+
+ it("holds the count on the predicted start day itself", async () => {
+ const { today, lastStart } = overdueBy(0);
+ seedRegular(lastStart);
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const { body } = await callCalendar();
+ const verdict = (body.data as Record).verdict as Record<
+ string,
+ unknown
+ >;
+
+ // Day 29 of a 28-day cycle: the period is due today and has not been
+ // logged, which is a cycle day, not an absence of data.
+ expect(verdict.state).toBe("IN_CYCLE");
+ expect(verdict.dayOfCycle).toBe(29);
+ expect(verdict.daysUntilNext).toBe(0);
+ });
+
+ it("stops extending the open window once the next period is logged", async () => {
+ // The same record with one more start logged four days ago: the earlier
+ // cycle closes at that start and today is day 5 of the new one.
+ const { today, lastStart } = overdueBy(3);
+ const withNewStart = [...regularStarts(lastStart), addDays(today, -4)];
+ vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
+ cycleRows(withNewStart) as never,
+ );
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const { body } = await callCalendar();
+ const verdict = (body.data as Record).verdict as Record<
+ string,
+ unknown
+ >;
+
+ expect(verdict.state).toBe("IN_CYCLE");
+ expect(verdict.dayOfCycle).toBe(5);
+ expect(verdict.cycleStartDate).toBe(addDays(today, -4));
+ });
+ });
+
it("says INSUFFICIENT_DATA when nothing has been logged", async () => {
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts
index ba23e7992..d23d5c1da 100644
--- a/src/app/api/cycle/insights/route.ts
+++ b/src/app/api/cycle/insights/route.ts
@@ -176,6 +176,7 @@ export const GET = apiHandler(async () => {
profile.lutealPhaseLength ?? LUTEAL_DEFAULT,
from,
today,
+ today,
);
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts
index b3ee48a32..c4f0b4cad 100644
--- a/src/lib/ai/coach/cycle-snapshot.ts
+++ b/src/lib/ai/coach/cycle-snapshot.ts
@@ -242,6 +242,7 @@ export async function buildCycleSnapshotBlock(
cycles,
prediction?.nextPeriodStart ?? null,
lutealLength,
+ today,
);
// Next predicted event — period range + confidence + method, fertile window
@@ -280,6 +281,7 @@ export async function buildCycleSnapshotBlock(
lutealLength,
from,
today,
+ today,
);
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
type: m.type,
diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts
index a6c3e1901..b2add008d 100644
--- a/src/lib/cycle/engine-adapter.ts
+++ b/src/lib/cycle/engine-adapter.ts
@@ -18,6 +18,8 @@ import {
type CyclePhase,
type PhaseCycle,
resolveLuteal,
+ clampLuteal,
+ POPULATION_DEFAULT_CYCLE,
} from "@/lib/cycle";
import type {
CycleProfile,
@@ -129,9 +131,30 @@ function periodLengthOf(
}
/**
- * Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's
- * span ends where the next begins; the latest open cycle runs to the
- * predicted next-period start). Used to label every calendar day's phase.
+ * Build a contiguous `PhaseCycle[]` from the observed cycles: each closed
+ * cycle's span ends where the next begins, and the latest open cycle runs to
+ * the predicted next-period start — or, when that day has come and gone with
+ * no period logged, on through `today`.
+ *
+ * That trailing extension is the difference between a late period and no
+ * record at all. A cycle stays open until the next one starts, and a forecast
+ * is a forecast: the day after a missed prediction is still a day of the cycle
+ * the person is in. Ending the span at the predicted start instead made every
+ * day past it carry no phase, which the verdict resolver could only read as
+ * "today falls outside every known cycle window" — so an ordinary three-days-
+ * late period reported INSUFFICIENT_DATA, and the honest OVERDUE state was
+ * unreachable unless the engine itself forecast a cycle longer than the grace
+ * window. The count is bounded on the other side: `resolveCycleVerdict` stops
+ * claiming a cycle day once the run passes the profile's typical length plus
+ * its grace window.
+ *
+ * The extension deliberately does NOT move the ovulation estimate. Left to
+ * `phaseForDay`, an ovulation with no confirmed date is back-calculated from
+ * the span's own length, so a window that grows a day per day of delay would
+ * walk the estimate — and every FOLLICULAR/OVULATORY/LUTEAL boundary already
+ * drawn for that cycle — forward with it. The open cycle therefore carries an
+ * explicit anchor derived from the PREDICTED length, which is the same date
+ * `phaseForDay` computes when the span ends where the forecast said it would.
*/
export function buildPhaseCycles(
cycles: readonly Pick<
@@ -140,18 +163,37 @@ export function buildPhaseCycles(
>[],
predictedNextStart: string | null,
lutealLength: number,
+ /** Today in the USER's timezone. The open cycle runs at least this far. */
+ today: string,
): PhaseCycle[] {
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
const out: PhaseCycle[] = [];
for (let i = 0; i < sorted.length; i++) {
const c = sorted[i];
const next = sorted[i + 1];
+ if (next) {
+ out.push({
+ startDate: c.startDate,
+ nextStart: next.startDate,
+ ovulationDate: c.ovulationDate,
+ periodLength: periodLengthOf(c),
+ lutealLength,
+ });
+ continue;
+ }
+
+ // The open cycle. `phaseForDay` treats `nextStart` as exclusive, so the
+ // window has to reach the day AFTER today for today to carry a phase.
+ const forecastEnd =
+ predictedNextStart ?? addDays(c.startDate, POPULATION_DEFAULT_CYCLE);
+ const throughToday = addDays(today, 1);
const nextStart =
- next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28);
+ dayDiff(throughToday, forecastEnd) > 0 ? throughToday : forecastEnd;
out.push({
startDate: c.startDate,
nextStart,
- ovulationDate: c.ovulationDate,
+ ovulationDate:
+ c.ovulationDate ?? addDays(forecastEnd, -clampLuteal(lutealLength)),
periodLength: periodLengthOf(c),
lutealLength,
});
@@ -186,11 +228,14 @@ export function phaseForDate(
>[],
predictedNextStart: string | null,
lutealLength: number,
+ /** Today in the USER's timezone — how far a still-open cycle runs. */
+ today: string,
): { phase: CyclePhase | null; dayOfCycle: number | null } {
const phaseCycles = buildPhaseCycles(
cycles,
predictedNextStart,
lutealLength,
+ today,
);
for (const pc of phaseCycles) {
const r = phaseForDay(date, pc);
@@ -219,11 +264,14 @@ export function buildPhaseDayMap(
lutealLength: number,
from: string,
to: string,
+ /** Today in the USER's timezone — how far a still-open cycle runs. */
+ today: string,
): Map {
const phaseCycles = buildPhaseCycles(
cycles,
predictedNextStart,
lutealLength,
+ today,
);
const out = new Map();
const span = dayDiff(to, from);
@@ -304,6 +352,7 @@ export function buildCalendar(
cycles,
prediction?.nextPeriodStart ?? null,
lutealLength,
+ today,
);
// Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).
From 6db2f6f9ae7acfab63ae4721b54a686b7c9f5313 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:03:10 +0200
Subject: [PATCH 09/18] fix(cycle): make removing a period start restore the
neighbour it moved
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Opening a cycle closes the one before it: the start write stamps the
previous cycle's endDate and lengthDays, and a back-filled start takes
its own end from its successor. Nothing undid any of that. Deleting the
start left the previous cycle as a closed torso holding an end date from
a cycle that no longer existed, and a record whose last cycle is closed
has no open cycle to forecast from — the prediction went away and there
was no way to bring it back from the app.
Both removal paths now re-derive the boundary from the cycles that
remain: a cycle ends the day before the next one begins, and with no
next one it is open again. That is a re-derivation rather than an undo
log, so it is correct for a history that came from an import or a
restore, and running it twice changes nothing the second time.
Deleting the day-log that opened a cycle now removes that cycle too.
The one-tap start writes both rows and the log sheet's delete is the
only way the web offers to take it back; leaving the cycle standing was
how the broken state survived the undo.
- soft-delete and re-anchor run in one transaction
- the audit row names the neighbour's moved boundary, the only part of
the write the tombstone cannot reconstruct
- round trip through the real routes: photograph the cycle rows, log the
start, remove it either way, compare field by field — including that
the forecast comes back
---
src/app/api/cycle/cycles/[id]/route.ts | 44 ++-
src/app/api/cycle/day-logs/[id]/route.ts | 56 +++-
src/lib/cycle/cycle-boundaries.ts | 85 +++++
.../cycle-start-round-trip.test.ts | 296 ++++++++++++++++++
4 files changed, 471 insertions(+), 10 deletions(-)
create mode 100644 src/lib/cycle/cycle-boundaries.ts
create mode 100644 tests/integration/cycle-start-round-trip.test.ts
diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts
index 642d5a81c..4b8900d00 100644
--- a/src/app/api/cycle/cycles/[id]/route.ts
+++ b/src/app/api/cycle/cycles/[id]/route.ts
@@ -2,6 +2,12 @@
* `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.
+ *
+ * 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
+ * left the previous cycle standing as a closed torso with an end date taken
+ * from a cycle that no longer exists — and a record whose last cycle is closed
+ * has no open cycle to forecast from.
*/
import { NextRequest } from "next/server";
@@ -9,8 +15,10 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
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 { requireCycleEnabled } from "@/lib/cycle/gate";
+import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
type RouteParams = { params: Promise<{ id: string }> };
@@ -28,21 +36,46 @@ export const DELETE = apiHandler(
const existing = await prisma.menstrualCycle.findUnique({
where: { id },
- select: { id: true, userId: true },
+ select: { id: true, userId: true, startDate: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Cycle not found", 404);
}
- await prisma.menstrualCycle.update({
- where: { id },
- data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ // Tombstone + re-anchor as one unit: a neighbour re-derived against a
+ // half-applied delete would read the row being removed as still live.
+ const reanchored = await prisma.$transaction(async (db) => {
+ await db.menstrualCycle.update({
+ where: { id },
+ data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ });
+ return reanchorAfterRemovedStart(db, user.id, existing.startDate);
});
await auditLog("cycle.cycle.delete", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { cycleId: id },
+ // The tombstone carries the deleted cycle's own columns, so the only
+ // unrecoverable part of this write is what it moved on the NEIGHBOUR.
+ details: {
+ cycleId: id,
+ startDate: existing.startDate,
+ ...(reanchored
+ ? {
+ reanchoredCycleId: reanchored.cycleId,
+ ...overwriteDetails({
+ before: {
+ endDate: reanchored.endDateBefore,
+ lengthDays: reanchored.lengthDaysBefore,
+ },
+ after: {
+ endDate: reanchored.endDateAfter,
+ lengthDays: reanchored.lengthDaysAfter,
+ },
+ }),
+ }
+ : {}),
+ },
});
annotate({
@@ -51,6 +84,7 @@ export const DELETE = apiHandler(
entity_type: "menstrual_cycle",
entity_id: id,
},
+ meta: { reanchored_prior: reanchored !== null },
});
return new Response(null, { status: 204 });
diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts
index 7cd6fb3b8..7b88e0479 100644
--- a/src/app/api/cycle/day-logs/[id]/route.ts
+++ b/src/app/api/cycle/day-logs/[id]/route.ts
@@ -23,6 +23,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
+import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
@@ -202,7 +203,7 @@ export const DELETE = apiHandler(
const existing = await prisma.cycleDayLog.findUnique({
where: { id },
- select: { id: true, userId: true },
+ select: { id: true, userId: true, date: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Day-log not found", 404);
@@ -211,15 +212,56 @@ export const DELETE = apiHandler(
// Soft-delete: leave the row in place so the `/api/sync/changes`
// delta feed surfaces it as a tombstone. A re-delete re-bumps
// `syncVersion` harmlessly (idempotent).
- await prisma.cycleDayLog.update({
- where: { id },
- data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ //
+ // A day that OPENS a cycle takes that cycle with it. The one-tap "started
+ // my period" writes both rows, and this delete is the only way the web
+ // offers to take it back — leaving the cycle behind meant the previous one
+ // stayed closed against a start whose last trace had just been removed, and
+ // the forecast never came back. The cycle boundary is then re-derived for
+ // the preceding cycle, the same repair the cycle delete does.
+ const removed = await prisma.$transaction(async (db) => {
+ await db.cycleDayLog.update({
+ 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),
+ };
});
await auditLog("cycle.day-log.delete", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { dayLogId: id },
+ details: {
+ dayLogId: id,
+ date: existing.date,
+ ...(removed.cycleId ? { removedCycleId: removed.cycleId } : {}),
+ ...(removed.reanchored
+ ? {
+ reanchoredCycleId: removed.reanchored.cycleId,
+ ...overwriteDetails({
+ before: {
+ endDate: removed.reanchored.endDateBefore,
+ lengthDays: removed.reanchored.lengthDaysBefore,
+ },
+ after: {
+ endDate: removed.reanchored.endDateAfter,
+ lengthDays: removed.reanchored.lengthDaysAfter,
+ },
+ }),
+ }
+ : {}),
+ },
});
annotate({
@@ -228,6 +270,10 @@ export const DELETE = apiHandler(
entity_type: "cycle_day_log",
entity_id: id,
},
+ meta: {
+ removed_cycle: removed.cycleId !== null,
+ reanchored_prior: removed.reanchored !== null,
+ },
});
return new Response(null, { status: 204 });
diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts
new file mode 100644
index 000000000..9d9e7f84a
--- /dev/null
+++ b/src/lib/cycle/cycle-boundaries.ts
@@ -0,0 +1,85 @@
+/**
+ * Cycle boundary re-derivation — the inverse of what a period-start write does
+ * to its neighbours.
+ *
+ * `POST /api/cycle/period` with `action:"start"` is not an insert. It closes
+ * the previous open cycle (stamps `endDate` and `lengthDays` from the new
+ * start) and, when the start is back-filled between two existing cycles, takes
+ * the inserted cycle's own end from its successor. Removing that start left
+ * every one of those overwrites standing: the previous cycle kept the end date
+ * a cycle that no longer exists gave it, and a record whose last cycle is
+ * closed has no open cycle for the engine to forecast from. The prediction
+ * disappeared and there was no way to bring it back from the app.
+ *
+ * The repair is a re-derivation rather than an undo log, which is why it is
+ * safe to run on a record whose history came from an import or a restore
+ * instead of from the one-tap boundary: a cycle ends the day before the next
+ * one begins, and when there is no next one it is open. That statement is true
+ * of every cycle in the table regardless of how the row got there, so applying
+ * it to the surviving neighbour restores exactly the state the start write
+ * moved — and applying it twice changes nothing the second time.
+ */
+import { addDays, dayDiff } from "./day-math";
+import type { Prisma, PrismaClient } from "@/generated/prisma/client";
+
+/** The Prisma client or an interactive-transaction handle. */
+type CycleDb = PrismaClient | Prisma.TransactionClient;
+
+/** The boundary columns the re-derivation moved, for the audit row. */
+export interface ReanchoredCycle {
+ cycleId: string;
+ endDateBefore: string | null;
+ endDateAfter: string | null;
+ lengthDaysBefore: number | null;
+ lengthDaysAfter: number | null;
+}
+
+/**
+ * Re-derive the boundary of the cycle that precedes `removedStartDate` from
+ * the cycles that remain. Call it AFTER the cycle at that date is soft-deleted
+ * (the queries below read live rows only, so a still-live row at the removed
+ * start would re-derive against itself and change nothing).
+ *
+ * Returns the columns it moved, or null when there is no preceding cycle or
+ * the preceding cycle already holds the derived boundary — an idempotent
+ * re-run must not bump `syncVersion` and make every device re-pull a row that
+ * did not change.
+ */
+export async function reanchorAfterRemovedStart(
+ db: CycleDb,
+ userId: string,
+ removedStartDate: string,
+): Promise {
+ const prior = await db.menstrualCycle.findFirst({
+ where: { userId, deletedAt: null, startDate: { lt: removedStartDate } },
+ orderBy: { startDate: "desc" },
+ select: { id: true, startDate: true, endDate: true, lengthDays: true },
+ });
+ if (!prior) return null;
+
+ const next = await db.menstrualCycle.findFirst({
+ where: { userId, deletedAt: null, startDate: { gt: removedStartDate } },
+ orderBy: { startDate: "asc" },
+ select: { startDate: true },
+ });
+
+ // A cycle ends the day before the next one starts; with no next one it is
+ // open again, which is the state the removed start took away from it.
+ const endDate = next ? addDays(next.startDate, -1) : null;
+ const lengthDays = next ? dayDiff(next.startDate, prior.startDate) : null;
+
+ if (prior.endDate === endDate && prior.lengthDays === lengthDays) return null;
+
+ await db.menstrualCycle.update({
+ where: { id: prior.id },
+ data: { endDate, lengthDays, syncVersion: { increment: 1 } },
+ });
+
+ return {
+ cycleId: prior.id,
+ endDateBefore: prior.endDate,
+ endDateAfter: endDate,
+ lengthDaysBefore: prior.lengthDays,
+ lengthDaysAfter: lengthDays,
+ };
+}
diff --git a/tests/integration/cycle-start-round-trip.test.ts b/tests/integration/cycle-start-round-trip.test.ts
new file mode 100644
index 000000000..8595df6f1
--- /dev/null
+++ b/tests/integration/cycle-start-round-trip.test.ts
@@ -0,0 +1,296 @@
+/**
+ * Logging a period start and taking it back has to leave the record where it
+ * found it.
+ *
+ * `action:"start"` does not only insert a cycle. It CLOSES the previous open
+ * one — stamps its `endDate` and `lengthDays` from the new start — and, when
+ * the start is back-filled between two existing cycles, re-anchors the one
+ * after it too. Nothing undid any of that. Deleting the start left the
+ * previous cycle standing as a closed torso, and a record whose last cycle is
+ * closed has no open cycle to forecast from, so the prediction disappeared and
+ * did not come back. There is no way to repair that from the app.
+ *
+ * So this is a round trip, not a delete test: it photographs every cycle row
+ * before the start is logged, logs it, removes it, and asserts the rows are
+ * back to the photograph field by field. Two removal paths reach a start —
+ * deleting the cycle, and deleting the day-log that opened it from the log
+ * sheet, which is the only one the web actually offers.
+ */
+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-round-trip-integration-32-bytes-min-1234567890";
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+const USER_ID = "user-cycle-round-trip";
+
+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),
+}));
+
+async function loginAs(userId: string): Promise {
+ cookieJar.clear();
+ headerJar.clear();
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ await getPrismaClient().user.create({
+ data: {
+ id: USER_ID,
+ username: "cycle-round-trip",
+ email: "cycle-round-trip@example.test",
+ gender: "FEMALE",
+ },
+ });
+});
+
+function jsonRequest(
+ path: string,
+ method: string,
+ body?: unknown,
+): NextRequest {
+ return new NextRequest(`http://localhost${path}`, {
+ method,
+ headers: { "content-type": "application/json" },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+}
+
+/** The cycle boundary state, which is exactly what a start write moves. */
+interface CycleShape {
+ startDate: string;
+ endDate: string | null;
+ lengthDays: number | null;
+ periodEndDate: string | null;
+ deletedAt: boolean;
+}
+
+async function cycleShapes(): Promise {
+ const rows = await getPrismaClient().menstrualCycle.findMany({
+ where: { userId: USER_ID },
+ orderBy: { startDate: "asc" },
+ select: {
+ startDate: true,
+ endDate: true,
+ lengthDays: true,
+ periodEndDate: true,
+ deletedAt: true,
+ },
+ });
+ return rows.map((r) => ({
+ startDate: r.startDate,
+ endDate: r.endDate,
+ lengthDays: r.lengthDays,
+ periodEndDate: r.periodEndDate,
+ deletedAt: r.deletedAt !== null,
+ }));
+}
+
+/** Only the cycles a read would see — a tombstone is not part of the record. */
+async function liveShapes(): Promise {
+ return (await cycleShapes()).filter((c) => !c.deletedAt);
+}
+
+async function startPeriod(date: string): Promise {
+ const { POST } = await import("@/app/api/cycle/period/route");
+ return POST(
+ jsonRequest("/api/cycle/period", "POST", {
+ action: "start",
+ date,
+ loggedAt: `${date}T08:00:00.000Z`,
+ }),
+ );
+}
+
+/** The three months of history the maintainer's record carried. */
+async function seedHistory(): Promise {
+ await startPeriod("2026-05-04");
+ await startPeriod("2026-06-01");
+ await startPeriod("2026-07-01");
+}
+
+describe("logging a period start and taking it back", () => {
+ it("re-opens the previous cycle when the start's own cycle is deleted", async () => {
+ await loginAs(USER_ID);
+ await seedHistory();
+ const before = await liveShapes();
+ // The July cycle is open — that is the state the record has to return to.
+ expect(before.at(-1)).toEqual({
+ startDate: "2026-07-01",
+ endDate: null,
+ lengthDays: null,
+ periodEndDate: null,
+ deletedAt: false,
+ });
+
+ await startPeriod("2026-08-07");
+ const during = await liveShapes();
+ expect(during).toHaveLength(4);
+ // The July cycle really was closed by the new start, so the delete below
+ // has something to undo.
+ expect(during.at(-2)).toMatchObject({
+ startDate: "2026-07-01",
+ endDate: "2026-08-06",
+ lengthDays: 37,
+ });
+
+ const added = await getPrismaClient().menstrualCycle.findFirstOrThrow({
+ where: { userId: USER_ID, startDate: "2026-08-07" },
+ select: { id: true },
+ });
+ const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
+ const res = await DELETE(
+ jsonRequest(`/api/cycle/cycles/${added.id}`, "DELETE"),
+ { params: Promise.resolve({ id: added.id }) },
+ );
+ expect(res.status).toBe(204);
+
+ expect(await liveShapes()).toEqual(before);
+ });
+
+ it("re-opens the previous cycle when the start's day-log is deleted", async () => {
+ // The path the web actually offers: the log sheet's delete button removes
+ // the day-log, and the cycle that one tap opened has to go with it.
+ await loginAs(USER_ID);
+ await seedHistory();
+ const before = await liveShapes();
+
+ await startPeriod("2026-08-07");
+ expect(await liveShapes()).toHaveLength(4);
+
+ const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
+ where: { userId: USER_ID, date: "2026-08-07" },
+ select: { id: true },
+ });
+ const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
+ const res = await DELETE(
+ jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"),
+ { params: Promise.resolve({ id: dayLog.id }) },
+ );
+ expect(res.status).toBe(204);
+
+ expect(await liveShapes()).toEqual(before);
+ });
+
+ it("restores the boundary a back-filled start took from BOTH neighbours", async () => {
+ // A start inserted between two existing cycles closes the one before it
+ // and takes its own end from the one after. Removing it has to hand the
+ // whole span back to the earlier cycle.
+ await loginAs(USER_ID);
+ await seedHistory();
+ await startPeriod("2026-08-07");
+ const before = await liveShapes();
+
+ await startPeriod("2026-06-15");
+ const during = await liveShapes();
+ expect(during).toHaveLength(5);
+ expect(during[1]).toMatchObject({
+ startDate: "2026-06-01",
+ endDate: "2026-06-14",
+ lengthDays: 14,
+ });
+ expect(during[2]).toMatchObject({
+ startDate: "2026-06-15",
+ endDate: "2026-06-30",
+ lengthDays: 16,
+ });
+
+ const inserted = await getPrismaClient().menstrualCycle.findFirstOrThrow({
+ where: { userId: USER_ID, startDate: "2026-06-15" },
+ select: { id: true },
+ });
+ const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
+ await DELETE(jsonRequest(`/api/cycle/cycles/${inserted.id}`, "DELETE"), {
+ params: Promise.resolve({ id: inserted.id }),
+ });
+
+ expect(await liveShapes()).toEqual(before);
+ });
+
+ it("leaves a day-log that opened no cycle alone", async () => {
+ // A plain entry on a day that is not a cycle start must not take a cycle
+ // with it when it is deleted.
+ await loginAs(USER_ID);
+ await seedHistory();
+ const before = await liveShapes();
+
+ const { POST } = await import("@/app/api/cycle/day-logs/route");
+ await POST(
+ jsonRequest("/api/cycle/day-logs", "POST", {
+ date: "2026-07-14",
+ flow: "SPOTTING",
+ loggedAt: "2026-07-14T08:00:00.000Z",
+ }),
+ );
+ const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
+ where: { userId: USER_ID, date: "2026-07-14" },
+ select: { id: true },
+ });
+ const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
+ await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), {
+ params: Promise.resolve({ id: dayLog.id }),
+ });
+
+ expect(await liveShapes()).toEqual(before);
+ });
+
+ it("gives the forecast back", async () => {
+ // The symptom the round trip exists for: after the start is taken back the
+ // engine must forecast again, from the same open cycle as before.
+ await loginAs(USER_ID);
+ await seedHistory();
+
+ const { GET } = await import("@/app/api/cycle/calendar/route");
+ const readForecast = async (): Promise => {
+ const res = await GET(
+ new NextRequest("http://localhost/api/cycle/calendar"),
+ );
+ const body = JSON.parse(await res.text());
+ return body.data?.prediction?.nextPeriodStart ?? null;
+ };
+
+ const before = await readForecast();
+ expect(before).not.toBeNull();
+
+ await startPeriod("2026-08-07");
+ const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
+ where: { userId: USER_ID, date: "2026-08-07" },
+ select: { id: true },
+ });
+ const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
+ await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), {
+ params: Promise.resolve({ id: dayLog.id }),
+ });
+
+ expect(await readForecast()).toBe(before);
+ });
+});
From ed274de0a01479d98b216a5fdafd34db2eded3fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:05:50 +0200
Subject: [PATCH 10/18] fix(cycle): keep the cycle day when the phase band is
withheld
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cold-start gate suppresses the calendar's phase band until three
cycles are observed, because a population-framed phase word off one or
two of them is not earned. The verdict read the day count off those same
labels, so the gate took the count with it: a record holding three
logged period starts resolved INSUFFICIENT_DATA and rendered an empty
ring under an offer to log a first period.
The count is not the claim the gate is about. It is the number of days
since a start the person entered themselves. The verdict now takes that
start from the cycle rows and falls back to counting from it whenever
today carries no phase, with the same overdue ceiling and no phase
claim. INSUFFICIENT_DATA is left for the case that really is one: no
period start on record at or before today.
The sheet's day header was already written for this state and could
never reach it — it renders a bare day number when no phase is
trustworthy.
- verdict round trip covers the still-learning day count and its overdue
side through the real route
- unit tests pin the anchor path, the absent-from-grid path, and that a
future-dated start is not a cycle anyone is in
---
.../__tests__/verdict-round-trip.test.ts | 66 +++++++++++++++++++
src/app/api/cycle/calendar/route.ts | 6 ++
src/lib/cycle/__tests__/verdict.test.ts | 54 +++++++++++++++
src/lib/cycle/verdict.ts | 64 +++++++++++++++++-
4 files changed, 188 insertions(+), 2 deletions(-)
diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
index 4e10ce533..259329ac8 100644
--- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
+++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
@@ -401,6 +401,72 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
});
});
+ /**
+ * The cold-start gate suppresses the PHASE band while the engine is still
+ * learning — a population-framed phase word off one or two cycles is not
+ * earned. The day count is not a guess: it is the number of days since a
+ * start the person logged themselves. Reading it off the phase labels meant
+ * the gate took both, so a record with three logged periods rendered an
+ * empty ring and offered to log a first period.
+ */
+ describe("while the engine is still learning", () => {
+ it("still counts the cycle day off the logged start", async () => {
+ const today = "2026-06-11";
+ const lastStart = addDays(today, -10);
+ // Three starts → two observed lengths → under the three-cycle gate.
+ vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
+ cycleRows([
+ addDays(lastStart, -56),
+ addDays(lastStart, -28),
+ lastStart,
+ ]) as never,
+ );
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const { body } = await callCalendar();
+ const data = body.data as Record;
+ // The gate really is on, so the phase below is suppressed rather than absent.
+ expect(data.stillLearning).toBe(true);
+
+ const verdict = data.verdict as Record;
+ expect(verdict.state).toBe("IN_CYCLE");
+ expect(verdict.dayOfCycle).toBe(11);
+ expect(verdict.cycleStartDate).toBe(lastStart);
+ // No phase claim — that part of the gate stands.
+ expect(verdict.phase).toBeNull();
+ // The ring still draws a full wheel rather than an empty circle.
+ expect(verdict.spans).toHaveLength(4);
+ });
+
+ it("still says how late a period is", async () => {
+ const today = "2026-06-11";
+ const lastStart = addDays(today, -50);
+ vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
+ cycleRows([
+ addDays(lastStart, -56),
+ addDays(lastStart, -28),
+ lastStart,
+ ]) as never,
+ );
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const { body } = await callCalendar();
+ const verdict = (body.data as Record).verdict as Record<
+ string,
+ unknown
+ >;
+
+ // Day 51 against a stated 28-day typical plus 14 days of grace.
+ expect(verdict.state).toBe("OVERDUE");
+ expect(verdict.overdueDays).toBe(23);
+ expect(verdict.cycleStartDate).toBe(lastStart);
+ });
+ });
+
it("says INSUFFICIENT_DATA when nothing has been logged", async () => {
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts
index f5f4d1f51..2c69eb869 100644
--- a/src/app/api/cycle/calendar/route.ts
+++ b/src/app/api/cycle/calendar/route.ts
@@ -195,6 +195,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
nextPeriodStart: predictionDto?.nextPeriodStart ?? null,
fertileWindowStart: predictionDto?.fertileWindowStart ?? null,
fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null,
+ // The latest start the person logged, read off the cycle rows rather than
+ // the grid. The grid's phase band is withheld while the engine is still
+ // learning, and the day count must not go with it — `cycles` is ordered by
+ // startDate ascending, so the last one at or before today is the anchor.
+ lastPeriodStart:
+ cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
});
annotate({
diff --git a/src/lib/cycle/__tests__/verdict.test.ts b/src/lib/cycle/__tests__/verdict.test.ts
index a7be69428..117c61efc 100644
--- a/src/lib/cycle/__tests__/verdict.test.ts
+++ b/src/lib/cycle/__tests__/verdict.test.ts
@@ -163,6 +163,60 @@ describe("resolveCycleVerdict — what the record supports", () => {
expect(v.cycleLength).toBe(8);
});
+ it("counts off the logged start when the grid withholds its phase labels", () => {
+ // What the calendar hands over while the engine is still learning: a grid
+ // with every phase suppressed. The count is not the suppressed claim.
+ const days = [day("2026-06-01", null), day("2026-06-11", null)];
+ const v = resolveCycleVerdict({
+ days,
+ today: "2026-06-11",
+ profile: { typicalCycleLength: 28 },
+ lastPeriodStart: "2026-06-01",
+ });
+ expect(v.state).toBe("IN_CYCLE");
+ expect(v.dayOfCycle).toBe(11);
+ expect(v.cycleStartDate).toBe("2026-06-01");
+ expect(v.phase).toBeNull();
+ expect(v.spans).toHaveLength(4);
+ });
+
+ it("says how late a period is with no phase labels to read", () => {
+ const days = [day("2026-06-11", null)];
+ const v = resolveCycleVerdict({
+ days,
+ today: "2026-06-11",
+ profile: { typicalCycleLength: 28 },
+ lastPeriodStart: "2026-04-22",
+ });
+ expect(v.state).toBe("OVERDUE");
+ expect(v.dayOfCycle).toBeNull();
+ expect(v.overdueDays).toBe(23);
+ expect(v.cycleStartDate).toBe("2026-04-22");
+ });
+
+ it("counts off the logged start when today is absent from the grid", () => {
+ const days = [day("2026-06-01", "MENSTRUAL")];
+ const v = resolveCycleVerdict({
+ days,
+ today: "2026-06-09",
+ lastPeriodStart: "2026-06-01",
+ });
+ expect(v.state).toBe("IN_CYCLE");
+ expect(v.dayOfCycle).toBe(9);
+ });
+
+ it("keeps INSUFFICIENT_DATA when the anchor is ahead of today", () => {
+ // A start dated in the future is not a cycle the person is in.
+ const days = [day("2026-06-02", null)];
+ const v = resolveCycleVerdict({
+ days,
+ today: "2026-06-02",
+ lastPeriodStart: "2026-06-20",
+ });
+ expect(v.state).toBe("INSUFFICIENT_DATA");
+ expect(v.cycleStartDate).toBeNull();
+ });
+
it("draws the canonical four arcs for a low-data tracker", () => {
// A two-day first-ever run: the observed-share math would let MENSTRUAL
// fill nearly the whole circle, so the ring falls back to the profile's
diff --git a/src/lib/cycle/verdict.ts b/src/lib/cycle/verdict.ts
index 5fb5ecadd..ae7a83c91 100644
--- a/src/lib/cycle/verdict.ts
+++ b/src/lib/cycle/verdict.ts
@@ -116,6 +116,21 @@ export interface ResolveCycleVerdictInput {
/** Already goal- and cold-start-gated fertile window bounds. */
fertileWindowStart?: string | null;
fertileWindowEnd?: string | null;
+ /**
+ * The latest LOGGED period start at or before today, or null when the record
+ * holds none. Read straight off the cycle rows, never off the grid.
+ *
+ * The grid's phase labels are gated: the calendar suppresses the whole phase
+ * band while the engine is still learning (fewer than three observed cycles),
+ * because a population-framed phase word off one or two cycles is not earned.
+ * The day count is not that kind of claim — it is the number of days since a
+ * start the person entered themselves — but deriving it from the labelled run
+ * meant the gate took both, and a record with three logged periods rendered an
+ * empty ring under an offer to log a first period. This anchor keeps the count
+ * and the honest overdue judgement available when the labels are withheld;
+ * the phase stays null, which is the part the gate is actually about.
+ */
+ lastPeriodStart?: string | null;
}
/** Below this many labelled days in the current cycle run, the ring would
@@ -234,6 +249,41 @@ function resolveFertileWindow(
};
}
+/**
+ * The verdict for a record whose grid carries no phase for today but whose
+ * cycle rows carry a logged period start: count from that start, and apply the
+ * same overdue ceiling. No phase is claimed — that is exactly the claim the
+ * missing labels were withholding. Null when no start precedes today, which is
+ * the one case that really is INSUFFICIENT_DATA.
+ */
+function verdictFromLoggedStart(
+ input: ResolveCycleVerdictInput,
+ fertileWindow: CycleVerdict["fertileWindow"],
+ daysUntilNext: number | null,
+): CycleVerdict | null {
+ const { today, profile, lastPeriodStart } = input;
+ if (!lastPeriodStart) return null;
+ const elapsed = dayDiff(today, lastPeriodStart);
+ if (elapsed < 0) return null;
+
+ const dayOfCycle = elapsed + 1;
+ const typicalLength = typicalCycleLength(profile);
+ const ideal = idealizedSpans(profile);
+ const overdue = dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS;
+
+ return {
+ state: overdue ? "OVERDUE" : "IN_CYCLE",
+ dayOfCycle: overdue ? null : dayOfCycle,
+ cycleLength: ideal.cycleLength,
+ phase: null,
+ spans: ideal.spans,
+ cycleStartDate: lastPeriodStart,
+ overdueDays: overdue ? dayOfCycle - typicalLength : null,
+ daysUntilNext,
+ fertileWindow,
+ };
+}
+
/**
* Resolve everything a client needs to render the cycle ring and its caption.
* The only place in the product that decides any of it.
@@ -259,12 +309,22 @@ export function resolveCycleVerdict(
fertileWindow,
};
- if (!todayDay || todayDay.phase == null) return nothingToSay;
+ if (!todayDay || todayDay.phase == null) {
+ return (
+ verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ??
+ nothingToSay
+ );
+ }
// Sort dates ascending and find today's index.
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
const todayIdx = sorted.findIndex((d) => d.date === today);
- if (todayIdx < 0) return nothingToSay;
+ if (todayIdx < 0) {
+ return (
+ verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ??
+ nothingToSay
+ );
+ }
const startIdx = findCycleStartIndex(sorted, todayIdx);
const cycleStartDate = sorted[startIdx].date;
From bd6089734de08c501f8b2a0c25684f4a1bb0f85c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:20:29 +0200
Subject: [PATCH 11/18] fix(cycle): let the month grid read the month it is
showing
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.
The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.
A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.
The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.
The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.
- calendar days carry isCycleStart, so the delete dialog can say that
removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
read, and comes back complete on a window over its month
- new strings in all six languages
---
docs/api/openapi.yaml | 4 +
messages/de.json | 15 ++-
messages/en.json | 15 ++-
messages/es.json | 15 ++-
messages/fr.json | 15 ++-
messages/it.json | 15 ++-
messages/pl.json | 15 ++-
.../cycle/__tests__/bbt-chart.test.tsx | 1 +
.../cycle/__tests__/calendar-window.test.ts | 93 +++++++++++++++
.../cycle/__tests__/cycle-calendar.test.tsx | 1 +
src/components/cycle/calendar-window.ts | 58 ++++++++++
src/components/cycle/cycle-calendar.tsx | 20 +++-
src/components/cycle/cycle-view.tsx | 79 +++++++------
src/components/cycle/log-day-sheet.tsx | 61 ++++++++--
src/components/cycle/types.ts | 2 +
src/components/cycle/use-cycle.ts | 45 +++++++-
src/lib/cycle/engine-adapter.ts | 10 ++
src/lib/openapi/routes/cycle.ts | 4 +
tests/integration/cycle-routes.test.ts | 107 ++++++++++++++++++
19 files changed, 508 insertions(+), 67 deletions(-)
create mode 100644 src/components/cycle/__tests__/calendar-window.test.ts
create mode 100644 src/components/cycle/calendar-window.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index eddfd2b8c..92583d744 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -16789,6 +16789,9 @@ components:
type: boolean
isPeriodLogged:
type: boolean
+ isCycleStart:
+ type: boolean
+ description: Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.
flow:
anyOf:
- type: string
@@ -16856,6 +16859,7 @@ components:
- isFertileWindow
- isPredictedOvulation
- isPeriodLogged
+ - isCycleStart
- flow
- hasSymptoms
- confidence
diff --git a/messages/de.json b/messages/de.json
index f3cf1a8af..46b5c16c3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -7709,7 +7709,7 @@
},
"sheet": {
"title": "Tag erfassen",
- "description": "Erfasse Stärke, Symptome und Notizen von heute.",
+ "description": "Stärke, Symptome und Notizen für diesen Tag.",
"flow": "Stärke",
"symptoms": "Symptome",
"temperature": "Basaltemperatur",
@@ -7744,7 +7744,10 @@
"intimacySection": "Intimität & Verhütung",
"testsSection": "Tests",
"phaseDayHeader": "Tag {day} · {phase}",
- "bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen."
+ "bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen.",
+ "titleForDate": "Eintrag für {date}",
+ "descriptionToday": "Heute. Stärke, Symptome und Notizen.",
+ "deleteBodyCycleStart": "Dieser Tag eröffnet einen Zyklus. Beim Löschen geht der Periodenbeginn mit, sodass der vorherige Zyklus wie zuvor weiterläuft. Alles hier Erfasste geht ebenfalls: Stärke, Temperatur, Symptome und deine Notiz. Es gibt keinen Weg zurück."
},
"settings": {
"title": "Zyklus-Einstellungen",
@@ -7821,7 +7824,13 @@
"intercourseLabel": "Was erfasst Geschlechtsverkehr?",
"protected": "Ob beim Verkehr verhütet wurde.",
"protectedLabel": "Was bedeutet geschützt?"
- }
+ },
+ "saveSuccess": "Eintrag gespeichert.",
+ "deleteSuccess": "Eintrag gelöscht.",
+ "periodStartSaved": "Periodenbeginn gespeichert.",
+ "periodEndSaved": "Periodenende gespeichert.",
+ "startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
+ "endedPeriodOnDate": "Periode an diesem Tag beendet"
},
"cycleReminders": {
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
diff --git a/messages/en.json b/messages/en.json
index 9969e7de4..5ca03bf0f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7709,7 +7709,7 @@
},
"sheet": {
"title": "Log your day",
- "description": "Capture today's flow, symptoms, and notes.",
+ "description": "Flow, symptoms and notes for this day.",
"flow": "Flow",
"symptoms": "Symptoms",
"temperature": "Basal body temperature",
@@ -7744,7 +7744,10 @@
"intimacySection": "Intimacy & contraception",
"testsSection": "Tests",
"phaseDayHeader": "Day {day} · {phase}",
- "bbtHint": "Take it first thing in the morning, before getting up."
+ "bbtHint": "Take it first thing in the morning, before getting up.",
+ "titleForDate": "Log for {date}",
+ "descriptionToday": "Today. Flow, symptoms and notes.",
+ "deleteBodyCycleStart": "This day opens a cycle. Deleting it takes the period start with it, so the previous cycle runs on as before. Everything logged here goes too: flow, temperature, symptoms and your note. There is no way to bring it back."
},
"testResult": {
"POSITIVE": "Positive",
@@ -7821,7 +7824,13 @@
"intercourseLabel": "What does intercourse track?",
"protected": "Whether contraception was used during intercourse.",
"protectedLabel": "What does protected mean?"
- }
+ },
+ "saveSuccess": "Entry saved.",
+ "deleteSuccess": "Entry deleted.",
+ "periodStartSaved": "Period start saved.",
+ "periodEndSaved": "End of period saved.",
+ "startedPeriodOnDate": "My period started this day",
+ "endedPeriodOnDate": "My period ended this day"
},
"cycleReminders": {
"periodSoonTitle": "Period likely soon",
diff --git a/messages/es.json b/messages/es.json
index a800a3727..af9f0d2ba 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -7709,7 +7709,7 @@
},
"sheet": {
"title": "Registrar tu día",
- "description": "Captura el flujo, los síntomas y las notas de hoy.",
+ "description": "Flujo, síntomas y notas de este día.",
"flow": "Flujo",
"symptoms": "Síntomas",
"temperature": "Temperatura basal",
@@ -7744,7 +7744,10 @@
"intimacySection": "Intimidad y anticoncepción",
"testsSection": "Pruebas",
"phaseDayHeader": "Día {day} · {phase}",
- "bbtHint": "Mídela a primera hora de la mañana, antes de levantarte."
+ "bbtHint": "Mídela a primera hora de la mañana, antes de levantarte.",
+ "titleForDate": "Registro del {date}",
+ "descriptionToday": "Hoy. Flujo, síntomas y notas.",
+ "deleteBodyCycleStart": "Este día abre un ciclo. Al eliminarlo se va con él el inicio del periodo, de modo que el ciclo anterior continúa como antes. También se va todo lo registrado aquí: flujo, temperatura, síntomas y tu nota. No hay forma de recuperarlo."
},
"settings": {
"title": "Ajustes del ciclo",
@@ -7821,7 +7824,13 @@
"intercourseLabel": "¿Qué registra relaciones?",
"protected": "Si se usó anticoncepción durante las relaciones.",
"protectedLabel": "¿Qué significa protegido?"
- }
+ },
+ "saveSuccess": "Entrada guardada.",
+ "deleteSuccess": "Entrada eliminada.",
+ "periodStartSaved": "Inicio del periodo guardado.",
+ "periodEndSaved": "Fin del periodo guardado.",
+ "startedPeriodOnDate": "Mi periodo empezó este día",
+ "endedPeriodOnDate": "Mi periodo terminó este día"
},
"cycleReminders": {
"periodSoonTitle": "El periodo llegará pronto",
diff --git a/messages/fr.json b/messages/fr.json
index b14279d03..52b0d5fd7 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -7709,7 +7709,7 @@
},
"sheet": {
"title": "Enregistrer ta journée",
- "description": "Saisis le flux, les symptômes et les notes du jour.",
+ "description": "Flux, symptômes et notes de cette journée.",
"flow": "Flux",
"symptoms": "Symptômes",
"temperature": "Température basale",
@@ -7744,7 +7744,10 @@
"intimacySection": "Intimité et contraception",
"testsSection": "Tests",
"phaseDayHeader": "Jour {day} · {phase}",
- "bbtHint": "Prends-la dès le matin, avant de te lever."
+ "bbtHint": "Prends-la dès le matin, avant de te lever.",
+ "titleForDate": "Saisie du {date}",
+ "descriptionToday": "Aujourd'hui. Flux, symptômes et notes.",
+ "deleteBodyCycleStart": "Cette journée ouvre un cycle. La supprimer emporte le début des règles, si bien que le cycle précédent se poursuit comme avant. Tout ce qui est saisi ici disparaît aussi : flux, température, symptômes et ta note. Rien ne peut être récupéré."
},
"settings": {
"title": "Réglages du cycle",
@@ -7821,7 +7824,13 @@
"intercourseLabel": "Que suit le rapport ?",
"protected": "Si une contraception a été utilisée pendant le rapport.",
"protectedLabel": "Que signifie protégé ?"
- }
+ },
+ "saveSuccess": "Entrée enregistrée.",
+ "deleteSuccess": "Entrée supprimée.",
+ "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à"
},
"cycleReminders": {
"periodSoonTitle": "Règles probablement bientôt",
diff --git a/messages/it.json b/messages/it.json
index 72a8faeb1..39489237a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -7709,7 +7709,7 @@
},
"sheet": {
"title": "Registra la tua giornata",
- "description": "Annota flusso, sintomi e note di oggi.",
+ "description": "Flusso, sintomi e note di questo giorno.",
"flow": "Flusso",
"symptoms": "Sintomi",
"temperature": "Temperatura basale",
@@ -7744,7 +7744,10 @@
"intimacySection": "Intimità e contraccezione",
"testsSection": "Test",
"phaseDayHeader": "Giorno {day} · {phase}",
- "bbtHint": "Misurala appena sveglia, prima di alzarti."
+ "bbtHint": "Misurala appena sveglia, prima di alzarti.",
+ "titleForDate": "Registrazione del {date}",
+ "descriptionToday": "Oggi. Flusso, sintomi e note.",
+ "deleteBodyCycleStart": "Questo giorno apre un ciclo. Eliminandolo sparisce anche l'inizio delle mestruazioni, così il ciclo precedente prosegue come prima. Se ne va pure tutto ciò che è annotato qui: flusso, temperatura, sintomi e la tua nota. Non c'è modo di recuperarlo."
},
"settings": {
"title": "Impostazioni del ciclo",
@@ -7821,7 +7824,13 @@
"intercourseLabel": "Cosa registra i rapporti?",
"protected": "Se durante il rapporto è stata usata la contraccezione.",
"protectedLabel": "Cosa significa protetto?"
- }
+ },
+ "saveSuccess": "Voce salvata.",
+ "deleteSuccess": "Voce eliminata.",
+ "periodStartSaved": "Inizio delle mestruazioni salvato.",
+ "periodEndSaved": "Fine delle mestruazioni salvata.",
+ "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
+ "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
},
"cycleReminders": {
"periodSoonTitle": "Ciclo probabilmente in arrivo",
diff --git a/messages/pl.json b/messages/pl.json
index ddd1b58b4..2b7c3e606 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -7709,7 +7709,7 @@
},
"sheet": {
"title": "Zapisz swój dzień",
- "description": "Zapisz dzisiejsze krwawienie, objawy i notatki.",
+ "description": "Krwawienie, objawy i notatki z tego dnia.",
"flow": "Krwawienie",
"symptoms": "Objawy",
"temperature": "Podstawowa temperatura ciała",
@@ -7744,7 +7744,10 @@
"intimacySection": "Intymność i antykoncepcja",
"testsSection": "Testy",
"phaseDayHeader": "Dzień {day} · {phase}",
- "bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem."
+ "bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem.",
+ "titleForDate": "Wpis z {date}",
+ "descriptionToday": "Dzisiaj. Krwawienie, objawy i notatki.",
+ "deleteBodyCycleStart": "Ten dzień otwiera cykl. Usunięcie go kasuje razem z nim początek miesiączki, więc poprzedni cykl biegnie dalej jak wcześniej. Znika też wszystko, co tu zapisano: krwawienie, temperatura, objawy i twoja notatka. Nie da się tego cofnąć."
},
"settings": {
"title": "Ustawienia cyklu",
@@ -7821,7 +7824,13 @@
"intercourseLabel": "Co śledzi stosunek?",
"protected": "Czy podczas stosunku zastosowano antykoncepcję.",
"protectedLabel": "Co oznacza zabezpieczony?"
- }
+ },
+ "saveSuccess": "Wpis zapisany.",
+ "deleteSuccess": "Wpis usunięty.",
+ "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"
},
"cycleReminders": {
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx
index 296831157..36437b9f7 100644
--- a/src/components/cycle/__tests__/bbt-chart.test.tsx
+++ b/src/components/cycle/__tests__/bbt-chart.test.tsx
@@ -19,6 +19,7 @@ function day(date: string, overrides: Partial = {}): CalendarDay {
isFertileWindow: false,
isPredictedOvulation: false,
isPeriodLogged: false,
+ isCycleStart: false,
flow: null,
hasSymptoms: false,
confidence: 1,
diff --git a/src/components/cycle/__tests__/calendar-window.test.ts b/src/components/cycle/__tests__/calendar-window.test.ts
new file mode 100644
index 000000000..79dfd4f2e
--- /dev/null
+++ b/src/components/cycle/__tests__/calendar-window.test.ts
@@ -0,0 +1,93 @@
+/**
+ * The window arithmetic behind the month grid's own read.
+ *
+ * The bug it exists for is a save that appeared not to happen: the page read a
+ * fixed ninety-days-back window, an April cell in August fell outside it, and
+ * the write landed with nothing on screen to show for it. The month bounds and
+ * the "is this month already covered" question are the whole of the decision,
+ * so they are pinned here rather than left inside a component no test can drive.
+ */
+import { describe, it, expect } from "vitest";
+
+import { monthBounds, monthOf, monthWindowOutside } from "../calendar-window";
+
+describe("monthBounds", () => {
+ it("spans a 30-day month", () => {
+ expect(monthBounds("2026-04")).toEqual({
+ from: "2026-04-01",
+ to: "2026-04-30",
+ });
+ });
+
+ it("spans a 31-day month", () => {
+ expect(monthBounds("2026-08")).toEqual({
+ from: "2026-08-01",
+ to: "2026-08-31",
+ });
+ });
+
+ it("spans February in a common year and in a leap year", () => {
+ expect(monthBounds("2026-02").to).toBe("2026-02-28");
+ expect(monthBounds("2028-02").to).toBe("2028-02-29");
+ });
+
+ it("zero-pads so a day key compares as a date", () => {
+ expect(monthBounds("2026-01")).toEqual({
+ from: "2026-01-01",
+ to: "2026-01-31",
+ });
+ expect(monthBounds("2026-09").from < monthBounds("2026-10").from).toBe(
+ true,
+ );
+ });
+});
+
+describe("monthWindowOutside", () => {
+ // The page's anchored read on 2026-08-07: 90 days back, 180 forward.
+ const anchor = { from: "2026-05-09", to: "2027-02-03" };
+
+ it("reads nothing more for the month the anchor already carries", () => {
+ expect(monthWindowOutside("2026-08", anchor)).toBeNull();
+ expect(monthWindowOutside("2026-06", anchor)).toBeNull();
+ });
+
+ it("asks for the month the maintainer could not back-fill into", () => {
+ expect(monthWindowOutside("2026-04", anchor)).toEqual({
+ from: "2026-04-01",
+ to: "2026-04-30",
+ });
+ });
+
+ it("asks for a month that only overlaps the anchor's edge", () => {
+ // May 2026 starts before 2026-05-09, so half of it is missing.
+ expect(monthWindowOutside("2026-05", anchor)).toEqual({
+ from: "2026-05-01",
+ to: "2026-05-31",
+ });
+ });
+
+ it("asks for a month past the forward edge too", () => {
+ expect(monthWindowOutside("2027-03", anchor)).toEqual({
+ from: "2027-03-01",
+ to: "2027-03-31",
+ });
+ });
+
+ it("stays a bounded read however far back the grid navigates", () => {
+ // The anchor cannot simply be widened: the calendar route caps a span at
+ // 400 days, and a union with a month two years back blows through it.
+ const far = monthWindowOutside("2024-01", anchor);
+ expect(far).toEqual({ from: "2024-01-01", to: "2024-01-31" });
+ const span =
+ (Date.parse(`${far!.to}T12:00:00Z`) -
+ Date.parse(`${far!.from}T12:00:00Z`)) /
+ 86_400_000;
+ expect(span).toBeLessThan(31);
+ });
+});
+
+describe("monthOf", () => {
+ it("takes the month a day key belongs to", () => {
+ expect(monthOf("2026-04-18")).toBe("2026-04");
+ });
+});
diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx
index e24b107cd..f57f782f2 100644
--- a/src/components/cycle/__tests__/cycle-calendar.test.tsx
+++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx
@@ -19,6 +19,7 @@ function dayBase(date: string): CalendarDay {
isFertileWindow: false,
isPredictedOvulation: false,
isPeriodLogged: false,
+ isCycleStart: false,
flow: null,
hasSymptoms: false,
confidence: 1,
diff --git a/src/components/cycle/calendar-window.ts b/src/components/cycle/calendar-window.ts
new file mode 100644
index 000000000..346ed8ee7
--- /dev/null
+++ b/src/components/cycle/calendar-window.ts
@@ -0,0 +1,58 @@
+/**
+ * The date range the /cycle page reads, and what a month outside it needs.
+ *
+ * The page reads one anchored window — ninety days back, half a year forward —
+ * and that window drives the ring, the forecast panel and the temperature
+ * chart, all of which are about today. The month grid is not: it navigates
+ * freely, and a month the anchored read does not cover came back with no days
+ * at all. The cells still rendered (they come from the month, not the read) and
+ * still opened the log sheet, and the write behind it still landed — the period
+ * route is built for back-dating and re-anchors the neighbouring cycles for it.
+ * What did not happen is anything visible. Save an April flow in August and the
+ * sheet closes onto a month the read never carried, so the entry appears not to
+ * have been made, and entering it again does not help.
+ *
+ * So the month grid gets its own read when it steps outside the anchor. A month
+ * is a bounded thing to fetch, the anchored window stays exactly as it was for
+ * every surface that means "today", and the two grids merge by date.
+ *
+ * Pure and dateless — the caller supplies both the month and the window.
+ */
+
+/** An inclusive `YYYY-MM-DD` range, the shape the calendar read takes. */
+export interface CalendarWindow {
+ from: string;
+ to: string;
+}
+
+/** Zero-padded `YYYY-MM-DD`. */
+function ymd(year: number, month: number, day: number): string {
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
+}
+
+/** First and last day of a `YYYY-MM` month. */
+export function monthBounds(month: string): CalendarWindow {
+ const [year, monthIndex] = month.split("-").map(Number);
+ // Day 0 of the following month is the last day of this one, leap years and all.
+ const lastDay = new Date(year, monthIndex, 0).getDate();
+ return { from: ymd(year, monthIndex, 1), to: ymd(year, monthIndex, lastDay) };
+}
+
+/**
+ * The extra window a visible month needs, or null when `anchor` already covers
+ * it and the page should read nothing more. Day keys are zero-padded, so a
+ * plain string compare is a date compare.
+ */
+export function monthWindowOutside(
+ month: string,
+ anchor: CalendarWindow,
+): CalendarWindow | null {
+ const bounds = monthBounds(month);
+ if (bounds.from >= anchor.from && bounds.to <= anchor.to) return null;
+ return bounds;
+}
+
+/** The `YYYY-MM` a `YYYY-MM-DD` day key belongs to. */
+export function monthOf(date: string): string {
+ return date.slice(0, 7);
+}
diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx
index d72c2fd38..e9fc3acfb 100644
--- a/src/components/cycle/cycle-calendar.tsx
+++ b/src/components/cycle/cycle-calendar.tsx
@@ -76,6 +76,13 @@ export interface CycleCalendarProps {
* rather than opening a sheet whose every button the server would refuse.
*/
onSelectDay?: (date: string) => void;
+ /**
+ * The month the grid moved to (`YYYY-MM`). The page's anchored read covers
+ * a fixed span around today, so a month outside it arrives with no days —
+ * the caller uses this to fetch that month rather than render empty cells
+ * over entries that exist.
+ */
+ onVisibleMonthChange?: (month: string) => void;
className?: string;
}
@@ -84,6 +91,7 @@ export function CycleCalendar({
today,
confirmedOvulation,
onSelectDay,
+ onVisibleMonthChange,
className,
}: CycleCalendarProps) {
const { t, locale } = useTranslations();
@@ -125,7 +133,17 @@ export function CycleCalendar({
});
function shiftMonth(delta: number) {
- setMonthAnchor((a) => new Date(a.getFullYear(), a.getMonth() + delta, 1));
+ const next = new Date(
+ monthAnchor.getFullYear(),
+ monthAnchor.getMonth() + delta,
+ 1,
+ );
+ setMonthAnchor(next);
+ // The page reads a window anchored on today, so a month outside it has no
+ // days until somebody asks for them.
+ onVisibleMonthChange?.(
+ `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`,
+ );
}
return (
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 65adf14de..ddccf448f 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -3,13 +3,7 @@
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
-import {
- Loader2,
- Plus,
- RefreshCw,
- SlidersHorizontal,
- Sparkles,
-} from "lucide-react";
+import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -29,6 +23,7 @@ import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
import { CycleSettings } from "./cycle-settings";
import { PHASE_HUE } from "./phase-tokens";
+import { monthOf, monthWindowOutside } from "./calendar-window";
import {
localYmd,
useCycleCalendar,
@@ -110,9 +105,7 @@ export function CycleView() {
: "calendar";
}, [searchParams]);
- // Controlled so the header "cycle settings" wrench (parity with the
- // labs/medications/illness modules, which expose a customize glyph) can jump
- // to the settings tab. Initialised from the `?tab=` deep-link, matching the
+ // Controlled tabs, initialised from the `?tab=` deep-link and matching the
// prior uncontrolled `defaultValue` behaviour (the initial param is honoured;
// a later param change does not force-switch a tab the user is reading).
const [tab, setTab] = useState(initialTab);
@@ -126,6 +119,29 @@ export function CycleView() {
const profileQuery = useCycleProfile();
const insights = useCycleInsights();
+ // The month grid navigates freely; the anchored read above does not follow
+ // it, because every other surface on this page (the ring, the forecast panel,
+ // the temperature chart) is about today and the route caps a span at 400
+ // days. A month outside the anchor therefore gets its own bounded read, and
+ // the two grids merge by date. Without it a back-dated entry landed on the
+ // server and had nothing on screen to land in.
+ const [visibleMonth, setVisibleMonth] = useState(() => monthOf(today));
+ const monthWindow = useMemo(
+ () => monthWindowOutside(visibleMonth, { from, to }),
+ [visibleMonth, from, to],
+ );
+ const monthCalendar = useCycleCalendar(
+ monthWindow?.from ?? from,
+ monthWindow?.to ?? to,
+ monthWindow !== null,
+ );
+ // Month days last: on the edge months the two reads overlap and the grid
+ // keys by date, so the later entry wins and both carry the same answer.
+ const calendarDays = useMemo(
+ () => [...(calendar.data?.days ?? []), ...(monthCalendar.data?.days ?? [])],
+ [calendar.data?.days, monthCalendar.data?.days],
+ );
+
const [sheetOpen, setSheetOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState(today);
@@ -167,30 +183,18 @@ export function CycleView() {
title={{t("cycle.title")}}
description={t("cycle.subtitle")}
actions={
- <>
- {canManage && (
- <>
-
-
- >
- )}
- >
+ // One action. The settings glyph that used to sit beside it was a
+ // second door to the tab directly below, and no other page in the
+ // app carries a gear in that corner.
+ canManage ? (
+
+ ) : null
}
/>
@@ -365,7 +369,7 @@ export function CycleView() {
) : null}
>
)}
@@ -458,6 +463,10 @@ export function CycleView() {
onOpenChange={setSheetOpen}
date={selectedDate}
today={today}
+ startsCycle={
+ calendarDays.find((d) => d.date === selectedDate)?.isCycleStart ??
+ false
+ }
activePeriod={verdict?.phase === "MENSTRUAL"}
phase={verdict?.phase ?? null}
dayOfCycle={verdict?.dayOfCycle ?? null}
diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx
index b7732e010..ebb633514 100644
--- a/src/components/cycle/log-day-sheet.tsx
+++ b/src/components/cycle/log-day-sheet.tsx
@@ -45,6 +45,8 @@ import {
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
+import { resolveIntlLocale } from "@/lib/format-locale";
+import type { Locale } from "@/lib/i18n/config";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
@@ -321,6 +323,11 @@ export interface LogDaySheetProps {
date: string;
/** Today (YYYY-MM-DD). */
today: string;
+ /**
+ * Whether a logged cycle opens on `date`. Deleting the day-log of a cycle
+ * start removes that start too, so the confirm dialog has to say so.
+ */
+ startsCycle?: boolean;
/**
* Whether a period is currently open (today is in the MENSTRUAL phase) — gates
* the one-tap "end period" affordance so it never shows when no period is in
@@ -463,10 +470,28 @@ function CervixRow({
);
}
+/**
+ * The day the sheet is writing to, spelled out in the reader's language.
+ *
+ * The sheet used to be titled "Log your day" whichever cell opened it, so a
+ * back-dated entry gave no clue which date it would land on and "Log today"
+ * gave no clue that any other date was possible. The weekday is included
+ * deliberately: it is how people recognise a date they are recalling.
+ */
+export function formatSheetDate(date: string, locale: Locale): string {
+ const [year, month, day] = date.split("-").map(Number);
+ return new Date(year, month - 1, day).toLocaleDateString(
+ resolveIntlLocale(locale),
+ { weekday: "long", day: "numeric", month: "long", year: "numeric" },
+ );
+}
+
export function LogDaySheet({
open,
onOpenChange,
date,
+ today,
+ startsCycle = false,
activePeriod = false,
phase = null,
dayOfCycle = null,
@@ -475,7 +500,7 @@ export function LogDaySheet({
rawChartMode = false,
cyclesObserved = 0,
}: LogDaySheetProps) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
const logDay = useLogDay();
const patchDay = usePatchDayLog();
const startPeriod = useStartPeriod();
@@ -682,8 +707,20 @@ export function LogDaySheet({
+ {t("cycle.sheet.titleForDate", {
+ date: formatSheetDate(date, locale),
+ })}
+
+ }
+ description={
+ date === today
+ ? t("cycle.sheet.descriptionToday")
+ : t("cycle.sheet.description")
+ }
contentWidth="lg"
footer={
<>
@@ -696,7 +733,11 @@ export function LogDaySheet({
label={t("cycle.sheet.delete")}
ariaLabel={t("cycle.sheet.delete")}
title={t("cycle.sheet.deleteTitle")}
- body={t("cycle.sheet.deleteBody")}
+ body={
+ startsCycle
+ ? t("cycle.sheet.deleteBodyCycleStart")
+ : t("cycle.sheet.deleteBody")
+ }
confirmLabel={t("cycle.sheet.deleteConfirm")}
disabled={busy && !deleteDay.isPending}
pending={deleteDay.isPending}
@@ -732,7 +773,9 @@ export function LogDaySheet({
{/* One-tap period boundaries — available on any selected date so a
forgotten day-1 can be corrected retroactively (M3). "End period"
- shows only while a period is actually open (M2). */}
+ shows only while a period is actually open (M2). The labels drop the
+ word "today" on a back-dated day: they always wrote to the selected
+ date, and saying otherwise is how a correction reads as a mistake. */}
diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts
index ed3b0ce97..983b9d5a3 100644
--- a/src/components/cycle/types.ts
+++ b/src/components/cycle/types.ts
@@ -29,6 +29,8 @@ export interface CalendarDay {
isFertileWindow: boolean;
isPredictedOvulation: boolean;
isPeriodLogged: boolean;
+ /** Whether a logged cycle opens on this day. */
+ isCycleStart: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;
diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts
index 323a7ee19..d21060e30 100644
--- a/src/components/cycle/use-cycle.ts
+++ b/src/components/cycle/use-cycle.ts
@@ -93,9 +93,16 @@ export function useCycleDayLog(date: string | null) {
});
}
-export function useCycleCalendar(from: string, to: string) {
+/**
+ * The calendar read for one window. `enabled` exists for the month grid's own
+ * read: the page's anchored window covers today, and a month the grid
+ * navigates outside it needs a second, bounded read rather than a widened
+ * anchor the route's span cap would refuse.
+ */
+export function useCycleCalendar(from: string, to: string, enabled = true) {
return useQuery({
queryKey: queryKeys.cycleCalendar(from, to),
+ enabled,
queryFn: () =>
apiGet(`/api/cycle/calendar?from=${from}&to=${to}`),
staleTime: 60_000,
@@ -220,15 +227,28 @@ function idempotencyKey(): string {
return crypto.randomUUID();
}
+/**
+ * Capture one day.
+ *
+ * The success toast is not decoration. A day-log write is silent by nature —
+ * the sheet closes and the grid repaints — and for a back-dated entry the grid
+ * had nothing to repaint, so a save that worked and a save that never happened
+ * looked identical. A write says so now, either way: this names the success and
+ * the sheet's inline strip names the failure.
+ */
export function useLogDay() {
const qc = useQueryClient();
+ const { t } = useTranslations();
return useMutation({
mutationFn: async (input: CycleDayLogInput) => {
return apiPost("/api/cycle/day-logs", input, {
headers: { "Idempotency-Key": idempotencyKey() },
});
},
- onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
+ onSuccess: () => {
+ void invalidateKeys(qc, cycleDependentKeys);
+ toast.success(t("cycle.saveSuccess"));
+ },
});
}
@@ -260,6 +280,7 @@ export interface CycleDayLogPatch {
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
export function usePatchDayLog() {
const qc = useQueryClient();
+ const { t } = useTranslations();
return useMutation({
mutationFn: async ({
id,
@@ -270,7 +291,10 @@ export function usePatchDayLog() {
}) => {
return apiPatch(`/api/cycle/day-logs/${id}`, patch);
},
- onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
+ onSuccess: () => {
+ void invalidateKeys(qc, cycleDependentKeys);
+ toast.success(t("cycle.saveSuccess"));
+ },
});
}
@@ -285,7 +309,10 @@ export function useStartPeriod() {
loggedAt: new Date().toISOString(),
});
},
- onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
+ onSuccess: () => {
+ void invalidateKeys(qc, cycleDependentKeys);
+ toast.success(t("cycle.periodStartSaved"));
+ },
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
onError: () => toast.error(t("cycle.saveError")),
@@ -304,7 +331,10 @@ export function useEndPeriod() {
loggedAt: new Date().toISOString(),
});
},
- onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
+ onSuccess: () => {
+ void invalidateKeys(qc, cycleDependentKeys);
+ toast.success(t("cycle.periodEndSaved"));
+ },
onError: () => toast.error(t("cycle.saveError")),
});
}
@@ -317,7 +347,10 @@ export function useDeleteDayLog() {
mutationFn: async (id: string) => {
await apiDelete(`/api/cycle/day-logs/${id}`);
},
- onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
+ onSuccess: () => {
+ void invalidateKeys(qc, cycleDependentKeys);
+ toast.success(t("cycle.deleteSuccess"));
+ },
onError: () => toast.error(t("cycle.deleteError")),
});
}
diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts
index b2add008d..cd7ddf227 100644
--- a/src/lib/cycle/engine-adapter.ts
+++ b/src/lib/cycle/engine-adapter.ts
@@ -34,6 +34,13 @@ export interface CalendarDayDTO {
isFertileWindow: boolean;
isPredictedOvulation: boolean;
isPeriodLogged: boolean;
+ /**
+ * Whether a logged cycle opens on this day. The client uses it to say what a
+ * delete would take: removing the day-log of a cycle start removes the start
+ * too, and a confirm dialog that does not name that is asking about the
+ * wrong thing.
+ */
+ isCycleStart: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;
@@ -368,6 +375,8 @@ export function buildCalendar(
const logByDate = new Map();
for (const l of dayLogs) logByDate.set(l.date, l);
+ const cycleStartDates = new Set(cycles.map((c) => c.startDate));
+
const days: CalendarDayDTO[] = [];
const span = dayDiff(to, from);
for (let i = 0; i <= span; i++) {
@@ -405,6 +414,7 @@ export function buildCalendar(
isFertileWindow,
isPredictedOvulation,
isPeriodLogged: log?.flow != null && log.flow !== "NONE",
+ isCycleStart: cycleStartDates.has(date),
flow: log?.flow ?? null,
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts
index 3fe139830..cb80b6e62 100644
--- a/src/lib/openapi/routes/cycle.ts
+++ b/src/lib/openapi/routes/cycle.ts
@@ -181,6 +181,10 @@ const cycleCalendarDayDto = z.object({
isFertileWindow: z.boolean(),
isPredictedOvulation: z.boolean(),
isPeriodLogged: z.boolean(),
+ isCycleStart: z.boolean().meta({
+ description:
+ "Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.",
+ }),
flow: flowLevelEnum.nullable(),
hasSymptoms: z.boolean(),
confidence: z.number(),
diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts
index 26f755aae..8dc05e92b 100644
--- a/tests/integration/cycle-routes.test.ts
+++ b/tests/integration/cycle-routes.test.ts
@@ -636,3 +636,110 @@ describe("sync/changes — cycle domains", () => {
expect(cycleTombs[0].id).toBe(cycle.id);
});
});
+
+describe("back-filling an entry months into the past", () => {
+ /** `YYYY-MM-DD`, `days` from today, in the process zone the route resolves. */
+ function dayKey(offset: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() + offset);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
+ d.getDate(),
+ ).padStart(2, "0")}`;
+ }
+
+ // Far enough back to fall outside the page's anchored ninety-day read, the
+ // way an April entry does in August. Dates are relative so the case cannot
+ // slide out of the window it is about as the calendar moves on.
+ const BACK_DATED = dayKey(-111);
+
+ it("writes the day and hands it back on a window that covers it", async () => {
+ await loginAs(FEMALE_USER_ID);
+ const prisma = getPrismaClient();
+
+ const dayLogs = await import("@/app/api/cycle/day-logs/route");
+ const written = await dayLogs.POST(
+ jsonRequest("/api/cycle/day-logs", "POST", {
+ date: BACK_DATED,
+ flow: "HEAVY",
+ loggedAt: `${BACK_DATED}T08:00:00.000Z`,
+ }),
+ );
+ // The route accepts a past date: only the future is refused.
+ expect(written.status).toBe(201);
+
+ // The row is really there, not just an accepted response.
+ const row = await prisma.cycleDayLog.findFirstOrThrow({
+ where: { userId: FEMALE_USER_ID, date: BACK_DATED, deletedAt: null },
+ });
+ expect(row.flow).toBe("HEAVY");
+
+ const { GET } = await import("@/app/api/cycle/calendar/route");
+ // The page's own window. The entry exists and this read cannot show it,
+ // which is the whole of what the client saw as "nothing was saved".
+ const anchored = await GET(
+ new NextRequest(
+ `http://localhost/api/cycle/calendar?from=${dayKey(-90)}&to=${dayKey(180)}`,
+ ),
+ );
+ const anchoredDays = (await anchored.json()).data.days as {
+ date: string;
+ }[];
+ expect(anchoredDays.some((d) => d.date === BACK_DATED)).toBe(false);
+
+ // A window over the month the grid is showing carries it, flow and all.
+ const month = BACK_DATED.slice(0, 7);
+ const lastDay = new Date(
+ Number(month.slice(0, 4)),
+ Number(month.slice(5, 7)),
+ 0,
+ ).getDate();
+ const scoped = await GET(
+ new NextRequest(
+ `http://localhost/api/cycle/calendar?from=${month}-01&to=${month}-${lastDay}`,
+ ),
+ );
+ expect(scoped.status).toBe(200);
+ const scopedDays = (await scoped.json()).data.days as {
+ date: string;
+ flow: string | null;
+ isPeriodLogged: boolean;
+ }[];
+ const hit = scopedDays.find((d) => d.date === BACK_DATED);
+ expect(hit).toBeDefined();
+ expect(hit?.flow).toBe("HEAVY");
+ expect(hit?.isPeriodLogged).toBe(true);
+ });
+
+ it("opens a back-dated cycle and marks the day as its start", async () => {
+ await loginAs(FEMALE_USER_ID);
+ const period = await import("@/app/api/cycle/period/route");
+ const res = await period.POST(
+ jsonRequest("/api/cycle/period", "POST", {
+ action: "start",
+ date: BACK_DATED,
+ loggedAt: `${BACK_DATED}T08:00:00.000Z`,
+ }),
+ );
+ expect(res.status).toBe(200);
+
+ const month = BACK_DATED.slice(0, 7);
+ const lastDay = new Date(
+ Number(month.slice(0, 4)),
+ Number(month.slice(5, 7)),
+ 0,
+ ).getDate();
+ const { GET } = await import("@/app/api/cycle/calendar/route");
+ const scoped = await GET(
+ new NextRequest(
+ `http://localhost/api/cycle/calendar?from=${month}-01&to=${month}-${lastDay}`,
+ ),
+ );
+ const days = (await scoped.json()).data.days as {
+ date: string;
+ isCycleStart: boolean;
+ }[];
+ // The flag the log sheet reads to say what a delete would take.
+ expect(days.find((d) => d.date === BACK_DATED)?.isCycleStart).toBe(true);
+ expect(days.filter((d) => d.isCycleStart)).toHaveLength(1);
+ });
+});
From 3596720905d03ba44fce31776eef729b77f309b1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:50:23 +0200
Subject: [PATCH 12/18] 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
---
src/app/api/cycle/day-logs/[id]/route.ts | 66 +++--
src/app/api/cycle/period/route.ts | 98 ++-----
.../cycle/__tests__/cycle-boundaries.test.ts | 67 +++++
src/lib/cycle/__tests__/day-log-write.test.ts | 49 +++-
src/lib/cycle/cycle-boundaries.ts | 216 +++++++++++++-
src/lib/cycle/day-log-write.ts | 24 +-
.../cycle-flow-opens-cycle.test.ts | 275 ++++++++++++++++++
7 files changed, 684 insertions(+), 111 deletions(-)
create mode 100644 src/lib/cycle/__tests__/cycle-boundaries.test.ts
create mode 100644 tests/integration/cycle-flow-opens-cycle.test.ts
diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts
index 7b88e0479..af4db03b8 100644
--- a/src/app/api/cycle/day-logs/[id]/route.ts
+++ b/src/app/api/cycle/day-logs/[id]/route.ts
@@ -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";
+}
diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts
index 8806cdc65..2223447d0 100644
--- a/src/app/api/cycle/period/route.ts
+++ b/src/app/api/cycle/period/route.ts
@@ -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 {
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
@@ -97,73 +91,15 @@ async function postPeriod(request: NextRequest): Promise {
// 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 {
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 {
moved: {
periodEndDateBefore: current.periodEndDate,
periodEndDateAfter: date,
- } satisfies MovedAnchors,
+ } satisfies PeriodMovedAnchors,
};
});
diff --git a/src/lib/cycle/__tests__/cycle-boundaries.test.ts b/src/lib/cycle/__tests__/cycle-boundaries.test.ts
new file mode 100644
index 000000000..58ff9f654
--- /dev/null
+++ b/src/lib/cycle/__tests__/cycle-boundaries.test.ts
@@ -0,0 +1,67 @@
+/**
+ * The rule that decides whether a logged bleeding day starts a cycle.
+ *
+ * It is the one piece of the flow-to-cycle inference that is a judgement
+ * rather than bookkeeping, and a wrong yes is worse than a wrong no: a false
+ * cycle start corrupts every length the engine derives after it, while a
+ * missed one leaves the person exactly where they were before, with the
+ * one-tap start still available on any date.
+ */
+import { describe, it, expect } from "vitest";
+
+import { bleedOpensCycle } from "../cycle-boundaries";
+import { HARD_CYCLE_MIN } from "../types";
+import { addDays } from "../day-math";
+
+const DAY = "2026-06-01";
+
+describe("bleedOpensCycle", () => {
+ it("opens the first cycle a record ever has", () => {
+ expect(bleedOpensCycle("MEDIUM", false, null, DAY)).toBe(true);
+ expect(bleedOpensCycle("HEAVY", false, null, DAY)).toBe(true);
+ expect(bleedOpensCycle("LIGHT", false, null, DAY)).toBe(true);
+ });
+
+ it("declines a day that records no bleeding", () => {
+ expect(bleedOpensCycle(null, false, null, DAY)).toBe(false);
+ expect(bleedOpensCycle("NONE", false, null, DAY)).toBe(false);
+ });
+
+ it("declines spotting", () => {
+ // As often the tail or the herald of a period as its first day, and the
+ // person has a chip for a real flow when it is one.
+ expect(bleedOpensCycle("SPOTTING", false, null, DAY)).toBe(false);
+ });
+
+ it("declines bleeding the person flagged as between periods", () => {
+ expect(bleedOpensCycle("HEAVY", true, null, DAY)).toBe(false);
+ });
+
+ it("declines a bleeding day inside the current cycle's plausible span", () => {
+ // Day two, day three, and a day somebody forgot and entered late: all of
+ // them are this period, not a new one.
+ for (const offset of [1, 2, 4, HARD_CYCLE_MIN - 1]) {
+ expect(bleedOpensCycle("HEAVY", false, addDays(DAY, -offset), DAY)).toBe(
+ false,
+ );
+ }
+ });
+
+ it("declines a bleeding day on a date a cycle already starts", () => {
+ // What the one-tap boundary's own day-log write hits.
+ expect(bleedOpensCycle("MEDIUM", false, DAY, DAY)).toBe(false);
+ });
+
+ it("opens the next cycle at the hard physiological minimum and beyond", () => {
+ expect(
+ bleedOpensCycle("MEDIUM", false, addDays(DAY, -HARD_CYCLE_MIN), DAY),
+ ).toBe(true);
+ expect(bleedOpensCycle("MEDIUM", false, addDays(DAY, -28), DAY)).toBe(true);
+ });
+
+ it("declines when the nearest cycle start is AFTER the day", () => {
+ // Never happens through the resolver (it queries `startDate <= date`), but
+ // a negative gap must not read as a large one.
+ expect(bleedOpensCycle("HEAVY", false, addDays(DAY, 10), DAY)).toBe(false);
+ });
+});
diff --git a/src/lib/cycle/__tests__/day-log-write.test.ts b/src/lib/cycle/__tests__/day-log-write.test.ts
index 8fdd8119e..3a2a49c23 100644
--- a/src/lib/cycle/__tests__/day-log-write.test.ts
+++ b/src/lib/cycle/__tests__/day-log-write.test.ts
@@ -11,15 +11,44 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
-const db = vi.hoisted(() => ({
- cycleDayLog: {
- findUnique: vi.fn(),
- upsert: vi.fn(),
- update: vi.fn(),
- },
- cycleSymptom: { findMany: vi.fn() },
- cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
-}));
+const db = vi.hoisted(() => {
+ const client: Record = {
+ cycleDayLog: {
+ findUnique: vi.fn(),
+ upsert: vi.fn(),
+ update: vi.fn(),
+ },
+ // The write path asks whether the logged day opens a cycle. These cases
+ // are about the encryption columns, so the fake answers "no cycles on
+ // record" and lets the boundary work run for real against itself — the
+ // inference itself is pinned by its own unit + integration cases.
+ menstrualCycle: {
+ findFirst: vi.fn(async () => null),
+ upsert: vi.fn(async () => ({ id: "cyc-1", endDate: null })),
+ update: vi.fn(),
+ },
+ cycleSymptom: { findMany: vi.fn() },
+ cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
+ };
+ client.$transaction = async (fn: (tx: unknown) => unknown) => fn(client);
+ return client as {
+ cycleDayLog: {
+ findUnique: ReturnType;
+ upsert: ReturnType;
+ update: ReturnType;
+ };
+ menstrualCycle: {
+ findFirst: ReturnType;
+ upsert: ReturnType;
+ update: ReturnType;
+ };
+ cycleSymptom: { findMany: ReturnType };
+ cycleSymptomLink: {
+ deleteMany: ReturnType;
+ createMany: ReturnType;
+ };
+ };
+});
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/cycle/profile", () => ({
@@ -70,6 +99,8 @@ function existingRow(overrides: Record = {}) {
beforeEach(() => {
vi.clearAllMocks();
db.cycleDayLog.upsert.mockResolvedValue({ id: "cdl-1" });
+ db.menstrualCycle.findFirst.mockResolvedValue(null);
+ db.menstrualCycle.upsert.mockResolvedValue({ id: "cyc-1", endDate: null });
});
describe("upsertCycleDayLog — C-1 undecryptable note preservation", () => {
diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts
index 9d9e7f84a..6c81cc0b0 100644
--- a/src/lib/cycle/cycle-boundaries.ts
+++ b/src/lib/cycle/cycle-boundaries.ts
@@ -19,12 +19,226 @@
* it to the surviving neighbour restores exactly the state the start write
* moved — and applying it twice changes nothing the second time.
*/
+import { prisma } from "@/lib/db";
import { addDays, dayDiff } from "./day-math";
-import type { Prisma, PrismaClient } from "@/generated/prisma/client";
+import { HARD_CYCLE_MIN } from "./types";
+import type {
+ FlowLevel,
+ Prisma,
+ PrismaClient,
+} from "@/generated/prisma/client";
/** The Prisma client or an interactive-transaction handle. */
type CycleDb = PrismaClient | Prisma.TransactionClient;
+/**
+ * The boundary columns an opening write moved on the cycles around it, so the
+ * audit row can name them. They are the only unrecoverable part of the write:
+ * the opened cycle is a row, the re-anchoring is an overwrite of two columns
+ * on somebody else's cycle history.
+ */
+export 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;
+}
+
+/**
+ * Open a cycle at `date`, closing the one before it and taking the opened
+ * cycle's own end from the one after it when the start is back-filled between
+ * two existing cycles. Without that second half an inserted cycle keeps a null
+ * endDate even though its successor's start is right there, and every history
+ * surface reads a stale open cycle.
+ *
+ * Idempotent on `(userId, startDate)`: a re-tap on the same day, or a flow
+ * entry on a day the one-tap boundary already anchored, updates the existing
+ * row rather than adding a second one.
+ *
+ * Callers run this inside a transaction — the close-prior and open-new pair
+ * must not interleave with a concurrent write, or two taps double-close a
+ * prior cycle or compute a length against a stale read.
+ */
+export async function openCycleAt(
+ db: CycleDb,
+ userId: string,
+ date: string,
+ tz: string | null,
+): Promise<{ cycleId: string; moved: MovedAnchors }> {
+ const moved: MovedAnchors = {};
+
+ // Close the prior open cycle: its end is the day before this start.
+ const prior = await db.menstrualCycle.findFirst({
+ where: { userId, deletedAt: null, startDate: { lt: date } },
+ orderBy: { startDate: "desc" },
+ select: { id: true, startDate: true, endDate: true, lengthDays: true },
+ });
+ 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 },
+ },
+ });
+ }
+
+ const cycle = await db.menstrualCycle.upsert({
+ where: { userId_startDate: { userId, startDate: date } },
+ create: { userId, startDate: date, tz, isPredicted: false },
+ update: {
+ deletedAt: null,
+ isPredicted: false,
+ syncVersion: { increment: 1 },
+ },
+ });
+
+ // Re-anchor the FOLLOWING neighbour's side of the boundary.
+ const next = await db.menstrualCycle.findFirst({
+ where: { userId, 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, moved };
+}
+
+/**
+ * Whether a logged day's bleeding is the first day of a period, and therefore
+ * opens a cycle.
+ *
+ * Cycle rows used to come only from the one-tap period boundary. Every other
+ * way of recording bleeding — the log sheet's flow chips, the bulk drain, an
+ * Apple Health export of menstrual-flow samples — wrote a day-log and stopped
+ * there, and the engine reads cycle rows. A person could log every period they
+ * had and the module would still hold that none had happened.
+ *
+ * The rule is deliberately conservative, because a false cycle start corrupts
+ * every length the engine derives afterwards:
+ *
+ * - SPOTTING does not open a cycle. It is as often the tail of a period or
+ * its herald as its first day, and the user has an explicit chip for a
+ * real flow when it is one.
+ * - Bleeding flagged as between periods does not open a cycle. That flag
+ * exists to say exactly this.
+ * - A bleeding day within the hard physiological minimum cycle length of an
+ * existing cycle's start is part of that cycle, not a new one. This is
+ * what keeps day three of a period — or a day two somebody forgot and
+ * entered later — from starting a cycle of its own, and it needs no
+ * contiguity check to do it.
+ *
+ * The cost is that a genuinely short cycle (under the hard minimum) is not
+ * inferred from flow alone. The one-tap start still opens one on any date, so
+ * the case has an answer; guessing it from flow does not.
+ */
+export function bleedOpensCycle(
+ flow: FlowLevel | null,
+ intermenstrualBleeding: boolean,
+ priorCycleStart: string | null,
+ date: string,
+): boolean {
+ if (flow === null || flow === "NONE" || flow === "SPOTTING") return false;
+ if (intermenstrualBleeding) return false;
+ if (priorCycleStart === null) return true;
+ return dayDiff(date, priorCycleStart) >= HARD_CYCLE_MIN;
+}
+
+/**
+ * Open a cycle for a bleeding day when `bleedOpensCycle` says it starts one.
+ * Returns the opened cycle's id, or null when the day changes nothing.
+ *
+ * Runs its own transaction so the boundary work is atomic even when the caller
+ * is a plain day-log write with no transaction of its own.
+ */
+export async function ensureCycleForBleedingDay(
+ userId: string,
+ date: string,
+ tz: string | null,
+ day: { flow: FlowLevel | null; intermenstrualBleeding: boolean },
+): Promise {
+ // Cheap pre-check before a transaction: most logged days are not a first
+ // bleeding day, and the flow grade alone rules most of them out.
+ if (
+ day.flow === null ||
+ day.flow === "NONE" ||
+ day.flow === "SPOTTING" ||
+ day.intermenstrualBleeding
+ ) {
+ return null;
+ }
+
+ return prisma.$transaction(async (db) => {
+ const prior = await db.menstrualCycle.findFirst({
+ where: { userId, deletedAt: null, startDate: { lte: date } },
+ orderBy: { startDate: "desc" },
+ select: { startDate: true },
+ });
+ if (
+ !bleedOpensCycle(
+ day.flow,
+ day.intermenstrualBleeding,
+ prior?.startDate ?? null,
+ date,
+ )
+ ) {
+ return null;
+ }
+ const { cycleId } = await openCycleAt(db, userId, date, tz);
+ return cycleId;
+ });
+}
+
+/**
+ * Remove the cycle that opens on `date`, if there is one, and hand its span
+ * back to the cycle before it. The inverse of `openCycleAt`, and the shared
+ * body behind every way of taking a period start back: deleting the day-log
+ * that opened it, clearing that day's flow, and deleting the cycle itself.
+ *
+ * Returns null when no live cycle starts on that date.
+ */
+export async function removeCycleStartedOn(
+ db: CycleDb,
+ userId: string,
+ date: string,
+): Promise<{ cycleId: string; reanchored: ReanchoredCycle | null } | null> {
+ const opened = await db.menstrualCycle.findFirst({
+ where: { userId, deletedAt: null, startDate: date },
+ select: { id: true },
+ });
+ if (!opened) return null;
+
+ await db.menstrualCycle.update({
+ where: { id: opened.id },
+ data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ });
+ return {
+ cycleId: opened.id,
+ reanchored: await reanchorAfterRemovedStart(db, userId, date),
+ };
+}
+
/** The boundary columns the re-derivation moved, for the audit row. */
export interface ReanchoredCycle {
cycleId: string;
diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts
index 8d447daf9..31a78d93f 100644
--- a/src/lib/cycle/day-log-write.ts
+++ b/src/lib/cycle/day-log-write.ts
@@ -31,6 +31,7 @@
import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
+import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries";
import type { CycleDayLogInput } from "@/lib/validations/cycle";
import type {
CervicalMucus,
@@ -387,6 +388,25 @@ async function writeDayLog(args: WriteArgs): Promise {
? (entry.cervixOpening ?? null)
: (existing?.cervixOpening ?? null);
+ // A first bleeding day opens the cycle it belongs to. Cycle rows used to
+ // come only from the one-tap period boundary, so a person who logged every
+ // period as flow had a module that held none had ever happened — no lengths,
+ // no forecast, an empty chart. This is the one shared seam behind the single
+ // capture, the bulk drain, the Apple Health fold and the boundary itself, so
+ // all four infer a start the same way (the boundary's own write lands on a
+ // date that already has a cycle, which the detector declines). The day is
+ // then attributed to the cycle it just opened rather than the one before it.
+ const openedCycleId = await ensureCycleForBleedingDay(
+ userId,
+ entry.date,
+ tz,
+ {
+ flow,
+ intermenstrualBleeding,
+ },
+ );
+ const attributedCycleId = openedCycleId ?? cycleId;
+
// Was any of the five sensitive fields actually supplied in THIS update?
// Used to decide whether an undecryptable existing envelope may be preserved
// verbatim (an unrelated-field edit) or must be rewritten (the user is
@@ -479,7 +499,7 @@ async function writeDayLog(args: WriteArgs): Promise {
const updateData: Prisma.CycleDayLogUncheckedUpdateInput = {
...baseData,
...(entry.externalId ? { date: entry.date } : {}),
- ...(cycleId !== null ? { cycleId } : {}),
+ ...(attributedCycleId !== null ? { cycleId: attributedCycleId } : {}),
deletedAt: null,
// Only bump syncVersion when a field actually changed — an unchanged
// re-post must be a true no-op on the /api/sync/changes feed so other
@@ -498,7 +518,7 @@ async function writeDayLog(args: WriteArgs): Promise {
tz,
source,
externalId: entry.externalId ?? null,
- cycleId,
+ cycleId: attributedCycleId,
flow,
intermenstrualBleeding,
basalBodyTempC,
diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts
new file mode 100644
index 000000000..5e81fda56
--- /dev/null
+++ b/tests/integration/cycle-flow-opens-cycle.test.ts
@@ -0,0 +1,275 @@
+/**
+ * A period logged as flow has to count as a period.
+ *
+ * `MenstrualCycle` rows were only ever written by the one-tap period
+ * boundary. Everything else that records bleeding — the log sheet's flow
+ * chips, the bulk drain, an Apple Health export full of menstrual-flow
+ * samples — wrote a `CycleDayLog` and nothing more, and the engine reads
+ * cycle rows. So a person could log every period they had and the module
+ * still held that none had ever 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.
+ *
+ * The rule the fix applies is deliberately conservative, and these cases are
+ * as much about what must NOT open a cycle as what must. Spotting does not
+ * (it is as often the tail or the herald of a period as its first day), an
+ * entry flagged as bleeding between periods does not, and a bleeding day
+ * inside an existing cycle's plausible span does not — otherwise day three of
+ * a period, or a forgotten day two, would each start a cycle of their own.
+ */
+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-flow-opens-integration-32-bytes-min-1234567890";
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+const USER_ID = "user-cycle-flow";
+
+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),
+}));
+
+async function loginAs(userId: string): Promise {
+ cookieJar.clear();
+ headerJar.clear();
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ await getPrismaClient().user.create({
+ data: {
+ id: USER_ID,
+ username: "cycle-flow",
+ email: "cycle-flow@example.test",
+ gender: "FEMALE",
+ },
+ });
+ await loginAs(USER_ID);
+});
+
+function jsonRequest(
+ path: string,
+ method: string,
+ body?: unknown,
+): NextRequest {
+ return new NextRequest(`http://localhost${path}`, {
+ method,
+ headers: { "content-type": "application/json" },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+}
+
+/** Log one day through the real capture route. */
+async function logDay(
+ date: string,
+ fields: Record,
+): Promise {
+ const { POST } = await import("@/app/api/cycle/day-logs/route");
+ return POST(
+ jsonRequest("/api/cycle/day-logs", "POST", {
+ date,
+ loggedAt: `${date}T08:00:00.000Z`,
+ ...fields,
+ }),
+ );
+}
+
+/** The live cycle starts, oldest first. */
+async function cycleStarts(): Promise {
+ const rows = await getPrismaClient().menstrualCycle.findMany({
+ where: { userId: USER_ID, deletedAt: null },
+ orderBy: { startDate: "asc" },
+ select: { startDate: true },
+ });
+ return rows.map((r) => r.startDate);
+}
+
+describe("a bleeding day opens a cycle", () => {
+ it("turns the first flow entry into a cycle the engine can see", async () => {
+ const res = await logDay("2026-05-04", { flow: "HEAVY" });
+ expect(res.status).toBe(201);
+
+ expect(await cycleStarts()).toEqual(["2026-05-04"]);
+ });
+
+ it("does not open a second cycle for the rest of the same period", async () => {
+ await logDay("2026-05-04", { flow: "HEAVY" });
+ await logDay("2026-05-05", { flow: "MEDIUM" });
+ // Day three with day two missing: the gap must not read as a new period.
+ await logDay("2026-05-07", { flow: "LIGHT" });
+
+ expect(await cycleStarts()).toEqual(["2026-05-04"]);
+ });
+
+ it("opens the next cycle when the next period arrives", async () => {
+ await logDay("2026-05-04", { flow: "HEAVY" });
+ await logDay("2026-06-01", { flow: "HEAVY" });
+ await logDay("2026-06-29", { flow: "MEDIUM" });
+
+ expect(await cycleStarts()).toEqual([
+ "2026-05-04",
+ "2026-06-01",
+ "2026-06-29",
+ ]);
+ });
+
+ it("leaves spotting alone", async () => {
+ await logDay("2026-05-04", { flow: "SPOTTING" });
+ expect(await cycleStarts()).toEqual([]);
+ });
+
+ it("leaves bleeding flagged as between periods alone", async () => {
+ await logDay("2026-05-04", { flow: "HEAVY" });
+ await logDay("2026-05-20", {
+ flow: "MEDIUM",
+ intermenstrualBleeding: true,
+ });
+
+ expect(await cycleStarts()).toEqual(["2026-05-04"]);
+ });
+
+ it("leaves a day with no flow alone", async () => {
+ await logDay("2026-05-04", { basalBodyTempC: 36.4 });
+ expect(await cycleStarts()).toEqual([]);
+ });
+
+ it("re-anchors the neighbours of a back-dated bleed", async () => {
+ // The same boundary work the one-tap start does: the inserted cycle takes
+ // its end from the cycle after it, and the one before it closes.
+ await logDay("2026-06-01", { flow: "HEAVY" });
+ await logDay("2026-07-01", { flow: "HEAVY" });
+ // Remembered later, and earlier than both.
+ await logDay("2026-05-04", { flow: "HEAVY" });
+
+ const rows = await getPrismaClient().menstrualCycle.findMany({
+ where: { userId: USER_ID, deletedAt: null },
+ orderBy: { startDate: "asc" },
+ select: { startDate: true, endDate: true, lengthDays: true },
+ });
+ expect(rows).toEqual([
+ { startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
+ { startDate: "2026-06-01", endDate: "2026-06-30", lengthDays: 30 },
+ { startDate: "2026-07-01", endDate: null, lengthDays: null },
+ ]);
+ });
+
+ it("does not duplicate the cycle the one-tap start already opened", async () => {
+ const { POST } = await import("@/app/api/cycle/period/route");
+ await POST(
+ jsonRequest("/api/cycle/period", "POST", {
+ action: "start",
+ date: "2026-05-04",
+ loggedAt: "2026-05-04T08:00:00.000Z",
+ }),
+ );
+ // The boundary write logs MEDIUM for the day; re-saving it richer must not
+ // open a second cycle on the same date.
+ await logDay("2026-05-04", { flow: "HEAVY" });
+
+ expect(await cycleStarts()).toEqual(["2026-05-04"]);
+ });
+
+ it("opens a cycle when an edit turns a day into a bleeding day", async () => {
+ await logDay("2026-05-04", { basalBodyTempC: 36.4 });
+ expect(await cycleStarts()).toEqual([]);
+
+ const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
+ where: { userId: USER_ID, date: "2026-05-04" },
+ select: { id: true },
+ });
+ const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
+ const res = await PATCH(
+ jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", {
+ flow: "HEAVY",
+ }),
+ { params: Promise.resolve({ id: dayLog.id }) },
+ );
+ expect(res.status).toBe(200);
+
+ expect(await cycleStarts()).toEqual(["2026-05-04"]);
+ });
+
+ it("removes the cycle again when an edit clears the flow", async () => {
+ // The inverse of the case above. A cycle that can be created by an edit
+ // and not undone by one leaves the record holding a period that nothing on
+ // the day says happened.
+ await logDay("2026-05-04", { flow: "HEAVY" });
+ await logDay("2026-06-01", { flow: "HEAVY" });
+ expect(await cycleStarts()).toEqual(["2026-05-04", "2026-06-01"]);
+
+ const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
+ where: { userId: USER_ID, date: "2026-06-01" },
+ select: { id: true },
+ });
+ const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
+ await PATCH(
+ jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: null }),
+ { params: Promise.resolve({ id: dayLog.id }) },
+ );
+
+ expect(await cycleStarts()).toEqual(["2026-05-04"]);
+ // And the May cycle is open again, not left closed against a start that
+ // no longer exists.
+ const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
+ where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
+ select: { endDate: true, lengthDays: true },
+ });
+ expect(may).toEqual({ endDate: null, lengthDays: null });
+ });
+
+ it("gives the record a forecast and a cycle day it never had", async () => {
+ // What the person actually sees. Four periods 28 days apart, logged as
+ // flow and nothing else, and today inside the last one.
+ const today = new Date();
+ const dayKey = (offset: number): string => {
+ const d = new Date(today);
+ d.setDate(d.getDate() + offset);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
+ d.getDate(),
+ ).padStart(2, "0")}`;
+ };
+ for (const offset of [-86, -58, -30, -2]) {
+ await logDay(dayKey(offset), { flow: "HEAVY" });
+ }
+
+ const { GET } = await import("@/app/api/cycle/calendar/route");
+ const res = await GET(
+ new NextRequest("http://localhost/api/cycle/calendar"),
+ );
+ const body = JSON.parse(await res.text());
+
+ expect(body.data.profile.cyclesObserved).toBe(3);
+ expect(body.data.prediction).not.toBeNull();
+ expect(body.data.verdict.state).toBe("IN_CYCLE");
+ expect(body.data.verdict.dayOfCycle).toBe(3);
+ expect(body.data.verdict.cycleStartDate).toBe(dayKey(-2));
+ });
+});
From 30cf1da1d379016e1466caa5eaf14cff247c9010 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:52:59 +0200
Subject: [PATCH 13/18] chore(cycle): add a read-only report for records the
module stopped reading
Two defects put a record into a state that looks, from the app, like no
period was ever logged. Both are fixed going forward; neither repairs
the rows already made, and neither is visible from the surface it broke.
A closed torso is a live cycle carrying an end date with no live cycle
after it. A cycle is closed by its successor, so closed with no
successor means the successor is gone, and a record whose last cycle is
closed has no open cycle for the engine to forecast from. The signature
is exact and needs no audit trail.
A period with no cycle row is bleeding that only ever existed as a
day-log, from the flow chips, the bulk drain or an import. The engine
reads cycle rows, so the record holds periods and reports none.
The report imports the bleeding-day rule from the write path rather than
restating it, so it and the app cannot drift on what counts as a period
start. Checked against seeded records: it names the torso, names the
three flow-only starts while passing over a day two and a spotting day,
and says nothing about a healthy record.
Nothing is written.
---
scripts/diagnose-cycle-state.ts | 222 ++++++++++++++++++++++++++++++++
1 file changed, 222 insertions(+)
create mode 100644 scripts/diagnose-cycle-state.ts
diff --git a/scripts/diagnose-cycle-state.ts b/scripts/diagnose-cycle-state.ts
new file mode 100644
index 000000000..85856e998
--- /dev/null
+++ b/scripts/diagnose-cycle-state.ts
@@ -0,0 +1,222 @@
+/**
+ * Read-only diagnostic for cycle records the module has stopped reading.
+ *
+ * Two defects put records into states that look, from the app, like no periods
+ * were ever logged. Both are fixed going forward; neither repairs the rows it
+ * already made, and neither is visible from the surface it broke.
+ *
+ * The first is a closed torso. Logging a period start closes the cycle before
+ * it, and removing that start used to leave the closure standing. The previous
+ * cycle keeps an end date that a cycle which no longer exists gave it, and a
+ * record whose last cycle is closed has no open cycle for the engine to
+ * forecast from, so the prediction disappears and does not come back. The
+ * signature is exact and needs no audit trail: a live cycle carrying an end
+ * date with no live cycle after it. A cycle is closed by its successor, so
+ * closed with no successor means the successor is gone.
+ *
+ * The second is a period that only ever existed as flow. Cycle rows used to be
+ * written by the one-tap period boundary alone, so bleeding entered through the
+ * log sheet's chips, the bulk drain or an Apple Health import produced day-logs
+ * and no cycle at all. The engine reads cycle rows, so the record holds periods
+ * and reports none.
+ *
+ * The bleeding-day rule below is imported from the write path rather than
+ * restated, so this report and the app cannot drift on what counts as a period
+ * start.
+ *
+ * Nothing is written. The output is a per-record inventory plus the two flags,
+ * so the state can be read before anything is decided about it.
+ *
+ * Run (inside the app container, which has DATABASE_URL):
+ * pnpm dlx tsx scripts/diagnose-cycle-state.ts
+ */
+import "dotenv/config";
+
+import { prisma } from "@/lib/db";
+import { bleedOpensCycle } from "@/lib/cycle/cycle-boundaries";
+import { dayDiff } from "@/lib/cycle/day-math";
+
+/** Below this many observed cycles the app withholds its phase band. */
+const STILL_LEARNING_BELOW = 3;
+
+interface CycleRow {
+ id: string;
+ startDate: string;
+ endDate: string | null;
+ lengthDays: number | null;
+ periodEndDate: string | null;
+ deletedAt: Date | null;
+ isPredicted: boolean;
+}
+
+interface DayRow {
+ date: string;
+ flow: string | null;
+ intermenstrualBleeding: boolean;
+}
+
+function describeCycle(c: CycleRow): string {
+ const state = c.deletedAt
+ ? `deleted ${c.deletedAt.toISOString().slice(0, 10)}`
+ : c.endDate
+ ? `closed ${c.endDate}`
+ : "open";
+ const length = c.lengthDays === null ? "-" : `${c.lengthDays}d`;
+ const period = c.periodEndDate ? ` periodEnd=${c.periodEndDate}` : "";
+ const predicted = c.isPredicted ? " PREDICTED" : "";
+ return `${c.startDate} ${state.padEnd(20)} length=${length.padEnd(5)}${period}${predicted}`;
+}
+
+async function main(): Promise {
+ // Every record that has ever touched the module, from either side: a cycle
+ // row, or a day-log. A record with day-logs and no cycles is precisely one
+ // of the cases this exists to find, so it cannot be found by cycles alone.
+ const [cycleOwners, dayOwners] = await Promise.all([
+ prisma.menstrualCycle.findMany({
+ distinct: ["userId"],
+ select: { userId: true },
+ }),
+ prisma.cycleDayLog.findMany({
+ distinct: ["userId"],
+ select: { userId: true },
+ }),
+ ]);
+ const userIds = Array.from(
+ new Set([
+ ...cycleOwners.map((r) => r.userId),
+ ...dayOwners.map((r) => r.userId),
+ ]),
+ ).sort();
+
+ if (userIds.length === 0) {
+ console.log("No cycle records on this instance. Nothing to report.");
+ return;
+ }
+
+ let torsoTotal = 0;
+ let flowOnlyTotal = 0;
+ let staleBoundaryTotal = 0;
+
+ for (const userId of userIds) {
+ const [cycles, days] = await Promise.all([
+ prisma.menstrualCycle.findMany({
+ where: { userId },
+ orderBy: { startDate: "asc" },
+ select: {
+ id: true,
+ startDate: true,
+ endDate: true,
+ lengthDays: true,
+ periodEndDate: true,
+ deletedAt: true,
+ isPredicted: true,
+ },
+ }) as Promise,
+ prisma.cycleDayLog.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { date: "asc" },
+ select: { date: true, flow: true, intermenstrualBleeding: true },
+ }) as Promise,
+ ]);
+
+ const live = cycles.filter((c) => c.deletedAt === null);
+ const liveStarts = live.map((c) => c.startDate);
+ const startSet = new Set(liveStarts);
+
+ console.log(`record ${userId}`);
+ console.log(
+ ` ${cycles.length} cycle row(s), ${live.length} live, ` +
+ `${days.length} day-log(s)`,
+ );
+ for (const c of cycles) console.log(` ${describeCycle(c)}`);
+
+ // A closed cycle with nothing after it: closed by a start that is gone.
+ const torsos = live.filter(
+ (c, i) => c.endDate !== null && i === live.length - 1,
+ );
+ // A closed cycle whose boundary does not match the successor it has.
+ const stale = live.filter((c, i) => {
+ const next = live[i + 1];
+ if (!next) return false;
+ const expectedLength = dayDiff(next.startDate, c.startDate);
+ return c.endDate === null || c.lengthDays !== expectedLength;
+ });
+
+ // Bleeding days the write path would treat as a period start today, on a
+ // date carrying no live cycle. Walked forward so each decision sees the
+ // starts that already exist plus the ones this pass would infer, exactly
+ // as a replay of the writes would.
+ const inferred: string[] = [...liveStarts].sort();
+ const flowOnly: string[] = [];
+ for (const d of days) {
+ if (startSet.has(d.date)) continue;
+ const prior = inferred.filter((s) => s <= d.date).at(-1) ?? null;
+ if (
+ bleedOpensCycle(
+ d.flow as never,
+ d.intermenstrualBleeding,
+ prior,
+ d.date,
+ )
+ ) {
+ flowOnly.push(d.date);
+ inferred.push(d.date);
+ inferred.sort();
+ }
+ }
+
+ if (torsos.length > 0) {
+ torsoTotal += torsos.length;
+ console.log(
+ ` CLOSED TORSO: ${torsos
+ .map((c) => `${c.startDate} (ends ${c.endDate})`)
+ .join(", ")}`,
+ );
+ console.log(
+ " The last cycle is closed and has no successor, so the engine has " +
+ "no open cycle to forecast from. Re-opening it means clearing its " +
+ "endDate and lengthDays.",
+ );
+ }
+ if (stale.length > 0) {
+ staleBoundaryTotal += stale.length;
+ console.log(
+ ` STALE BOUNDARY: ${stale.map((c) => c.startDate).join(", ")}`,
+ );
+ console.log(
+ " The stored end or length disagrees with the next cycle's start.",
+ );
+ }
+ if (flowOnly.length > 0) {
+ flowOnlyTotal += flowOnly.length;
+ console.log(` PERIOD WITH NO CYCLE ROW: ${flowOnly.join(", ")}`);
+ console.log(
+ " Bleeding was logged on these days and no cycle opens on them. " +
+ "Re-saving each day through the log sheet opens it now.",
+ );
+ }
+ if (live.length > 0 && live.length - 1 < STILL_LEARNING_BELOW) {
+ console.log(
+ ` Only ${live.length - 1} observed cycle length(s): under the ` +
+ `${STILL_LEARNING_BELOW}-cycle gate, so the phase band stays withheld.`,
+ );
+ }
+ console.log("");
+ }
+
+ console.log(
+ `${userIds.length} record(s) inspected. ` +
+ `${torsoTotal} closed torso(s), ${staleBoundaryTotal} stale boundary(ies), ` +
+ `${flowOnlyTotal} logged period(s) with no cycle row.`,
+ );
+ console.log("Nothing was changed.");
+}
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(() => {
+ void prisma.$disconnect();
+ });
From 47d41a13f45357d214d00b12c9eb0ddc0e7b81e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:53:55 +0200
Subject: [PATCH 14/18] docs(cycle): say why the fertile window is seven days,
not six
The constants make ovulation minus five through ovulation plus one, and
the comment above them claimed a six-day window. Wilcox's six days are
the five before ovulation plus the day itself, measured against a KNOWN
ovulation day. Ours is estimated, usually by back-calculating a luteal
length from a median cycle, so a window drawn tight to the estimate
loses its last day whenever the estimate lands early, and the ovum stays
viable for most of a day after release besides.
The direction is decided by who reads the window. It is surfaced to
people trying to conceive and to people avoiding it, and for the second
group a window one day short is a window that misses. The constants keep
the conservative reading and the comment now states it, with the note
that changing either one is a coordination matter because the iOS client
reimplements the same spec.
The width is pinned by a test rather than left to be re-read off the
numbers the code already uses.
---
src/lib/cycle/__tests__/prediction.test.ts | 31 ++++++++++++++++++++++
src/lib/cycle/types.ts | 21 ++++++++++++++-
2 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts
index 4e97d3963..08b994f4a 100644
--- a/src/lib/cycle/__tests__/prediction.test.ts
+++ b/src/lib/cycle/__tests__/prediction.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { addDays } from "../day-math";
+import { FERTILE_POST, FERTILE_PRE } from "../types";
import {
clampLuteal,
confirmSymptothermal,
@@ -1091,3 +1092,33 @@ describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)",
expect(goalGated.predictedOvulation).toBeNull();
});
});
+
+describe("cycle/prediction — the fertile window's width (A10)", () => {
+ it("spans seven days, ending the day after the ovulation estimate", () => {
+ // The comment on the constants used to say six days and the constants made
+ // seven. The width is now a decision with a reason written at the constant,
+ // so it is pinned here rather than left to be re-read off the same numbers
+ // the code uses: five days before the estimate, the estimate, and one after.
+ const cycles = cyclesFromGaps("2026-01-01", [28, 28, 28]);
+ const p = predictCycle(cycles, [], BASE_PROFILE, "2026-03-20", []);
+
+ expect(p.predictedOvulation).not.toBeNull();
+ expect(p.fertileWindowStart).toBe(addDays(p.predictedOvulation!, -5));
+ expect(p.fertileWindowEnd).toBe(addDays(p.predictedOvulation!, 1));
+
+ const span =
+ (Date.parse(`${p.fertileWindowEnd}T12:00:00Z`) -
+ Date.parse(`${p.fertileWindowStart}T12:00:00Z`)) /
+ 86_400_000;
+ // Inclusive of both ends.
+ expect(span + 1).toBe(7);
+ });
+
+ it("keeps the extra day on the side that costs least to be wrong about", () => {
+ // The trailing day is estimator slack plus ovum viability. Dropping it
+ // would shorten the window for somebody avoiding pregnancy, which is the
+ // direction that misses.
+ expect(FERTILE_PRE).toBe(5);
+ expect(FERTILE_POST).toBe(1);
+ });
+});
diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts
index 16eada87f..33983bc14 100644
--- a/src/lib/cycle/types.ts
+++ b/src/lib/cycle/types.ts
@@ -103,7 +103,26 @@ export const LUTEAL_DEFAULT = 14;
export const LUTEAL_MIN = 10;
export const LUTEAL_MAX = 16;
-/** §4 — six-day fertile window: ovulation − 5 … ovulation + 1. */
+/**
+ * §4 — the fertile window: ovulation − 5 … ovulation + 1, seven days.
+ *
+ * Wilcox's six-day window is the five days BEFORE ovulation plus the day
+ * itself, and the trailing day here is a deliberate departure from it. The
+ * six-day figure is measured against a KNOWN ovulation day; ours is estimated,
+ * usually by back-calculating a luteal length from a median cycle, and an
+ * estimate that lands a day early would put the last fertile day outside a
+ * window drawn tight to it. The ovum also stays viable for the better part of a
+ * day after release, so the extra day is not only estimator slack.
+ *
+ * The asymmetry of the cost decides the direction. This window is surfaced to
+ * people trying to conceive and to people avoiding it, and for the second group
+ * a window one day too short is a window that misses. The constants are the
+ * conservative reading; the comment used to claim six days and describe seven,
+ * which is the only part of this that was wrong.
+ *
+ * Changing either constant changes what the iOS client reimplements from the
+ * same spec, so it needs a coordination note, not just an edit here.
+ */
export const FERTILE_PRE = 5;
export const FERTILE_POST = 1;
From 8e3834f38d033b006ba2af8f6f634fd5639aa752 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 15:57:21 +0200
Subject: [PATCH 15/18] fix(cycle): count the cycle day from the logged start,
not the grid window
The day count was the length of the labelled run inside the calendar
grid, so the same day read as day 11 over a month and day 23 over three:
the run was cut off at the window's edge rather than at the cycle's
start. That was a latent contract question while every caller asked for
the same ninety days, and it stopped being latent when the month grid
began reading its own month, which is thirty days by construction.
The verdict now anchors on the period start the record holds and falls
back to walking the grid only when no caller supplied one, so the ring's
arcs stay grid-derived (they are about what can be drawn) while the day
is about what the person logged. The overdue ceiling then measures the
real elapsed count too.
Covers D-list edge 15, both shapes: a window that clips the run and a
window that excludes the cycle start altogether.
---
.../__tests__/verdict-round-trip.test.ts | 73 +++++++++++++++++++
src/lib/cycle/verdict.ts | 18 ++++-
2 files changed, 88 insertions(+), 3 deletions(-)
diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
index 259329ac8..08f125662 100644
--- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
+++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts
@@ -467,6 +467,79 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
});
});
+ /**
+ * D-list edge 15 — the day count under a narrowed `from` window.
+ *
+ * The count used to be the length of the labelled run inside the grid, so a
+ * caller that asked for a shorter span got a smaller number for the same day:
+ * the run was cut off at the window's edge, not at the cycle's start. That was
+ * a latent contract question while every caller asked for the same ninety
+ * days. It stopped being latent when the month grid started reading its own
+ * month, which is a thirty-day window by construction.
+ */
+ describe("under a narrowed window", () => {
+ async function callWindow(
+ from: string,
+ to: string,
+ ): Promise> {
+ const res = await GET(
+ new NextRequest(
+ `http://localhost/api/cycle/calendar?from=${from}&to=${to}`,
+ ),
+ );
+ const body = JSON.parse(await res.text());
+ return (body.data as Record).verdict as Record<
+ string,
+ unknown
+ >;
+ }
+
+ it("counts from the logged start, not from the window's edge", async () => {
+ const today = "2026-06-11";
+ const lastStart = "2026-05-20"; // day 23 of the open cycle
+ vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
+ cycleRows([
+ addDays(lastStart, -84),
+ addDays(lastStart, -56),
+ addDays(lastStart, -28),
+ lastStart,
+ ]) as never,
+ );
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ const wide = await callWindow(addDays(today, -90), addDays(today, 180));
+ expect(wide.dayOfCycle).toBe(23);
+
+ // The month the grid would be showing: the cycle start is inside it, but
+ // the window begins after several earlier cycles.
+ const narrow = await callWindow("2026-06-01", "2026-06-30");
+ expect(narrow.dayOfCycle).toBe(23);
+ expect(narrow.cycleStartDate).toBe(lastStart);
+ });
+
+ it("counts correctly when the window excludes the start entirely", async () => {
+ const today = "2026-06-25";
+ const lastStart = "2026-05-20";
+ vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
+ cycleRows([
+ addDays(lastStart, -56),
+ addDays(lastStart, -28),
+ lastStart,
+ ]) as never,
+ );
+ vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+
+ // June only: the cycle opened in May, outside the window entirely.
+ const narrow = await callWindow("2026-06-01", "2026-06-30");
+ expect(narrow.cycleStartDate).toBe(lastStart);
+ expect(narrow.dayOfCycle).toBe(37);
+ });
+ });
+
it("says INSUFFICIENT_DATA when nothing has been logged", async () => {
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
diff --git a/src/lib/cycle/verdict.ts b/src/lib/cycle/verdict.ts
index ae7a83c91..5ee273556 100644
--- a/src/lib/cycle/verdict.ts
+++ b/src/lib/cycle/verdict.ts
@@ -327,9 +327,21 @@ export function resolveCycleVerdict(
}
const startIdx = findCycleStartIndex(sorted, todayIdx);
- const cycleStartDate = sorted[startIdx].date;
- const run = sorted.slice(startIdx, todayIdx + 1);
- const dayOfCycle = run.length;
+
+ // The day count comes from the logged start when the caller supplied one,
+ // and only falls back to the length of the labelled run when it did not.
+ // The run is bounded by the grid, so counting rows made the answer depend on
+ // how much calendar the caller happened to ask for: the same day reads as
+ // day 11 over a month and day 23 over three. That was latent while every
+ // caller asked for the same ninety days, and stopped being latent when the
+ // month grid started reading its own month. The arcs below stay grid-derived
+ // — they are about what the ring can draw, not about what day it is.
+ const anchoredStart =
+ input.lastPeriodStart && dayDiff(today, input.lastPeriodStart) >= 0
+ ? input.lastPeriodStart
+ : sorted[startIdx].date;
+ const cycleStartDate = anchoredStart;
+ const dayOfCycle = dayDiff(today, anchoredStart) + 1;
// Honest ceiling: once the run overshoots the typical length + grace, the
// count reflects the engine's open-ended cycle window, not an observed
From 85240e96b1a1b56eaff278862e9001b2aebf3d26 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 16:14:28 +0200
Subject: [PATCH 16/18] fix(cycle): route the new save signals through the
shared affordances
Two repo-wide guards caught the additions rather than a reviewer, which
is what they are for.
The success toasts reached for sonner directly. The success affordance
belongs to the outcome module, which binds it to the success key and
nothing else, so a surface cannot raise a green toast for a write that
wrote nothing. They go through toastWrittenOutcome now.
The sheet's date header formatted a day key in the browser's zone. A
cycle day key is a calendar day, not an instant, so it is anchored at
noon UTC and rendered in UTC, the same anchor the domain's day
arithmetic uses. Otherwise a reader east or west of the browser zone
could see the sheet name a different day than the one it writes to.
---
src/components/cycle/log-day-sheet.tsx | 15 ++++++++++++---
src/components/cycle/use-cycle.ts | 11 ++++++-----
2 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx
index ebb633514..815b77df7 100644
--- a/src/components/cycle/log-day-sheet.tsx
+++ b/src/components/cycle/log-day-sheet.tsx
@@ -479,10 +479,19 @@ function CervixRow({
* deliberately: it is how people recognise a date they are recalling.
*/
export function formatSheetDate(date: string, locale: Locale): string {
- const [year, month, day] = date.split("-").map(Number);
- return new Date(year, month - 1, day).toLocaleDateString(
+ // A cycle day key is a calendar day, not an instant, so it is anchored at
+ // noon UTC and rendered in UTC — the same anchor the domain's day arithmetic
+ // uses. Formatting it in the browser's zone would let a reader east or west
+ // of it see the sheet name a different day than the one it writes to.
+ return new Date(`${date}T12:00:00Z`).toLocaleDateString(
resolveIntlLocale(locale),
- { weekday: "long", day: "numeric", month: "long", year: "numeric" },
+ {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ },
);
}
diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts
index d21060e30..0e8b4fd22 100644
--- a/src/components/cycle/use-cycle.ts
+++ b/src/components/cycle/use-cycle.ts
@@ -20,6 +20,7 @@ import {
apiPost,
} from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
+import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
cycleDependentKeys,
@@ -247,7 +248,7 @@ export function useLogDay() {
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
- toast.success(t("cycle.saveSuccess"));
+ toastWrittenOutcome("success", t("cycle.saveSuccess"));
},
});
}
@@ -293,7 +294,7 @@ export function usePatchDayLog() {
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
- toast.success(t("cycle.saveSuccess"));
+ toastWrittenOutcome("success", t("cycle.saveSuccess"));
},
});
}
@@ -311,7 +312,7 @@ export function useStartPeriod() {
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
- toast.success(t("cycle.periodStartSaved"));
+ toastWrittenOutcome("success", t("cycle.periodStartSaved"));
},
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
@@ -333,7 +334,7 @@ export function useEndPeriod() {
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
- toast.success(t("cycle.periodEndSaved"));
+ toastWrittenOutcome("success", t("cycle.periodEndSaved"));
},
onError: () => toast.error(t("cycle.saveError")),
});
@@ -349,7 +350,7 @@ export function useDeleteDayLog() {
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
- toast.success(t("cycle.deleteSuccess"));
+ toastWrittenOutcome("success", t("cycle.deleteSuccess"));
},
onError: () => toast.error(t("cycle.deleteError")),
});
From 76698684799792ee0d0e4f72a6f16e8dd1e9063d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 18:32:38 +0200
Subject: [PATCH 17/18] refactor(measurements): drop the entry-unit helpers
nothing calls
`hasEntryUnitConversion` and `canonicalValueToEntry` shipped beside the
hours-to-minutes conversion for an editing surface that does not exist and has
no ticket. Neither has a caller, so both were only two more things to keep
true, and the dead-code gate is right to say so.
The module comment claimed the inverse was there so a future surface could not
land on a different answer. It says what is actually true now: one direction,
one place. The git history keeps the rest if that surface ever arrives.
---
src/lib/measurements/entry-units.ts | 22 ++++++++--------------
1 file changed, 8 insertions(+), 14 deletions(-)
diff --git a/src/lib/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts
index 147886507..73e2a1e45 100644
--- a/src/lib/measurements/entry-units.ts
+++ b/src/lib/measurements/entry-units.ts
@@ -16,8 +16,14 @@
* that short.
*
* The storage unit stays minutes. The conversion belongs at the entry
- * boundary, in one place, with the inverse beside it so a future editing
- * surface cannot land on a different answer.
+ * boundary, in one place, so a second surface that starts taking sleep by hand
+ * has one answer to reach for rather than its own.
+ *
+ * Only the direction that exists is here. An inverse and a "does this type
+ * convert" predicate shipped alongside it at first, written for an editing
+ * surface that does not exist and has no ticket; they were nothing but two
+ * more things to keep true. The git history holds them if that surface ever
+ * arrives.
*/
export const MINUTES_PER_HOUR = 60;
@@ -30,11 +36,6 @@ const ENTRY_TO_CANONICAL_FACTOR: Readonly> = {
SLEEP_DURATION: MINUTES_PER_HOUR,
};
-/** Whether the type's entry unit differs from the unit its column stores. */
-export function hasEntryUnitConversion(type: string): boolean {
- return type in ENTRY_TO_CANONICAL_FACTOR;
-}
-
/**
* Convert a value as typed in the entry form to the unit the column stores.
*
@@ -49,13 +50,6 @@ export function entryValueToCanonical(type: string, entered: number): number {
return Math.round(entered * factor);
}
-/** The inverse: a stored value expressed in the unit the entry form asks for. */
-export function canonicalValueToEntry(type: string, canonical: number): number {
- const factor = ENTRY_TO_CANONICAL_FACTOR[type];
- if (factor === undefined || !Number.isFinite(canonical)) return canonical;
- return canonical / factor;
-}
-
/**
* Read a number out of an entry field, accepting the decimal comma.
*
From fb8723ebb589044a99835354d186235ce6fa6c85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 7 Aug 2026 23:39:12 +0200
Subject: [PATCH 18/18] ci: run the integration suite as two shards
The suite outgrew its runner. The 25-minute guard was sized for 128 files
taking about 13 minutes; the sharing release took the suite to 180 files and
past the cap on CI runners, so every run died at the timeout with every test
to that point green, reported as cancelled. The guard's own rule applies:
split, don't raise.
The files run as two vitest shards, each with its own Postgres testcontainer.
A gate job carries the exact name branch protection requires and fails when
any shard failed or was cancelled, so a half-green matrix cannot satisfy it.
vitest is invoked directly because pnpm passes the -- separator through to
the script literally: the flag never applied through the script form, and
both "shards" ran the full suite. Proven by paired local runs: the script
form ran 180 files twice, the direct form splits 90/90 with 927 plus 689
tests, all green.
---
.github/workflows/integration.yml | 46 +++++++++++++++++++++++--------
1 file changed, 35 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 79b2c50bc..ceb9557f5 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -10,18 +10,21 @@ permissions:
contents: read
jobs:
- integration:
+ # The suite outgrew a single runner: 128 files took ~13 minutes when the
+ # 25-minute guard was written, and the v1.37 sharing work took it to 173
+ # files and past 25 minutes of wall clock, so every run died at the timeout
+ # with every test to that point green. The guard's own rule applies — split,
+ # don't raise — so the files run as two vitest shards. Each shard boots its
+ # own Postgres testcontainer; the timeout stays a hang detector, sized so a
+ # healthy half-suite clears it on a slow runner.
+ shard:
runs-on: ubuntu-latest
- # This guard exists to kill a hung testcontainer or a deadlocked query, not
- # to hold the suite to a performance budget. At 15 it had stopped telling
- # those apart: the suite itself takes around 13 minutes of job time (128
- # files against a real Postgres), so two consecutive runs of the SAME
- # commit range came in at 12m50s and then over 15m, and the second was
- # killed mid-run with every test to that point green. A cap that a healthy
- # run clears only on a fast runner reports runner weather, not health.
- # 25 leaves real headroom and still catches a hang inside one CI cycle.
- # If the suite ever approaches this number, split it rather than raise it.
timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2]
+ name: integration shard ${{ matrix.shard }}/2
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -36,7 +39,12 @@ jobs:
- run: pnpm db:generate
- - run: pnpm test:integration
+ # vitest is invoked directly: pnpm passes the `--` separator through to
+ # the script literally, so `pnpm test:integration -- --shard=…` hands
+ # vitest a stray positional argument and the shard flag never applies —
+ # both "shards" then run the full suite. Proven by a paired local run
+ # before this workflow shipped.
+ - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2
env:
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
@@ -50,3 +58,19 @@ jobs:
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
+
+ # Branch protection requires a check named exactly "integration". This job
+ # carries that name and answers for both shards: it fails when any shard
+ # failed or was cancelled, so a half-green matrix cannot satisfy the gate.
+ integration:
+ runs-on: ubuntu-latest
+ needs: shard
+ if: always()
+ timeout-minutes: 5
+ steps:
+ - name: Every shard finished green
+ run: |
+ if [ "${{ needs.shard.result }}" != "success" ]; then
+ echo "shard result: ${{ needs.shard.result }}"
+ exit 1
+ fi