mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
96c5e79539
commit
f2b9f843b1
9 files changed
+269
-606
No files matched your search
@@ -82,14 +82,44 @@ const sampleData = {
|
||||
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
// v1.4.25 W3e — the targets page now also queries
|
||||
// ["insights", "provider-chain"] to gate the per-card Coach
|
||||
// CTA. Return null for that query (no AI provider configured)
|
||||
// so the CTA hides and the existing test cases keep asserting
|
||||
// the unchanged visual surface.
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
|
||||
@@ -14,18 +14,48 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
targets: [],
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: { heightCm: null, age: null, gender: null, glucoseUnit: null },
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
targets: [],
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: {
|
||||
heightCm: null,
|
||||
age: null,
|
||||
gender: null,
|
||||
glucoseUnit: null,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
|
||||
+124
-565
@@ -1,64 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Scale,
|
||||
Heart,
|
||||
Activity,
|
||||
Moon,
|
||||
Percent,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Smile,
|
||||
Droplet,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
TargetCard,
|
||||
type TargetCardData,
|
||||
} from "@/components/targets/target-card";
|
||||
import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header";
|
||||
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
||||
import { useCoachHandoff } from "@/hooks/use-coach-handoff";
|
||||
|
||||
interface TargetData {
|
||||
type: string;
|
||||
label: string;
|
||||
current: number | null;
|
||||
average30: number | null;
|
||||
trend: "up" | "down" | "stable" | null;
|
||||
unit: string;
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
// v1.4.25 W3e — consistency strip + recency + streak. Optional on the
|
||||
// client so the i18n / spacing tests that only pre-populate
|
||||
// classification + range still type-check; the runtime route always
|
||||
// sets these.
|
||||
daysInRange7d?: number;
|
||||
daysLogged7d?: number;
|
||||
daysInRange30d?: number;
|
||||
daysLogged30d?: number;
|
||||
lastMetGoalAt?: string | null;
|
||||
streakDays?: number;
|
||||
insufficientData?: boolean;
|
||||
consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
type TargetData = TargetCardData;
|
||||
|
||||
interface BpDiastolic {
|
||||
current: number | null;
|
||||
@@ -84,37 +42,22 @@ interface TargetsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, typeof Scale> = {
|
||||
WEIGHT: Scale,
|
||||
BLOOD_PRESSURE: Heart,
|
||||
BLOOD_PRESSURE_IN_TARGET: Heart,
|
||||
PULSE: Activity,
|
||||
SLEEP_DURATION: Moon,
|
||||
BODY_FAT: Percent,
|
||||
BMI: Scale,
|
||||
MOOD_SCORE: Smile,
|
||||
MOOD_STABILITY: Smile,
|
||||
BLOOD_GLUCOSE_FASTING: Droplet,
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: Droplet,
|
||||
BLOOD_GLUCOSE_RANDOM: Droplet,
|
||||
BLOOD_GLUCOSE_BEDTIME: Droplet,
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
WEIGHT: "text-dracula-purple",
|
||||
BLOOD_PRESSURE: "text-dracula-pink",
|
||||
BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink",
|
||||
PULSE: "text-dracula-green",
|
||||
SLEEP_DURATION: "text-dracula-cyan",
|
||||
BODY_FAT: "text-dracula-orange",
|
||||
BMI: "text-dracula-yellow",
|
||||
MOOD_SCORE: "text-dracula-lavender",
|
||||
MOOD_STABILITY: "text-dracula-lavender",
|
||||
BLOOD_GLUCOSE_FASTING: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_RANDOM: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
|
||||
};
|
||||
/**
|
||||
* v1.4.25 W3e — provider-chain status used to gate the per-card Coach
|
||||
* CTA. Returns true when at least one provider is configured AND
|
||||
* enabled. The Settings → AI surface owns the same query
|
||||
* (`useQuery({ queryKey: ["insights", "provider-chain"] })`) so the
|
||||
* cache is shared.
|
||||
*/
|
||||
interface ProviderChainStatus {
|
||||
activeProvider: string | null;
|
||||
cachedActiveProvider: string | null;
|
||||
configuredChain: Array<{
|
||||
providerType: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
const GLUCOSE_TYPES = new Set([
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
@@ -124,115 +67,45 @@ const GLUCOSE_TYPES = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Map server-emitted classification.category strings to the i18n
|
||||
* key under `targets.status.*`. The server is the source of truth
|
||||
* for the category set (lib/analytics/classifications.ts +
|
||||
* pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
|
||||
* A7 wires this map so the German locale stops surfacing English
|
||||
* status pills like "Low / On Target / Stable / Moderate" — the
|
||||
* exact regression the maintainer reported.
|
||||
* Fixed card order per Marc directive — explicitly NOT status-sorted.
|
||||
* Marc wants a stable visual hierarchy so the user's eye lands on the
|
||||
* same metric in the same place every visit. Cards whose type is not
|
||||
* in this list (glucose contexts, future metrics) sort after the
|
||||
* core six in their server-emitted order.
|
||||
*/
|
||||
const STATUS_CATEGORY_KEY: Record<string, string> = {
|
||||
// BMI
|
||||
Underweight: "underweight",
|
||||
Normal: "normal",
|
||||
Overweight: "overweight",
|
||||
"Obesity Grade I": "obesityGrade1",
|
||||
"Obesity Grade II": "obesityGrade2",
|
||||
"Obesity Grade III": "obesityGrade3",
|
||||
// BP
|
||||
Optimal: "optimal",
|
||||
"High-normal": "highNormal",
|
||||
"Hypertension Grade 1": "hypertensionGrade1",
|
||||
"Hypertension Grade 2": "hypertensionGrade2",
|
||||
"Hypertension Grade 3": "hypertensionGrade3",
|
||||
// Pulse
|
||||
Bradycardia: "bradycardia",
|
||||
Elevated: "elevated",
|
||||
Tachycardia: "tachycardia",
|
||||
// Pulse target / sleep target
|
||||
"Significantly low": "significantlyLow",
|
||||
"Slightly low": "slightlyLow",
|
||||
"On target": "onTarget",
|
||||
"Slightly elevated": "slightlyElevated",
|
||||
"Significantly elevated": "significantlyElevated",
|
||||
// Sleep
|
||||
"Far too short": "farTooShort",
|
||||
"Too short": "tooShort",
|
||||
"Slightly long": "slightlyLong",
|
||||
"Far too long": "farTooLong",
|
||||
// Body fat
|
||||
"Below essential": "belowEssential",
|
||||
Essential: "essential",
|
||||
Athletic: "athletic",
|
||||
Fitness: "fitness",
|
||||
Acceptable: "acceptable",
|
||||
Obese: "obese",
|
||||
// Steps
|
||||
"Very low": "veryLow",
|
||||
"Low active": "lowActive",
|
||||
"Moderately active": "moderatelyActive",
|
||||
Active: "active",
|
||||
"Very active": "veryActive",
|
||||
// Generic / shared (BP-on-target, mood, medication compliance, glucose)
|
||||
Good: "good",
|
||||
Moderate: "moderate",
|
||||
Low: "low",
|
||||
High: "high",
|
||||
"Very good": "veryGood",
|
||||
"Very stable": "veryStable",
|
||||
Stable: "stable",
|
||||
Fluctuating: "fluctuating",
|
||||
const FIXED_TARGET_ORDER: Record<string, number> = {
|
||||
BLOOD_PRESSURE: 0,
|
||||
BLOOD_PRESSURE_IN_TARGET: 1,
|
||||
WEIGHT: 2,
|
||||
PULSE: 3,
|
||||
BMI: 4,
|
||||
MOOD_SCORE: 5,
|
||||
MOOD_STABILITY: 6,
|
||||
MEDICATION_COMPLIANCE: 7,
|
||||
SLEEP_DURATION: 8,
|
||||
BODY_FAT: 9,
|
||||
ACTIVITY_STEPS: 10,
|
||||
};
|
||||
|
||||
/** Resolve a server-emitted classification.category to its translated label. */
|
||||
function translateStatus(
|
||||
category: string,
|
||||
t: (key: string, vars?: Record<string, string>) => string,
|
||||
): string {
|
||||
const key = STATUS_CATEGORY_KEY[category];
|
||||
if (!key) return category; // unmapped — fall back to verbatim string
|
||||
return t(`targets.status.${key}`);
|
||||
function sortKey(target: TargetData): number {
|
||||
return FIXED_TARGET_ORDER[target.type] ?? 100;
|
||||
}
|
||||
|
||||
/** Resolve a target's display label.
|
||||
*
|
||||
* Glucose contexts are special-cased: the server emits an i18n key
|
||||
* (e.g. `targets.glucoseFasting`) as its `label`, and the page's
|
||||
* top-level `visibleTargets` mapping resolves that to a localised
|
||||
* string before the card mounts. So when this helper runs, glucose
|
||||
* targets already carry the localised label and the i18n lookup
|
||||
* below misses (no `targets.label.BLOOD_GLUCOSE_FASTING` key) — the
|
||||
* fallback returns the verbatim resolved label, which is correct.
|
||||
*
|
||||
* Every other target type carries a stable English label from the
|
||||
* server. Look it up under `targets.label.<TYPE>` here so the card
|
||||
* title respects the user's locale.
|
||||
/**
|
||||
* Source-citation URL for each target type. Kept on the page (rather
|
||||
* than the card) so the card stays presentational and the routing
|
||||
* stays declarative.
|
||||
*/
|
||||
function translateTargetLabel(
|
||||
target: TargetData,
|
||||
t: (key: string, vars?: Record<string, string>) => string,
|
||||
): string {
|
||||
const i18nKey = `targets.label.${target.type}`;
|
||||
const localised = t(i18nKey);
|
||||
if (localised && localised !== i18nKey) {
|
||||
return localised;
|
||||
}
|
||||
return target.label;
|
||||
}
|
||||
|
||||
function getTargetSourceLink(target: TargetData): string | null {
|
||||
if (target.type === "WEIGHT" || target.type === "BMI") {
|
||||
return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
|
||||
}
|
||||
|
||||
if (target.type === "BLOOD_PRESSURE") {
|
||||
if (
|
||||
target.type === "BLOOD_PRESSURE" ||
|
||||
target.type === "BLOOD_PRESSURE_IN_TARGET"
|
||||
) {
|
||||
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
|
||||
}
|
||||
if (target.type === "BLOOD_PRESSURE_IN_TARGET") {
|
||||
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
|
||||
}
|
||||
|
||||
if (target.type === "PULSE") {
|
||||
if (target.source.includes("CDC/NCHS")) {
|
||||
return "https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf";
|
||||
@@ -241,374 +114,18 @@ function getTargetSourceLink(target: TargetData): string | null {
|
||||
return "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse";
|
||||
}
|
||||
}
|
||||
|
||||
if (target.type === "SLEEP_DURATION") {
|
||||
return "https://aasm.org/seven-or-more-hours-of-sleep-per-night-a-health-necessity-for-adults/";
|
||||
}
|
||||
|
||||
if (target.type === "BODY_FAT") {
|
||||
return "https://www.acefitness.org/resources/everyone/blog/6596/what-are-the-guidelines-for-percentage-of-body-fat-loss/";
|
||||
}
|
||||
|
||||
if (target.type === "ACTIVITY_STEPS") {
|
||||
return "https://www.who.int/publications/i/item/9789240015128";
|
||||
}
|
||||
|
||||
if (target.type === "MOOD_SCORE" || target.type === "MOOD_STABILITY") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
|
||||
if (trend === "up") {
|
||||
return <TrendingUp className="h-4 w-4 text-orange-400" />;
|
||||
}
|
||||
if (trend === "down") {
|
||||
return <TrendingDown className="h-4 w-4 text-cyan-400" />;
|
||||
}
|
||||
if (trend === "stable") {
|
||||
return <Minus className="h-4 w-4 text-green-400" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A horizontal range bar with green/yellow/red zones showing where
|
||||
* the current value falls relative to a target range.
|
||||
*/
|
||||
function RangeBar({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
unit,
|
||||
orangeMin,
|
||||
orangeMax,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
unit: string;
|
||||
orangeMin?: number;
|
||||
orangeMax?: number;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const span = max - min;
|
||||
const defaultOrangeWidth = span * 0.3;
|
||||
const computedOrangeMin = min - defaultOrangeWidth;
|
||||
const computedOrangeMax = max + defaultOrangeWidth;
|
||||
const effectiveOrangeMin =
|
||||
orangeMin != null ? Math.min(orangeMin, min) : computedOrangeMin;
|
||||
const effectiveOrangeMax =
|
||||
orangeMax != null ? Math.max(orangeMax, max) : computedOrangeMax;
|
||||
|
||||
const orangeSpan = Math.max(1, effectiveOrangeMax - effectiveOrangeMin);
|
||||
const sidePadding = Math.max(1, orangeSpan * 0.18);
|
||||
const visualMin = effectiveOrangeMin - sidePadding;
|
||||
const visualMax = effectiveOrangeMax + sidePadding;
|
||||
const visualSpan = visualMax - visualMin;
|
||||
const clampedValue = Math.max(visualMin, Math.min(visualMax, value));
|
||||
const rawPosition = ((clampedValue - visualMin) / visualSpan) * 100;
|
||||
const EDGE_PADDING_PERCENT = 4;
|
||||
const position = Math.max(
|
||||
EDGE_PADDING_PERCENT,
|
||||
Math.min(100 - EDGE_PADDING_PERCENT, rawPosition),
|
||||
);
|
||||
|
||||
// Zone boundaries (percent of visual bar)
|
||||
const greenStart = Math.max(0, ((min - visualMin) / visualSpan) * 100);
|
||||
const greenEnd = Math.min(100, ((max - visualMin) / visualSpan) * 100);
|
||||
const yellowLeftStart = Math.max(
|
||||
0,
|
||||
((effectiveOrangeMin - visualMin) / visualSpan) * 100,
|
||||
);
|
||||
const yellowRightEnd = Math.min(
|
||||
100,
|
||||
((effectiveOrangeMax - visualMin) / visualSpan) * 100,
|
||||
);
|
||||
|
||||
// Determine marker color
|
||||
const inGreen = value >= min && value <= max;
|
||||
const inYellow =
|
||||
!inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax;
|
||||
|
||||
const markerColor = inGreen
|
||||
? "var(--dracula-green)"
|
||||
: inYellow
|
||||
? "var(--dracula-orange)"
|
||||
: "var(--dracula-red)";
|
||||
const minLabelPosition = Math.max(5, Math.min(95, greenStart));
|
||||
const maxLabelPosition = Math.max(5, Math.min(95, greenEnd));
|
||||
|
||||
// Delta to target range
|
||||
const delta = value < min ? min - value : value > max ? value - max : 0;
|
||||
const deltaText =
|
||||
delta > 0
|
||||
? value < min
|
||||
? t("targets.belowTarget", { delta: delta.toFixed(1), unit })
|
||||
: t("targets.aboveTarget", { delta: delta.toFixed(1), unit })
|
||||
: t("targets.inTarget");
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="bg-muted/50 relative h-3 w-full overflow-hidden rounded-full">
|
||||
{/* Red background (full bar) */}
|
||||
<div className="absolute inset-0 rounded-full bg-red-500/8" />
|
||||
{/* Yellow zones */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-yellow-500/12"
|
||||
style={{
|
||||
left: `${yellowLeftStart}%`,
|
||||
width: `${greenStart - yellowLeftStart}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 h-full bg-yellow-500/12"
|
||||
style={{
|
||||
left: `${greenEnd}%`,
|
||||
width: `${yellowRightEnd - greenEnd}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Green zone */}
|
||||
<div
|
||||
className="absolute top-0 h-full bg-green-500/20"
|
||||
style={{
|
||||
left: `${greenStart}%`,
|
||||
width: `${greenEnd - greenStart}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Current value marker with tooltip */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 shadow-sm"
|
||||
style={{
|
||||
left: `${position}%`,
|
||||
backgroundColor: markerColor,
|
||||
borderColor: markerColor,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs font-medium">
|
||||
{t("targets.currentValue", { value: String(value), unit })}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{t("targets.targetRangeValue", {
|
||||
min: String(min),
|
||||
max: String(max),
|
||||
unit,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-medium">{deltaText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="text-muted-foreground relative h-4 text-xs">
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${minLabelPosition}%` }}
|
||||
>
|
||||
{min} {unit}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${maxLabelPosition}%` }}
|
||||
>
|
||||
{max} {unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetCard({
|
||||
target,
|
||||
bpDiastolic,
|
||||
}: {
|
||||
target: TargetData;
|
||||
bpDiastolic?: BpDiastolic;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const Icon = TYPE_ICONS[target.type] ?? Activity;
|
||||
const iconColor = TYPE_COLORS[target.type] ?? "text-primary";
|
||||
const isBp = target.type === "BLOOD_PRESSURE";
|
||||
const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE";
|
||||
const medicationBreakdown = target.details?.medications ?? [];
|
||||
const sourceLink = getTargetSourceLink(target);
|
||||
|
||||
const localisedLabel = translateTargetLabel(target, t);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${iconColor}`} />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{localisedLabel}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<TrendIcon trend={target.trend} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
{/* Current value */}
|
||||
{target.current != null ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
{isBp && bpDiastolic?.current != null ? (
|
||||
<>
|
||||
<span className="text-3xl font-bold">
|
||||
{Math.round(target.current)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-lg">/</span>
|
||||
<span className="text-2xl font-bold">
|
||||
{Math.round(bpDiastolic.current)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-3xl font-bold">
|
||||
{target.type === "BODY_FAT"
|
||||
? target.current.toFixed(1)
|
||||
: Math.round(target.current * 10) / 10}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{target.unit}
|
||||
</span>
|
||||
</div>
|
||||
{target.average30 != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("targets.average30d")}{" "}
|
||||
{isBp && bpDiastolic?.average30 != null
|
||||
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
|
||||
: Math.round(target.average30 * 10) / 10}{" "}
|
||||
{target.unit}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{t("targets.noMeasurementYet")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Range bar */}
|
||||
{target.range && target.current != null && (
|
||||
<RangeBar
|
||||
value={target.current}
|
||||
min={target.range.min}
|
||||
max={target.range.max}
|
||||
unit={target.unit}
|
||||
orangeMin={isMedicationCompliance ? 70 : undefined}
|
||||
orangeMax={isMedicationCompliance ? 100 : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BP diastolic range bar */}
|
||||
{isBp && bpDiastolic?.range && bpDiastolic.current != null && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("targets.diastolic")}
|
||||
</p>
|
||||
<RangeBar
|
||||
value={bpDiastolic.current}
|
||||
min={bpDiastolic.range.min}
|
||||
max={bpDiastolic.range.max}
|
||||
unit="mmHg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMedicationCompliance && medicationBreakdown.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("targets.compliancePerMedication")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{medicationBreakdown.map((medication) => (
|
||||
<div
|
||||
key={medication.name}
|
||||
className="text-muted-foreground flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="truncate">{medication.name}</span>
|
||||
<span className="shrink-0">
|
||||
{medication.compliance7.toFixed(1)}% (7T) ·{" "}
|
||||
{medication.compliance30.toFixed(1)}% (30T)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification + source */}
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
{target.classification ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
className="cursor-help"
|
||||
style={{
|
||||
backgroundColor: `${target.classification.color}20`,
|
||||
color: target.classification.color,
|
||||
}}
|
||||
>
|
||||
{translateStatus(target.classification.category, t)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{target.range && (
|
||||
<p className="text-xs">
|
||||
{t("targets.targetRangeValue", {
|
||||
min: String(target.range.min),
|
||||
max: String(target.range.max),
|
||||
unit: target.unit,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs">
|
||||
{t("targets.sourceLabel", { source: target.source })}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="invisible text-xs">placeholder</span>
|
||||
)}
|
||||
{target.type !== "MEDICATION_COMPLIANCE" ? (
|
||||
sourceLink ? (
|
||||
<a
|
||||
href={sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span>{target.source}</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{target.source}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TargetsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -624,6 +141,58 @@ export default function TargetsPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.4.25 W3e — share the cache with Settings → AI section. When the
|
||||
// user has no configured provider, `activeProvider` is null and the
|
||||
// per-card Coach CTA hides entirely (no broken-button state).
|
||||
const { data: chainStatus } = useQuery({
|
||||
queryKey: ["insights", "provider-chain"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/provider-chain");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as ProviderChainStatus;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const aiEnabled = chainStatus?.activeProvider != null;
|
||||
|
||||
// v1.4.25 W3e — Coach drawer state owned by the page. The per-card
|
||||
// CTA + summary header both feed the same drawer so the user only
|
||||
// ever sees one drawer instance.
|
||||
const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } =
|
||||
useCoachHandoff();
|
||||
|
||||
const visibleTargets = useMemo(() => {
|
||||
if (!data) return [] as TargetData[];
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(
|
||||
data.profile.glucoseUnit ?? null,
|
||||
);
|
||||
return data.targets
|
||||
.filter((target) => target.current != null)
|
||||
.map((target) => {
|
||||
if (!GLUCOSE_TYPES.has(target.type)) return target;
|
||||
// Server label is an i18n key for glucose; resolve here.
|
||||
const label = t(target.label);
|
||||
// Convert mg/dL canonical values to the user's display unit.
|
||||
const convert = (v: number | null) =>
|
||||
v == null ? null : convertGlucose(v, displayGlucoseUnit);
|
||||
return {
|
||||
...target,
|
||||
label,
|
||||
unit: displayGlucoseUnit,
|
||||
current: convert(target.current),
|
||||
average30: convert(target.average30),
|
||||
range: target.range
|
||||
? {
|
||||
min: convertGlucose(target.range.min, displayGlucoseUnit),
|
||||
max: convertGlucose(target.range.max, displayGlucoseUnit),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => sortKey(a) - sortKey(b));
|
||||
}, [data, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -641,32 +210,6 @@ export default function TargetsPage() {
|
||||
}
|
||||
|
||||
const profileIncomplete = !data.profile.heightCm || !data.profile.age;
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(
|
||||
data.profile.glucoseUnit ?? null,
|
||||
);
|
||||
const visibleTargets = data.targets
|
||||
.filter((target) => target.current != null)
|
||||
.map((target) => {
|
||||
if (!GLUCOSE_TYPES.has(target.type)) return target;
|
||||
// Server label is an i18n key for glucose; resolve here.
|
||||
const label = t(target.label);
|
||||
// Convert mg/dL canonical values to the user's display unit.
|
||||
const convert = (v: number | null) =>
|
||||
v == null ? null : convertGlucose(v, displayGlucoseUnit);
|
||||
return {
|
||||
...target,
|
||||
label,
|
||||
unit: displayGlucoseUnit,
|
||||
current: convert(target.current),
|
||||
average30: convert(target.average30),
|
||||
range: target.range
|
||||
? {
|
||||
min: convertGlucose(target.range.min, displayGlucoseUnit),
|
||||
max: convertGlucose(target.range.max, displayGlucoseUnit),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
// v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted
|
||||
@@ -711,9 +254,12 @@ export default function TargetsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Target cards grid */}
|
||||
{/* v1.4.25 W3e — responsive grid. Mobile (default): single
|
||||
column. sm (640px+): two columns. lg (1024px+): three columns,
|
||||
matching the dashboard / insights rhythm. Cards reflow
|
||||
internally too — see <TargetCard>. */}
|
||||
{visibleTargets.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
|
||||
{visibleTargets.map((target) => (
|
||||
<TargetCard
|
||||
key={target.type}
|
||||
@@ -721,6 +267,9 @@ export default function TargetsPage() {
|
||||
bpDiastolic={
|
||||
target.type === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
|
||||
}
|
||||
aiEnabled={aiEnabled}
|
||||
onAskCoach={askCoach}
|
||||
sourceLink={getTargetSourceLink(target)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -729,6 +278,16 @@ export default function TargetsPage() {
|
||||
{t("targets.noMeasurementData")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.25 W3e — Coach drawer mounted at the page level. The
|
||||
drawer is fully-controlled; per-card CTAs flip `coachOpen`
|
||||
and seed `coachPrefill` via the `askCoach()` hook callback. */}
|
||||
<CoachDrawer
|
||||
open={coachOpen}
|
||||
onOpenChange={setCoachOpen}
|
||||
prefill={coachPrefill}
|
||||
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { CoachScope } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — controlled state for the Coach drawer when mounted
|
||||
* outside the `/insights` page (currently `/targets`, future
|
||||
* `/insights/<metric>` sub-pages).
|
||||
*
|
||||
* Encapsulates the open / prefill / scope triple so each new surface
|
||||
* only has to mount `<CoachDrawer>` and wire the page-level CTAs to
|
||||
* the returned `askCoach()` callback. The drawer itself owns no
|
||||
* cross-surface state; this hook is the binding point.
|
||||
*
|
||||
* Reset behaviour mirrors the v1.4.20 Insights pattern: closing the
|
||||
* drawer clears prefill so the next open starts blank unless a CTA
|
||||
* supplies a fresh prefill.
|
||||
*/
|
||||
export interface CoachHandoffApi {
|
||||
coachOpen: boolean;
|
||||
setCoachOpen: (next: boolean) => void;
|
||||
coachPrefill: string | null;
|
||||
coachScope: CoachScope | null;
|
||||
askCoach: (payload: { prefill: string; scope: CoachScope }) => void;
|
||||
}
|
||||
|
||||
export function useCoachHandoff(): CoachHandoffApi {
|
||||
const [coachOpen, setOpen] = useState(false);
|
||||
const [coachPrefill, setPrefill] = useState<string | null>(null);
|
||||
const [coachScope, setScope] = useState<CoachScope | null>(null);
|
||||
|
||||
const setCoachOpen = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setPrefill(null);
|
||||
setScope(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const askCoach = useCallback(
|
||||
({ prefill, scope }: { prefill: string; scope: CoachScope }) => {
|
||||
setPrefill(prefill);
|
||||
setScope(scope);
|
||||
setOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach };
|
||||
}
|
||||
@@ -58,8 +58,7 @@ const TEMPLATES_EN: Record<string, Template> = {
|
||||
MOOD_SCORE: (i) =>
|
||||
`My latest mood score is ${fmt(i.current)} / 5 and my target is ${i.range ? `${i.range.min}+ / 5` : "3.5+ / 5"}. How does my mood compare to recent weeks and what could I try?`,
|
||||
MOOD_STABILITY: (i) => {
|
||||
const label =
|
||||
i.current != null ? moodStabilityLabel(i.current) : "stable";
|
||||
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
|
||||
const verbal =
|
||||
label === "stable"
|
||||
? "stable"
|
||||
@@ -86,8 +85,7 @@ const TEMPLATES_DE: Record<string, Template> = {
|
||||
MOOD_SCORE: (i) =>
|
||||
`Mein letzter Stimmungs-Wert ist ${fmt(i.current)} / 5, mein Ziel liegt bei ${i.range ? `${i.range.min}+ / 5` : "3,5+ / 5"}. Wie ist meine Stimmung im Vergleich zu den letzten Wochen, und was könnte ich ausprobieren?`,
|
||||
MOOD_STABILITY: (i) => {
|
||||
const label =
|
||||
i.current != null ? moodStabilityLabel(i.current) : "stable";
|
||||
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
|
||||
const verbal =
|
||||
label === "stable"
|
||||
? "stabil"
|
||||
|
||||
Reference in new issue
Block a user