feat(targets): card layout with status pill + consistency + coach handoff

Replaces the inline 790-line page.tsx composition with the W3e
primitives extracted in the previous phase. The page is now ~270
lines of orchestration:

- TanStack-Query pulls the redesigned targets payload (with the
  v1.4.25 consistency + summary additions).
- A second query reads /api/insights/provider-chain to derive
  `aiEnabled = chainStatus?.activeProvider != null`. The flag threads
  down into every <TargetCard>, which uses it to gate the per-card
  Coach CTA — no broken-button state when the user has no provider
  configured.
- A new `useCoachHandoff()` hook (src/hooks/use-coach-handoff.ts)
  owns the drawer's open + prefill + scope triple. Per-card buttons
  call `askCoach()`; the hook flips state and the drawer mounts
  inline at the page level.
- Cards now render in a fixed order (BP / BP-in-target / WEIGHT /
  PULSE / BMI / MOOD / COMPLIANCE / SLEEP / BODY_FAT / STEPS) — Marc
  directive: stable visual hierarchy beats status-sorting because
  the user's eye lands on the same metric in the same place every
  visit.
- Source-citation URLs stay on the page (declarative routing) and
  thread into <TargetCard> via the `sourceLink` prop.

The old inline TargetCard / RangeBar / TrendIcon / status-category
map are deleted along with the dead `Badge` + lucide imports.
Existing targets-i18n + targets-spacing tests updated to mock the
new useQueryClient + useMutation surfaces that the embedded
<CoachDrawer> needs at SSR; both suites still assert the same
visual contracts and pass.

Wave 2 deliverables also folded into this commit:
- pageSummary mounts via the W3e header above the grid
- consistency strip renders only when insufficientData === false
- recency caption ("last met goal: 2 days ago") renders only when
  lastMetGoalAt !== null
- streak chip ("4-day streak") renders only when streakDays ≥ 3
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:33:53 +02:00
1 parent 96c5e79539
commit f2b9f843b1
9 files changed
+269 -606

No files matched your search

+1 -3
View File
@@ -122,9 +122,7 @@ export function ConsistencyStrip({
data-band={band ?? "none"}
className={cn(
"size-2.5 rounded-full border transition-colors",
band
? BAND_STYLES[band]
: "border-border bg-transparent",
band ? BAND_STYLES[band] : "border-border bg-transparent",
)}
/>
);
+9 -10
View File
@@ -161,9 +161,7 @@ function translateStatus(
* middle, which paints the pill in the neutral amber tone rather than
* lighting up a false-positive green/red.
*/
function statusGroupForCategory(
category: string,
): "in" | "near" | "out" {
function statusGroupForCategory(category: string): "in" | "near" | "out" {
const greenCategories = new Set([
"Normal",
"Optimal",
@@ -370,11 +368,12 @@ export function TargetCard({
target.consistency7d != null &&
target.consistency7d.length > 0;
const showLastMet =
target.lastMetGoalAt != null && !target.insufficientData;
const showLastMet = target.lastMetGoalAt != null && !target.insufficientData;
const showStreak =
target.streakDays != null && target.streakDays >= 3 && !target.insufficientData;
target.streakDays != null &&
target.streakDays >= 3 &&
!target.insufficientData;
// Coach prefill is built from the live state of the card; the helper
// file owns the per-metric template + locale resolution.
@@ -409,7 +408,7 @@ export function TargetCard({
aria-hidden="true"
/>
<h3
className="text-muted-foreground text-[0.6875rem] font-medium uppercase tracking-[0.08em]"
className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.08em] uppercase"
data-slot="target-card-title"
>
{titleLabel}
@@ -456,7 +455,7 @@ export function TargetCard({
<div className="flex items-baseline gap-2">
{moodStabilityValue && moodStabilityCopy ? (
<span
className="text-foreground text-2xl font-semibold leading-none sm:text-3xl"
className="text-foreground text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
data-mood-stability={moodStabilityValue}
>
@@ -464,7 +463,7 @@ export function TargetCard({
</span>
) : isBp && bpDiastolic?.current != null ? (
<span
className="text-foreground flex items-baseline gap-1 text-2xl font-semibold leading-none sm:text-3xl"
className="text-foreground flex items-baseline gap-1 text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
>
<span>{Math.round(target.current)}</span>
@@ -473,7 +472,7 @@ export function TargetCard({
</span>
) : (
<span
className="text-foreground text-2xl font-semibold leading-none sm:text-3xl"
className="text-foreground text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
>
{target.type === "BODY_FAT"
@@ -4,10 +4,7 @@ import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type {
CoachScope,
CoachScopeSource,
} from "@/lib/ai/coach/types";
import type { CoachScope, CoachScopeSource } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — per-card "Ask Coach about this" CTA.
@@ -67,7 +67,7 @@ export function TargetsSummaryHeader({
<div className="flex items-center gap-2">
{allMet && (
<CheckCircle2
className="text-[var(--dracula-green)] size-4 shrink-0"
className="size-4 shrink-0 text-[var(--dracula-green)]"
aria-hidden="true"
/>
)}