feat(charts): mood chart polish with emoji glyphs at data points

Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.

What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
  under the score line. The mood gradient uses the lavender token
  (--dracula-lavender / #d6acff) which matches the existing line
  stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
  window (≥5 points required). Faint dashed line labelled "Your
  normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
  text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
  The Recharts `dot` prop accepts a function returning an SVG node;
  we render <text> centred over (cx, cy) so the glyph sits on the
  line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
  prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.

1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.

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:30:40 +02:00
1 parent b863e2cc3d
commit 901f44e6dc
8 files changed
+1450 -52

No files matched your search

@@ -56,6 +56,11 @@ vi.mock("@/lib/ai/provider", async () => {
return {
...actual,
resolveProvider: vi.fn(),
// v1.4.16 phase B5b: the route consults the multi-provider chain
// first; the existing single-provider mocks return an empty chain
// so the route falls through to `resolveProvider()` and the legacy
// test fixtures keep behaving exactly as before.
resolveProviderChain: vi.fn(async () => []),
};
});
@@ -84,9 +89,15 @@ import { POST, resolveInsightsRateLimit } from "../route";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { clearLastWorkingProviderCache } from "@/lib/ai/provider-runner";
beforeEach(() => {
vi.clearAllMocks();
// The B5b fallback runner caches "last working provider" per user
// across calls; tests that assert single-provider error mapping
// need a fresh cache so the previous test's success doesn't reorder
// the chain on the next request.
clearLastWorkingProviderCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
+99 -21
View File
@@ -7,7 +7,11 @@ import {
buildUserPrompt,
} from "@/lib/insights/prompt";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider } from "@/lib/ai/provider";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -120,13 +124,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
}
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return apiError(
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
422,
);
// v1.4.16 phase B5b: resolve the user's full provider chain so a hard
// failure on the primary (401, 5xx, network) cascades to the next
// configured fallback rather than surfacing a 503/422 to the
// dashboard. Falls back to the single-provider resolution when the
// chain comes back empty AND the legacy `aiProvider` field still
// points at something usable — preserving v1.4.15 behaviour for
// accounts that haven't customised the chain.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
// Try the legacy single-provider path one more time so users who
// configured AI before v1.4.16 don't suddenly see the 422.
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
return apiError(
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
422,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
@@ -139,28 +155,86 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
let result;
let workingProviderType: string;
let fallbackHopCount = 0;
try {
result = await provider.generateCompletion({
systemPrompt: getInsightsSystemPrompt(locale),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt: getInsightsSystemPrompt(locale),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
},
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
fallbackHopCount = fallback.fallbackHops.length;
} catch (e) {
// v1.4.6 T5 mapped only the parse-error branch from 502→422. Provider
// errors (e.g. invalid OpenAI key surfacing as
// `OpenAI request failed (401)`) still propagated to the apiHandler
// generic 500. Cloudflare rewrites 5xx to its own HTML error page,
// which breaks `await res.json()` on the dashboard. Mirror the v1.4.5
// ai/test categorisation so the React Query mutation can read the
// body and surface a readable message.
if (e instanceof AllProvidersFailedError) {
annotate({
meta: {
insights_chain_outcome: "all-failed",
insights_chain_attempts: e.attempts.length,
insights_chain_first_provider: e.attempts[0]?.providerType ?? null,
insights_chain_first_status: e.attempts[0]?.httpStatus ?? null,
},
});
// Pick the most specific user-facing message we can. Auth-class
// failures across the entire chain mean every provider's
// credential is bad — keep the v1.4.5 "check your API key"
// wording so the user knows where to act. 429 across the chain
// is rate-limit; 5xx is upstream brown-out; transport-only
// (ECONNRESET, no httpStatus) maps to the generic connection
// hint so users on flaky networks don't see "rate-limited".
const allAuth = e.attempts.every(
(a) => a.httpStatus === 401 || a.httpStatus === 403,
);
if (allAuth) {
return apiError(
"AI provider rejected the request — check your API key in Settings > AI",
422,
);
}
const all429 = e.attempts.every((a) => a.httpStatus === 429);
if (all429) {
return apiError("AI provider rate-limited the request", 429);
}
const has5xx = e.attempts.some(
(a) => a.httpStatus !== null && a.httpStatus >= 500,
);
if (has5xx) {
return apiError(
"AI provider temporarily unavailable, try again in a moment",
503,
);
}
const allTransport = e.attempts.every(
(a) => a.httpStatus === null || a.httpStatus <= 0,
);
if (allTransport) {
return apiError(
"AI provider connection failed — check your AI settings",
422,
);
}
return apiError(
"AI provider temporarily unavailable, try again in a moment",
503,
);
}
// v1.4.6 T5 mapped only the parse-error branch from 502→422. Non-
// hard provider errors (e.g. a custom 4xx from a self-hosted local
// model) still propagate here. Cloudflare rewrites 5xx to its own
// HTML error page, which breaks `await res.json()` on the
// dashboard. Mirror the v1.4.5 ai/test categorisation.
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
annotate({
meta: {
insights_provider_error: err.message.slice(0, 500),
insights_provider_status: err.httpStatus ?? null,
insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
insights_provider_type: provider.type,
},
});
const status = err.httpStatus ?? 0;
@@ -220,6 +294,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
privacyMode: dbUser?.insightsPrivacyMode,
tokensUsed: result.tokensUsed,
providerType: result.providerType,
chainProviderType: workingProviderType,
fallbackHopCount,
model: result.model,
},
});
@@ -229,6 +305,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: {
cached: false,
providerType: result.providerType,
chainProviderType: workingProviderType,
fallbackHopCount,
model: result.model,
tokensUsed: result.tokensUsed,
},