fix(cycle): log past dates as themselves, in any order (#1004)

The log sheet took its "Day N" header and its "period ended" button
from the verdict, which is the answer about today. A January date opened
in September read "Day 2", and a back-dated period could only be closed
on dates where today happened to be a bleeding day, so the button came
and went as unrelated months were filled in.

The calendar read now carries, per date, the cycle day of the logged
cycle that date belongs to (null before the first start, after today, or
past the verdict's ceiling for an open cycle) and whether a one-tap
period end can land there. The sheet reads both off the grid day for the
date it opened on.

Saving a new entry with nothing picked used to write an empty row: the
request succeeded, the sheet closed and the calendar had nothing to
draw, which read as a save that did nothing. Save now needs something to
store and says what, and an emptied existing entry still saves. The
sheet also stops writing until the opened date's own entry has loaded,
because until then it held the previous date's row id and a quick Save
patched the wrong day.

On the server, a start recorded a few days after an earlier one is the
same period entered in the other order: opening the earlier start now
folds the later one into it instead of leaving a one-day cycle in front
of it, and a bleed too far before the next start to be its first day and
too close to be a cycle of its own no longer infers a start. Opening or
removing a start re-attributes the day-logs whose cycle changed.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:02:00 +02:00
1 parent aa58495cbe
commit 7d25bb7352
24 files changed
+877 -33

No files matched your search

@@ -20,6 +20,8 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 1,
@@ -20,6 +20,8 @@ function dayBase(date: string): CalendarDay {
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 1,
@@ -0,0 +1,137 @@
/**
* The log sheet speaks about the date it opened on, not about today (#1004).
*
* It used to take its header count and its "period ended" button from the
* verdict, which is the server's answer about TODAY. A January date opened
* in September therefore read "Day 2", and a back-dated period could not be
* closed unless today happened to be a bleeding day. The fixture below makes
* the two sources disagree on purpose: the verdict says day 2 of a period,
* the grid day for the open date says day 20 with no period end possible.
* The sheet must follow the grid.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { CalendarDay } from "../types";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
const TODAY = "2026-07-01";
function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
return {
date,
phase: null,
isPredictedPeriod: false,
isFertileWindow: false,
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 0,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
...over,
};
}
const calendarQuery = {
data: {
profile: {
goal: "GENERAL_HEALTH",
rawChartMode: false,
predictionEnabled: true,
cyclesObserved: 4,
},
prediction: null,
verdict: {
state: "IN_CYCLE",
dayOfCycle: 2,
cycleLength: 28,
phase: "MENSTRUAL",
spans: [],
cycleStartDate: "2026-06-30",
overdueDays: null,
daysUntilNext: null,
fertileWindow: { start: null, end: null, active: false },
},
stillLearning: false,
days: [gridDay(TODAY, { cycleDay: 20, phase: "LUTEAL" })],
meta: { generatedAt: "2026-07-01T00:00:00Z" },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
};
const idleQuery = {
data: undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
};
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => TODAY,
useCycleCalendar: () => calendarQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
useCycleProfile: () => idleQuery,
useCycleDayLog: () => idleQuery,
useCustomSymptoms: () => idleQuery,
useLogDay: () => mutationStub,
usePatchDayLog: () => mutationStub,
useStartPeriod: () => mutationStub,
useEndPeriod: () => mutationStub,
useDeleteDayLog: () => mutationStub,
useCreateCustomSymptom: () => mutationStub,
useDeleteCustomSymptom: () => mutationStub,
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit",
CustomSymptomError: class extends Error {},
}));
const sheetProps: Record<string, unknown>[] = [];
vi.mock("../log-day-sheet", async (importActual) => {
const actual = await importActual<typeof import("../log-day-sheet")>();
return {
...actual,
LogDaySheet: (props: Record<string, unknown>) => {
sheetProps.push(props);
return null;
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { CycleView } from "../cycle-view";
describe("<CycleView> — the log sheet describes the date it opened on", () => {
it("feeds the sheet the grid day's count and period-end answer, not today's verdict", () => {
sheetProps.length = 0;
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CycleView />
</I18nProvider>,
);
const props = sheetProps.at(-1);
expect(props).toBeDefined();
expect(props?.date).toBe(TODAY);
expect(props?.dayOfCycle).toBe(20);
expect(props?.phase).toBe("LUTEAL");
expect(props?.periodEndable).toBe(false);
});
});
@@ -8,8 +8,12 @@ import {
intimacyCount,
testsCount,
noteCount,
isBlankDayLog,
dayLogSaveState,
sheetDayContext,
type DayLogFormState,
} from "../log-day-sheet";
import type { CalendarDay } from "../types";
function blank(): DayLogFormState {
return {
@@ -204,3 +208,116 @@ describe("section summary-badge counts (v1.17.0)", () => {
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
});
});
describe("saving a day (#1004)", () => {
it("treats a form with nothing picked as blank", () => {
expect(isBlankDayLog(blank())).toBe(true);
// A disturbed-flag or protection toggle alone records nothing: both only
// mean something next to the value they qualify.
expect(isBlankDayLog({ ...blank(), bbtDisturbed: true })).toBe(true);
expect(isBlankDayLog({ ...blank(), protectedSex: true })).toBe(true);
expect(isBlankDayLog({ ...blank(), note: " " })).toBe(true);
});
it("treats any recorded sign as content", () => {
expect(isBlankDayLog({ ...blank(), flow: "LIGHT" })).toBe(false);
expect(isBlankDayLog({ ...blank(), intermenstrual: true })).toBe(false);
expect(isBlankDayLog({ ...blank(), bbt: "36.5" })).toBe(false);
expect(isBlankDayLog({ ...blank(), mucus: "DRY" })).toBe(false);
expect(isBlankDayLog({ ...blank(), intercourse: true })).toBe(false);
expect(isBlankDayLog({ ...blank(), note: "headache" })).toBe(false);
expect(
isBlankDayLog({ ...blank(), symptoms: new Map([["cramps", null]]) }),
).toBe(false);
});
it("does not save an empty new entry, so no empty row lands unseen", () => {
const state = dayLogSaveState({
hydrated: true,
rowId: null,
form: blank(),
});
expect(state.canSave).toBe(false);
expect(state.blankNewEntry).toBe(true);
});
it("still saves an emptied existing entry, because clearing is an edit", () => {
const state = dayLogSaveState({
hydrated: true,
rowId: "row-1",
form: blank(),
});
expect(state.canSave).toBe(true);
expect(state.rowId).toBe("row-1");
});
it("neither saves nor names a row before the date's own entry has loaded", () => {
// The form still holds the previously opened date. A Save here used to
// PATCH that other date's row.
const state = dayLogSaveState({
hydrated: false,
rowId: "row-of-another-date",
form: { ...blank(), flow: "HEAVY" },
});
expect(state.canSave).toBe(false);
expect(state.rowId).toBeNull();
});
});
function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
return {
date,
phase: null,
isPredictedPeriod: false,
isFertileWindow: false,
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 0,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
...over,
};
}
describe("sheetDayContext (#1004)", () => {
it("describes the selected date from its own grid day", () => {
const ctx = sheetDayContext("2026-01-27", [
gridDay("2026-01-26", { isCycleStart: true, cycleDay: 1 }),
gridDay("2026-01-27", {
cycleDay: 2,
periodEndable: true,
phase: "MENSTRUAL",
}),
]);
expect(ctx).toEqual({
startsCycle: false,
periodEndable: true,
phase: "MENSTRUAL",
dayOfCycle: 2,
});
});
it("makes no claim about a date the grid does not hold", () => {
expect(sheetDayContext("2025-12-01", [gridDay("2026-01-26")])).toEqual({
startsCycle: false,
periodEndable: false,
phase: null,
dayOfCycle: null,
});
});
it("takes the later of two overlapping grid reads, as the calendar does", () => {
const ctx = sheetDayContext("2026-04-01", [
gridDay("2026-04-01", { cycleDay: null }),
gridDay("2026-04-01", { cycleDay: 4, periodEndable: true }),
]);
expect(ctx.dayOfCycle).toBe(4);
expect(ctx.periodEndable).toBe(true);
});
});
+5 -8
View File
@@ -18,7 +18,7 @@ import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { CycleCalendar } from "./cycle-calendar";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } from "./log-day-sheet";
import { LogDaySheet, sheetDayContext } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
@@ -469,13 +469,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}
// Everything the sheet says about the day is about THAT day: its own
// cycle day, its own phase, whether a period end can land on it. The
// verdict answers for today and only ever labelled today correctly.
{...sheetDayContext(selectedDate, calendarDays)}
goal={goal}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
+122 -16
View File
@@ -51,7 +51,7 @@ import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-share
import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
import type { CyclePhase, CycleGoal } from "./types";
import type { CalendarDay, CyclePhase, CycleGoal } from "./types";
import {
useCreateCustomSymptom,
useCustomSymptoms,
@@ -311,6 +311,89 @@ export function noteCount(s: DayLogFormState): number {
return s.note.trim() ? 1 : 0;
}
/**
* Whether the form holds nothing a new day-log would record.
*
* Saving such a form used to write an empty row: the request succeeded, the
* sheet closed, and the calendar had nothing to draw, so a person who picked
* a past date and pressed Save to mark it saw the save "do nothing". A new
* entry now needs at least one thing to store. An existing row keeps Save
* enabled, because emptying it is a real edit.
*/
export function isBlankDayLog(s: DayLogFormState): boolean {
return (
s.flow === null &&
!s.intermenstrual &&
resolveBbt(s.bbt) === null &&
s.opk === null &&
s.mucus === null &&
s.cervixPosition === null &&
s.cervixFirmness === null &&
s.cervixOpening === null &&
!s.intercourse &&
s.pregnancyTest === null &&
s.progesteroneTest === null &&
s.contraceptive === null &&
s.note.trim() === "" &&
s.symptoms.size === 0
);
}
/**
* What the sheet may do with the form right now.
*
* `hydrated` is false between opening a date and its stored row arriving.
* In that window the form still holds the PREVIOUS date's values and row id,
* so a quick Save used to PATCH the other date's row and Delete would have
* removed it. Nothing writes until the form belongs to the date on screen.
*/
export function dayLogSaveState({
hydrated,
rowId,
form,
}: {
hydrated: boolean;
rowId: string | null;
form: DayLogFormState;
}): { rowId: string | null; canSave: boolean; blankNewEntry: boolean } {
if (!hydrated) return { rowId: null, canSave: false, blankNewEntry: false };
const blankNewEntry = rowId === null && isBlankDayLog(form);
return { rowId, canSave: !blankNewEntry, blankNewEntry };
}
/** The sheet's per-date context, read off the calendar grid for that date. */
export interface SheetDayContext {
startsCycle: boolean;
periodEndable: boolean;
phase: CyclePhase | null;
dayOfCycle: number | null;
}
/**
* Resolve what the sheet says about `date` from the server's grid day for it.
* The grid may hold the same date twice where the anchored read and the
* month read overlap; the later entry wins, as it does in the calendar.
* A date the grid does not hold gets no claims at all.
*/
export function sheetDayContext(
date: string,
days: readonly CalendarDay[],
): SheetDayContext {
let day: CalendarDay | undefined;
for (let i = days.length - 1; i >= 0; i--) {
if (days[i].date === date) {
day = days[i];
break;
}
}
return {
startsCycle: day?.isCycleStart ?? false,
periodEndable: day?.periodEndable ?? false,
phase: day?.phase ?? null,
dayOfCycle: day?.cycleDay ?? null,
};
}
/** The MIN_CYCLES gate the phase-education card uses — mirrored here so the
* sheet's phase-context header makes the same honesty claim (no phase label
* until prediction is on, not raw-chart, and at least three cycles seen). */
@@ -329,17 +412,21 @@ export interface LogDaySheetProps {
*/
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
* progress (QA M2).
* Whether a one-tap period end can land on `date` (the server's per-date
* `periodEndable`). It used to follow TODAY's phase, so the button was
* missing on every back-dated period and appeared on unrelated dates
* whenever today happened to be a bleeding day.
*/
activePeriod?: boolean;
periodEndable?: boolean;
/**
* The active cycle phase from the calendar read (or null). Drives the
* The phase of `date` from the calendar read (or null). Drives the
* hue-tinted phase-context header so the sheet visually belongs to the ring.
*/
phase?: CyclePhase | null;
/** Day-of-cycle for the header ("Day 14 · Ovulatory"); null when no cycle. */
/**
* The cycle day of `date` for the header ("Day 14 · Ovulatory"); null when
* the date sits in no logged cycle. Never today's count.
*/
dayOfCycle?: number | null;
/**
* The user's cycle goal — fertility goals (TTC / avoid-pregnancy) auto-open
@@ -501,7 +588,7 @@ export function LogDaySheet({
date,
today,
startsCycle = false,
activePeriod = false,
periodEndable = false,
phase = null,
dayOfCycle = null,
goal,
@@ -611,6 +698,7 @@ export function LogDaySheet({
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
}
const hydrated = open && lastFormKey === formKey;
function toggleSymptom(key: string) {
setSymptoms((prev) => {
@@ -651,16 +739,20 @@ export function LogDaySheet({
symptoms,
};
const saveState = dayLogSaveState({ hydrated, rowId, form: formState });
const currentRowId = saveState.rowId;
async function handleSave() {
if (!saveState.canSave) return;
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
// footer carries the visible failure signal and the sheet stays open.
try {
if (rowId) {
if (currentRowId) {
// Editing an existing row → PATCH with explicit nulls so a deselected
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({
id: rowId,
id: currentRowId,
patch: buildDayLogPatch(formState),
});
} else {
@@ -695,9 +787,9 @@ export function LogDaySheet({
}
async function handleDelete() {
if (!rowId) return;
if (!currentRowId) return;
try {
await deleteDay.mutateAsync(rowId);
await deleteDay.mutateAsync(currentRowId);
onOpenChange(false);
} catch {
/* toast shown by useDeleteDayLog onError */
@@ -733,7 +825,7 @@ export function LogDaySheet({
contentWidth="lg"
footer={
<>
{rowId ? (
{currentRowId ? (
<ConfirmButton
slot="cycle-day-delete"
variant="outline"
@@ -760,7 +852,11 @@ export function LogDaySheet({
>
{t("cycle.sheet.cancel")}
</Button>
<Button onClick={handleSave} disabled={busy}>
<Button
onClick={handleSave}
disabled={busy || !saveState.canSave}
data-slot="cycle-day-save"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
@@ -782,7 +878,8 @@ 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). The labels drop the
shows only where it can land: inside the first days of the logged
cycle the selected date belongs to. 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. */}
<div className="flex flex-wrap gap-2">
@@ -802,7 +899,7 @@ export function LogDaySheet({
? t("cycle.startedPeriod")
: t("cycle.startedPeriodOnDate")}
</Button>
{activePeriod ? (
{periodEndable ? (
<Button
variant="outline"
className="flex-1 justify-start gap-2"
@@ -1197,6 +1294,15 @@ export function LogDaySheet({
/>
</SheetSection>
{saveState.blankNewEntry ? (
<p
className="text-muted-foreground text-xs"
data-slot="cycle-day-empty-hint"
>
{t("cycle.sheet.emptyHint")}
</p>
) : null}
{logDay.isError || patchDay.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("cycle.sheet.saveError")}
+7
View File
@@ -31,6 +31,13 @@ export interface CalendarDay {
isPeriodLogged: boolean;
/** Whether a logged cycle opens on this day. */
isCycleStart: boolean;
/**
* The 1-based day of the logged cycle this date belongs to, resolved by the
* server for THIS date (not today). Null when the record cannot say.
*/
cycleDay: number | null;
/** Whether the one-tap period end can land on this date. */
periodEndable: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;