mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'fix/v139-ui-bugs' into release/v1.39.0
This commit is contained in:
commit
352de95854
31 files changed
+1418
-151
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
@@ -760,6 +760,7 @@
|
||||
"typeWaistToHeight": "허리-키 비율",
|
||||
"unitScore": "점",
|
||||
"unitRatio": "비율",
|
||||
"unitRating": "단계",
|
||||
"typeWho5Score": "WHO-5 점수",
|
||||
"typeSciScore": "SCI 점수"
|
||||
},
|
||||
@@ -8266,6 +8267,7 @@
|
||||
"saving": "저장 중…",
|
||||
"cancel": "취소",
|
||||
"saveError": "저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"emptyHint": "이 날을 저장하려면 생리량이나 증상을 고르거나, 위의 생리 시작 버튼을 눌러 주세요.",
|
||||
"temperatureSection": "체온 및 배란",
|
||||
"intimacySection": "성관계 및 피임",
|
||||
"testsSection": "검사",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SymptomRow
|
||||
symptomKey="cramps"
|
||||
icon={Droplet}
|
||||
label="Cramps"
|
||||
active={active}
|
||||
severity={severity}
|
||||
onToggle={() => {}}
|
||||
onSeverity={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 = /<div[^>]*data-slot="cycle-symptom-severity"[^>]*>/.exec(html);
|
||||
if (!m) throw new Error("no severity group rendered");
|
||||
return m[0];
|
||||
}
|
||||
|
||||
describe("<SymptomRow>", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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 `<button>` with `aria-pressed`; the sheet
|
||||
* a11y: every chip and symptom toggle is a real `<button>` with `aria-pressed`; the sheet
|
||||
* footer sticky-pins Save/Cancel.
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
@@ -867,9 +964,9 @@ export function LogDaySheet({
|
||||
<cat.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(cat.labelKey)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-1.5">
|
||||
{cat.symptoms.map((s) => (
|
||||
<SymptomChip
|
||||
<SymptomRow
|
||||
key={s.key}
|
||||
symptomKey={s.key}
|
||||
icon={s.icon}
|
||||
@@ -891,9 +988,9 @@ export function LogDaySheet({
|
||||
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("cycle.symptomCategory.custom")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-1.5">
|
||||
{(customSymptoms.data?.symptoms ?? []).map((s) => (
|
||||
<SymptomChip
|
||||
<SymptomRow
|
||||
key={s.key}
|
||||
symptomKey={s.key}
|
||||
icon={customIcon(s.icon)}
|
||||
@@ -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")}
|
||||
@@ -1228,12 +1334,19 @@ function Field({
|
||||
}
|
||||
|
||||
/**
|
||||
* One symptom chip + its inline 1-4 severity selector (shared by the seeded
|
||||
* catalogue chips and the custom chips so both render identically). A custom
|
||||
* chip additionally carries a tiny remove (×) affordance that soft-hides the
|
||||
* symptom from the catalogue.
|
||||
* One symptom per line: the toggle on the left, its optional 1-4 intensity
|
||||
* on the right (shared by the seeded catalogue and the custom symptoms so both
|
||||
* render identically). A custom row additionally carries a remove control that
|
||||
* soft-hides the symptom from the catalogue.
|
||||
*
|
||||
* The intensity used to appear inline next to a chip in a wrapping row, so
|
||||
* selecting a symptom widened its chip and pushed every chip after it along,
|
||||
* often right out from under the finger reaching for the intensity. The row
|
||||
* now reserves the intensity's space whether or not it is shown: selecting a
|
||||
* symptom changes colours and reveals the intensity in place, and nothing on
|
||||
* the sheet moves.
|
||||
*/
|
||||
function SymptomChip({
|
||||
export function SymptomRow({
|
||||
symptomKey,
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -1269,26 +1382,41 @@ function SymptomChip({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex items-center">
|
||||
<Chip active={active} onClick={() => onToggle(symptomKey)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
</Chip>
|
||||
{custom ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-slot="cycle-symptom-row"
|
||||
data-symptom={symptomKey}
|
||||
data-active={active ? "true" : "false"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggle(symptomKey)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 flex min-h-11 min-w-0 flex-1 items-center gap-2 rounded-md border px-3 py-1.5 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{/* Wraps rather than truncates: the intensity column leaves a narrow
|
||||
label on a phone, and a symptom name cut to "Breast tendern…" is
|
||||
not a name. A wrapped row is taller in both states, so it still
|
||||
never moves on select. */}
|
||||
<span className="min-w-0 leading-snug break-words">{label}</span>
|
||||
</button>
|
||||
{custom ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
disabled={deleteCustom.isPending}
|
||||
aria-label={t("cycle.symptom.custom.remove", { label })}
|
||||
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-accent focus-visible:ring-ring/50 grid size-11 shrink-0 place-items-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9"
|
||||
>
|
||||
<X className="size-2.5" aria-hidden="true" />
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{custom ? (
|
||||
<ConfirmDialog
|
||||
slot="cycle-custom-symptom-remove"
|
||||
open={confirmRemove}
|
||||
@@ -1299,41 +1427,45 @@ function SymptomChip({
|
||||
pending={deleteCustom.isPending}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={severity === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
|
||||
onClick={() => onSeverity(symptomKey, lvl)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-8",
|
||||
severity === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{/* Always laid out, only shown while the symptom is selected: the
|
||||
reserved width is what keeps the row still when it appears. */}
|
||||
<div
|
||||
className={cn("flex shrink-0 gap-1", !active && "invisible")}
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
aria-hidden={active ? undefined : true}
|
||||
data-slot="cycle-symptom-severity"
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={severity === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
|
||||
onClick={() => onSeverity(symptomKey, lvl)}
|
||||
disabled={!active}
|
||||
tabIndex={active ? undefined : -1}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
|
||||
severity === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<Plus className="size-3.5" aria-hidden="true" />
|
||||
{t("cycle.symptom.custom.add")}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const biomarkerTriggerRef = useRef<HTMLButtonElement | null>(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({
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
|
||||
{/* 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. */}
|
||||
<Select
|
||||
value={biomarkerId || undefined}
|
||||
value={biomarkerId}
|
||||
onValueChange={handleSelect}
|
||||
disabled={!!lockedBiomarkerId || catalogLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="lab-biomarker"
|
||||
ref={biomarkerTriggerRef}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectTrigger id="lab-biomarker" className="w-full">
|
||||
<SelectValue placeholder={t("labs.form.biomarkerPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The manual-entry form offers every body-composition reading the server
|
||||
* accepts from a person (#1013).
|
||||
*
|
||||
* The form keeps a hand-written type list, and the server's list of
|
||||
* measurement types grew past it: visceral fat, fat mass, fat-free mass and
|
||||
* lean body mass all arrived from scales and Apple Health, with units and
|
||||
* plausibility ranges, while a person reading the same number off a scale's
|
||||
* display had no row to type it into. Nothing noticed, because nothing
|
||||
* compared the two.
|
||||
*
|
||||
* The comparison is derived from the server's own grouping
|
||||
* (`MEASUREMENT_CATEGORIES`, the "body" family), so a type added to that
|
||||
* family later fails here until it is either given a form row or named below
|
||||
* with the reason a person would not type it.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
|
||||
import { VALUE_RANGES, getUnitForType } from "@/lib/validations/measurement";
|
||||
import { hasDisplayTransform } from "@/lib/measurements/display-transform";
|
||||
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
|
||||
import {
|
||||
MEASUREMENT_FORM_TYPE_VALUES,
|
||||
MEASUREMENT_TYPES,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* Body-composition types deliberately absent from the manual form, each with
|
||||
* the reason. Keep this list short and honest: "a scale reports it" is a
|
||||
* reason to be in the form, not out of it.
|
||||
*/
|
||||
const NOT_HAND_ENTERED: Readonly<Record<string, string>> = {
|
||||
BODY_MASS_INDEX:
|
||||
"Derived from weight and height. A typed copy would be a second number that can disagree with the weight it came from.",
|
||||
};
|
||||
|
||||
const BODY_COMPOSITION = [...MEASUREMENT_CATEGORIES.entries()]
|
||||
.filter(([, category]) => category === "body")
|
||||
.map(([type]) => type as string);
|
||||
|
||||
function translate(key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
en,
|
||||
);
|
||||
}
|
||||
|
||||
describe("manual measurement form — body composition", () => {
|
||||
it("reads a non-empty body family off the server's categories", () => {
|
||||
// A guard over an empty set passes for the wrong reason.
|
||||
expect(BODY_COMPOSITION.length).toBeGreaterThan(5);
|
||||
expect(BODY_COMPOSITION).toContain("VISCERAL_FAT");
|
||||
});
|
||||
|
||||
it.each(BODY_COMPOSITION)(
|
||||
"%s is offered in the form or excluded with a reason",
|
||||
(type) => {
|
||||
const inForm = MEASUREMENT_FORM_TYPE_VALUES.includes(type);
|
||||
const excluded = type in NOT_HAND_ENTERED;
|
||||
expect(
|
||||
inForm !== excluded,
|
||||
`${type}: add a form row, or name it in NOT_HAND_ENTERED with a reason (and never both)`,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("excludes only real body-composition types, each for a stated reason", () => {
|
||||
for (const [type, reason] of Object.entries(NOT_HAND_ENTERED)) {
|
||||
expect(BODY_COMPOSITION, `${type} is not a body type`).toContain(type);
|
||||
expect(reason.trim().length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
const formBodyRows = MEASUREMENT_TYPES.filter((row) =>
|
||||
BODY_COMPOSITION.includes(row.value),
|
||||
);
|
||||
|
||||
it.each(formBodyRows.map((row) => [row.value, row] as const))(
|
||||
"%s asks in the unit the server stores, with a label in every place it needs one",
|
||||
(type, row) => {
|
||||
const canonical = getUnitForType(type);
|
||||
expect(canonical).not.toBe("unknown");
|
||||
if ("unit" in row && !hasDisplayTransform(type)) {
|
||||
expect(row.unit).toBe(canonical);
|
||||
}
|
||||
if ("unitKey" in row) {
|
||||
expect(typeof translate(row.unitKey)).toBe("string");
|
||||
}
|
||||
expect(typeof translate(row.labelKey)).toBe("string");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(formBodyRows.map((row) => [row.value, row] as const))(
|
||||
"%s suggests a placeholder the server would accept",
|
||||
(type, row) => {
|
||||
const range = VALUE_RANGES[type];
|
||||
expect(range, `${type} has no plausibility range`).toBeDefined();
|
||||
const placeholder =
|
||||
"placeholder" in row ? parseDecimalEntry(row.placeholder) : null;
|
||||
expect(placeholder).not.toBeNull();
|
||||
expect(placeholder!).toBeGreaterThanOrEqual(range.min);
|
||||
expect(placeholder!).toBeLessThanOrEqual(range.max);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
|
||||
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
|
||||
|
||||
const MEASUREMENT_TYPES = [
|
||||
export const MEASUREMENT_TYPES = [
|
||||
{
|
||||
value: "BLOOD_PRESSURE",
|
||||
labelKey: "measurements.typeBloodPressure",
|
||||
@@ -104,6 +104,46 @@ const MEASUREMENT_TYPES = [
|
||||
placeholder: "3.2",
|
||||
placeholderImperial: "7",
|
||||
},
|
||||
// The rest of what a body-composition scale reports. The server has
|
||||
// accepted all of these since the Withings and Apple Health work; only this
|
||||
// list lagged, so a reading typed off the scale's display had no row. The
|
||||
// guard in `measurement-form-body-composition.test.ts` keeps the two lists
|
||||
// from drifting again.
|
||||
{
|
||||
value: "FAT_MASS",
|
||||
labelKey: "measurements.typeFatMass",
|
||||
unit: "kg",
|
||||
placeholder: "18",
|
||||
placeholderImperial: "40",
|
||||
},
|
||||
{
|
||||
value: "FAT_FREE_MASS",
|
||||
labelKey: "measurements.typeFatFreeMass",
|
||||
unit: "kg",
|
||||
placeholder: "58",
|
||||
placeholderImperial: "128",
|
||||
},
|
||||
{
|
||||
value: "LEAN_BODY_MASS",
|
||||
labelKey: "measurements.typeLeanBodyMass",
|
||||
unit: "kg",
|
||||
placeholder: "55",
|
||||
placeholderImperial: "121",
|
||||
},
|
||||
{
|
||||
value: "MUSCLE_MASS",
|
||||
labelKey: "measurements.typeMuscleMass",
|
||||
unit: "kg",
|
||||
placeholder: "52",
|
||||
placeholderImperial: "115",
|
||||
},
|
||||
{
|
||||
// A scale's own rating (Withings prints 1-12), not a percent.
|
||||
value: "VISCERAL_FAT",
|
||||
labelKey: "measurements.typeVisceralFat",
|
||||
unitKey: "measurements.unitRating",
|
||||
placeholder: "7",
|
||||
},
|
||||
{
|
||||
value: "OXYGEN_SATURATION",
|
||||
labelKey: "measurements.typeOxygenSaturation",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
@@ -120,11 +183,58 @@ export async function openCycleAt(
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else if (cycle.endDate != null || cycle.lengthDays != null) {
|
||||
// With nothing after it the cycle is open. A re-tapped start whose row
|
||||
// still carries an end from a successor that no longer exists (the
|
||||
// one-day cycles an earlier rule left in front of a real start, once
|
||||
// that start is folded in) would otherwise stay closed on a stale date.
|
||||
moved.openedEndDateBefore = cycle.endDate;
|
||||
moved.openedEndDateAfter = null;
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: { endDate: null, lengthDays: null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
// 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 +315,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 +359,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.
|
||||
|
||||
@@ -278,21 +278,16 @@ describe("resolveRichMetric — localised metric names", () => {
|
||||
});
|
||||
|
||||
// Body fat, fat mass, fat-free mass and lean body mass are four distinct
|
||||
// measurements that the Romance and Polish bundles collapse into two words:
|
||||
// "masse grasse" / "massa grassa" cover the first pair, "masse maigre" /
|
||||
// "massa magra" / "masa beztłuszczowa" the second. The index drops a word
|
||||
// two metrics claim rather than picking one — answering kilograms to
|
||||
// someone who asked for a percentage is worse than saying the word was not
|
||||
// recognised. Pinned, so a NEW collision fails here instead of silently
|
||||
// resolving to whichever entry happened to be built first.
|
||||
// measurements, and the French, Italian and Polish bundles used to collapse
|
||||
// them into two words each. The index drops a word two metrics claim rather
|
||||
// than picking one — answering kilograms to someone who asked for a
|
||||
// percentage is worse than saying the word was not recognised. The labels
|
||||
// now name four different things in every language (the manual entry form
|
||||
// lists all four side by side, where two identical names were unusable), so
|
||||
// the set is empty and pinned empty: a NEW collision fails here instead of
|
||||
// silently resolving to whichever entry happened to be built first.
|
||||
it("refuses a word two different metrics claim, and only those words", () => {
|
||||
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([
|
||||
"masa_beztluszczowa",
|
||||
"massa_grassa",
|
||||
"massa_magra",
|
||||
"masse_grasse",
|
||||
"masse_maigre",
|
||||
]);
|
||||
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([]);
|
||||
});
|
||||
|
||||
// The property, not a sample: over every metric and every shipped locale,
|
||||
@@ -331,15 +326,11 @@ describe("resolveRichMetric — localised metric names", () => {
|
||||
partial.push(`${type}: ${resolved.join(",")}`);
|
||||
}
|
||||
}
|
||||
// The body-composition types whose bundles are genuinely ambiguous (see
|
||||
// the collision guard above), plus the same three English-substring
|
||||
// artefacts.
|
||||
// Only the three English-substring artefacts: with no collisions left,
|
||||
// every body-composition type is nameable in every language.
|
||||
expect(partial.sort()).toEqual([
|
||||
"BODY_TEMPERATURE_DEVIATION: en",
|
||||
"BREATHING_DISTURBANCE_EVENT: en",
|
||||
"FAT_FREE_MASS: de,en,es,ko",
|
||||
"FAT_MASS: de,en,es,pl,ko",
|
||||
"LEAN_BODY_MASS: de,en,es,ko",
|
||||
"WALKING_STEADINESS_EVENT: en",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -538,10 +538,12 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
|
||||
//
|
||||
// Every OTHER measurement type is still entered, value-less, because
|
||||
// ambiguity has to be judged over the whole label space rather than over the
|
||||
// exposed slice of it. French and Italian call body fat and fat mass by one
|
||||
// phrase ("masse grasse" / "massa grassa"); only one of the two is exposed,
|
||||
// so an index that knew about the exposed one alone would have seen no
|
||||
// collision and answered kilograms to someone who asked for a percentage.
|
||||
// exposed slice of it. French and Italian once called body fat and fat mass
|
||||
// by one phrase ("masse grasse" / "massa grassa") while only one of the two
|
||||
// was exposed, so an index that knew about the exposed one alone would have
|
||||
// seen no collision and answered kilograms to someone who asked for a
|
||||
// percentage. Those labels are distinct now; the whole-space check stays so a
|
||||
// future bundle cannot reintroduce the trap unseen.
|
||||
//
|
||||
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
|
||||
// `vo2max` are protocol slugs, not translations of anything.
|
||||
@@ -568,9 +570,9 @@ const LOCALISED_METRIC_INDEX = (() => {
|
||||
|
||||
/**
|
||||
* Words two different measurement types claim in some bundle, and which
|
||||
* therefore resolve to neither. Exported so the guard can pin the set: the
|
||||
* known members are a genuine translation collision, a NEW one is a
|
||||
* regression to look at rather than something to resolve by coin flip.
|
||||
* therefore resolve to neither. Exported so the guard can pin the set, which
|
||||
* is empty: a NEW member is a regression to look at rather than something to
|
||||
* resolve by coin flip.
|
||||
*/
|
||||
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
|
||||
LOCALISED_METRIC_INDEX.ambiguous;
|
||||
|
||||
@@ -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,205 @@ 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("repairs a one-day cycle the old rule left, with one tap on the real first day", async () => {
|
||||
// The state the previous rule produced when day two of a period was
|
||||
// logged before day one: a cycle one day long in front of the real one.
|
||||
// Seeded directly, because the current code no longer creates it.
|
||||
const prisma = getPrismaClient();
|
||||
const may = await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
startDate: "2026-05-04",
|
||||
endDate: "2026-05-31",
|
||||
lengthDays: 28,
|
||||
},
|
||||
});
|
||||
const stub = await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-01",
|
||||
lengthDays: 1,
|
||||
},
|
||||
});
|
||||
const real = await prisma.menstrualCycle.create({
|
||||
data: { userId: USER_ID, startDate: "2026-06-02" },
|
||||
});
|
||||
await prisma.cycleDayLog.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: USER_ID,
|
||||
date: "2026-06-01",
|
||||
flow: "HEAVY",
|
||||
cycleId: stub.id,
|
||||
},
|
||||
{
|
||||
userId: USER_ID,
|
||||
date: "2026-06-02",
|
||||
flow: "MEDIUM",
|
||||
cycleId: real.id,
|
||||
},
|
||||
{
|
||||
userId: USER_ID,
|
||||
date: "2026-06-06",
|
||||
flow: "LIGHT",
|
||||
cycleId: real.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// What the release note tells the person to do.
|
||||
const { POST } = await import("@/app/api/cycle/period/route");
|
||||
const res = await POST(
|
||||
jsonRequest("/api/cycle/period", "POST", {
|
||||
action: "start",
|
||||
date: "2026-06-01",
|
||||
loggedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const rows = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { id: true, startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
expect(rows.map(({ id: _id, ...r }) => r)).toEqual([
|
||||
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
|
||||
{ startDate: "2026-06-01", endDate: null, lengthDays: null },
|
||||
]);
|
||||
expect(rows[0].id).toBe(may.id);
|
||||
const june = rows[1].id;
|
||||
const days = await prisma.cycleDayLog.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { date: "asc" },
|
||||
select: { date: true, cycleId: true },
|
||||
});
|
||||
expect(days).toEqual([
|
||||
{ date: "2026-06-01", cycleId: june },
|
||||
{ date: "2026-06-02", cycleId: june },
|
||||
{ date: "2026-06-06", cycleId: june },
|
||||
]);
|
||||
});
|
||||
|
||||
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