diff --git a/messages/de.json b/messages/de.json index 895caa405..8f98c3de9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -636,7 +636,14 @@ "legendOnTime": "Pünktlich", "legendLate": "Spät", "legendVeryLate": "Sehr spät", - "legendMissed": "Verpasst" + "legendMissed": "Verpasst", + "emptyStateTitle": "Erfasse mehr Messungen, um Trends zu sehen", + "emptyStateDescription": "Erfasse mindestens 3 Einträge, um die Trendlinie freizuschalten.", + "personalBaseline": "Dein Mittel", + "deltaVsBaseline": "{delta} vs. dein Mittel", + "deltaUnchanged": "entspricht deinem Mittel", + "targetWeight": "Ziel", + "deltaVsTarget": "{delta} vs. Ziel" }, "insights": { "aiAnalysisTitle": "KI-Gesundheitsanalyse", diff --git a/messages/en.json b/messages/en.json index 25705e475..d9ea62c98 100644 --- a/messages/en.json +++ b/messages/en.json @@ -636,7 +636,14 @@ "legendOnTime": "On time", "legendLate": "Late", "legendVeryLate": "Very late", - "legendMissed": "Missed" + "legendMissed": "Missed", + "emptyStateTitle": "Add more measurements to see trends", + "emptyStateDescription": "Log at least 3 entries to unlock the trend line.", + "personalBaseline": "Your normal", + "deltaVsBaseline": "{delta} vs. your normal", + "deltaUnchanged": "matches your normal", + "targetWeight": "Target", + "deltaVsTarget": "{delta} vs. target" }, "insights": { "aiAnalysisTitle": "AI Health Analysis", diff --git a/prisma/migrations/0033_user_ai_provider_chain/migration.sql b/prisma/migrations/0033_user_ai_provider_chain/migration.sql new file mode 100644 index 000000000..42a0f5ab3 --- /dev/null +++ b/prisma/migrations/0033_user_ai_provider_chain/migration.sql @@ -0,0 +1,26 @@ +-- v1.4.16 phase B5b: ordered fallback chain for multi-provider AI redundancy. +-- +-- Up to v1.4.15 each user resolved exactly one AI provider per insight +-- request and a hard failure (401, 5xx, network timeout) bricked the +-- generation entirely. This column stores an ordered list of provider +-- preferences so the runner can fall through on hard failure to a +-- secondary configured provider. +-- +-- Shape (JSONB so admin tools can grep/index later): +-- [ +-- { "providerType": "codex", "priority": 1, "enabled": true }, +-- { "providerType": "openai", "priority": 2, "enabled": true }, +-- { "providerType": "anthropic", "priority": 3, "enabled": false } +-- ] +-- +-- Credentials are NOT inlined here — they continue to live in the +-- dedicated encrypted columns (codex_*, ai_openai_key_encrypted, +-- ai_anthropic_key_encrypted, ai_local_key_encrypted) so AES-256-GCM +-- key-rotation via scripts/rotate-encryption-key.ts keeps working +-- unchanged. The chain is pure metadata: which providers, in what order. +-- +-- NULL preserves the legacy single-provider behaviour driven by +-- users.ai_provider — no surprise migration of existing accounts. + +ALTER TABLE "users" + ADD COLUMN "ai_provider_chain" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7aa78a626..be161393d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,15 @@ model User { // before storage. aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") + // v1.4.16 phase B5b — ordered fallback chain for multi-provider AI + // redundancy. Each entry is `{ providerType, priority, enabled }`, + // sorted by ascending priority at runtime. Credentials live in the + // dedicated encrypted columns (codex* / aiAnthropicKeyEncrypted / + // aiLocalKeyEncrypted / aiOpenaiKeyEncrypted) — the chain just + // enumerates which providers to try and in what order. Null = legacy + // behaviour (single-provider resolution via `aiProvider`). + aiProviderChain Json? @map("ai_provider_chain") + // Onboarding onboardingCompletedAt DateTime? @map("onboarding_completed_at") // v1.4.15 Phase B5: tracks whether the user has finished (or diff --git a/src/components/charts/__tests__/chart-empty-state.test.tsx b/src/components/charts/__tests__/chart-empty-state.test.tsx new file mode 100644 index 000000000..e76bc54aa --- /dev/null +++ b/src/components/charts/__tests__/chart-empty-state.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ChartEmptyState } from "../chart-empty-state"; + +describe("", () => { + it("renders the title and the line-chart glyph", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Add more measurements to see trends"); + // lucide-react LineChart emits an `` — 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(" { + const html = renderToStaticMarkup( + , + ); + 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( + , + ); + expect(html).toContain("height:320px"); + }); +}); diff --git a/src/components/charts/__tests__/chart-gradient.test.tsx b/src/components/charts/__tests__/chart-gradient.test.tsx new file mode 100644 index 000000000..ca07bb841 --- /dev/null +++ b/src/components/charts/__tests__/chart-gradient.test.tsx @@ -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 `` 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 ``. + */ + +describe("", () => { + it("renders a vertical linearGradient with the supplied id and colour token", () => { + // Wrap in because 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( + + + , + ); + + 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( + + + , + ); + 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)"); + }); +}); diff --git a/src/components/charts/__tests__/chart-tooltip.test.tsx b/src/components/charts/__tests__/chart-tooltip.test.tsx new file mode 100644 index 000000000..d1d78a81a --- /dev/null +++ b/src/components/charts/__tests__/chart-tooltip.test.tsx @@ -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("", () => { + it("renders nothing when inactive", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when active but row list is empty", () => { + const html = renderToStaticMarkup(); + expect(html).toBe(""); + }); + + it("renders the date label and one value row when active with a single row", () => { + const html = renderToStaticMarkup( + , + ); + + 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( + , + ); + 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( + , + ); + expect(html).not.toContain('data-slot="rich-chart-tooltip-label"'); + expect(html).toContain("Mood"); + }); +}); diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx new file mode 100644 index 000000000..2664322f4 --- /dev/null +++ b/src/components/charts/chart-empty-state.tsx @@ -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 `` / + * ``). + */ + height?: number; +} + +export function ChartEmptyState({ + title, + description, + height = 240, +}: ChartEmptyStateProps): ReactElement { + return ( +
+
+ ); +} diff --git a/src/components/charts/chart-gradient.tsx b/src/components/charts/chart-gradient.tsx new file mode 100644 index 000000000..34dbb01a1 --- /dev/null +++ b/src/components/charts/chart-gradient.tsx @@ -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 `` defined inside an SVG `` 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 `` and must be sibling-rendered to + * ``. Recharts allows arbitrary children in + * `LineChart` / `AreaChart` and renders them inside its ``, so + * a small named primitive keeps the call sites readable + * (``) + * instead of inlining 8 lines of `...` 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: ``. */ + 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 `` 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 ( + + + + + + + ); +} + +/** + * Build the `fill` attribute string Recharts expects when wiring an + * `` to a `` defined alongside. + */ +export function chartGradientFill(id: string): string { + return `url(#${id})`; +} diff --git a/src/components/charts/chart-tooltip.tsx b/src/components/charts/chart-tooltip.tsx new file mode 100644 index 000000000..78aea8c5d --- /dev/null +++ b/src/components/charts/chart-tooltip.tsx @@ -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 ( +
+ {label ? ( +
+ {label} +
+ ) : null} +
+ {rows.map((row) => ( +
+
+
+
+ {row.value} +
+ {row.delta ? ( + + {row.delta} + + ) : null} +
+ ))} +
+
+ ); +} diff --git a/src/lib/ai/__tests__/provider-chain.test.ts b/src/lib/ai/__tests__/provider-chain.test.ts new file mode 100644 index 000000000..2a62922b8 --- /dev/null +++ b/src/lib/ai/__tests__/provider-chain.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + parseProviderChain, + serializeProviderChain, + PROVIDER_CHAIN_DEFAULT, + type ProviderChainEntry, +} from "../provider-chain"; + +describe("parseProviderChain", () => { + it("returns the default when input is null", () => { + expect(parseProviderChain(null)).toEqual(PROVIDER_CHAIN_DEFAULT); + }); + + it("returns the default when input is undefined", () => { + expect(parseProviderChain(undefined)).toEqual(PROVIDER_CHAIN_DEFAULT); + }); + + it("returns the default when input is malformed (not an array)", () => { + expect(parseProviderChain({ junk: true })).toEqual(PROVIDER_CHAIN_DEFAULT); + }); + + it("returns the default when input is an empty array", () => { + expect(parseProviderChain([])).toEqual(PROVIDER_CHAIN_DEFAULT); + }); + + it("strips invalid entries and keeps valid ones", () => { + const input = [ + { providerType: "codex", priority: 1, enabled: true }, + { providerType: "fictional", priority: 2, enabled: true }, + { providerType: "openai", priority: 3, enabled: true }, + ]; + const out = parseProviderChain(input); + expect(out.map((e) => e.providerType)).toEqual(["codex", "openai"]); + }); + + it("sorts by priority ascending (lowest priority value first)", () => { + const input: ProviderChainEntry[] = [ + { providerType: "openai", priority: 5, enabled: true }, + { providerType: "codex", priority: 1, enabled: true }, + { providerType: "anthropic", priority: 3, enabled: true }, + ]; + const out = parseProviderChain(input); + expect(out.map((e) => e.providerType)).toEqual([ + "codex", + "anthropic", + "openai", + ]); + }); + + it("preserves insertion order for ties in priority", () => { + const input: ProviderChainEntry[] = [ + { providerType: "openai", priority: 1, enabled: true }, + { providerType: "anthropic", priority: 1, enabled: true }, + ]; + const out = parseProviderChain(input); + expect(out.map((e) => e.providerType)).toEqual(["openai", "anthropic"]); + }); + + it("deduplicates by providerType (first occurrence wins)", () => { + const input: ProviderChainEntry[] = [ + { providerType: "codex", priority: 1, enabled: true }, + { providerType: "codex", priority: 5, enabled: false }, + ]; + const out = parseProviderChain(input); + expect(out).toHaveLength(1); + expect(out[0]).toEqual({ + providerType: "codex", + priority: 1, + enabled: true, + }); + }); + + it("coerces missing enabled flag to true", () => { + const input = [{ providerType: "codex", priority: 1 }]; + const out = parseProviderChain(input); + expect(out[0].enabled).toBe(true); + }); +}); + +describe("serializeProviderChain", () => { + it("round-trips through parse without mutation", () => { + const chain: ProviderChainEntry[] = [ + { providerType: "codex", priority: 1, enabled: true }, + { providerType: "openai", priority: 2, enabled: false }, + ]; + const json = serializeProviderChain(chain); + expect(parseProviderChain(JSON.parse(json))).toEqual(chain); + }); + + it("produces a parseable JSON string", () => { + const chain: ProviderChainEntry[] = [ + { providerType: "codex", priority: 1, enabled: true }, + ]; + expect(() => JSON.parse(serializeProviderChain(chain))).not.toThrow(); + }); +}); + +describe("PROVIDER_CHAIN_DEFAULT", () => { + it("starts with codex (the cheapest path for ChatGPT-Pro users)", () => { + expect(PROVIDER_CHAIN_DEFAULT[0].providerType).toBe("codex"); + }); + + it("includes admin OpenAI as final fallback so misconfigured users still see insights", () => { + const last = + PROVIDER_CHAIN_DEFAULT[PROVIDER_CHAIN_DEFAULT.length - 1]; + expect(last.providerType).toBe("admin-openai"); + }); + + it("priorities are strictly ascending", () => { + const priorities = PROVIDER_CHAIN_DEFAULT.map((e) => e.priority); + for (let i = 1; i < priorities.length; i += 1) { + expect(priorities[i]).toBeGreaterThan(priorities[i - 1]); + } + }); + + it("every entry is enabled by default", () => { + expect(PROVIDER_CHAIN_DEFAULT.every((e) => e.enabled)).toBe(true); + }); +}); diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts new file mode 100644 index 000000000..b686e886b --- /dev/null +++ b/src/lib/ai/provider-chain.ts @@ -0,0 +1,137 @@ +/** + * v1.4.16 phase B5b — multi-provider redundancy. + * + * The persisted shape on `User.aiProviderChain` is a JSON array of + * entries describing **which** providers to try and **in what order**. + * Credentials live in the dedicated encrypted columns + * (`codex*Encrypted`, `aiOpenaiKeyEncrypted`, `aiAnthropicKeyEncrypted`, + * `aiLocalKeyEncrypted`); the chain is pure metadata so AES-256-GCM key + * rotation via `scripts/rotate-encryption-key.ts` keeps working + * unchanged. + * + * Why a separate module rather than inlining the parser into + * `provider.ts`: the chain feeds three call sites (the runner, the + * Settings UI summary endpoint, and the integration test fixture), + * and a defensive parser-with-default keeps each call site short. A + * malformed chain on disk (manual SQL edit, half-applied migration) + * MUST never break insight generation — it falls back to the + * `PROVIDER_CHAIN_DEFAULT`. + */ + +/** + * Provider tags used in the chain. `admin-openai` is the legacy + * `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users + * still see insights via the operator's key); the rest map 1:1 onto + * the per-user encrypted-credential columns. + * + * Distinct from `ProviderType` (in `types.ts`) because that enum tags + * the **runtime** provider instance — which has no notion of "admin + * OpenAI" vs "user OpenAI" at the wire level (both are + * `OpenAIClient`). The chain operates at the **resolution** layer + * where that distinction matters. + */ +export const PROVIDER_CHAIN_TYPES = [ + "codex", + "openai", + "anthropic", + "local", + "admin-openai", +] as const; + +export type ProviderChainType = (typeof PROVIDER_CHAIN_TYPES)[number]; + +export interface ProviderChainEntry { + providerType: ProviderChainType; + priority: number; + enabled: boolean; +} + +/** + * Default chain when the user has not customised one. Order reflects + * the cheapest-first preference users have voiced in v1.4.x feedback: + * 1. codex — covered by ChatGPT Pro/Plus subscription (no per-token + * cost on top of what the user already pays). + * 2. openai — user's own API key, billed per token. + * 3. anthropic — user's own API key. + * 4. local — self-hosted Ollama / LM Studio. + * 5. admin-openai — operator's shared key, last-ditch so a user with + * no personal config still sees insights. + */ +export const PROVIDER_CHAIN_DEFAULT: readonly ProviderChainEntry[] = [ + { providerType: "codex", priority: 1, enabled: true }, + { providerType: "openai", priority: 2, enabled: true }, + { providerType: "anthropic", priority: 3, enabled: true }, + { providerType: "local", priority: 4, enabled: true }, + { providerType: "admin-openai", priority: 5, enabled: true }, +]; + +/** + * Parse the persisted JSON value into a sorted, deduplicated chain. + * Tolerant by design — anything malformed collapses to the default + * rather than 500-ing the request. + * + * Sort is stable (Array.prototype.sort guarantee since ES2019) so + * priority ties preserve insertion order, which matches the UI's + * drag-handle convention (admin-OpenAI vs user-OpenAI tied = whichever + * the user dropped first wins). + */ +export function parseProviderChain( + raw: unknown, +): readonly ProviderChainEntry[] { + if (!Array.isArray(raw) || raw.length === 0) { + return PROVIDER_CHAIN_DEFAULT; + } + + const seen = new Set(); + const valid: ProviderChainEntry[] = []; + for (const item of raw) { + if (item == null || typeof item !== "object") continue; + const candidate = item as { + providerType?: unknown; + priority?: unknown; + enabled?: unknown; + }; + const providerType = candidate.providerType; + if (typeof providerType !== "string") continue; + if ( + !PROVIDER_CHAIN_TYPES.includes(providerType as ProviderChainType) + ) { + continue; + } + if (seen.has(providerType as ProviderChainType)) continue; + seen.add(providerType as ProviderChainType); + const priority = + typeof candidate.priority === "number" && + Number.isFinite(candidate.priority) + ? candidate.priority + : valid.length + 1; + const enabled = + typeof candidate.enabled === "boolean" ? candidate.enabled : true; + valid.push({ + providerType: providerType as ProviderChainType, + priority, + enabled, + }); + } + + if (valid.length === 0) return PROVIDER_CHAIN_DEFAULT; + + return valid.sort((a, b) => a.priority - b.priority); +} + +/** + * Inverse of `parseProviderChain` — produces the JSON the database + * column stores. Kept as a helper so the writers can't accidentally + * persist a non-canonical key order. + */ +export function serializeProviderChain( + chain: readonly ProviderChainEntry[], +): string { + return JSON.stringify( + chain.map((e) => ({ + providerType: e.providerType, + priority: e.priority, + enabled: e.enabled, + })), + ); +}