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] 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. */}
{activePeriod ? ( ) : null}
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); + }); +});