feat(db): User.aiProviderChain for ordered fallback config

Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.

Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:20:12 +02:00
1 parent 6891bf9cf9
commit 2611bb4fae
12 files changed
+766 -2

No files matched your search

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ChartEmptyState } from "../chart-empty-state";
describe("<ChartEmptyState>", () => {
it("renders the title and the line-chart glyph", () => {
const html = renderToStaticMarkup(
<ChartEmptyState title="Add more measurements to see trends" />,
);
expect(html).toContain("Add more measurements to see trends");
// lucide-react LineChart emits an `<svg ...>` — the line-chart icon
// is identifiable by its `class="lucide lucide-line-chart"` (or
// similar) root class. We just assert one svg landed in the output.
expect(html).toContain("<svg");
expect(html).toContain('data-slot="chart-empty-state"');
});
it("renders an optional description below the title", () => {
const html = renderToStaticMarkup(
<ChartEmptyState
title="Not enough mood data"
description="Log at least 3 entries to unlock the trend line."
/>,
);
expect(html).toContain("Not enough mood data");
expect(html).toContain(
"Log at least 3 entries to unlock the trend line.",
);
});
it("uses a custom height when supplied", () => {
const html = renderToStaticMarkup(
<ChartEmptyState title="..." height={320} />,
);
expect(html).toContain("height:320px");
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
ChartLinearGradient,
chartGradientFill,
} from "../chart-gradient";
/**
* v1.4.16 B1a — pin the contract for the gradient primitive that every
* chart wrapper now consumes:
*
* - emits an SVG `<defs><linearGradient>` block with the requested id,
* - top stop carries the metric colour at ~35 % alpha by default,
* - bottom stop fades to 0 %,
* - the helper `chartGradientFill()` builds the matching `url(#id)`
* reference Recharts expects on `<Area fill=...>`.
*/
describe("<ChartLinearGradient>", () => {
it("renders a vertical linearGradient with the supplied id and colour token", () => {
// Wrap in <svg> because <defs> outside an SVG triggers a React
// hydration warning under jsdom; here we render to static markup so
// the wrapper just keeps the output well-formed.
const html = renderToStaticMarkup(
<svg>
<ChartLinearGradient id="bp-gradient" colorVar="--dracula-purple" />
</svg>,
);
expect(html).toContain('id="bp-gradient"');
// Vertical gradient — top to bottom.
expect(html).toContain('x1="0"');
expect(html).toContain('y1="0"');
expect(html).toContain('y2="1"');
// The colour token round-trips into a `var(...)` reference.
expect(html).toContain("var(--dracula-purple)");
// Default opacity: 0.35 at top, 0 at bottom.
expect(html).toContain('stop-opacity="0.35"');
expect(html).toContain('stop-opacity="0"');
// Marker used by visual-regression / e2e smoke tests.
expect(html).toContain('data-slot="chart-linear-gradient"');
expect(html).toContain('data-color-var="--dracula-purple"');
});
it("respects custom topOpacity / bottomOpacity overrides", () => {
const html = renderToStaticMarkup(
<svg>
<ChartLinearGradient
id="weight-gradient"
colorVar="--dracula-cyan"
topOpacity={0.5}
bottomOpacity={0.05}
/>
</svg>,
);
expect(html).toContain('stop-opacity="0.5"');
expect(html).toContain('stop-opacity="0.05"');
});
});
describe("chartGradientFill()", () => {
it("builds a Recharts-shaped url() reference", () => {
expect(chartGradientFill("bp-gradient")).toBe("url(#bp-gradient)");
expect(chartGradientFill("mood")).toBe("url(#mood)");
});
});
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { RichChartTooltip } from "../chart-tooltip";
/**
* v1.4.16 B1a — Apple-Health-style tooltip primitive contract.
*/
describe("<RichChartTooltip>", () => {
it("renders nothing when inactive", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active={false}
rows={[{ name: "Systolic", value: "128 mmHg", color: "#bd93f9" }]}
/>,
);
expect(html).toBe("");
});
it("renders nothing when active but row list is empty", () => {
const html = renderToStaticMarkup(<RichChartTooltip active rows={[]} />);
expect(html).toBe("");
});
it("renders the date label and one value row when active with a single row", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active
label="Mon, May 5"
rows={[
{
name: "Systolic",
value: "128 mmHg",
color: "#bd93f9",
delta: "+3 vs. your normal",
},
]}
/>,
);
expect(html).toContain("Mon, May 5");
expect(html).toContain("Systolic");
expect(html).toContain("128 mmHg");
expect(html).toContain("+3 vs. your normal");
// Marker hooks for e2e + visual.
expect(html).toContain('data-slot="rich-chart-tooltip"');
expect(html).toContain('data-slot="rich-chart-tooltip-label"');
expect(html).toContain('data-slot="rich-chart-tooltip-row"');
expect(html).toContain('data-slot="rich-chart-tooltip-delta"');
// Coloured dot uses the row colour (inline style).
expect(html).toContain("background-color:#bd93f9");
});
it("supports multiple rows and omits delta sub-line when not provided", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active
label="May 5"
rows={[
{ name: "Systolic", value: "128 mmHg", color: "#bd93f9" },
{ name: "Diastolic", value: "82 mmHg", color: "#ff79c6" },
]}
/>,
);
expect(html).toContain("Systolic");
expect(html).toContain("Diastolic");
expect(html).toContain("128 mmHg");
expect(html).toContain("82 mmHg");
// No delta block painted when no delta supplied.
expect(html).not.toContain('data-slot="rich-chart-tooltip-delta"');
});
it("hides the date label when label is empty string", () => {
const html = renderToStaticMarkup(
<RichChartTooltip
active
label=""
rows={[{ name: "Mood", value: "4.2", color: "#d6acff" }]}
/>,
);
expect(html).not.toContain('data-slot="rich-chart-tooltip-label"');
expect(html).toContain("Mood");
});
});
@@ -0,0 +1,58 @@
"use client";
import type { ReactElement, ReactNode } from "react";
import { LineChart as LineChartIcon } from "lucide-react";
/**
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
*
* Per Marc's research §1A — "personal-baseline framing first" — when a
* user has only 1-2 data points there's nothing useful to plot, so we
* paint a friendly hint instead of a near-empty canvas. The hint reuses
* the `lucide-react/LineChart` glyph the rest of the app uses for chart
* surfaces, so it visually matches the chart-card shell that wraps it.
*
* Design constraints:
* - Same outer footprint as the painted chart (240-280 px tall) so
* the dashboard layout doesn't reflow when a chart drops to / out
* of the empty state.
* - i18n-driven copy — the wrapper passes already-translated strings.
* - SSR-safe — pure markup, tested via `renderToStaticMarkup`.
*/
export interface ChartEmptyStateProps {
/** Translated headline, e.g. "Add more measurements to see trends". */
title: string;
/**
* Optional secondary line. Sometimes useful to nudge the user toward
* a specific action ("3 measurements minimum"); often omitted.
*/
description?: ReactNode;
/**
* Optional explicit height in pixels to match the surrounding chart
* card. Defaults to 240 (matches `<HealthChart>` /
* `<MedicationComplianceChart>`).
*/
height?: number;
}
export function ChartEmptyState({
title,
description,
height = 240,
}: ChartEmptyStateProps): ReactElement {
return (
<div
data-slot="chart-empty-state"
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"
style={{ height }}
>
<LineChartIcon
className="text-muted-foreground/60 h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-xs">{description}</p> : null}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import type { ReactElement } from "react";
/**
* Reusable Apple-Health-style linear gradient for area / line fills.
*
* Recharts ships an Area component that accepts a `fill="url(#id)"` prop
* pointing at a `<linearGradient>` defined inside an SVG `<defs>` block.
* v1.4.16 B1a paints a soft Dracula-token gradient under the line so the
* chart reads like the Apple Health "Trends" cards: the metric colour at
* the top of the area fades down to nearly transparent at the bottom.
*
* Why a wrapper component:
* 1. The gradient lives in `<defs>` and must be sibling-rendered to
* `<Area fill="url(#id)" />`. Recharts allows arbitrary children in
* `LineChart` / `AreaChart` and renders them inside its `<svg>`, so
* a small named primitive keeps the call sites readable
* (`<ChartLinearGradient id="bp" colorVar="--dracula-purple" />`)
* instead of inlining 8 lines of `<defs><linearGradient>...` per
* chart.
* 2. Tokens stay canonical — only the `colorVar` prop is exposed; the
* stop offsets / opacities are baked in to enforce a consistent
* look across all 5 charts (BP, weight, pulse, mood, medication).
* 3. SSR safe — pure SVG, no DOM APIs touched. The unit test renders
* it via `renderToStaticMarkup`.
*
* Defaults follow the Apple Health pattern: ~35 % alpha at the top of
* the area, 0 % at the bottom. The pinned stops also stay clear of the
* grid lines so a domain like "60 .. 110 mmHg" still reads as a
* gradient, not a flat colour wash.
*/
export interface ChartLinearGradientProps {
/** SVG id used by Recharts: `<Area fill="url(#${id})" />`. */
id: string;
/**
* CSS variable name (e.g. `--dracula-purple`). The component wraps it
* in `var(...)` so callers can pass the token verbatim.
*/
colorVar: string;
/** Alpha at the top of the area. Defaults to 0.35. */
topOpacity?: number;
/** Alpha at the bottom of the area. Defaults to 0.0. */
bottomOpacity?: number;
}
/**
* Renders a `<defs><linearGradient .../></defs>` block.
*
* The gradient runs vertically (x1=0,x2=0,y1=0,y2=1) so it shades the
* area top-down, matching how Apple Health paints the metric colour
* over the chart background.
*/
export function ChartLinearGradient({
id,
colorVar,
topOpacity = 0.35,
bottomOpacity = 0,
}: ChartLinearGradientProps): ReactElement {
const fill = `var(${colorVar})`;
return (
<defs>
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
data-slot="chart-linear-gradient"
data-color-var={colorVar}
>
<stop offset="0%" stopColor={fill} stopOpacity={topOpacity} />
<stop offset="100%" stopColor={fill} stopOpacity={bottomOpacity} />
</linearGradient>
</defs>
);
}
/**
* Build the `fill` attribute string Recharts expects when wiring an
* `<Area>` to a `<ChartLinearGradient id="x">` defined alongside.
*/
export function chartGradientFill(id: string): string {
return `url(#${id})`;
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import type { ReactElement } from "react";
/**
* Apple-Health-style rich tooltip for chart wrappers (B1a v1.4.16).
*
* Recharts' default tooltip is a flat list of `name: value` rows. Apple
* Health paints something denser and friendlier: rounded card with a
* faint drop shadow, the date on its own row, the metric value in
* tabular numerals, and an optional sub-line with delta vs. baseline /
* average. This wrapper renders that layout from a Recharts
* `TooltipProps.content` callback.
*
* Design constraints (per the v1.4.16 brief):
* - SSR-safe — no `window` / `document` access. The unit test renders
* it via `renderToStaticMarkup`.
* - Dracula tokens canonical. The card surface uses `--card`/`--border`
* so dark mode + light mode both look right; per-row metric colour
* comes from the dot fill the chart already declares.
* - The component is dumb — it accepts a fully-shaped payload and
* renders. The chart wrappers compute baseline / delta themselves.
*/
export interface RichTooltipRow {
/** Display name, e.g. "Systolic" or "Mood". */
name: string;
/**
* Already-formatted value string (the chart wrapper applies units +
* locale formatting). Example: `"128 mmHg"`.
*/
value: string;
/** Stroke colour of the corresponding line — used as a left dot. */
color: string;
/**
* Optional second-line annotation for *this* row, e.g.
* `"+3 mmHg vs. your normal"`. Painted in muted-foreground at 11px.
*/
delta?: string;
}
export interface RichChartTooltipProps {
/**
* Recharts passes `active=true` and a non-empty payload only when the
* cursor is over a data point. Outside a hover the renderer should
* paint nothing.
*/
active?: boolean;
/** Row label shown at the top of the card — typically a formatted
* date string (`"Mon, May 5"`). Empty string hides the row. */
label?: string;
/** Already-shaped, sorted rows. The tooltip never re-orders. */
rows: RichTooltipRow[];
}
/**
* Renders the tooltip card.
*
* Style choices that match the Apple Health "Trends" pop-over:
* - rounded-xl (matches the surrounding chart card)
* - drop-shadow `shadow-lg` for elevation
* - tabular-nums on the value cell so digits don't jiggle while
* hovering across a horizontal series
* - 11px sub-row (delta vs. baseline) keeps the tooltip compact
*/
export function RichChartTooltip({
active,
label,
rows,
}: RichChartTooltipProps): ReactElement | null {
if (!active || rows.length === 0) return null;
return (
<div
data-slot="rich-chart-tooltip"
className="bg-card border-border min-w-[140px] rounded-xl border p-2.5 text-xs shadow-lg"
style={{
// The card itself is layered above the chart's own SVG; without
// an explicit background the gradient bleeds through the
// semi-transparent border in Safari.
backgroundColor: "var(--card)",
boxShadow:
"0 6px 20px rgba(0, 0, 0, 0.32), 0 1px 2px rgba(0, 0, 0, 0.16)",
}}
>
{label ? (
<div
className="text-muted-foreground mb-1.5 text-[11px] tracking-wide uppercase"
data-slot="rich-chart-tooltip-label"
>
{label}
</div>
) : null}
<div className="flex flex-col gap-1">
{rows.map((row) => (
<div
key={row.name}
className="flex flex-col"
data-slot="rich-chart-tooltip-row"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="text-foreground/80">{row.name}</span>
</div>
<span className="text-foreground tabular-nums">{row.value}</span>
</div>
{row.delta ? (
<span
className="text-muted-foreground mt-0.5 ml-3.5 text-[11px]"
data-slot="rich-chart-tooltip-delta"
>
{row.delta}
</span>
) : null}
</div>
))}
</div>
</div>
);
}