diff --git a/src/components/insights/__tests__/recommendations-grid.test.tsx b/src/components/insights/__tests__/recommendations-grid.test.tsx
new file mode 100644
index 000000000..65701ec03
--- /dev/null
+++ b/src/components/insights/__tests__/recommendations-grid.test.tsx
@@ -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: () =>
,
+}));
+vi.mock("@/components/charts/mood-chart", () => ({
+ MoodChart: () => ,
+}));
+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(
+ {node},
+ );
+}
+
+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("", () => {
+ it("renders cards in a 2-col responsive grid", () => {
+ const recs = [
+ rec("a", "info", "alpha"),
+ rec("b", "urgent", "bravo"),
+ ];
+ const html = render();
+ 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();
+ // 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();
+ // 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();
+ 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();
+ // 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"/);
+ });
+});
diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx
index 2433a4511..29a3ea29f 100644
--- a/src/components/insights/insight-advisor-card.tsx
+++ b/src/components/insights/insight-advisor-card.tsx
@@ -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({
)}
- {/* 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 . 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 && (
-
- {insight.recommendations.map((rec, i) => (
-
- ))}
-
+
)}
diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx
index b513d2076..b63aaa042 100644
--- a/src/components/insights/recommendation-card.tsx
+++ b/src/components/insights/recommendation-card.tsx
@@ -329,7 +329,7 @@ export function RecommendationCard({
: null;
return (
-
)}
-
+
);
}
diff --git a/src/components/insights/recommendations-grid.tsx b/src/components/insights/recommendations-grid.tsx
new file mode 100644
index 000000000..091850929
--- /dev/null
+++ b/src/components/insights/recommendations-grid.tsx
@@ -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 ``:
+ * - 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 ``
+ * wrapper so the underlying `
` (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 = {
+ 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 `` 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 = {
+ 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 (
+
+ {ordered.map((rec, index) => {
+ const severity = typeof rec === "string" ? null : rec.severity;
+ const borderClass =
+ (severity && SEVERITY_BORDER_CLASSES[severity]) ||
+ SEVERITY_BORDER_FALLBACK;
+ return (
+
+
+
+ );
+ })}
+
+ );
+}