From 7d25bb7352033a9c12d7c77c99b491b0bd59cc3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:02:00 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 15 ++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + src/app/api/cycle/period/route.ts | 8 + .../cycle/__tests__/bbt-chart.test.tsx | 2 + .../cycle/__tests__/cycle-calendar.test.tsx | 2 + .../cycle-view-sheet-context.test.tsx | 137 +++++++++++++++++ .../cycle/__tests__/log-day-sheet.test.ts | 117 +++++++++++++++ src/components/cycle/cycle-view.tsx | 13 +- src/components/cycle/log-day-sheet.tsx | 138 ++++++++++++++++-- src/components/cycle/types.ts | 7 + .../cycle/__tests__/cycle-boundaries.test.ts | 27 +++- src/lib/cycle/__tests__/day-log-write.test.ts | 2 + .../cycle/__tests__/engine-adapter.test.ts | 112 +++++++++++++- src/lib/cycle/cycle-boundaries.ts | 135 ++++++++++++++++- src/lib/cycle/engine-adapter.ts | 27 ++++ src/lib/cycle/verdict.ts | 35 +++++ src/lib/openapi/routes/cycle.ts | 8 + .../cycle-flow-opens-cycle.test.ts | 118 +++++++++++++++ 24 files changed, 877 insertions(+), 33 deletions(-) create mode 100644 src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 22eb9ba0b..eeb11e7e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30687,6 +30687,19 @@ components: isCycleStart: type: boolean description: Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it. + cycleDay: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: The 1-based day of the logged cycle this date belongs to. Null before the first logged start, after today, + or once an open cycle has run past the point where the verdict stops counting. For today it equals + verdict.dayOfCycle. + periodEndable: + type: boolean + description: Whether a one-tap period end (POST /api/cycle/period, action end) on this date lands inside the first days + of a logged cycle. Resolved per date, independent of today's phase. flow: anyOf: - $ref: "#/components/schemas/FlowLevel" @@ -30738,6 +30751,8 @@ components: - isPredictedOvulation - isPeriodLogged - isCycleStart + - cycleDay + - periodEndable - flow - hasSymptoms - confidence diff --git a/messages/de.json b/messages/de.json index 9f4402d06..798c0da4a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8255,6 +8255,7 @@ "saving": "Speichern…", "cancel": "Abbrechen", "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", + "emptyHint": "Wähle eine Stärke oder ein Symptom, um diesen Tag zu speichern, oder nutze oben den Knopf für den Periodenbeginn.", "intermenstrualBleeding": "Schmierblutung zwischen Perioden", "pregnancyTest": "Schwangerschaftstest", "progesteroneTest": "Progesterontest", diff --git a/messages/en.json b/messages/en.json index d161c041f..7edf5d6cc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8265,6 +8265,7 @@ "saving": "Saving…", "cancel": "Cancel", "saveError": "Could not save. Try again.", + "emptyHint": "Pick a flow or a symptom to save this day, or use the period start button above.", "temperatureSection": "Temperature & ovulation", "intimacySection": "Intimacy & contraception", "testsSection": "Tests", diff --git a/messages/es.json b/messages/es.json index c827a0e75..dbeeb3ccb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8255,6 +8255,7 @@ "saving": "Guardando…", "cancel": "Cancelar", "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "emptyHint": "Elige un flujo o un síntoma para guardar este día, o usa arriba el botón de inicio del periodo.", "intermenstrualBleeding": "Manchado entre periodos", "pregnancyTest": "Prueba de embarazo", "progesteroneTest": "Prueba de progesterona", diff --git a/messages/fr.json b/messages/fr.json index 498ea6b2b..a000d2afb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8255,6 +8255,7 @@ "saving": "Enregistrement…", "cancel": "Annuler", "saveError": "Enregistrement impossible. Réessaie.", + "emptyHint": "Choisis un flux ou un symptôme pour enregistrer cette journée, ou utilise le bouton de début des règles ci-dessus.", "intermenstrualBleeding": "Saignements entre les règles", "pregnancyTest": "Test de grossesse", "progesteroneTest": "Test de progestérone", diff --git a/messages/it.json b/messages/it.json index f10d86210..7f56d945d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8255,6 +8255,7 @@ "saving": "Salvataggio…", "cancel": "Annulla", "saveError": "Impossibile salvare. Riprova.", + "emptyHint": "Scegli un flusso o un sintomo per salvare questo giorno, oppure usa in alto il pulsante di inizio mestruazioni.", "intermenstrualBleeding": "Spotting tra i cicli", "pregnancyTest": "Test di gravidanza", "progesteroneTest": "Test del progesterone", diff --git a/messages/ko.json b/messages/ko.json index f4015502f..10c33002e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8265,6 +8265,7 @@ "saving": "저장 중…", "cancel": "취소", "saveError": "저장하지 못했어요. 다시 시도해 주세요.", + "emptyHint": "이 날을 저장하려면 생리량이나 증상을 고르거나, 위의 생리 시작 버튼을 눌러 주세요.", "temperatureSection": "체온 및 배란", "intimacySection": "성관계 및 피임", "testsSection": "검사", diff --git a/messages/pl.json b/messages/pl.json index 9276ce2cd..5528f98ab 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8255,6 +8255,7 @@ "saving": "Zapisywanie…", "cancel": "Anuluj", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "emptyHint": "Wybierz krwawienie lub objaw, aby zapisać ten dzień, albo użyj przycisku początku miesiączki powyżej.", "intermenstrualBleeding": "Plamienie między okresami", "pregnancyTest": "Test ciążowy", "progesteroneTest": "Test progesteronu", diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 26eca5a6d..4fc37c6a8 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -180,6 +180,14 @@ async function postPeriod(request: NextRequest): Promise { ...(txResult.moved.priorCycleId ? { priorCycleId: txResult.moved.priorCycleId } : {}), + // A later start folded into this one (the same period, recorded in + // the other order): name the row that stopped existing and its date. + ...(txResult.moved.absorbedCycleId + ? { + absorbedCycleId: txResult.moved.absorbedCycleId, + absorbedStartDate: txResult.moved.absorbedStartDate, + } + : {}), ...overwriteDetails({ before: { priorEndDate: txResult.moved.priorEndDateBefore, diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index 36437b9f7..a07a707f2 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -20,6 +20,8 @@ function day(date: string, overrides: Partial = {}): CalendarDay { isPredictedOvulation: false, isPeriodLogged: false, isCycleStart: false, + cycleDay: null, + periodEndable: false, flow: null, hasSymptoms: false, confidence: 1, diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index f57f782f2..800802a61 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -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, diff --git a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx new file mode 100644 index 000000000..e32bb1c3a --- /dev/null +++ b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx @@ -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 { + 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[] = []; +vi.mock("../log-day-sheet", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + LogDaySheet: (props: Record) => { + sheetProps.push(props); + return null; + }, + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CycleView } from "../cycle-view"; + +describe(" — 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( + + + , + ); + 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); + }); +}); diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index e7b281c00..9f0a1e56a 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index b8c084508..7bb5e2876 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -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} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 815b77df7..9fad24d67 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -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 ? ( {t("cycle.sheet.cancel")} - - {activePeriod ? ( + {periodEndable ? (