mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(daily): add the daily-digest spine and priority-card primitive
Introduce the two foundational primitives of the unified daily-value system, with no user surface yet: - PriorityItem: the one wire-serialisable model behind every "worth a look" rail item, coach check-in, milestone, and device-verdict pointer. Its Lucide glyph is derived from the closed `kind` enum in the renderer so the model itself stays JSON-serialisable across the API and future native-client boundaries. - PriorityCard: a calm, token-only, reduced-motion-aware card composed entirely from the shipped Card / TileHeader / ProseBlocks primitives — no new visual language, semantic-token status wash only, no markdown. - buildDailyDigest: one pure, deterministic server composer that assembles the day's read from data that already exists — the nightly briefing lifted read-only from the insights cache plus the dashboard-snapshot health score, meds-today, and sleep freshness. It computes no analytics and never reaches an AI provider. It emits the DailyDigest DTO the Today surface, the daily push, and a future iOS widget will all consume. Deterministic item builders cover overdue doses, broken integrations, and due Vorsorge reminders; the push line prefers the cached briefing lead with a deterministic score floor so a keyless self-hoster still gets a first-class line. The read seam GET /api/daily/digest gathers the cached inputs, gates on the insights module (cookie or Bearer; userId from the session), and returns the digest. OpenAPI regenerated; the route is covered by the module gate inventory. Digest strings ship in all six locales.
This commit is contained in:
1 parent
ecc34880ba
commit
ba6c0990bf
18 files changed
+1580
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { PriorityCard } from "../priority-card";
|
||||
import {
|
||||
PRIORITY_ITEM_KINDS,
|
||||
type PriorityItem,
|
||||
type PriorityItemKind,
|
||||
} from "@/lib/daily/priority-item";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function item(
|
||||
over: Partial<PriorityItem> & { kind: PriorityItemKind },
|
||||
): PriorityItem {
|
||||
return {
|
||||
title: "Something to look at",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.reconnect",
|
||||
intent: "sync.reconnect",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<PriorityCard>", () => {
|
||||
it("renders every kind of the discriminated union without throwing", () => {
|
||||
for (const kind of PRIORITY_ITEM_KINDS) {
|
||||
const html = render(<PriorityCard item={item({ kind })} />);
|
||||
expect(html).toContain(`data-kind="${kind}"`);
|
||||
expect(html).toContain("Something to look at");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the grounded body one-liner", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({ kind: "dose_window", body: "Ramipril is due today." })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Ramipril is due today.");
|
||||
});
|
||||
|
||||
it("applies a semantic status wash (meaning, not decoration)", () => {
|
||||
const warn = render(
|
||||
<PriorityCard item={item({ kind: "sync_issue", status: "warning" })} />,
|
||||
);
|
||||
expect(warn).toContain("bg-warning/10");
|
||||
const info = render(
|
||||
<PriorityCard item={item({ kind: "preventive_care", status: "info" })} />,
|
||||
);
|
||||
expect(info).toContain("bg-info/10");
|
||||
});
|
||||
|
||||
it("renders a navigation action as a link carrying the href", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "sync_issue",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.reconnect",
|
||||
intent: "sync.reconnect",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
expect(html).toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("renders a non-navigation action as a button (no href)", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "coach_checkin",
|
||||
actions: [{ labelKey: "daily.action.logDose", intent: "dose.log" }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("<button");
|
||||
expect(html).toContain("Log dose");
|
||||
});
|
||||
|
||||
it("resolves action label keys through the active locale", () => {
|
||||
const html = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "preventive_care",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("View check-ups");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* P1 — `PriorityCard`, the ONE card behind every "worth a look" rail item.
|
||||
*
|
||||
* It renders a `PriorityItem` and never fetches. Every later consumer reuses
|
||||
* it verbatim: the Today rail (S2), a coach check-in (S3), a milestone
|
||||
* celebration (S12), a new-ECG pointer (S10), a tension window (S11). A new
|
||||
* rail type is a new `kind` on the item + a server builder — never a new
|
||||
* component (§1.2 P1).
|
||||
*
|
||||
* Design: composed ENTIRELY from shipped primitives — `Card` (compact
|
||||
* density) + `TileHeader` (size `sm`, foreground icon) + `ProseBlocks` for the
|
||||
* grounded one-liner, semantic-token status wash only, no raw palette colour
|
||||
* (the `no-raw-palette-color` rule errors), no markdown. The Lucide glyph is
|
||||
* derived from `kind` so the wire DTO stays serialisable (the icon lives with
|
||||
* the renderer, the closed enum with the model). The `animate-insight-in`
|
||||
* reveal carries its own reduced-motion fallback in `globals.css`.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
Award,
|
||||
CalendarClock,
|
||||
HeartPulse,
|
||||
MessageCircle,
|
||||
Pill,
|
||||
RefreshCw,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
PriorityItem,
|
||||
PriorityItemAction,
|
||||
PriorityItemKind,
|
||||
PriorityItemStatus,
|
||||
} from "@/lib/daily/priority-item";
|
||||
|
||||
/** Deterministic glyph per kind — the icon the serialisable item cannot hold. */
|
||||
const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
|
||||
dose_window: Pill,
|
||||
preventive_care: CalendarClock,
|
||||
sync_issue: RefreshCw,
|
||||
coach_checkin: MessageCircle,
|
||||
milestone: Award,
|
||||
ecg_new_recording: HeartPulse,
|
||||
tension_window: Activity,
|
||||
};
|
||||
|
||||
/**
|
||||
* Status wash — a subtle semantic-token tint carrying MEANING, not decoration
|
||||
* (the `recommendation-card` pattern). Static classes so Tailwind sees them.
|
||||
*/
|
||||
const STATUS_WASH: Record<PriorityItemStatus, string> = {
|
||||
success: "bg-success/10 border-success/25",
|
||||
warning: "bg-warning/10 border-warning/25",
|
||||
info: "bg-info/10 border-info/25",
|
||||
destructive: "bg-destructive/10 border-destructive/25",
|
||||
};
|
||||
|
||||
export interface PriorityCardProps {
|
||||
item: PriorityItem;
|
||||
/**
|
||||
* Tap handler for non-navigation actions. Receives the action's stable
|
||||
* `intent` token. Navigation actions (those carrying an `href`) render as
|
||||
* links and do not call this.
|
||||
*/
|
||||
onAction?: (intent: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Tap-target floor per P1: `min-h-11` on touch, `min-h-9` on pointer. */
|
||||
const ACTION_SIZE = "min-h-11 sm:min-h-9";
|
||||
|
||||
export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const Icon = KIND_ICON[item.kind];
|
||||
const actions = item.actions.slice(0, 3);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="priority-card"
|
||||
data-kind={item.kind}
|
||||
className={cn(
|
||||
"animate-insight-in gap-2 py-3 md:gap-2 md:py-4",
|
||||
item.status ? STATUS_WASH[item.status] : null,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<TileHeader icon={Icon} title={item.title} size="sm" />
|
||||
{item.body ? (
|
||||
<div className="text-foreground text-sm">
|
||||
<ProseBlocks text={item.body} strip={false} linkify={false} />
|
||||
</div>
|
||||
) : null}
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{actions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.intent}
|
||||
action={action}
|
||||
label={t(action.labelKey)}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
action,
|
||||
label,
|
||||
onAction,
|
||||
}: {
|
||||
action: PriorityItemAction;
|
||||
label: string;
|
||||
onAction?: (intent: string) => void;
|
||||
}) {
|
||||
if (action.href) {
|
||||
return (
|
||||
<Link
|
||||
href={action.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
ACTION_SIZE,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={ACTION_SIZE}
|
||||
onClick={() => onAction?.(action.intent)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user