mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(targets): page-level summary header
Adds a one-line summary above the card grid that surfaces the
account-level state at a glance:
"4 of 6 targets met this week" / "All 6 targets met this week ✓"
When every target hits the bar we surface a calm green check mark; no
animation, no celebration. Per Marc's "muss nicht so sein wie die KI
Sache" — the summary stays scannable and quieter than the v1.4.20
Insights hero strip.
A streak highlight chip ("5-day streak: Blood pressure") renders to
the right when one metric carries a current ≥ 3 day streak. Resolves
via the existing targets.label.{TYPE} i18n map so the metric name
respects the user's locale.
The new <TargetsSummaryHeader> primitive lives under
src/components/targets/ alongside the rest of the W3e extraction.
4 tests cover partial / all-met / streak / empty branches.
This commit is contained in:
1 parent
97583cf891
commit
96c5e79539
3 files changed
+166
No files matched your search
@@ -27,6 +27,7 @@ import {
|
||||
Droplet,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header";
|
||||
|
||||
interface TargetData {
|
||||
type: string;
|
||||
@@ -699,6 +700,17 @@ export default function TargetsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* v1.4.25 W3e — page-level summary line. Renders nothing when
|
||||
the API hasn't shipped pageSummary yet (older clients during
|
||||
rollout / mocked test fixtures that pre-date this addition). */}
|
||||
{data.pageSummary && (
|
||||
<TargetsSummaryHeader
|
||||
targetsMetThisWeek={data.pageSummary.targetsMetThisWeek}
|
||||
totalTargets={data.pageSummary.totalTargets}
|
||||
streakHighlight={data.pageSummary.streakHighlight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Target cards grid */}
|
||||
{visibleTargets.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TargetsSummaryHeader } from "../targets-summary-header";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — page-level summary line. Three load-bearing cases:
|
||||
* 1. Partial: "4 of 6 targets met this week"
|
||||
* 2. Full: "All 6 targets met this week" + check mark
|
||||
* 3. With streak highlight chip
|
||||
*/
|
||||
|
||||
function render(props: Parameters<typeof TargetsSummaryHeader>[0]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TargetsSummaryHeader {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TargetsSummaryHeader>", () => {
|
||||
it("renders the partial-count headline when met < total", () => {
|
||||
const html = render({
|
||||
targetsMetThisWeek: 4,
|
||||
totalTargets: 6,
|
||||
streakHighlight: null,
|
||||
});
|
||||
expect(html).toContain('data-slot="targets-summary-title"');
|
||||
expect(html).toContain("4 of 6 targets met this week");
|
||||
// Check mark should NOT render in the partial state.
|
||||
expect(html).not.toContain("lucide-check");
|
||||
});
|
||||
|
||||
it("renders the all-met headline + check mark when every target is met", () => {
|
||||
const html = render({
|
||||
targetsMetThisWeek: 6,
|
||||
totalTargets: 6,
|
||||
streakHighlight: null,
|
||||
});
|
||||
expect(html).toContain("All 6 targets met this week");
|
||||
expect(html).toContain("text-[var(--dracula-green)]");
|
||||
});
|
||||
|
||||
it("renders the streak highlight chip when present", () => {
|
||||
const html = render({
|
||||
targetsMetThisWeek: 4,
|
||||
totalTargets: 6,
|
||||
streakHighlight: { metric: "BLOOD_PRESSURE", days: 5 },
|
||||
});
|
||||
expect(html).toContain('data-slot="targets-summary-streak"');
|
||||
expect(html).toContain("5-day streak: Blood pressure");
|
||||
});
|
||||
|
||||
it("renders nothing when there are no targets at all", () => {
|
||||
const html = render({
|
||||
targetsMetThisWeek: 0,
|
||||
totalTargets: 0,
|
||||
streakHighlight: null,
|
||||
});
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — page-level summary line.
|
||||
*
|
||||
* "4 of 6 targets met this week" (DE: "4 von 6 Zielwerten diese Woche
|
||||
* erreicht"). When every target hits the bar we surface a calm check
|
||||
* mark + "All N targets met this week" copy — no animation, no
|
||||
* celebration. Marc directive: stay quieter than the v1.4.20 Insights
|
||||
* hero.
|
||||
*
|
||||
* Optional streak highlight chip when one metric has a current ≥ 3 day
|
||||
* streak; the metric label is resolved from the targets.label.* i18n
|
||||
* map so it renders in the user's locale.
|
||||
*/
|
||||
export interface TargetsSummaryHeaderProps {
|
||||
targetsMetThisWeek: number;
|
||||
totalTargets: number;
|
||||
streakHighlight: { metric: string; days: number } | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TargetsSummaryHeader({
|
||||
targetsMetThisWeek,
|
||||
totalTargets,
|
||||
streakHighlight,
|
||||
className,
|
||||
}: TargetsSummaryHeaderProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (totalTargets === 0) return null;
|
||||
|
||||
const allMet = targetsMetThisWeek === totalTargets;
|
||||
const headline = allMet
|
||||
? t("targets.summary.weekTitleAll", { total: String(totalTargets) })
|
||||
: t("targets.summary.weekTitle", {
|
||||
met: String(targetsMetThisWeek),
|
||||
total: String(totalTargets),
|
||||
});
|
||||
|
||||
let highlightCopy: string | null = null;
|
||||
if (streakHighlight && streakHighlight.days >= 3) {
|
||||
const metricLabel = t(`targets.label.${streakHighlight.metric}`);
|
||||
const resolvedMetric =
|
||||
metricLabel === `targets.label.${streakHighlight.metric}`
|
||||
? streakHighlight.metric
|
||||
: metricLabel;
|
||||
highlightCopy = t("targets.summary.streakHighlight", {
|
||||
count: String(streakHighlight.days),
|
||||
metric: resolvedMetric,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="targets-summary-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{allMet && (
|
||||
<CheckCircle2
|
||||
className="text-[var(--dracula-green)] size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="targets-summary-title"
|
||||
>
|
||||
{headline}
|
||||
</p>
|
||||
</div>
|
||||
{highlightCopy && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs sm:before:mx-1 sm:before:content-['·']"
|
||||
data-slot="targets-summary-streak"
|
||||
>
|
||||
{highlightCopy}
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user