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:
Marc-André Bombeck committed 2026-09-24 10:16:53 +02:00
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;
}
+27 -55
View File
@@ -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);
});
});
+5 -8
View File
@@ -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}
+203 -71
View File
@@ -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>
+7
View File
@@ -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("");
});
});
+64 -30
View File
@@ -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()}`,
);