mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user