mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction routes. Conflicts resolved: - The digest keeps its capability-driven reaction line and check-in, now through the shared `aiCapabilityForRecord`; the local copy of it is removed. - The route inventories classify the extraction routes (documents, labs OCR, medication text, fenced chat and attachments) as actions; a capability call counts as the gate for the module inventory. - With every caller moved, `requireAssistantSurface` and `AssistantDisabledError` are deleted. The `assistant.disabled.*` codes stay on the capability refusal, and the guard now asserts the retired gate is gone. - `aiCapabilityToServe` answers whether stored model text may be shown from the record's own state, apart from who is reading. Refs #975
This commit is contained in:
commit
c509afeb2e
256 files changed
+13048
-4642
No files matched your search
@@ -93,9 +93,10 @@ describe("<HealthChart> — bounded range fetch", () => {
|
||||
expect(measurementsUrl!).toMatch(/aggregate=daily/);
|
||||
});
|
||||
|
||||
it("omits aggregate=daily on windows of 7 days or fewer", async () => {
|
||||
// v1.4.29 C3 — short windows keep raw fetching so the user can
|
||||
// see hour-by-hour detail on the 7-day view.
|
||||
it("asks for daily rows on the 7-day window too", async () => {
|
||||
// The chart folds every range into days. The 7-day window used to read
|
||||
// raw rows oldest-first under the 5000-row cap, which on a per-minute
|
||||
// stream cut off the newest half of the week.
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
@@ -116,7 +117,7 @@ describe("<HealthChart> — bounded range fetch", () => {
|
||||
u.includes("/api/measurements"),
|
||||
);
|
||||
expect(measurementsUrl).toBeDefined();
|
||||
expect(measurementsUrl!).not.toMatch(/aggregate=daily/);
|
||||
expect(measurementsUrl!).toMatch(/aggregate=daily/);
|
||||
});
|
||||
|
||||
it("re-keys the query when the range window changes", async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* A chart mount that passes no `userTimezone` must still cut days in the
|
||||
* signed-in user's zone. Several mounts (the medications page, the coach
|
||||
* chat, the recovery section) pass none, and the component used to fall
|
||||
* back to a fixed Europe/Berlin, so a user in Asia/Kolkata saw those charts
|
||||
* bucketed and labelled in Berlin days while every other chart used their
|
||||
* own. The zone now comes from the account, which the server resolves.
|
||||
*/
|
||||
|
||||
let lastKey: unknown[] | undefined;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
keepPreviousData: (previous: unknown) => previous,
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
lastKey = queryKey;
|
||||
return { data: [], isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { id: "u1", timezone: "Asia/Kolkata" },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart> timezone", () => {
|
||||
it("uses the account's zone when the mount passes none", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart types={["ACTIVITY_STEPS"]} title="Steps" unit="" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(lastKey).toBeDefined();
|
||||
expect(lastKey).toContain("Asia/Kolkata");
|
||||
expect(lastKey).not.toContain("Europe/Berlin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Query string for one type's chart series (`GET /api/measurements`).
|
||||
*
|
||||
* Every range asks for one row per local calendar day. The 7-day range used
|
||||
* to ask for raw rows oldest-first under a 5 000-row limit; a per-minute
|
||||
* heart-rate stream holds 10 080 rows in seven days, so the answer stopped
|
||||
* half-way through the week and the newest days were silently missing. The
|
||||
* chart folds rows into days either way, so the daily answer draws the same
|
||||
* points while its size depends only on the number of days.
|
||||
*
|
||||
* Pure so the request the chart makes can be tested against the real route.
|
||||
*/
|
||||
export function chartSeriesParams(
|
||||
type: string,
|
||||
window: { from: string; to: string },
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", type);
|
||||
params.set("sortBy", "measuredAt");
|
||||
params.set("sortDir", "asc");
|
||||
params.set("from", window.from);
|
||||
params.set("to", window.to);
|
||||
params.set("limit", "5000");
|
||||
params.set("aggregate", "daily");
|
||||
params.set("source", "rollup");
|
||||
return params;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { chartSeriesParams } from "@/components/charts/chart-series-request";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
@@ -213,10 +215,11 @@ interface HealthChartProps {
|
||||
/**
|
||||
* v1.4.25 W7b — per-user display timezone. When passed (mount sites
|
||||
* thread `useAuth().user?.timezone`), x-axis tick labels and the
|
||||
* per-day bucket keys both render in the user's zone instead of the
|
||||
* legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older
|
||||
* callers that haven't yet adopted the prop keep their previous
|
||||
* behaviour bit-for-bit.
|
||||
* per-day bucket keys both render in the user's zone. When a mount
|
||||
* passes none, the chart uses the signed-in account's zone (which
|
||||
* `/api/auth/me` reports already resolved against the server default),
|
||||
* and only before that has loaded the shared `DEFAULT_TIMEZONE` the
|
||||
* server itself falls back to.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
@@ -327,8 +330,8 @@ interface MeasurementApiRow {
|
||||
value: number;
|
||||
// v1.8.5 — per-day min / max emitted by the rollup daily-aggregate
|
||||
// path (non-cumulative metrics only). Drive the Apple-Health-style
|
||||
// range band shaded around the mean line. Absent on the raw-row path
|
||||
// (windows ≤ 7 days) and on cumulative metrics.
|
||||
// range band shaded around the mean line. Not drawn on the 7-day range
|
||||
// and absent on cumulative metrics.
|
||||
// v1.18.6 — `null` accepted alongside `undefined` so the batched
|
||||
// series endpoint's row shape (which carries `number | null`) feeds
|
||||
// `preloadedSeries` directly. The bucketing loop already guards each
|
||||
@@ -340,8 +343,7 @@ interface MeasurementApiRow {
|
||||
// the underlying raw-row count per bucket. Used downstream to gate
|
||||
// the "more days needed" empty-state copy on the actual measurement
|
||||
// count rather than the number of distinct calendar days. Optional
|
||||
// because the short-window raw-row path does not populate it (each
|
||||
// row already represents one measurement, count = 1).
|
||||
// because the per-night sleep series does not carry it.
|
||||
count?: number;
|
||||
}
|
||||
|
||||
@@ -614,7 +616,7 @@ export function HealthChart({
|
||||
chartKey,
|
||||
annotations,
|
||||
verticalMarkers,
|
||||
userTimezone = "Europe/Berlin",
|
||||
userTimezone: userTimezoneProp,
|
||||
valueScale = 1,
|
||||
valueOffset = 0,
|
||||
onVisibleStats,
|
||||
@@ -625,6 +627,9 @@ export function HealthChart({
|
||||
showDataTable = false,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// A mount without the prop used to pin Europe/Berlin, so those charts
|
||||
// cut days in Berlin for every user whatever their profile said.
|
||||
const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE;
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
|
||||
@@ -764,12 +769,11 @@ export function HealthChart({
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
// v1.18.6 — honour the dashboard's batched slice ONLY for the
|
||||
// daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY
|
||||
// when EVERY non-sleep type the chart renders is present in the slice.
|
||||
// SLEEP_DURATION is never batched (per-night `/series`), so a chart
|
||||
// that includes it always self-fetches. A range-tab change to a ≤7-day
|
||||
// window (raw rows) drops batched coverage and self-fetches.
|
||||
// v1.18.6 — honour the dashboard's batched slice ONLY for windows over
|
||||
// 7 days and ONLY when EVERY non-sleep type the chart renders is present
|
||||
// in the slice. SLEEP_DURATION is never batched (per-night `/series`),
|
||||
// so a chart that includes it always self-fetches. The 7-day tab
|
||||
// self-fetches its own window.
|
||||
//
|
||||
// v1.19.0 — also require that the requested window FITS within the
|
||||
// batch's actual coverage. The dashboard batches only a ~30-day slice;
|
||||
@@ -916,41 +920,7 @@ export function HealthChart({
|
||||
await fetchSleepNights();
|
||||
return;
|
||||
}
|
||||
const typeParams = new URLSearchParams();
|
||||
typeParams.set("type", type);
|
||||
typeParams.set("sortBy", "measuredAt");
|
||||
typeParams.set("sortDir", "asc");
|
||||
typeParams.set("from", fetchWindow.from);
|
||||
typeParams.set("to", fetchWindow.to);
|
||||
typeParams.set("limit", "5000");
|
||||
// v1.4.29 C3 — windows over 7 days ask the server to bucket
|
||||
// daily. Caps the chart's per-type payload at ~365 rows
|
||||
// instead of ~5 000 for high-density types (pulse), and drops
|
||||
// Recharts paint cost ~50× on continuous-monitoring accounts.
|
||||
// Short windows keep raw fetching so hour-by-hour detail
|
||||
// stays visible.
|
||||
//
|
||||
// v1.4.36 W1 — also route the daily aggregate through the
|
||||
// persistent rollup buckets via `source=rollup`. The route
|
||||
// reads from `measurement_rollups` instead of running a live
|
||||
// `date_trunc` scan over the raw measurements table; the
|
||||
// three parallel daily fetches on the Insights trends row
|
||||
// (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small
|
||||
// indexed read against the ~5 k-row rollup table. The server
|
||||
// falls back to live SQL when the rollup is empty for the
|
||||
// requested window so brand-new accounts still see a chart
|
||||
// on their first render.
|
||||
//
|
||||
// v1.19.2 — the `aggregate=daily` ask still holds, but for windows
|
||||
// wider than the DAY bucket cap the server steps the rollup tier
|
||||
// up to WEEK / MONTH and returns whole-history coverage at that
|
||||
// tier. Each returned row is one coarse bucket; the daily fold +
|
||||
// `bucketTimeSeries` below downsample it to the visible range, so
|
||||
// the request contract is unchanged.
|
||||
if (fetchWindow.windowDays > 7) {
|
||||
typeParams.set("aggregate", "daily");
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
const typeParams = chartSeriesParams(type, fetchWindow);
|
||||
|
||||
// v1.18.6 — when the dashboard handed this chart a batched slice
|
||||
// for the type, fold those rows instead of a per-type fetch. The
|
||||
@@ -1011,8 +981,11 @@ export function HealthChart({
|
||||
// same scale to the spread so the band tracks the line. The
|
||||
// BMI view drops the range tuple in its `select` callback (it
|
||||
// has never rendered the band), so the raw cache always
|
||||
// carries the spread.
|
||||
// carries the spread. The 7-day range never drew the band (it
|
||||
// used to read raw rows, which carry no spread) and still does
|
||||
// not, so its picture is unchanged now that it reads days too.
|
||||
if (
|
||||
fetchWindow.windowDays > 7 &&
|
||||
typeof measurement.minValue === "number" &&
|
||||
typeof measurement.maxValue === "number"
|
||||
) {
|
||||
@@ -1044,10 +1017,9 @@ export function HealthChart({
|
||||
for (const [type, stats] of Object.entries(bucket.values)) {
|
||||
// v1.4.29.1 — cumulative HealthKit types (steps, active energy,
|
||||
// distance, flights, daylight) must reduce with sum, not the
|
||||
// per-sample average. The server already does this when the
|
||||
// chart fetches with `aggregate=daily` (windows over 7 days);
|
||||
// the 7-day path still pulls raw rows and aggregates here,
|
||||
// so the same distinction must hold client-side.
|
||||
// per-sample average. The server already returns one row per
|
||||
// day; a day that arrives as several rows (the coarser tiers
|
||||
// of the "All" range) must still add up rather than average.
|
||||
const isCumulative = CUMULATIVE_HK_TYPES.has(
|
||||
type as MeasurementType,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Issue #1028 — the Verlauf ledger's trailing window must be resolved when
|
||||
* the request fires, not when the tab mounts.
|
||||
*
|
||||
* The ledger used to pin `from` and `to` in state at mount and reuse them for
|
||||
* every fetch. A refetch after adding a dose therefore asked for rows up to
|
||||
* the moment the tab opened, and a dose recorded afterwards stayed out of
|
||||
* "Last 90 days" until a reload, while the unwindowed full history showed it.
|
||||
*
|
||||
* The test captures the query function the ledger hands to TanStack Query and
|
||||
* runs it twice at two different clock readings: both requests must reach the
|
||||
* present, which means no pinned `to` and a `from` that moves with the clock.
|
||||
*/
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const captured: { queryFn?: () => Promise<unknown>; queryKey?: unknown } = {};
|
||||
|
||||
vi.mock("@tanstack/react-query", async (orig) => {
|
||||
const actual = await orig<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (opts: {
|
||||
queryFn: () => Promise<unknown>;
|
||||
queryKey: unknown;
|
||||
}) => {
|
||||
captured.queryFn = opts.queryFn;
|
||||
captured.queryKey = opts.queryKey;
|
||||
return { data: undefined, isLoading: true, isError: false };
|
||||
},
|
||||
useQueryClient: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn().mockResolvedValue({ rows: [] }),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "t", role: "USER", timezone: "Asia/Kolkata" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canWriteDomain: () => true }),
|
||||
useActiveRecordName: () => null,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("DoseHistoryLedger request window (#1028)", () => {
|
||||
it("resolves the window at fetch time so a later refetch reaches the present", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const mountedAt = new Date("2026-09-22T10:00:00Z");
|
||||
vi.setSystemTime(mountedAt);
|
||||
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DoseHistoryLedger
|
||||
medicationId="med-1"
|
||||
medicationName="Test"
|
||||
schedules={[]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(captured.queryFn).toBeTypeOf("function");
|
||||
|
||||
await captured.queryFn!();
|
||||
// An hour later the user adds a dose; the invalidation refetches.
|
||||
const later = new Date(mountedAt.getTime() + 3_600_000);
|
||||
vi.setSystemTime(later);
|
||||
await captured.queryFn!();
|
||||
|
||||
const urls = vi.mocked(apiGet).mock.calls.map((c) => String(c[0]));
|
||||
expect(urls).toHaveLength(2);
|
||||
for (const url of urls) {
|
||||
const params = new URL(url, "http://x").searchParams;
|
||||
// No pinned upper bound: the server's `to` is its own now.
|
||||
expect(params.get("to")).toBeNull();
|
||||
}
|
||||
const secondFrom = new URL(urls[1], "http://x").searchParams.get("from");
|
||||
expect(secondFrom).toBe(
|
||||
new Date(later.getTime() - 90 * 86_400_000).toISOString(),
|
||||
);
|
||||
// The cache key carries no instant, so it stays stable across renders.
|
||||
expect(captured.queryKey).toEqual([
|
||||
"medications",
|
||||
"med-1",
|
||||
"dose-history",
|
||||
90,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -200,27 +200,18 @@ export function DoseHistoryLedger({
|
||||
slotAt: string;
|
||||
} | null>(null);
|
||||
|
||||
// Stable window captured once at mount (a lazy state initializer keeps the
|
||||
// impure `Date.now()` out of render) so the query key + the `from`/`to`
|
||||
// request stay byte-equal across renders rather than re-keying every paint.
|
||||
const [{ fromIso, toIso }] = useState(() => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
toIso: new Date(now).toISOString(),
|
||||
fromIso: new Date(now - windowDays * DAY_MS).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const queryKey = queryKeys.medicationDoseHistory(
|
||||
medicationId,
|
||||
fromIso,
|
||||
toIso,
|
||||
);
|
||||
// The window is resolved when the request fires, not when the ledger
|
||||
// mounts. A window pinned at mount made every refetch (including the one
|
||||
// after adding an entry) ask for rows up to the moment the tab opened, so a
|
||||
// dose recorded afterwards stayed invisible until a reload (#1028). Only
|
||||
// `from` travels: the server's `to` defaults to its own now.
|
||||
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<LedgerPayload>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const search = new URLSearchParams({ from: fromIso, to: toIso });
|
||||
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
|
||||
const search = new URLSearchParams({ from });
|
||||
return apiGet<LedgerPayload>(
|
||||
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user