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 ? (