mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(cycle): log past dates as themselves, in any order (#1004)
The log sheet took its "Day N" header and its "period ended" button from the verdict, which is the answer about today. A January date opened in September read "Day 2", and a back-dated period could only be closed on dates where today happened to be a bleeding day, so the button came and went as unrelated months were filled in. The calendar read now carries, per date, the cycle day of the logged cycle that date belongs to (null before the first start, after today, or past the verdict's ceiling for an open cycle) and whether a one-tap period end can land there. The sheet reads both off the grid day for the date it opened on. Saving a new entry with nothing picked used to write an empty row: the request succeeded, the sheet closed and the calendar had nothing to draw, which read as a save that did nothing. Save now needs something to store and says what, and an emptied existing entry still saves. The sheet also stops writing until the opened date's own entry has loaded, because until then it held the previous date's row id and a quick Save patched the wrong day. On the server, a start recorded a few days after an earlier one is the same period entered in the other order: opening the earlier start now folds the later one into it instead of leaving a one-day cycle in front of it, and a bleed too far before the next start to be its first day and too close to be a cycle of its own no longer infers a start. Opening or removing a start re-attributes the day-logs whose cycle changed.
This commit is contained in:
1 parent
aa58495cbe
commit
7d25bb7352
24 files changed
+877
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8265,6 +8265,7 @@
|
||||
"saving": "저장 중…",
|
||||
"cancel": "취소",
|
||||
"saveError": "저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"emptyHint": "이 날을 저장하려면 생리량이나 증상을 고르거나, 위의 생리 시작 버튼을 눌러 주세요.",
|
||||
"temperatureSection": "체온 및 배란",
|
||||
"intimacySection": "성관계 및 피임",
|
||||
"testsSection": "검사",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -180,6 +180,14 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
...(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,
|
||||
|
||||
@@ -20,6 +20,8 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
isCycleStart: false,
|
||||
cycleDay: null,
|
||||
periodEndable: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
|
||||
@@ -20,6 +20,8 @@ function dayBase(date: string): CalendarDay {
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
isCycleStart: false,
|
||||
cycleDay: null,
|
||||
periodEndable: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The log sheet speaks about the date it opened on, not about today (#1004).
|
||||
*
|
||||
* It used to take its header count and its "period ended" button from the
|
||||
* verdict, which is the server's answer about TODAY. A January date opened
|
||||
* in September therefore read "Day 2", and a back-dated period could not be
|
||||
* closed unless today happened to be a bleeding day. The fixture below makes
|
||||
* the two sources disagree on purpose: the verdict says day 2 of a period,
|
||||
* the grid day for the open date says day 20 with no period end possible.
|
||||
* The sheet must follow the grid.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TODAY = "2026-07-01";
|
||||
|
||||
function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase: null,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
isCycleStart: false,
|
||||
cycleDay: null,
|
||||
periodEndable: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 0,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const calendarQuery = {
|
||||
data: {
|
||||
profile: {
|
||||
goal: "GENERAL_HEALTH",
|
||||
rawChartMode: false,
|
||||
predictionEnabled: true,
|
||||
cyclesObserved: 4,
|
||||
},
|
||||
prediction: null,
|
||||
verdict: {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle: 2,
|
||||
cycleLength: 28,
|
||||
phase: "MENSTRUAL",
|
||||
spans: [],
|
||||
cycleStartDate: "2026-06-30",
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
},
|
||||
stillLearning: false,
|
||||
days: [gridDay(TODAY, { cycleDay: 20, phase: "LUTEAL" })],
|
||||
meta: { generatedAt: "2026-07-01T00:00:00Z" },
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
const idleQuery = {
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
|
||||
|
||||
vi.mock("../use-cycle", () => ({
|
||||
localYmd: () => TODAY,
|
||||
useCycleCalendar: () => calendarQuery,
|
||||
useCycleHistory: () => idleQuery,
|
||||
useCycleInsights: () => idleQuery,
|
||||
useCycleProfile: () => idleQuery,
|
||||
useCycleDayLog: () => idleQuery,
|
||||
useCustomSymptoms: () => idleQuery,
|
||||
useLogDay: () => mutationStub,
|
||||
usePatchDayLog: () => mutationStub,
|
||||
useStartPeriod: () => mutationStub,
|
||||
useEndPeriod: () => mutationStub,
|
||||
useDeleteDayLog: () => mutationStub,
|
||||
useCreateCustomSymptom: () => mutationStub,
|
||||
useDeleteCustomSymptom: () => mutationStub,
|
||||
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit",
|
||||
CustomSymptomError: class extends Error {},
|
||||
}));
|
||||
|
||||
const sheetProps: Record<string, unknown>[] = [];
|
||||
vi.mock("../log-day-sheet", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../log-day-sheet")>();
|
||||
return {
|
||||
...actual,
|
||||
LogDaySheet: (props: Record<string, unknown>) => {
|
||||
sheetProps.push(props);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CycleView } from "../cycle-view";
|
||||
|
||||
describe("<CycleView> — the log sheet describes the date it opened on", () => {
|
||||
it("feeds the sheet the grid day's count and period-end answer, not today's verdict", () => {
|
||||
sheetProps.length = 0;
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<CycleView />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const props = sheetProps.at(-1);
|
||||
expect(props).toBeDefined();
|
||||
expect(props?.date).toBe(TODAY);
|
||||
expect(props?.dayOfCycle).toBe(20);
|
||||
expect(props?.phase).toBe("LUTEAL");
|
||||
expect(props?.periodEndable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
intimacyCount,
|
||||
testsCount,
|
||||
noteCount,
|
||||
isBlankDayLog,
|
||||
dayLogSaveState,
|
||||
sheetDayContext,
|
||||
type DayLogFormState,
|
||||
} from "../log-day-sheet";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
function blank(): DayLogFormState {
|
||||
return {
|
||||
@@ -204,3 +208,116 @@ describe("section summary-badge counts (v1.17.0)", () => {
|
||||
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saving a day (#1004)", () => {
|
||||
it("treats a form with nothing picked as blank", () => {
|
||||
expect(isBlankDayLog(blank())).toBe(true);
|
||||
// A disturbed-flag or protection toggle alone records nothing: both only
|
||||
// mean something next to the value they qualify.
|
||||
expect(isBlankDayLog({ ...blank(), bbtDisturbed: true })).toBe(true);
|
||||
expect(isBlankDayLog({ ...blank(), protectedSex: true })).toBe(true);
|
||||
expect(isBlankDayLog({ ...blank(), note: " " })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats any recorded sign as content", () => {
|
||||
expect(isBlankDayLog({ ...blank(), flow: "LIGHT" })).toBe(false);
|
||||
expect(isBlankDayLog({ ...blank(), intermenstrual: true })).toBe(false);
|
||||
expect(isBlankDayLog({ ...blank(), bbt: "36.5" })).toBe(false);
|
||||
expect(isBlankDayLog({ ...blank(), mucus: "DRY" })).toBe(false);
|
||||
expect(isBlankDayLog({ ...blank(), intercourse: true })).toBe(false);
|
||||
expect(isBlankDayLog({ ...blank(), note: "headache" })).toBe(false);
|
||||
expect(
|
||||
isBlankDayLog({ ...blank(), symptoms: new Map([["cramps", null]]) }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not save an empty new entry, so no empty row lands unseen", () => {
|
||||
const state = dayLogSaveState({
|
||||
hydrated: true,
|
||||
rowId: null,
|
||||
form: blank(),
|
||||
});
|
||||
expect(state.canSave).toBe(false);
|
||||
expect(state.blankNewEntry).toBe(true);
|
||||
});
|
||||
|
||||
it("still saves an emptied existing entry, because clearing is an edit", () => {
|
||||
const state = dayLogSaveState({
|
||||
hydrated: true,
|
||||
rowId: "row-1",
|
||||
form: blank(),
|
||||
});
|
||||
expect(state.canSave).toBe(true);
|
||||
expect(state.rowId).toBe("row-1");
|
||||
});
|
||||
|
||||
it("neither saves nor names a row before the date's own entry has loaded", () => {
|
||||
// The form still holds the previously opened date. A Save here used to
|
||||
// PATCH that other date's row.
|
||||
const state = dayLogSaveState({
|
||||
hydrated: false,
|
||||
rowId: "row-of-another-date",
|
||||
form: { ...blank(), flow: "HEAVY" },
|
||||
});
|
||||
expect(state.canSave).toBe(false);
|
||||
expect(state.rowId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase: null,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
isCycleStart: false,
|
||||
cycleDay: null,
|
||||
periodEndable: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 0,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sheetDayContext (#1004)", () => {
|
||||
it("describes the selected date from its own grid day", () => {
|
||||
const ctx = sheetDayContext("2026-01-27", [
|
||||
gridDay("2026-01-26", { isCycleStart: true, cycleDay: 1 }),
|
||||
gridDay("2026-01-27", {
|
||||
cycleDay: 2,
|
||||
periodEndable: true,
|
||||
phase: "MENSTRUAL",
|
||||
}),
|
||||
]);
|
||||
expect(ctx).toEqual({
|
||||
startsCycle: false,
|
||||
periodEndable: true,
|
||||
phase: "MENSTRUAL",
|
||||
dayOfCycle: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("makes no claim about a date the grid does not hold", () => {
|
||||
expect(sheetDayContext("2025-12-01", [gridDay("2026-01-26")])).toEqual({
|
||||
startsCycle: false,
|
||||
periodEndable: false,
|
||||
phase: null,
|
||||
dayOfCycle: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the later of two overlapping grid reads, as the calendar does", () => {
|
||||
const ctx = sheetDayContext("2026-04-01", [
|
||||
gridDay("2026-04-01", { cycleDay: null }),
|
||||
gridDay("2026-04-01", { cycleDay: 4, periodEndable: true }),
|
||||
]);
|
||||
expect(ctx.dayOfCycle).toBe(4);
|
||||
expect(ctx.periodEndable).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -51,7 +51,7 @@ import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-share
|
||||
import { FieldInfo } from "./field-info";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
|
||||
import type { CyclePhase, CycleGoal } from "./types";
|
||||
import type { CalendarDay, CyclePhase, CycleGoal } from "./types";
|
||||
import {
|
||||
useCreateCustomSymptom,
|
||||
useCustomSymptoms,
|
||||
@@ -311,6 +311,89 @@ export function noteCount(s: DayLogFormState): number {
|
||||
return s.note.trim() ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the form holds nothing a new day-log would record.
|
||||
*
|
||||
* Saving such a form used to write an empty row: the request succeeded, the
|
||||
* sheet closed, and the calendar had nothing to draw, so a person who picked
|
||||
* a past date and pressed Save to mark it saw the save "do nothing". A new
|
||||
* entry now needs at least one thing to store. An existing row keeps Save
|
||||
* enabled, because emptying it is a real edit.
|
||||
*/
|
||||
export function isBlankDayLog(s: DayLogFormState): boolean {
|
||||
return (
|
||||
s.flow === null &&
|
||||
!s.intermenstrual &&
|
||||
resolveBbt(s.bbt) === null &&
|
||||
s.opk === null &&
|
||||
s.mucus === null &&
|
||||
s.cervixPosition === null &&
|
||||
s.cervixFirmness === null &&
|
||||
s.cervixOpening === null &&
|
||||
!s.intercourse &&
|
||||
s.pregnancyTest === null &&
|
||||
s.progesteroneTest === null &&
|
||||
s.contraceptive === null &&
|
||||
s.note.trim() === "" &&
|
||||
s.symptoms.size === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sheet may do with the form right now.
|
||||
*
|
||||
* `hydrated` is false between opening a date and its stored row arriving.
|
||||
* In that window the form still holds the PREVIOUS date's values and row id,
|
||||
* so a quick Save used to PATCH the other date's row and Delete would have
|
||||
* removed it. Nothing writes until the form belongs to the date on screen.
|
||||
*/
|
||||
export function dayLogSaveState({
|
||||
hydrated,
|
||||
rowId,
|
||||
form,
|
||||
}: {
|
||||
hydrated: boolean;
|
||||
rowId: string | null;
|
||||
form: DayLogFormState;
|
||||
}): { rowId: string | null; canSave: boolean; blankNewEntry: boolean } {
|
||||
if (!hydrated) return { rowId: null, canSave: false, blankNewEntry: false };
|
||||
const blankNewEntry = rowId === null && isBlankDayLog(form);
|
||||
return { rowId, canSave: !blankNewEntry, blankNewEntry };
|
||||
}
|
||||
|
||||
/** The sheet's per-date context, read off the calendar grid for that date. */
|
||||
export interface SheetDayContext {
|
||||
startsCycle: boolean;
|
||||
periodEndable: boolean;
|
||||
phase: CyclePhase | null;
|
||||
dayOfCycle: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve what the sheet says about `date` from the server's grid day for it.
|
||||
* The grid may hold the same date twice where the anchored read and the
|
||||
* month read overlap; the later entry wins, as it does in the calendar.
|
||||
* A date the grid does not hold gets no claims at all.
|
||||
*/
|
||||
export function sheetDayContext(
|
||||
date: string,
|
||||
days: readonly CalendarDay[],
|
||||
): SheetDayContext {
|
||||
let day: CalendarDay | undefined;
|
||||
for (let i = days.length - 1; i >= 0; i--) {
|
||||
if (days[i].date === date) {
|
||||
day = days[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
startsCycle: day?.isCycleStart ?? false,
|
||||
periodEndable: day?.periodEndable ?? false,
|
||||
phase: day?.phase ?? null,
|
||||
dayOfCycle: day?.cycleDay ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The MIN_CYCLES gate the phase-education card uses — mirrored here so the
|
||||
* sheet's phase-context header makes the same honesty claim (no phase label
|
||||
* until prediction is on, not raw-chart, and at least three cycles seen). */
|
||||
@@ -329,17 +412,21 @@ export interface LogDaySheetProps {
|
||||
*/
|
||||
startsCycle?: boolean;
|
||||
/**
|
||||
* Whether a period is currently open (today is in the MENSTRUAL phase) — gates
|
||||
* the one-tap "end period" affordance so it never shows when no period is in
|
||||
* progress (QA M2).
|
||||
* Whether a one-tap period end can land on `date` (the server's per-date
|
||||
* `periodEndable`). It used to follow TODAY's phase, so the button was
|
||||
* missing on every back-dated period and appeared on unrelated dates
|
||||
* whenever today happened to be a bleeding day.
|
||||
*/
|
||||
activePeriod?: boolean;
|
||||
periodEndable?: boolean;
|
||||
/**
|
||||
* The active cycle phase from the calendar read (or null). Drives the
|
||||
* The phase of `date` from the calendar read (or null). Drives the
|
||||
* hue-tinted phase-context header so the sheet visually belongs to the ring.
|
||||
*/
|
||||
phase?: CyclePhase | null;
|
||||
/** Day-of-cycle for the header ("Day 14 · Ovulatory"); null when no cycle. */
|
||||
/**
|
||||
* The cycle day of `date` for the header ("Day 14 · Ovulatory"); null when
|
||||
* the date sits in no logged cycle. Never today's count.
|
||||
*/
|
||||
dayOfCycle?: number | null;
|
||||
/**
|
||||
* The user's cycle goal — fertility goals (TTC / avoid-pregnancy) auto-open
|
||||
@@ -501,7 +588,7 @@ export function LogDaySheet({
|
||||
date,
|
||||
today,
|
||||
startsCycle = false,
|
||||
activePeriod = false,
|
||||
periodEndable = false,
|
||||
phase = null,
|
||||
dayOfCycle = null,
|
||||
goal,
|
||||
@@ -611,6 +698,7 @@ export function LogDaySheet({
|
||||
} else if (!open && lastFormKey !== null) {
|
||||
setLastFormKey(null);
|
||||
}
|
||||
const hydrated = open && lastFormKey === formKey;
|
||||
|
||||
function toggleSymptom(key: string) {
|
||||
setSymptoms((prev) => {
|
||||
@@ -651,16 +739,20 @@ export function LogDaySheet({
|
||||
symptoms,
|
||||
};
|
||||
|
||||
const saveState = dayLogSaveState({ hydrated, rowId, form: formState });
|
||||
const currentRowId = saveState.rowId;
|
||||
|
||||
async function handleSave() {
|
||||
if (!saveState.canSave) return;
|
||||
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
|
||||
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
|
||||
// footer carries the visible failure signal and the sheet stays open.
|
||||
try {
|
||||
if (rowId) {
|
||||
if (currentRowId) {
|
||||
// Editing an existing row → PATCH with explicit nulls so a deselected
|
||||
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
|
||||
await patchDay.mutateAsync({
|
||||
id: rowId,
|
||||
id: currentRowId,
|
||||
patch: buildDayLogPatch(formState),
|
||||
});
|
||||
} else {
|
||||
@@ -695,9 +787,9 @@ export function LogDaySheet({
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!rowId) return;
|
||||
if (!currentRowId) return;
|
||||
try {
|
||||
await deleteDay.mutateAsync(rowId);
|
||||
await deleteDay.mutateAsync(currentRowId);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
/* toast shown by useDeleteDayLog onError */
|
||||
@@ -733,7 +825,7 @@ export function LogDaySheet({
|
||||
contentWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
{rowId ? (
|
||||
{currentRowId ? (
|
||||
<ConfirmButton
|
||||
slot="cycle-day-delete"
|
||||
variant="outline"
|
||||
@@ -760,7 +852,11 @@ export function LogDaySheet({
|
||||
>
|
||||
{t("cycle.sheet.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={busy}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={busy || !saveState.canSave}
|
||||
data-slot="cycle-day-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
@@ -782,7 +878,8 @@ export function LogDaySheet({
|
||||
|
||||
{/* One-tap period boundaries — available on any selected date so a
|
||||
forgotten day-1 can be corrected retroactively (M3). "End period"
|
||||
shows only while a period is actually open (M2). The labels drop the
|
||||
shows only where it can land: inside the first days of the logged
|
||||
cycle the selected date belongs to. The labels drop the
|
||||
word "today" on a back-dated day: they always wrote to the selected
|
||||
date, and saying otherwise is how a correction reads as a mistake. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -802,7 +899,7 @@ export function LogDaySheet({
|
||||
? t("cycle.startedPeriod")
|
||||
: t("cycle.startedPeriodOnDate")}
|
||||
</Button>
|
||||
{activePeriod ? (
|
||||
{periodEndable ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 justify-start gap-2"
|
||||
@@ -1197,6 +1294,15 @@ export function LogDaySheet({
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
{saveState.blankNewEntry ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="cycle-day-empty-hint"
|
||||
>
|
||||
{t("cycle.sheet.emptyHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{logDay.isError || patchDay.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.sheet.saveError")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { bleedOpensCycle } from "../cycle-boundaries";
|
||||
import { HARD_CYCLE_MIN } from "../types";
|
||||
import {
|
||||
bleedOpensCycle,
|
||||
isSamePeriodStartedEarlier,
|
||||
} from "../cycle-boundaries";
|
||||
import { HARD_CYCLE_MIN, PERIOD_MAX } from "../types";
|
||||
import { addDays } from "../day-math";
|
||||
|
||||
const DAY = "2026-06-01";
|
||||
@@ -65,3 +68,23 @@ describe("bleedOpensCycle", () => {
|
||||
expect(bleedOpensCycle("HEAVY", false, addDays(DAY, 10), DAY)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSamePeriodStartedEarlier", () => {
|
||||
it("reads a start a few days later as the same period entered out of order", () => {
|
||||
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, 1))).toBe(true);
|
||||
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, PERIOD_MAX - 1))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reach past the longest period the engine accepts", () => {
|
||||
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, PERIOD_MAX))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("never folds the same day or an earlier start", () => {
|
||||
expect(isSamePeriodStartedEarlier(DAY, DAY)).toBe(false);
|
||||
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, -3))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ const db = vi.hoisted(() => {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// An opened cycle re-attributes the days it now owns.
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
// The write path asks whether the logged day opens a cycle. These cases
|
||||
// are about the encryption columns, so the fake answers "no cycles on
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildCalendar, type CalendarDayLogRow } from "../engine-adapter";
|
||||
import {
|
||||
buildCalendar,
|
||||
type CalendarDayDTO,
|
||||
type CalendarDayLogRow,
|
||||
} from "../engine-adapter";
|
||||
import type { CycleProfile, MenstrualCycle } from "@/generated/prisma/client";
|
||||
|
||||
function profile(overrides: Partial<CycleProfile> = {}): CycleProfile {
|
||||
@@ -226,4 +230,110 @@ describe("buildCalendar", () => {
|
||||
expect(days.some((d) => d.phase !== null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the position of each date in its own cycle (#1004)", () => {
|
||||
// The log sheet used to label every date with TODAY's cycle day, so a
|
||||
// January date opened in September read "Day 2", and it offered the
|
||||
// one-tap period end only when today was a bleeding day. Both answers now
|
||||
// come per date, from the cycle that date actually belongs to.
|
||||
const history = [
|
||||
cycle("2026-01-01", { endDate: "2026-01-28", lengthDays: 28 }),
|
||||
cycle("2026-01-29", { endDate: "2026-02-25", lengthDays: 28 }),
|
||||
cycle("2026-02-26"),
|
||||
];
|
||||
|
||||
function dayOf(days: CalendarDayDTO[], date: string): CalendarDayDTO {
|
||||
const d = days.find((x) => x.date === date);
|
||||
if (!d) throw new Error(`no grid day for ${date}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
it("counts a historical date from the start of the cycle it sits in", () => {
|
||||
const { days } = buildCalendar(
|
||||
profile(),
|
||||
history,
|
||||
[],
|
||||
[],
|
||||
"2025-12-20",
|
||||
"2026-03-20",
|
||||
"2026-03-10",
|
||||
false,
|
||||
);
|
||||
expect(dayOf(days, "2026-01-26").cycleDay).toBe(26);
|
||||
expect(dayOf(days, "2026-01-29").cycleDay).toBe(1);
|
||||
expect(dayOf(days, "2026-02-27").cycleDay).toBe(2);
|
||||
// Today, in the open cycle: the same count the verdict reports.
|
||||
expect(dayOf(days, "2026-03-10").cycleDay).toBe(13);
|
||||
});
|
||||
|
||||
it("claims no cycle day before the first logged start or after today", () => {
|
||||
const { days } = buildCalendar(
|
||||
profile(),
|
||||
history,
|
||||
[],
|
||||
[],
|
||||
"2025-12-20",
|
||||
"2026-03-20",
|
||||
"2026-03-10",
|
||||
false,
|
||||
);
|
||||
expect(dayOf(days, "2025-12-31").cycleDay).toBeNull();
|
||||
expect(dayOf(days, "2026-03-11").cycleDay).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a period end only inside the first days of a logged cycle", () => {
|
||||
const { days } = buildCalendar(
|
||||
profile(),
|
||||
history,
|
||||
[],
|
||||
[],
|
||||
"2025-12-20",
|
||||
"2026-03-20",
|
||||
"2026-03-10",
|
||||
false,
|
||||
);
|
||||
expect(dayOf(days, "2026-01-01").periodEndable).toBe(true);
|
||||
expect(dayOf(days, "2026-01-05").periodEndable).toBe(true);
|
||||
expect(dayOf(days, "2026-02-01").periodEndable).toBe(true);
|
||||
expect(dayOf(days, "2026-01-26").periodEndable).toBe(false);
|
||||
expect(dayOf(days, "2025-12-31").periodEndable).toBe(false);
|
||||
expect(dayOf(days, "2026-03-11").periodEndable).toBe(false);
|
||||
});
|
||||
|
||||
it("answers the same whichever order the starts were entered in", () => {
|
||||
// A September start entered first, then January: January's own days
|
||||
// count from January, never from September or from today.
|
||||
const { days } = buildCalendar(
|
||||
profile(),
|
||||
[cycle("2026-09-17"), cycle("2026-01-26")],
|
||||
[],
|
||||
[],
|
||||
"2026-01-01",
|
||||
"2026-02-28",
|
||||
"2026-09-24",
|
||||
false,
|
||||
);
|
||||
expect(dayOf(days, "2026-01-26").cycleDay).toBe(1);
|
||||
expect(dayOf(days, "2026-01-28").cycleDay).toBe(3);
|
||||
expect(dayOf(days, "2026-01-28").periodEndable).toBe(true);
|
||||
expect(dayOf(days, "2026-01-25").cycleDay).toBeNull();
|
||||
});
|
||||
|
||||
it("stops counting an open cycle where the verdict stops", () => {
|
||||
// One start in January and nothing since: by August the count is no
|
||||
// longer an observed cycle day, the same ceiling the ring applies.
|
||||
const { days } = buildCalendar(
|
||||
profile(),
|
||||
[cycle("2026-01-01")],
|
||||
[],
|
||||
[],
|
||||
"2026-01-01",
|
||||
"2026-09-24",
|
||||
"2026-09-24",
|
||||
false,
|
||||
);
|
||||
expect(dayOf(days, "2026-01-05").cycleDay).toBe(5);
|
||||
expect(dayOf(days, "2026-08-01").cycleDay).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays, dayDiff } from "./day-math";
|
||||
import { HARD_CYCLE_MIN } from "./types";
|
||||
import { HARD_CYCLE_MIN, PERIOD_MAX } from "./types";
|
||||
import type {
|
||||
FlowLevel,
|
||||
Prisma,
|
||||
@@ -46,6 +46,27 @@ export interface MovedAnchors {
|
||||
priorLengthAfter?: number | null;
|
||||
openedEndDateBefore?: string | null;
|
||||
openedEndDateAfter?: string | null;
|
||||
/**
|
||||
* A cycle that started a few days AFTER the opened date and was folded into
|
||||
* it: the period began earlier than first recorded, not twice.
|
||||
*/
|
||||
absorbedCycleId?: string;
|
||||
absorbedStartDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cycle starting at `laterStart` is the same period as one starting
|
||||
* at `date`, just recorded late: it begins within a maximum-length period
|
||||
* after `date`. Two starts that close together are one period entered out of
|
||||
* order, never two cycles, and keeping both leaves a one- or two-day cycle
|
||||
* that corrupts every length after it.
|
||||
*/
|
||||
export function isSamePeriodStartedEarlier(
|
||||
date: string,
|
||||
laterStart: string,
|
||||
): boolean {
|
||||
const gap = dayDiff(laterStart, date);
|
||||
return gap > 0 && gap < PERIOD_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +92,34 @@ export async function openCycleAt(
|
||||
): Promise<{ cycleId: string; moved: MovedAnchors }> {
|
||||
const moved: MovedAnchors = {};
|
||||
|
||||
// A start recorded a few days after this date is the same period, entered
|
||||
// in the other order (day two first, day one remembered later). Fold it
|
||||
// into the start being opened instead of leaving a cycle a day or two long
|
||||
// in front of it. What that row knew beyond its start carries over below.
|
||||
const following = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
periodEndDate: true,
|
||||
ovulationDate: true,
|
||||
ovulationConfirmed: true,
|
||||
},
|
||||
});
|
||||
const absorbed =
|
||||
following && isSamePeriodStartedEarlier(date, following.startDate)
|
||||
? following
|
||||
: null;
|
||||
if (absorbed) {
|
||||
moved.absorbedCycleId = absorbed.id;
|
||||
moved.absorbedStartDate = absorbed.startDate;
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: absorbed.id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
// Close the prior open cycle: its end is the day before this start.
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { lt: date } },
|
||||
@@ -93,12 +142,26 @@ export async function openCycleAt(
|
||||
});
|
||||
}
|
||||
|
||||
const carried = absorbed
|
||||
? {
|
||||
...(absorbed.periodEndDate
|
||||
? { periodEndDate: absorbed.periodEndDate }
|
||||
: {}),
|
||||
...(absorbed.ovulationDate
|
||||
? {
|
||||
ovulationDate: absorbed.ovulationDate,
|
||||
ovulationConfirmed: absorbed.ovulationConfirmed,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {};
|
||||
const cycle = await db.menstrualCycle.upsert({
|
||||
where: { userId_startDate: { userId, startDate: date } },
|
||||
create: { userId, startDate: date, tz, isPredicted: false },
|
||||
create: { userId, startDate: date, tz, isPredicted: false, ...carried },
|
||||
update: {
|
||||
deletedAt: null,
|
||||
isPredicted: false,
|
||||
...carried,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
@@ -122,9 +185,45 @@ export async function openCycleAt(
|
||||
});
|
||||
}
|
||||
|
||||
// The days between this start and the next one belong to this cycle now.
|
||||
// A start back-filled into the middle of an existing cycle used to leave
|
||||
// them attributed to the cycle before it.
|
||||
await attributeDaysToCycle(
|
||||
db,
|
||||
userId,
|
||||
cycle.id,
|
||||
date,
|
||||
next?.startDate ?? null,
|
||||
);
|
||||
|
||||
return { cycleId: cycle.id, moved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every live day-log in `[from, until)` at `cycleId` (open-ended when
|
||||
* `until` is null), bumping `syncVersion` only on rows whose attribution
|
||||
* actually changes so the sync feed does not re-pull untouched days.
|
||||
*/
|
||||
async function attributeDaysToCycle(
|
||||
db: CycleDb,
|
||||
userId: string,
|
||||
cycleId: string | null,
|
||||
from: string,
|
||||
until: string | null,
|
||||
): Promise<void> {
|
||||
await db.cycleDayLog.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: { gte: from, ...(until ? { lt: until } : {}) },
|
||||
...(cycleId === null
|
||||
? { cycleId: { not: null } }
|
||||
: { OR: [{ cycleId: null }, { cycleId: { not: cycleId } }] }),
|
||||
},
|
||||
data: { cycleId, syncVersion: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a logged day's bleeding is the first day of a period, and therefore
|
||||
* opens a cycle.
|
||||
@@ -205,6 +304,22 @@ export async function ensureCycleForBleedingDay(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// The same judgement looking forward. A logged start a few days later is
|
||||
// this period entered out of order, and `openCycleAt` folds it in. One
|
||||
// further out but still inside the hard minimum is neither the same
|
||||
// period nor a cycle of its own, so nothing is inferred from the flow.
|
||||
const following = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (
|
||||
following &&
|
||||
dayDiff(following.startDate, date) < HARD_CYCLE_MIN &&
|
||||
!isSamePeriodStartedEarlier(date, following.startDate)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const { cycleId } = await openCycleAt(db, userId, date, tz);
|
||||
return cycleId;
|
||||
});
|
||||
@@ -233,10 +348,18 @@ export async function removeCycleStartedOn(
|
||||
where: { id: opened.id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
return {
|
||||
cycleId: opened.id,
|
||||
reanchored: await reanchorAfterRemovedStart(db, userId, date),
|
||||
};
|
||||
const reanchored = await reanchorAfterRemovedStart(db, userId, date);
|
||||
// The removed cycle's days go back to the cycle before it (or to none).
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { lt: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
await db.cycleDayLog.updateMany({
|
||||
where: { userId, cycleId: opened.id },
|
||||
data: { cycleId: prior?.id ?? null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return { cycleId: opened.id, reanchored };
|
||||
}
|
||||
|
||||
/** The boundary columns the re-derivation moved, for the audit row. */
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
resolveLuteal,
|
||||
clampLuteal,
|
||||
POPULATION_DEFAULT_CYCLE,
|
||||
PERIOD_MAX,
|
||||
} from "@/lib/cycle";
|
||||
import { resolveCycleDay } from "./verdict";
|
||||
import type {
|
||||
CycleProfile,
|
||||
CycleDayLog,
|
||||
@@ -41,6 +43,21 @@ export interface CalendarDayDTO {
|
||||
* wrong thing.
|
||||
*/
|
||||
isCycleStart: boolean;
|
||||
/**
|
||||
* The 1-based day of the logged cycle this date belongs to, or null before
|
||||
* the first logged start, after today, or past the point where an open
|
||||
* cycle's count stops being an observed fact (the verdict's ceiling). For
|
||||
* today it equals `verdict.dayOfCycle`. The log sheet labels a date with
|
||||
* this, never with today's count.
|
||||
*/
|
||||
cycleDay: number | null;
|
||||
/**
|
||||
* Whether the one-tap period end can land on this date: it sits inside the
|
||||
* first `PERIOD_MAX` days of a logged cycle, which is where the end
|
||||
* boundary stamps `periodEndDate`. Resolved per date so a back-dated period
|
||||
* can be closed whatever phase today is in.
|
||||
*/
|
||||
periodEndable: boolean;
|
||||
flow: string | null;
|
||||
hasSymptoms: boolean;
|
||||
confidence: number;
|
||||
@@ -376,6 +393,12 @@ export function buildCalendar(
|
||||
for (const l of dayLogs) logByDate.set(l.date, l);
|
||||
|
||||
const cycleStartDates = new Set(cycles.map((c) => c.startDate));
|
||||
const sortedStarts = [...cycleStartDates].sort((a, b) => dayDiff(a, b));
|
||||
const profileLengths = {
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
};
|
||||
|
||||
const days: CalendarDayDTO[] = [];
|
||||
const span = dayDiff(to, from);
|
||||
@@ -405,6 +428,8 @@ export function buildCalendar(
|
||||
prediction?.predictedOvulation != null &&
|
||||
prediction.predictedOvulation === date;
|
||||
|
||||
const cycleDay = resolveCycleDay(date, sortedStarts, today, profileLengths);
|
||||
|
||||
days.push({
|
||||
date,
|
||||
// No asserted phase band while learning — a population-28 frame off a
|
||||
@@ -415,6 +440,8 @@ export function buildCalendar(
|
||||
isPredictedOvulation,
|
||||
isPeriodLogged: log?.flow != null && log.flow !== "NONE",
|
||||
isCycleStart: cycleStartDates.has(date),
|
||||
cycleDay,
|
||||
periodEndable: cycleDay !== null && cycleDay <= PERIOD_MAX,
|
||||
flow: log?.flow ?? null,
|
||||
hasSymptoms: log?.hasSymptoms ?? false,
|
||||
confidence: prediction?.confidence ?? 0,
|
||||
|
||||
@@ -284,6 +284,41 @@ function verdictFromLoggedStart(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 1-based day of the logged cycle `date` falls in, or null when the record
|
||||
* cannot say.
|
||||
*
|
||||
* This is the per-date sibling of the verdict's `dayOfCycle`, and it answers
|
||||
* for the date asked about rather than for today. A date belongs to the latest
|
||||
* logged start at or before it. A cycle that has a successor is closed, so any
|
||||
* day inside it is a recorded fact. The open cycle runs through today and is
|
||||
* held to the same ceiling as the verdict: past the typical length plus the
|
||||
* grace window the count is the engine's open-ended window, not an observed
|
||||
* day. No count before the first logged start, and none after today.
|
||||
*
|
||||
* `starts` must be sorted ascending.
|
||||
*/
|
||||
export function resolveCycleDay(
|
||||
date: string,
|
||||
starts: readonly string[],
|
||||
today: string,
|
||||
profile?: CycleProfileLengths,
|
||||
): number | null {
|
||||
if (dayDiff(date, today) > 0) return null;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
if (dayDiff(date, starts[i]) >= 0) idx = i;
|
||||
else break;
|
||||
}
|
||||
if (idx < 0) return null;
|
||||
const day = dayDiff(date, starts[idx]) + 1;
|
||||
const isOpenCycle = idx === starts.length - 1;
|
||||
if (isOpenCycle && day > typicalCycleLength(profile) + OVERDUE_GRACE_DAYS) {
|
||||
return null;
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a client needs to render the cycle ring and its caption.
|
||||
* The only place in the product that decides any of it.
|
||||
|
||||
@@ -197,6 +197,14 @@ const cycleCalendarDayDto = z.object({
|
||||
description:
|
||||
"Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.",
|
||||
}),
|
||||
cycleDay: z.number().int().nullable().meta({
|
||||
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: z.boolean().meta({
|
||||
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: flowLevelEnumOpenapi.nullable(),
|
||||
hasSymptoms: z.boolean(),
|
||||
confidence: z.number(),
|
||||
|
||||
@@ -253,6 +253,124 @@ describe("a bleeding day opens a cycle", () => {
|
||||
expect(may).toEqual({ endDate: null, lengthDays: null });
|
||||
});
|
||||
|
||||
it("moves a start earlier when an earlier first day is remembered later (#1004)", async () => {
|
||||
// Day two logged first, day one filled in afterwards. The period began a
|
||||
// day earlier; it did not begin twice. The old rule only looked at the
|
||||
// cycle BEFORE the day and opened a one-day cycle in front of the other.
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
await logDay("2026-06-02", { flow: "HEAVY" });
|
||||
await logDay("2026-06-01", { flow: "HEAVY" });
|
||||
|
||||
const rows = await getPrismaClient().menstrualCycle.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
|
||||
{ startDate: "2026-06-01", endDate: null, lengthDays: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lands on the same history whichever order the periods are entered in", async () => {
|
||||
// September first, then January to August, is what a person back-filling
|
||||
// a year does. The result must not depend on it.
|
||||
const starts = [
|
||||
"2026-01-05",
|
||||
"2026-02-03",
|
||||
"2026-03-04",
|
||||
"2026-04-02",
|
||||
"2026-05-01",
|
||||
];
|
||||
for (const d of [starts[4], ...starts.slice(0, 4)]) {
|
||||
// Day two of each period first, then its first day.
|
||||
const dayTwo = new Date(`${d}T12:00:00Z`);
|
||||
dayTwo.setUTCDate(dayTwo.getUTCDate() + 1);
|
||||
await logDay(dayTwo.toISOString().slice(0, 10), { flow: "MEDIUM" });
|
||||
await logDay(d, { flow: "HEAVY" });
|
||||
}
|
||||
const outOfOrder = await getPrismaClient().menstrualCycle.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "cycle-flow",
|
||||
email: "cycle-flow@example.test",
|
||||
gender: "FEMALE",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
await loginAs(USER_ID);
|
||||
for (const d of starts) {
|
||||
await logDay(d, { flow: "HEAVY" });
|
||||
}
|
||||
const inOrder = await getPrismaClient().menstrualCycle.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
|
||||
expect(outOfOrder).toEqual(inOrder);
|
||||
expect(inOrder.map((r) => r.startDate)).toEqual(starts);
|
||||
});
|
||||
|
||||
it("does not guess a start from a bleed too far before the next period to be its first day", async () => {
|
||||
await logDay("2026-06-15", { flow: "HEAVY" });
|
||||
// Twelve days earlier: too close to be a cycle of its own, too far to be
|
||||
// the same period. Nothing is inferred; the one-tap start still decides.
|
||||
await logDay("2026-06-03", { flow: "HEAVY" });
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-06-15"]);
|
||||
});
|
||||
|
||||
it("re-attributes the days a back-filled start now owns", async () => {
|
||||
await logDay("2026-05-01", { flow: "HEAVY" });
|
||||
await logDay("2026-06-05", { basalBodyTempC: 36.4 });
|
||||
// A June start remembered afterwards: 5 June belongs to it, not to May.
|
||||
await logDay("2026-06-01", { flow: "HEAVY" });
|
||||
|
||||
const june = await getPrismaClient().menstrualCycle.findFirstOrThrow({
|
||||
where: { userId: USER_ID, startDate: "2026-06-01", deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const day = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-06-05" },
|
||||
select: { cycleId: true },
|
||||
});
|
||||
expect(day.cycleId).toBe(june.id);
|
||||
});
|
||||
|
||||
it("hands a removed start's days back to the cycle before it", async () => {
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
await logDay("2026-06-01", { flow: "HEAVY" });
|
||||
await logDay("2026-06-05", { basalBodyTempC: 36.4 });
|
||||
|
||||
const start = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-06-01" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
|
||||
await PATCH(
|
||||
jsonRequest(`/api/cycle/day-logs/${start.id}`, "PATCH", { flow: null }),
|
||||
{ params: Promise.resolve({ id: start.id }) },
|
||||
);
|
||||
|
||||
const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
|
||||
where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const day = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-06-05" },
|
||||
select: { cycleId: true },
|
||||
});
|
||||
expect(day.cycleId).toBe(may.id);
|
||||
});
|
||||
|
||||
it("gives the record a forecast and a cycle day it never had", async () => {
|
||||
// What the person actually sees. Four periods 28 days apart, logged as
|
||||
// flow and nothing else, and today inside the last one.
|
||||
|
||||
Reference in new issue
Block a user