From 9e8be4b359ba23622fe5ffb2d4124f2a71ede96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:59:18 +0200 Subject: [PATCH] feat(insights): recommendations grid with severity ordering + animated reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat
    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) --- .../__tests__/recommendations-grid.test.tsx | 178 ++++++++++++++++++ .../insights/insight-advisor-card.tsx | 21 +-- .../insights/recommendation-card.tsx | 4 +- .../insights/recommendations-grid.tsx | 111 +++++++++++ 4 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 src/components/insights/__tests__/recommendations-grid.test.tsx create mode 100644 src/components/insights/recommendations-grid.tsx 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 ( -
  1. )} - + ); } 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 ( +
    + +
    + ); + })} +
    + ); +}