diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3930a4101..5920c17c8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30709,6 +30709,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" @@ -30760,6 +30773,8 @@ components: - isPredictedOvulation - isPeriodLogged - isCycleStart + - cycleDay + - periodEndable - flow - hasSymptoms - confidence diff --git a/messages/de.json b/messages/de.json index ef7fb3863..0edd57f47 100644 --- a/messages/de.json +++ b/messages/de.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Taille-Größe-Verhältnis", "unitScore": "Pkt.", "unitRatio": "Verhältnis", + "unitRating": "Stufe", "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert" }, @@ -8256,6 +8257,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 238396831..fc22621e5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Waist-to-height ratio", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "rating", "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score" }, @@ -8266,6 +8267,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 477047e58..865f35997 100644 --- a/messages/es.json +++ b/messages/es.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Índice cintura-altura", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "nivel", "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI" }, @@ -8256,6 +8257,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 3ab4ed449..8fb4f9d19 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -619,7 +619,7 @@ "typeBpSys": "Sys", "typeBpDia": "Dia", "typePulse": "Pouls", - "typeBodyFat": "Masse grasse", + "typeBodyFat": "Graisse corporelle", "typeSleep": "Sommeil", "typeSteps": "Pas", "typeBloodGlucose": "Glycémie", @@ -633,7 +633,7 @@ "typeWalkingRunningDistance": "Distance à pied et en course", "typeVo2Max": "VO₂ max", "typeBodyTemperature": "Température corporelle", - "typeFatFreeMass": "Masse maigre", + "typeFatFreeMass": "Masse non grasse", "typeFatMass": "Masse grasse", "typeMuscleMass": "Masse musculaire", "typeSkinTemperature": "Température cutanée", @@ -760,6 +760,7 @@ "typeWaistToHeight": "Rapport taille-taille", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "niveau", "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI" }, @@ -8256,6 +8257,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 6d2a00f08..3f1777ff9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -619,7 +619,7 @@ "typeBpSys": "Sis", "typeBpDia": "Dia", "typePulse": "Polso", - "typeBodyFat": "Massa grassa", + "typeBodyFat": "Grasso corporeo", "typeSleep": "Sonno", "typeSteps": "Passi", "typeBloodGlucose": "Glicemia", @@ -633,7 +633,7 @@ "typeWalkingRunningDistance": "Distanza a piedi e di corsa", "typeVo2Max": "VO₂ max", "typeBodyTemperature": "Temperatura corporea", - "typeFatFreeMass": "Massa magra", + "typeFatFreeMass": "Massa libera da grasso", "typeFatMass": "Massa grassa", "typeMuscleMass": "Massa muscolare", "typeSkinTemperature": "Temperatura cutanea", @@ -760,6 +760,7 @@ "typeWaistToHeight": "Rapporto vita-altezza", "unitScore": "pti", "unitRatio": "rapporto", + "unitRating": "livello", "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI" }, @@ -8256,6 +8257,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 347493505..30f388897 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "허리-키 비율", "unitScore": "점", "unitRatio": "비율", + "unitRating": "단계", "typeWho5Score": "WHO-5 점수", "typeSciScore": "SCI 점수" }, @@ -8266,6 +8267,7 @@ "saving": "저장 중…", "cancel": "취소", "saveError": "저장하지 못했어요. 다시 시도해 주세요.", + "emptyHint": "이 날을 저장하려면 생리량이나 증상을 고르거나, 위의 생리 시작 버튼을 눌러 주세요.", "temperatureSection": "체온 및 배란", "intimacySection": "성관계 및 피임", "testsSection": "검사", diff --git a/messages/pl.json b/messages/pl.json index 126af9283..69afeac71 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -647,7 +647,7 @@ "typeAudioExposureEvent": "Zdarzenie głośnego słuchania", "typeRespiratoryRate": "Częstość oddechów", "typeBodyMassIndex": "Wskaźnik masy ciała", - "typeLeanBodyMass": "Masa beztłuszczowa", + "typeLeanBodyMass": "Szczupła masa ciała", "typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)", "typeWalkingAsymmetry": "Asymetria chodu", "typeWalkingDoubleSupport": "Podwójna podpora chodu", @@ -760,6 +760,7 @@ "typeWaistToHeight": "Stosunek talii do wzrostu", "unitScore": "pkt", "unitRatio": "stosunek", + "unitRating": "poziom", "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI" }, @@ -8256,6 +8257,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/__tests__/symptom-row.test.tsx b/src/components/cycle/__tests__/symptom-row.test.tsx new file mode 100644 index 000000000..fb84de6a5 --- /dev/null +++ b/src/components/cycle/__tests__/symptom-row.test.tsx @@ -0,0 +1,75 @@ +/** + * Selecting a symptom must not move anything on the sheet (#1001 follow-up). + * + * The intensity selector used to appear inline beside a symptom chip in a + * wrapping row, so a selection widened the chip and pushed the chips after it + * along, sometimes out from under the finger reaching for the intensity. A + * symptom is now one row with its intensity beside it, and the intensity's + * space is laid out whether or not it is shown. What the test pins is exactly + * that: the selected and unselected row render the same boxes, the intensity + * group is present in both, and only its visibility and the colours change. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Droplet } from "lucide-react"; + +vi.mock("../use-cycle", () => ({ + useDeleteCustomSymptom: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SymptomRow } from "../log-day-sheet"; + +function render(active: boolean, severity: number | null = null): string { + return renderToStaticMarkup( + + {}} + onSeverity={() => {}} + /> + , + ); +} + +/** The markup with every class and state attribute removed: the box tree. */ +function skeleton(html: string): string { + return html + .replace(/ class="[^"]*"/g, "") + .replace(/ (aria-[a-z]+|data-active|disabled|tabindex)(="[^"]*")?/g, ""); +} + +function severityGroup(html: string): string { + const m = /]*data-slot="cycle-symptom-severity"[^>]*>/.exec(html); + if (!m) throw new Error("no severity group rendered"); + return m[0]; +} + +describe("", () => { + it("lays out the same boxes whether or not the symptom is selected", () => { + expect(skeleton(render(true, 2))).toBe(skeleton(render(false))); + }); + + it("reserves the intensity's space while hidden, and reveals it in place", () => { + const idle = severityGroup(render(false)); + const picked = severityGroup(render(true)); + expect(idle).toContain("invisible"); + expect(idle).toContain('aria-hidden="true"'); + expect(picked).not.toContain("invisible"); + expect(picked).not.toContain("aria-hidden"); + }); + + it("keeps the intensity optional and out of reach until the symptom is picked", () => { + const idle = render(false); + // Four levels exist in both states, none of them focusable while hidden. + expect(idle.match(/aria-label="Severity \d of 4"/g)).toHaveLength(4); + expect(idle.match(/tabindex="-1"/g)).toHaveLength(4); + const picked = render(true); + expect(picked).not.toContain('tabindex="-1"'); + expect(picked.match(/aria-pressed="true"/g)).toHaveLength(1); + }); +}); 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..8d7c5411f 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, @@ -88,9 +88,9 @@ import type { * detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus, * intercourse + protection, free-text note). The write is optimistic-ish * (the mutation invalidates the cycle keys on success so the calendar + - * wheel repaint); the symptom chips reuse the mood-tag chip styling. + * wheel repaint); symptoms list one per line with their intensity beside them. * - * a11y: every chip is a real ` - - {activePeriod ? ( + {periodEndable ? ( + {custom ? ( + <> - ) : null} - {custom ? ( - ) : null} - - {active ? ( -
- {SEVERITY_LEVELS.map((lvl) => ( - - ))} -
+ ) : null} + {/* Always laid out, only shown while the symptom is selected: the + reserved width is what keeps the row still when it appears. */} +
+ {SEVERITY_LEVELS.map((lvl) => ( + + ))} +
); } /** - * The dashed ghost-chip in the symptom grid that opens a compact label + icon - * popover to mint a new custom symptom. Matches the chip grid (same rounded- - * full pill, same size) so it reads as part of the grid, not a foreign button. + * The dashed ghost row at the end of the custom symptoms that opens a compact + * label + icon popover to mint a new custom symptom. Same height and shape as + * a symptom row so it reads as part of the list, not a foreign button. */ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) { const { t } = useTranslations(); @@ -1380,9 +1512,9 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) { diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 983b9d5a3..8667b61b8 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -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; diff --git a/src/components/labs/__tests__/lab-form-keep-open.test.ts b/src/components/labs/__tests__/lab-form-keep-open.test.ts new file mode 100644 index 000000000..389df8ecc --- /dev/null +++ b/src/components/labs/__tests__/lab-form-keep-open.test.ts @@ -0,0 +1,44 @@ +/** + * "Save & add another" keeps the report and the marker, clears the reading + * (#1029). + * + * It used to clear the biomarker too. The picker then fell out of its + * controlled state and went on showing the marker it last held while the + * form's own value was empty, so the second save answered "Pick a biomarker + * first" under a visibly picked marker. Keeping the marker removes the reset + * that caused the disagreement; the picker itself is now always controlled, + * so an empty pick shows the placeholder instead of a stale marker. + */ +import { describe, it, expect } from "vitest"; + +import { nextEntryAfterKeepOpen, type LabEntryDraft } from "../lab-form"; + +const saved: LabEntryDraft = { + biomarkerId: "bm-vitamin-d", + value: "42", + valueText: "", + takenAt: "2026-09-01T08:30", + note: "fasting", + sourceRange: "30 - 100", + visitId: "visit-1", +}; + +describe("nextEntryAfterKeepOpen", () => { + it("keeps the picked biomarker so the next save does not ask for it again", () => { + expect(nextEntryAfterKeepOpen(saved).biomarkerId).toBe("bm-vitamin-d"); + }); + + it("keeps what the report shares: its draw date and its visit", () => { + const next = nextEntryAfterKeepOpen(saved); + expect(next.takenAt).toBe("2026-09-01T08:30"); + expect(next.visitId).toBe("visit-1"); + }); + + it("clears the reading itself and its printed window", () => { + const next = nextEntryAfterKeepOpen({ ...saved, valueText: "negative" }); + expect(next.value).toBe(""); + expect(next.valueText).toBe(""); + expect(next.note).toBe(""); + expect(next.sourceRange).toBe(""); + }); +}); diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index f22d1efe8..a27babb65 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; -import { useId, useMemo, useRef, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; @@ -57,6 +57,40 @@ function parseDecimal(raw: string): number | null { return Number.isFinite(n) ? n : null; } +/** The per-reading fields "Save & add another" decides to keep or clear. */ +export interface LabEntryDraft { + biomarkerId: string; + value: string; + valueText: string; + takenAt: string; + note: string; + sourceRange: string; + visitId: string | null; +} + +/** + * The form after "Save & add another": the reading is cleared, everything that + * describes the report it came from stays. The draw date and the visit are + * shared by every value on one report, and the biomarker stays because a run + * of entries is as often one marker over several dates as several markers on + * one date; re-picking it each time is what the button exists to avoid. + * + * Clearing the biomarker here used to drop the picker out of its controlled + * state. It kept showing the marker it last held while the form value was + * empty, so the next save refused with "Pick a biomarker first" under a + * visibly picked marker. + */ +export function nextEntryAfterKeepOpen(draft: LabEntryDraft): LabEntryDraft { + return { + ...draft, + value: "", + valueText: "", + note: "", + // The next reading has its own printed window. + sourceRange: "", + }; +} + interface LabFormProps { /** When set, the biomarker is pre-selected and locked (add-from-detail). */ lockedBiomarkerId?: string; @@ -157,7 +191,6 @@ export function LabForm({ const [defineFooterEl, setDefineFooterEl] = useState( null, ); - const biomarkerTriggerRef = useRef(null); const allMarkers = catalog?.biomarkers ?? []; const selected = allMarkers.find((m) => m.id === biomarkerId); @@ -256,30 +289,31 @@ export function LabForm({ : undefined, ); if (keepOpen) { - // A real lab report shares one blood-draw date across every - // analyte — clear the reading but deliberately KEEP `takenAt` so - // the next row doesn't need it re-set away from "now" again. - // The biomarker resets too (unless locked to one detail page) - // since the whole point is entering the NEXT analyte. - if (!lockedBiomarkerId) setBiomarkerId(""); - setValue(""); - setValueText(""); - setNote(""); - // The next analyte off the same report has its own printed window. - setSourceRange(""); + const next = nextEntryAfterKeepOpen({ + biomarkerId, + value, + valueText, + takenAt, + note, + sourceRange, + visitId, + }); + setBiomarkerId(next.biomarkerId); + setValue(next.value); + setValueText(next.valueText); + setTakenAt(next.takenAt); + setNote(next.note); + setSourceRange(next.sourceRange); + setVisitId(next.visitId); setError(null); onSavedKeepOpen?.(created); - // Return focus to wherever the next entry starts so the flow - // stays keyboard/screen-reader friendly across repeated saves. - if (!lockedBiomarkerId) { - biomarkerTriggerRef.current?.focus(); - } else { - document - .getElementById( - resultType === "numeric" ? "lab-value" : "lab-valueText", - ) - ?.focus(); - } + // The next entry starts at the value, so focus goes there and keeps + // the flow keyboard/screen-reader friendly across repeated saves. + document + .getElementById( + resultType === "numeric" ? "lab-value" : "lab-valueText", + ) + ?.focus(); } else { onSuccess?.(created); } @@ -355,16 +389,16 @@ export function LabForm({
+ {/* Always controlled: `""` shows the placeholder. Passing + `undefined` for an empty pick switched the picker to its own + internal state, which kept displaying the last marker while the + form held none. */}