mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user