diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 1a84a9cd1..ac9d4cd07 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -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(), }), }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index ee68e1142..774f30e5e 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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" diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 33715a22e..b1a81e5c8 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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" > diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 8c42009ba..dcf249db0 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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 >; @@ -338,6 +358,9 @@ function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs { comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline) ? candidate.comparisonBaseline : "none", + rangePoints: isChartRangePoints(candidate.rangePoints) + ? candidate.rangePoints + : undefined, }; }