feat(insights): recommendations grid with severity ordering + animated reveal

Replaces the flat <ol> list inside InsightAdvisorCard with a responsive
2-col grid wrapper that severity-orders the recs (urgent → important →
suggestion → info), paints a Dracula-token left border per severity,
and staggers a 100ms fade-in across the row so the cards sweep in on
first render. Hover on desktop lifts the card by 0.5px and grows the
shadow.

Internal slots of RecommendationCard untouched — the severity border +
hover-lift + animation-delay live on a thin shell wrapper around the
card so the B5c/d/e plumbing (rationale, confidence, feedback) keeps
working unchanged.

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 01:59:18 +02:00
1 parent 4d7d0748fa
commit 9e8be4b359
4 files changed
+298 -16

No files matched your search

@@ -0,0 +1,178 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
RecommendationsGrid,
sortRecommendationsBySeverity,
} from "../recommendations-grid";
import type { InsightRecommendation } from "@/lib/ai/types";
/**
* v1.4.16 phase B1b — recommendations grid + severity-ordering wrapper.
*
* Pure presentational shell around the existing RecommendationCard:
* - 1-col mobile, 2-col desktop layout
* - severity-priority ordering: urgent → important → suggestion → info
* - staggered fade-in (animation-delay derived from index)
* - the cards keep their own per-rec rendering — this wrapper is a
* layout shell only.
*/
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: () => <div data-testid="health-chart-mock" />,
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart-mock" />,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function rec(
id: string,
severity: "info" | "suggestion" | "important" | "urgent",
text: string,
): InsightRecommendation {
return {
id,
text,
severity,
rationale: {
dataWindow: "last7days",
comparedTo: "your 90-day median",
deviation: "moderate deviation",
},
metricSource: {
type: "weight",
timeRange: "last7days",
summary: "84.2 kg avg over 7 days",
},
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("sortRecommendationsBySeverity", () => {
it("orders urgent → important → suggestion → info", () => {
const recs: InsightRecommendation[] = [
rec("a", "info", "info-a"),
rec("b", "urgent", "urgent-b"),
rec("c", "suggestion", "suggestion-c"),
rec("d", "important", "important-d"),
];
const sorted = sortRecommendationsBySeverity(recs);
const ids = sorted.map((r) => (typeof r === "string" ? r : (r.id ?? "")));
expect(ids).toEqual(["b", "d", "c", "a"]);
});
it("preserves original order within the same severity bucket (stable)", () => {
const recs: InsightRecommendation[] = [
rec("a", "important", "first"),
rec("b", "important", "second"),
rec("c", "important", "third"),
];
const sorted = sortRecommendationsBySeverity(recs);
expect(sorted.map((r) => (typeof r === "string" ? r : r.id))).toEqual([
"a",
"b",
"c",
]);
});
it("treats plain-string recs and missing-severity recs as lowest priority", () => {
const recs: InsightRecommendation[] = [
"plain string rec",
rec("a", "urgent", "urgent-a"),
// partial — no severity
{ id: "b", text: "untyped" },
];
const sorted = sortRecommendationsBySeverity(recs);
expect(typeof sorted[0]).toBe("object");
if (typeof sorted[0] !== "string") {
expect(sorted[0].id).toBe("a");
}
// plain-string and id=b both fall to the bottom; relative order
// among the bottom bucket is stable (the string came first in the
// input).
expect(typeof sorted[1]).toBe("string");
});
});
describe("<RecommendationsGrid>", () => {
it("renders cards in a 2-col responsive grid", () => {
const recs = [
rec("a", "info", "alpha"),
rec("b", "urgent", "bravo"),
];
const html = render(<RecommendationsGrid recs={recs} />);
expect(html).toMatch(/data-slot="rec-grid"/);
expect(html).toMatch(/grid-cols-1/);
expect(html).toMatch(/lg:grid-cols-2/);
});
it("renders cards in severity-ordered sequence (urgent first)", () => {
const recs = [
rec("a", "info", "alpha"),
rec("b", "urgent", "bravo"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// bravo (urgent) must appear before alpha (info) in the output.
expect(html.indexOf("bravo")).toBeLessThan(html.indexOf("alpha"));
expect(html.indexOf("bravo")).toBeGreaterThan(-1);
});
it("paints severity-coloured left border on each card", () => {
const recs = [
rec("a", "urgent", "urgent-rec"),
rec("b", "important", "important-rec"),
rec("c", "suggestion", "suggestion-rec"),
rec("d", "info", "info-rec"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// Each card gets a Dracula-token border-l class. The exact class
// varies by severity; we assert at least one of each is in the
// output.
expect(html).toMatch(/border-l-dracula-red/);
expect(html).toMatch(/border-l-dracula-orange/);
expect(html).toMatch(/border-l-dracula-purple/);
expect(html).toMatch(/border-l-dracula-cyan/);
});
it("renders nothing when recs is empty", () => {
const html = render(<RecommendationsGrid recs={[]} />);
expect(html).not.toMatch(/data-slot="rec-grid"/);
});
it("applies the staggered animation-delay style per card", () => {
const recs = [
rec("a", "urgent", "first"),
rec("b", "important", "second"),
rec("c", "suggestion", "third"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// Cards 1+ get a non-zero animationDelay; the first card has 0ms.
expect(html).toMatch(/data-stagger-index="0"/);
expect(html).toMatch(/data-stagger-index="1"/);
expect(html).toMatch(/data-stagger-index="2"/);
});
});
@@ -27,7 +27,7 @@ import {
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
import { RecommendationCard } from "./recommendation-card";
import { RecommendationsGrid } from "./recommendations-grid";
// ─── Types ────────────────────────────────────────────────
@@ -542,22 +542,15 @@ export function InsightAdvisorCard({
</div>
)}
{/* Recommendations — v1.4.16 phase B5c uses RecommendationCard
* so each rec gets the Oura-style expand-card with rationale
* rows + mini-chart + citation footnote. Plain-string recs
* and recs without rationale render non-expanded. */}
{/* Recommendations — v1.4.16 phase B1b polish: severity-ordered
* 2-col responsive grid via <RecommendationsGrid>. Each card
* keeps the Oura-style expand reveal from B5c and gains a
* severity-coloured left border + hover-lift + staggered
* fade-in around its outer shell. */}
{insight.recommendations.length > 0 && (
<div className="space-y-3">
<SectionSeparator label={t("insights.recommendationsTitle")} />
<ol className="space-y-2 pl-0">
{insight.recommendations.map((rec, i) => (
<RecommendationCard
key={typeof rec === "string" ? `${i}-${rec}` : (rec.id ?? i)}
rec={rec}
index={i}
/>
))}
</ol>
<RecommendationsGrid recs={insight.recommendations} />
</div>
)}
@@ -329,7 +329,7 @@ export function RecommendationCard({
: null;
return (
<li
<div
data-slot="rec-card"
data-index={index}
className="border-border/40 bg-card/20 rounded-lg border px-3 py-2.5"
@@ -412,6 +412,6 @@ export function RecommendationCard({
<CitationFootnote referenceId={norm.referenceId} locale={locale} />
</div>
)}
</li>
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import type { InsightRecommendation } from "@/lib/ai/types";
import { RecommendationCard } from "./recommendation-card";
/**
* v1.4.16 phase B1b — Apple-Health-style recommendations grid.
*
* Pure layout shell around `<RecommendationCard>`:
* - 1-col mobile, 2-col desktop (no `xl:grid-cols-3` — recs benefit
* from breathing room on a wide viewport, and the card's expanded
* rationale + mini-chart pushes the natural max-content well past
* the 3-col break)
* - severity-priority ordering (urgent → important → suggestion → info)
* - severity-coloured left border per card via a thin shell `<div>`
* wrapper so the underlying `<RecommendationCard>` (B5c/d/e shape)
* doesn't need its own border refactor
* - staggered fade-in via inline animationDelay so the cards sweep in
* left-to-right, top-to-bottom on first render
* - hover-lift on desktop (`md:hover:-translate-y-0.5`,
* `md:hover:shadow-lg`)
*
* The grid is unmounted entirely when `recs` is empty so the parent
* doesn't have to gate every call site.
*/
interface RecommendationsGridProps {
recs: InsightRecommendation[];
}
const SEVERITY_RANK: Record<string, number> = {
urgent: 0,
important: 1,
suggestion: 2,
info: 3,
};
const SEVERITY_RANK_FALLBACK = 4;
/**
* Stable sort by severity priority. Plain-string recs and recs missing
* a severity field fall to the bottom but keep their relative order
* (the wrapping `<RecommendationCard>` still renders them — just under
* the actionable severities).
*/
export function sortRecommendationsBySeverity(
recs: InsightRecommendation[],
): InsightRecommendation[] {
return recs
.map((rec, index) => {
const severity = typeof rec === "string" ? null : rec.severity;
const rank =
severity && severity in SEVERITY_RANK
? SEVERITY_RANK[severity]
: SEVERITY_RANK_FALLBACK;
return { rec, index, rank };
})
.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
return a.index - b.index;
})
.map(({ rec }) => rec);
}
const SEVERITY_BORDER_CLASSES: Record<string, string> = {
urgent: "border-l-dracula-red/70",
important: "border-l-dracula-orange/70",
suggestion: "border-l-dracula-purple/70",
info: "border-l-dracula-cyan/70",
};
const SEVERITY_BORDER_FALLBACK = "border-l-border";
/**
* Stagger interval, in ms. Brief asks for 100ms — chosen so a 4-card
* grid finishes the cascade in ~400ms (matches the existing
* `animate-insight-in` 400ms keyframe, so by the time the last card
* starts animating, the first has finished). Globally muted by
* `prefers-reduced-motion: reduce` via the existing media query in
* `globals.css`.
*/
const STAGGER_INTERVAL_MS = 100;
export function RecommendationsGrid({ recs }: RecommendationsGridProps) {
if (recs.length === 0) return null;
const ordered = sortRecommendationsBySeverity(recs);
return (
<div
data-slot="rec-grid"
role="list"
className="grid grid-cols-1 gap-3 lg:grid-cols-2"
>
{ordered.map((rec, index) => {
const severity = typeof rec === "string" ? null : rec.severity;
const borderClass =
(severity && SEVERITY_BORDER_CLASSES[severity]) ||
SEVERITY_BORDER_FALLBACK;
return (
<div
key={typeof rec === "string" ? `${index}-${rec}` : (rec.id ?? index)}
data-stagger-index={index}
role="listitem"
className={`animate-insight-in transition-all md:hover:-translate-y-0.5 md:hover:shadow-lg ${borderClass} rounded-lg border-l-2`}
style={{ animationDelay: `${index * STAGGER_INTERVAL_MS}ms` }}
>
<RecommendationCard rec={rec} index={index} />
</div>
);
})}
</div>
);
}