mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
095578fa57
commit
eeaa563026
9 files changed
+164
-51
No files matched your search
@@ -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() {
|
||||
<MedicationComplianceChart
|
||||
key="medications"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
|
||||
@@ -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<ChartDataPoint>((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<string, Record<string, number>>();
|
||||
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
|
||||
|
||||
@@ -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<RangeDays>(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
|
||||
|
||||
@@ -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<string, number>();
|
||||
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
|
||||
|
||||
@@ -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" ? (
|
||||
<MoodChart mini={mini} />
|
||||
<MoodChart mini={mini} userTimezone={user?.timezone} />
|
||||
) : (
|
||||
<HealthChart types={[metric]} title={title} mini={mini} />
|
||||
<HealthChart
|
||||
types={[metric]}
|
||||
title={title}
|
||||
mini={mini}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) ? (
|
||||
<MoodChart mini windowOverride={rationale.dataWindow} />
|
||||
<MoodChart
|
||||
mini
|
||||
windowOverride={rationale.dataWindow}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
|
||||
<HealthChart
|
||||
types={chartTypes}
|
||||
title={metricType ?? ""}
|
||||
mini
|
||||
windowOverride={rationale.dataWindow}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<TrendAnnotation
|
||||
metric="bp"
|
||||
@@ -135,6 +139,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
<TrendAnnotation
|
||||
metric="weight"
|
||||
@@ -147,7 +152,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
data-metric="mood"
|
||||
className="flex h-full min-h-[300px] flex-col gap-2"
|
||||
>
|
||||
<MoodChart title={t("charts.mood")} mini />
|
||||
<MoodChart title={t("charts.mood")} mini userTimezone={userTimezone} />
|
||||
<TrendAnnotation
|
||||
metric="mood"
|
||||
annotation={moodAnnotation}
|
||||
|
||||
@@ -359,10 +359,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
|
||||
}
|
||||
|
||||
const { GET } = await import("@/app/api/dashboard/summary/route");
|
||||
const res = await GET(
|
||||
// The handler is typed `() => Promise<Response>`, 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<Response>;
|
||||
const res = await handler(
|
||||
new Request("http://localhost/api/dashboard/summary", {
|
||||
method: "GET",
|
||||
}) as Parameters<typeof GET>[0],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
|
||||
Reference in new issue
Block a user