Files
HealthLog/src/components/charts/scatter-correlation-chart.tsx
T
Marc-André Bombeck f6ca89b963 v1.4.34.1 — insights cold-mount perf hotfix + scatter-card sizing
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
every cold load against accounts populated by the v1.4.34 Apple Health
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
blood-pressure classification, target adherence, and four Pearson
correlations from the same pile of rows. On a 100 000+ row account the
route consumed one of the 20 pool connections for the whole 29 s, which
cascaded sibling endpoints into Cloudflare "no available server" 503s.
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
Recharts warning on first paint because its dynamic-mount lacked a
non-zero floor before aspect-ratio resolved.

Changed:
  - /api/insights/comprehensive now reads through a SQL-side aggregator
    (src/lib/insights/comprehensive-aggregator.ts) grouping by metric
    type with Postgres-native AVG / MIN / MAX / REGR_SLOPE / REGR_R2 on
    the measurements (user_id, type, measured_at) index path. Returns
    the same DataSummary-shaped per-type bundle the legacy route
    stitched together; the medication-compliance block keeps its
    bounded Prisma reads. Wrapped through the server cache keyed on
    `${userId}|comprehensive` (60 s TTL). Cold path drops from ~29 s
    to a few hundred ms against fixture data matching the production
    volume that triggered the bug; warm path resolves in single-digit
    milliseconds.
  - Cache-wrap the five remaining read routes the v1.4.34 server-cache
    blueprint left staged but unwired: /api/mood/analytics,
    /api/workouts, /api/bugreport/status, /api/medications,
    /api/dashboard/widgets. The Cloudflare 503 cascade on
    /api/measurements?aggregate=daily resolves as a downstream
    consequence — once the comprehensive route stops monopolising the
    pool, the origin accepts new connections fast enough that
    Cloudflare's circuit-breaker no longer trips.
  - /api/dashboard/chart-overlay-prefs PUT now invalidates the new
    dashboard-widgets cache so the partial-update path doesn't leave
    stale layout state in the cache for up to TTL.

Fixed:
  - Scatter-correlation card no longer logs the Recharts
    `width(-1)/height(-1)` warning on first mount. The wrapper now
    carries a min-h-[180px] floor that matches the loading skeleton so
    ResponsiveContainer always measures a non-zero parent before
    aspect-ratio settles.
2026-05-16 22:26:59 +02:00

160 lines
6.0 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import {
CartesianGrid,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
/**
* Self-contained correlation scatter chart used by /insights.
*
* The insights page renders four near-identical scatter cards (weight vs BP,
* BP vs medication continuity, mood vs BP, mood vs weight) inside `length >= 5`
* gates below the fold. Recharts is ~108 KiB Brotli, so we want the bundle
* split — but the previous attempt (v1.5 phase 4) split at the *primitive*
* level (each `XAxis`/`YAxis`/`Scatter`/etc. wrapped in `next/dynamic`). That
* breaks Recharts because the chart relies on `findAllByType(child)` against
* the original component identity to discover its own children — once each
* primitive is wrapped in a `next/dynamic` HOC the identity check fails and
* the chart renders empty axes / no data points.
*
* The correct seam is one level higher: a single self-contained component
* that statically imports every Recharts symbol it needs internally. The
* insights page then dynamic-imports *this wrapper* (matching the existing
* HealthChart / MoodChart pattern) so Recharts still ships in the deferred
* chunk while child-type detection keeps working.
*/
export interface ScatterAxisConfig {
/** Object key in `data` that supplies the value. */
dataKey: string;
/** Tooltip / a11y name for the axis. */
name: string;
/** Optional unit suffix shown by Recharts in tooltip / axis. */
unit?: string;
/** Optional axis label rendered below (X) the plot area. */
label?: string;
/** Optional fixed numeric domain (e.g. mood 1..5). */
domain?: [number, number];
/** Optional explicit tick values. */
ticks?: number[];
/** Optional formatter for tick labels. */
tickFormatter?: (value: number) => string;
}
export interface ScatterCorrelationChartProps<
T extends Record<string, number>,
> {
/** Data array. May be undefined while a parent query is loading; the
* insights cards already gate rendering behind a `length >= 5` check. */
data: T[] | undefined;
/** Marker fill colour, e.g. "var(--dracula-cyan)". */
fill: string;
xAxis: ScatterAxisConfig;
yAxis: ScatterAxisConfig;
/**
* Optional tooltip value formatter. Mirrors Recharts' `Formatter<ValueType,
* NameType>` signature; values may be string/number/ReadonlyArray and
* either side may be undefined while the tooltip is hydrating. Recharts
* also passes (item, index, payload) — we keep the rest-args open so
* existing inline formatters (which only read value+name) work as-is.
*/
tooltipFormatter?: (
value: number | string | ReadonlyArray<number | string> | undefined,
name: number | string | undefined,
...rest: unknown[]
) => [ReactNode, number | string] | ReactNode;
/** Plot height in pixels. Defaults to 250 to match existing layout. */
height?: number;
}
export function ScatterCorrelationChart<T extends Record<string, number>>({
data,
fill,
xAxis,
yAxis,
tooltipFormatter,
height = 250,
}: ScatterCorrelationChartProps<T>) {
// v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a
// responsive aspect-ratio on mobile so the chart never compresses
// below readable axes. The `aspect-square` ratio at `<sm` keeps the
// plot area as a centred square (matches Apple Health correlation
// viz); from `sm:` upwards the chart falls back to the legacy
// pixel-height contract via a 3:2 aspect-ratio fallback that
// approximates the previous 250 px shape.
return (
<div
// v1.4.34.1 — reserve a minimum height matching the dynamic-import
// skeleton so ResponsiveContainer always measures a non-zero parent
// at first paint. The `aspect-ratio` rules still drive the final
// shape once layout settles; this floor only prevents the brief
// zero-height window that fires Recharts' width(-1)/height(-1)
// console warning when the chart swaps in after the skeleton.
className="touch-pan-y aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto"
style={{ height: undefined }}
data-explicit-height={height}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey={xAxis.dataKey}
type="number"
name={xAxis.name}
unit={xAxis.unit}
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
tickMargin={8}
height={52}
interval="preserveStartEnd"
padding={{ left: 8, right: 8 }}
stroke="var(--dracula-comment)"
domain={xAxis.domain}
ticks={xAxis.ticks}
tickFormatter={xAxis.tickFormatter}
label={
xAxis.label
? {
value: xAxis.label,
position: "bottom",
fontSize: 12,
fill: "var(--dracula-comment)",
}
: undefined
}
/>
<YAxis
dataKey={yAxis.dataKey}
type="number"
name={yAxis.name}
unit={yAxis.unit}
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
domain={yAxis.domain}
ticks={yAxis.ticks}
tickFormatter={yAxis.tickFormatter}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={tooltipFormatter}
/>
<Scatter data={data} fill={fill} opacity={0.8} />
</ScatterChart>
</ResponsiveContainer>
</div>
);
}