fix(charts): persist the range-tab pick like the overlay toggles

rangePoints lived in plain useState(30), so every chart reset to the
30 d tab on mount while the sibling trend/comparison/target-band
toggles on the same card persisted per chart. Add rangePoints to
ChartOverlayPrefs (server-validated against the closed 0/7/30/90 set)
and hydrate it once the prefs resolve; a range-tab tap now persists
through the same PUT the toggles already use.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 03:52:32 +02:00
1 parent a66a346d0d
commit 3c468b5e00
4 files changed
+96 -1

No files matched your search

@@ -44,6 +44,12 @@ const prefsSchema = z.object({
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.default("none"),
// v1.30.1 — persisted range-tab selection (M2 QoL fix). Optional so
// a client on an older cached bundle that never sends it doesn't
// 422. Literal set mirrors `CHART_RANGE_POINTS` in dashboard-layout.
rangePoints: z
.union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)])
.optional(),
}),
});
+36
View File
@@ -632,6 +632,32 @@ export function HealthChart({
? overlayPrefs.prefs.comparisonBaseline
: compareBaseline;
// v1.30.1 M2 — hydrate the range tab from the persisted prefs once
// they arrive, mirroring the overlay toggles above. A plain
// `useState(30)` initializer can't see the persisted value because
// the dashboard-layout query resolves after mount; `rangePoints` also
// drives several derived memos below by identity, so it stays local
// state (unlike the toggles, which read `overlayPrefs.prefs` live)
// and is synced in exactly once. `windowOverride` charts (mini mode)
// are pinned and never adopt a persisted range. Adjusted during
// render (React's documented "adjust state during render" escape
// hatch, not an effect — the lint rule that bans ref reads during
// render is why this is a second piece of state rather than a ref)
// so the very first render that sees the resolved prefs already
// reflects them; `rangeHydrated` guards against re-running past the
// first hit, so a later cache refetch can't stomp a range the user
// already picked this session.
const [rangeHydrated, setRangeHydrated] = useState(false);
if (
!windowOverride &&
chartKey &&
!rangeHydrated &&
typeof overlayPrefs.prefs.rangePoints === "number"
) {
setRangeHydrated(true);
setRangePoints(overlayPrefs.prefs.rangePoints);
}
// v1.4.19 A2 — viewport-aware tick density. Recharts' default is one
// tick per data point; the helper caps that at 4-10 visible labels
// depending on viewport width so the X-axis reads consistently across
@@ -1674,7 +1700,17 @@ export function HealthChart({
// v1.12.8 — a range-tab change re-slices `chartData`; the
// visible-range stats memo recomputes off the new slice and
// the stat strip follows automatically.
setRangeHydrated(true);
setRangePoints(r.points);
// v1.30.1 M2 — persist the pick per chart, same model
// as the overlay toggles, so the range survives a
// remount instead of resetting to 30 d every visit.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
+31 -1
View File
@@ -323,6 +323,25 @@ export function MoodChart({
const effectiveCompareBaseline =
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
// v1.30.1 M2 — hydrate the range tab from the persisted prefs once
// they arrive; see health-chart.tsx for the full rationale (the
// dashboard-layout query resolves after mount, and `rangePoints`
// stays local state because several memos depend on it by identity).
// Adjusted during render, not an effect — React's documented "adjust
// state during render" pattern (a second state flag rather than a
// ref, since the lint rule bans ref reads during render).
const [rangeHydrated, setRangeHydrated] = useState(false);
if (
!mini &&
!windowOverride &&
chartKey &&
!rangeHydrated &&
typeof overlayPrefs.prefs.rangePoints === "number"
) {
setRangeHydrated(true);
setRangePoints(overlayPrefs.prefs.rangePoints);
}
// v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth();
@@ -1047,7 +1066,18 @@ export function MoodChart({
aria-pressed={rangePoints === r.points}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.points);
// v1.30.1 M2 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
+23
View File
@@ -306,11 +306,24 @@ export const CHART_OVERLAY_KEYS = [
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
/**
* v1.30.1 — the range tabs (`7 / 30 / 90 / All`) every chart card
* exposes. `0` means "All". Shared here so both the client hook and
* the server route validate against the same closed set instead of
* each chart component re-declaring it.
*/
export const CHART_RANGE_POINTS = [0, 7, 30, 90] as const;
export type ChartRangePoints = (typeof CHART_RANGE_POINTS)[number];
export interface ChartOverlayPrefs {
showTrendIndicator: boolean;
showTrendArrow: boolean;
showTargetRange: boolean;
comparisonBaseline: ComparisonBaseline;
// v1.30.1 — persisted range-tab selection. Undefined = never saved
// for this chart yet, so the chart falls back to its own 30 d
// default rather than forcing every unopened chart to look "saved".
rangePoints?: ChartRangePoints;
}
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
@@ -320,6 +333,13 @@ export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
comparisonBaseline: "none",
};
function isChartRangePoints(value: unknown): value is ChartRangePoints {
return (
typeof value === "number" &&
(CHART_RANGE_POINTS as readonly number[]).includes(value)
);
}
export type ChartOverlayPrefsMap = Partial<
Record<ChartOverlayKey, ChartOverlayPrefs>
>;
@@ -338,6 +358,9 @@ function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
? candidate.comparisonBaseline
: "none",
rangePoints: isChartRangePoints(candidate.rangePoints)
? candidate.rangePoints
: undefined,
};
}