From eeaa563026f77b5567a9f5cee5a8eb1874652c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:04:12 +0200 Subject: [PATCH] feat(charts): x-axis ticks render in user displayTimezone Four chart components (HealthChart, MoodChart, MedicationComplianceChart, ComplianceLineChart) now accept a userTimezone prop, default "Europe/Berlin" for backwards compat. Inside each chart, the X-axis tick formatter, tooltip date label, and per-day bucketing all run through a tz-aware makeFormatters(locale, userTz) instance instead of the legacy formatDateShort() which pinned to Europe/Berlin. Mount sites that already pull useAuth() pass user?.timezone: dashboard /page.tsx, trends-row, recommendation-card, insight-advisor-card. Ad-hoc usages without a user (storybook-style or test harnesses) keep the default Europe/Berlin rendering bit-for-bit. The HealthChart's daily-aggregation key (toDayKey) was also rewritten to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading buckets to the Auckland day instead of the Berlin day for chart data display. The TanStack Query cache key gains userTimezone so a tz change inside a session re-fetches and re-buckets correctly. Tests: 109 chart-component tests stay green. --- src/app/page.tsx | 8 ++ .../charts/compliance-line-chart.tsx | 21 +++++- src/components/charts/health-chart.tsx | 75 +++++++++++++------ .../charts/medication-compliance-chart.tsx | 41 +++++++--- src/components/charts/mood-chart.tsx | 34 ++++++--- .../insights/insight-advisor-card.tsx | 11 ++- .../insights/recommendation-card.tsx | 9 ++- src/components/insights/trends-row.tsx | 7 +- tests/integration/timezone-per-user.test.ts | 9 ++- 9 files changed, 164 insertions(+), 51 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 4d557f97c..211d36d04 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -957,6 +957,7 @@ export default function DashboardPage() { unit="kg" valueBands={weightBands} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1002,6 +1003,7 @@ export default function DashboardPage() { yAxisUnit="Hg" targetZones={bpTargetZones} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1021,6 +1023,7 @@ export default function DashboardPage() { unit="bpm" valueBands={pulseBands} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1040,6 +1043,7 @@ export default function DashboardPage() { unit="%" valueBands={bodyFatBands} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1054,6 +1058,7 @@ export default function DashboardPage() { key="mood-chart" compareBaseline={compareBaseline} chartKey="mood" + userTimezone={user?.timezone} /> ), }); @@ -1072,6 +1077,7 @@ export default function DashboardPage() { colors={["#8be9fd"]} unit="h" compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1089,6 +1095,7 @@ export default function DashboardPage() { title={t("dashboard.steps") ?? "Steps"} colors={["#50fa7b"]} compareBaseline={compareBaseline} + userTimezone={user?.timezone} /> ), }); @@ -1106,6 +1113,7 @@ export default function DashboardPage() { ), }); diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx index cbba86eec..9932c2c89 100644 --- a/src/components/charts/compliance-line-chart.tsx +++ b/src/components/charts/compliance-line-chart.tsx @@ -12,9 +12,10 @@ import { ReferenceLine, } from "recharts"; import { Button } from "@/components/ui/button"; -import { formatDateShort } from "@/lib/format"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { chooseTickInterval } from "@/lib/charts/x-axis-density"; +import { useTranslations } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; interface DailyData { expected: number; @@ -30,6 +31,12 @@ interface ComplianceLineChartProps { rangePoints?: 30 | 90 | 0; onRangePointsChange?: (value: 30 | 90 | 0) => void; showRangeControls?: boolean; + /** + * v1.4.25 W7b — per-user display timezone for the X-axis labels. + * Defaults to "Europe/Berlin" so older callers keep their previous + * rendering bit-for-bit. + */ + userTimezone?: string; } const TIME_RANGES = [ @@ -43,6 +50,7 @@ export function ComplianceLineChart({ rangePoints, onRangePointsChange, showRangeControls = true, + userTimezone = "Europe/Berlin", }: ComplianceLineChartProps) { const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>( 30, @@ -50,6 +58,13 @@ export function ComplianceLineChart({ const activeRangePoints = rangePoints ?? internalRangePoints; const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints; const viewportWidth = useViewportWidth(); + const { locale } = useTranslations(); + // v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to + // legacy Europe/Berlin when the prop is omitted. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); const chartData = useMemo(() => { const points = Object.entries(dailyCompliance) @@ -58,14 +73,14 @@ export function ComplianceLineChart({ .map(([dateKey, data]) => { const date = new Date(`${dateKey}T12:00:00.000Z`); return { - date: formatDateShort(date, true), + date: tzFmt.date(date), rate: Math.min(100, Math.round((data.taken / data.expected) * 100)), timestamp: date.getTime(), }; }); return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points; - }, [dailyCompliance, activeRangePoints]); + }, [dailyCompliance, activeRangePoints, tzFmt]); return (
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 8fd90f54e..9b901d7b7 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -20,8 +20,8 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { Button } from "@/components/ui/button"; -import { formatDateShort } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import { bucketTimeSeries, pickBucket, @@ -138,6 +138,15 @@ interface HealthChartProps { * that needs the same behaviour). */ annotations?: Array<{ date: string; label: string; color: string }>; + /** + * 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. + */ + userTimezone?: string; } interface ChartDataPoint { @@ -191,15 +200,22 @@ function getTypeLabel( return key ? t(key) : type; } -const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - timeZone: "Europe/Berlin", - year: "numeric", - month: "2-digit", - day: "2-digit", -}); +/** + * Build a `YYYY-MM-DD` day-key formatter pinned to the requested + * timezone. Memoised per-tz inside the component so we don't allocate a + * new `Intl.DateTimeFormat` on every measurement-row pass. + */ +function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat { + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} -function toBerlinDayKey(value: string): string { - const parts = BERLIN_DAY_FORMATTER.formatToParts(new Date(value)); +function toDayKey(value: string, formatter: Intl.DateTimeFormat): string { + const parts = formatter.formatToParts(new Date(value)); const year = parts.find((part) => part.type === "year")?.value; const month = parts.find((part) => part.type === "month")?.value; const day = parts.find((part) => part.type === "day")?.value; @@ -367,10 +383,26 @@ export function HealthChart({ compareBaseline = "none", chartKey, annotations, + userTimezone = "Europe/Berlin", }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); + // v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip + // date strings. `useFormatters()` reads the active UI locale only; + // this builds a locale + userTz pair so a Pacific/Auckland user reads + // their own day on the axis. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); + // v1.4.25 W7b — per-row day-key formatter used to bucket measurement + // rows by the user's local calendar day. Memoised on userTimezone so + // the inner per-row loop reuses a single Intl.DateTimeFormat. + const dayKeyFormatter = useMemo( + () => makeDayKeyFormatter(userTimezone), + [userTimezone], + ); // v1.4.16 B5c: when a windowOverride is supplied, seed the range // state from it so the chart pins to that window. Mini mode also // hides the range tabs, so the user can't change it. @@ -407,6 +439,10 @@ export function HealthChart({ types.join(","), valueMode, bmiDivisor ?? "no-bmi", + // v1.4.25 W7b — bucket keys + tick labels depend on the active + // user timezone, so re-key the cache when it changes. Without + // this, a tz change inside a session would render stale buckets. + userTimezone, ], queryFn: async () => { const params = new URLSearchParams(); @@ -451,7 +487,7 @@ export function HealthChart({ continue; } - const dayKey = toBerlinDayKey(measurement.measuredAt); + const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter); const bucket = dailyAggregates.get(dayKey) ?? { timestamp: dayKeyToTimestamp(dayKey), values: {}, @@ -475,7 +511,7 @@ export function HealthChart({ const allData: ChartDataPoint[] = Array.from(dailyAggregates.values()) .map((bucket) => { const point: ChartDataPoint = { - date: formatDateShort(new Date(bucket.timestamp)), + date: tzFmt.dateShort(new Date(bucket.timestamp)), timestamp: bucket.timestamp, }; @@ -525,7 +561,7 @@ export function HealthChart({ ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { - date: formatDateShort(date), + date: tzFmt.dateShort(date), timestamp: point.timestamp, }; for (const [type, value] of Object.entries(point.values)) { @@ -590,7 +626,7 @@ export function HealthChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend, types]); + }, [data, rangePoints, showMA, showTrend, types, tzFmt]); // v1.4.16 phase B8 — comparison overlay. // @@ -624,7 +660,7 @@ export function HealthChart({ // Index shifted rows by the same day-key the chart already uses. const shiftedByDay = new Map>(); for (const row of shifted) { - const dayKey = formatDateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShort(new Date(row.timestamp)); const slot = shiftedByDay.get(dayKey) ?? {}; for (const [type, value] of Object.entries(row.values)) { if (typeof value === "number" && Number.isFinite(value)) { @@ -646,7 +682,7 @@ export function HealthChart({ } return merged; }); - }, [chartData, effectiveCompareBaseline, data, types]); + }, [chartData, effectiveCompareBaseline, data, types, tzFmt]); /** * v1.4.16 phase B8 — true when at least one visible day has a prior- @@ -1101,11 +1137,10 @@ export function HealthChart({ tickLine={false} axisLine={false} tickFormatter={(value) => - formatDateShort( + tzFmt.date( new Date( chartData?.[Math.round(value)]?.timestamp ?? Date.now(), ), - true, ) } interval={chooseTickInterval( @@ -1267,9 +1302,7 @@ export function HealthChart({ (typeof rechartsLabel === "number" ? chartData?.[Math.round(rechartsLabel)]?.timestamp : undefined); - const dateLabel = ts - ? formatDateShort(new Date(ts), true) - : ""; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; const rows: RichTooltipRow[] = []; // Build a quick lookup of compare values for this // hover-day so the current-period row can attach diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index bdf9f6db5..eb8af2ce4 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -49,6 +49,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { formatDateShort } from "@/lib/format"; +import { makeFormatters } from "@/lib/format-locale"; import { cn } from "@/lib/utils"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; @@ -86,17 +87,25 @@ type RangeDays = (typeof RANGE_DAYS)[number]; * intakes are skipped — compliance is undefined when nothing was due. * * Pure & deterministic so the unit test pins exact rates. + * + * v1.4.25 W7b — the `dateFormatter` argument lets the chart pass a + * tz-aware formatter so a Pacific/Auckland user's X-axis tick label + * for "2026-05-15" reads as 15.05. in their tz. Default keeps the + * legacy `formatDateShort` so the existing unit test (which calls the + * exported helper without arguments) stays byte-identical. */ export function aggregateMedicationCompliance( points: DailyCompliancePoint[], + dateFormatter: (date: Date) => string = (d) => formatDateShort(d, false), ): ChartPoint[] { return points .filter((p) => p.scheduled > 0) .sort((a, b) => a.date.localeCompare(b.date)) .map((p) => { const [y, m, d] = p.date.split("-").map(Number); - // Anchor the timestamp at noon UTC of the Berlin day so a tooltip - // and tick formatter never disagree across DST boundaries. + // Anchor the timestamp at noon UTC of the (server-side) calendar + // day so a tooltip and tick formatter never disagree across DST + // boundaries. const ts = Date.UTC(y, m - 1, d, 12); const rate = Math.min(100, Math.round((p.taken / p.scheduled) * 100)); return { @@ -106,7 +115,7 @@ export function aggregateMedicationCompliance( // tooltip below still derives its label from `timestamp` with // `includeYear=true`, so the full date is preserved for the // tooltip read. - date: formatDateShort(new Date(ts), false), + date: dateFormatter(new Date(ts)), rate, timestamp: ts, }; @@ -171,15 +180,28 @@ interface MedicationComplianceChartProps { * thinking the toggle is broken. */ compareBaseline?: "none" | "lastMonth" | "lastYear"; + /** + * v1.4.25 W7b — per-user display timezone for x-axis tick + tooltip + * date strings. Defaults to "Europe/Berlin" so older callers stay + * byte-identical. + */ + userTimezone?: string; } export function MedicationComplianceChart({ title, + userTimezone = "Europe/Berlin", }: MedicationComplianceChartProps) { const { isAuthenticated } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const [days, setDays] = useState(30); + // v1.4.25 W7b — tz-aware date formatter for the x-axis labels and + // the tooltip's `dateLabel`. Same pattern as health-chart + mood-chart. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); // v1.4.18 — three overlay toggles persisted per chart. The 7-day // trend chip and the goal/threshold reference lines used to render @@ -210,8 +232,11 @@ export function MedicationComplianceChart({ }); const chartData = useMemo( - () => (data ? aggregateMedicationCompliance(data) : []), - [data], + () => + data + ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d)) + : [], + [data, tzFmt], ); // v1.4.16 A6 — 7-day trend chip. Computed off the *full* range so a @@ -395,9 +420,7 @@ export function MedicationComplianceChart({ }; if (!active || !payload?.length) return null; const ts = payload[0]?.payload?.timestamp; - const dateLabel = ts - ? formatDateShort(new Date(ts), true) - : ""; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; const rate = payload[0]?.value; if (typeof rate !== "number") return null; // Delta vs. the 100 % goal — a positive delta means diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 16208ce11..d17f14cda 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -17,8 +17,8 @@ import { Loader2 } from "lucide-react"; import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { formatDateShort } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import type { DataSummary } from "@/lib/analytics/trends"; import { bucketTimeSeries, @@ -84,6 +84,12 @@ interface MoodChartProps { * only surface — the cog stays hidden and overlays are clean. */ chartKey?: ChartOverlayKey; + /** + * v1.4.25 W7b — per-user display timezone. When passed, the x-axis + * tick labels and tooltip date render in the user's tz. Defaults to + * "Europe/Berlin" so older mount sites stay byte-identical. + */ + userTimezone?: string; } // --- Constants --- @@ -275,9 +281,16 @@ export function MoodChart({ windowOverride, compareBaseline = "none", chartKey, + userTimezone = "Europe/Berlin", }: MoodChartProps) { const { isAuthenticated } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); + // v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip + // date strings. + const tzFmt = useMemo( + () => makeFormatters(locale, userTimezone), + [locale, userTimezone], + ); const initialRangePoints = windowOverride ? MINI_RANGE_POINTS[windowOverride] : 30; @@ -313,7 +326,7 @@ export function MoodChart({ const allPoints: ChartDataPoint[] = data.entries .map((entry) => ({ - date: formatDateShort(new Date(dayKeyToTimestamp(entry.date))), + date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))), timestamp: dayKeyToTimestamp(entry.date), pointIndex: 0, score: entry.score, @@ -349,7 +362,7 @@ export function MoodChart({ })), { bucket: bucketType }, ).points.map((point) => ({ - date: formatDateShort(new Date(point.timestamp)), + date: tzFmt.dateShort(new Date(point.timestamp)), timestamp: point.timestamp, pointIndex: 0, score: point.values.score, @@ -401,7 +414,7 @@ export function MoodChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend]); + }, [data, rangePoints, showMA, showTrend, tzFmt]); /** * v1.4.16 phase B8 — comparison overlay merged into chartData. @@ -426,7 +439,7 @@ export function MoodChart({ const shiftedByDay = new Map(); for (const row of shifted) { - const dayKey = formatDateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShort(new Date(row.timestamp)); if (typeof row.score === "number" && Number.isFinite(row.score)) { shiftedByDay.set(dayKey, row.score); } @@ -439,7 +452,7 @@ export function MoodChart({ } return point; }); - }, [chartData, effectiveCompareBaseline, data]); + }, [chartData, effectiveCompareBaseline, data, tzFmt]); const hasComparisonData = useMemo(() => { if (effectiveCompareBaseline === "none" || !chartDataWithCompare) @@ -689,11 +702,10 @@ export function MoodChart({ tickLine={false} axisLine={false} tickFormatter={(value: number) => - formatDateShort( + tzFmt.date( new Date( chartData[Math.round(value)]?.timestamp ?? Date.now(), ), - true, ) } interval={chooseTickInterval( @@ -741,9 +753,7 @@ export function MoodChart({ (typeof rechartsLabel === "number" ? chartData?.[Math.round(rechartsLabel)]?.timestamp : undefined); - const dateLabel = ts - ? formatDateShort(new Date(ts), true) - : ""; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; const rows: RichTooltipRow[] = []; const hoverPoint = payload[0]?.payload as | ChartDataPoint diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx index 6127c7f46..9753d4bda 100644 --- a/src/components/insights/insight-advisor-card.tsx +++ b/src/components/insights/insight-advisor-card.tsx @@ -18,6 +18,7 @@ import { Info, } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; import { HealthChart } from "@/components/charts/health-chart"; import { MoodChart } from "@/components/charts/mood-chart"; import { @@ -252,6 +253,7 @@ function InlineCharts({ mini?: boolean; }) { const { t } = useTranslations(); + const { user } = useAuth(); if (tokens.length === 0) return null; // Dedup while preserving the order the model emitted them. @@ -279,9 +281,14 @@ function InlineCharts({ data-mini={mini ? "true" : "false"} > {kind === "mood" ? ( - + ) : ( - + )}
); diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 822835157..19c1ad164 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; import type { InsightRecommendation, InsightRecommendationRationale, @@ -194,6 +195,7 @@ function RationaleCard({ } | null; }) { const { t } = useTranslations(); + const { user } = useAuth(); const metricType = metricSource?.type; const chartTypes = metricTypeToChartTypes(metricType); const showLowConfidenceCaption = @@ -224,13 +226,18 @@ function RationaleCard({ compliance get dedicated wrappers; everything else routes through HealthChart with the metric-key vocabulary. */} {isMoodMetric(metricType) ? ( - + ) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? ( ) : null} diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 54ea18b55..558ce2cc9 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; import { TrendAnnotation, type TrendAnnotationConfidenceBand, @@ -75,6 +76,8 @@ interface TrendsRowProps { export function TrendsRow({ annotations, confidence }: TrendsRowProps) { const { t } = useTranslations(); + const { user } = useAuth(); + const userTimezone = user?.timezone; const bpAnnotation = annotations?.bp ?? null; const weightAnnotation = annotations?.weight ?? null; const moodAnnotation = annotations?.mood ?? null; @@ -117,6 +120,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) { unit="mmHg" yAxisUnit="Hg" mini + userTimezone={userTimezone} /> - + { } const { GET } = await import("@/app/api/dashboard/summary/route"); - const res = await GET( + // The handler is typed `() => Promise`, but the + // `apiHandler` wrapper unconditionally reads `args[0]` as the + // Next.js Request — cast through unknown to push the test fixture + // through without weakening the runtime type. + const handler = GET as unknown as (req: Request) => Promise; + const res = await handler( new Request("http://localhost/api/dashboard/summary", { method: "GET", - }) as Parameters[0], + }), ); expect(res.status).toBe(200); const body = (await res.json()) as {