mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge branch 'fix/v139-server-bugs' into release/v1.39.0
This commit is contained in:
commit
c3ec437cad
51 files changed
+2329
-1105
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,
|
||||
);
|
||||
|
||||
@@ -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