diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 3e0596001..de25296d6 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -518,6 +518,16 @@ function getMedicationComplianceSectionStatus(input: { }; } +// v1.4.20 phase D reconcile — module-scope so the map isn't recreated +// on every render of `` (the page renders often on +// TanStack-Query refetches; this map carries no closures over locals). +const STORYBOARD_COLOR_BY_CATEGORY: Record = { + medication: "var(--dracula-pink)", + event: "var(--dracula-cyan)", + milestone: "var(--dracula-green)", + warning: "var(--dracula-orange)", +}; + export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -915,14 +925,9 @@ export default function InsightsPage() { // v1.4.20 phase B4 — storyboard annotations for the 90-day BP chart. // The advisor payload may carry up to 20 entries; we transform them // into the {date, label, color} shape consumes. The - // colour map mirrors the four canonical categories. Cached payloads - // from before PROMPT_VERSION 4.20.2 simply produce an empty array. - const STORYBOARD_COLOR_BY_CATEGORY: Record = { - medication: "var(--dracula-pink)", - event: "var(--dracula-cyan)", - milestone: "var(--dracula-green)", - warning: "var(--dracula-orange)", - }; + // colour map mirrors the four canonical categories (hoisted to + // module scope above). Cached payloads from before PROMPT_VERSION + // 4.20.2 simply produce an empty array. const rawStoryboard = ( advisor.payload?.insights as | { storyboardAnnotations?: Array<{ diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 9148ec1fe..84a6ca0c0 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { useCoachConversations, @@ -37,22 +38,6 @@ export interface HistoryRailProps { className?: string; } -function formatRelativeTime( - iso: string, - t: (key: string, params?: Record) => string, -): string { - const target = new Date(iso).getTime(); - if (Number.isNaN(target)) return ""; - const diffMs = Date.now() - target; - if (diffMs < 60_000) return t("insights.relativeJustNow"); - const minutes = Math.floor(diffMs / 60_000); - if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes }); - const hours = Math.floor(minutes / 60); - if (hours < 24) return t("insights.relativeHoursAgo", { count: hours }); - const days = Math.floor(hours / 24); - return t("insights.relativeDaysAgo", { count: days }); -} - export function HistoryRail({ activeId, onSelect, diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 47daa0436..f1fbe9d38 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -82,7 +82,11 @@ export function useCoachConversations(enabled = true) { */ export function useCoachConversation(id: string | null) { return useQuery({ - queryKey: id ? QUERY_KEYS.one(id) : ["coachConversation", "null"], + // The query is gated on `enabled: id !== null`, so the queryFn + // never fires when `id` is null. Key directly on `id` (TanStack + // Query accepts `null` in the array) — the previous "null" + // placeholder branch was structurally equivalent dead code. + queryKey: ["coachConversation", id] as const, queryFn: async (): Promise => { if (!id) throw new Error("missing id"); const res = await fetch(`/api/insights/chat/${id}`, { diff --git a/src/components/insights/confidence-badge.ts b/src/components/insights/confidence-badge.ts new file mode 100644 index 000000000..0cf0d5c17 --- /dev/null +++ b/src/components/insights/confidence-badge.ts @@ -0,0 +1,16 @@ +/** + * v1.4.20 phase D reconcile — shared confidence-band classnames. + * + * Both `` and `` paint a discrete + * "low / moderate / high" badge with byte-identical Dracula utility + * classes. Per-component i18n key maps stay local because the copy + * differs (correlationRow.* vs trendAnnotation.*); only the classname + * table is the load-bearing duplication. + */ +export type ConfidenceBand = "low" | "moderate" | "high"; + +export const CONFIDENCE_BADGE_CLASS: Record = { + high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green", + moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange", + low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground", +}; diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index 5092b2f8d..98644418c 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { CorrelationResult } from "@/lib/insights/correlations"; +import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; /** * v1.4.20 phase B3 — single Correlation card. @@ -58,14 +59,6 @@ const SUBTITLE_KEY: Record = { "weight-weekday": "insights.correlationRow.card.weightWeekdaySubtitle", }; -const CONFIDENCE_BADGE_CLASSNAME: Record<"low" | "moderate" | "high", string> = - { - high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green", - moderate: - "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange", - low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground", - }; - const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = { high: "insights.correlationRow.confidenceHigh", moderate: "insights.correlationRow.confidenceModerate", @@ -102,7 +95,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) { variant="outline" className={cn( "shrink-0 text-[10px]", - CONFIDENCE_BADGE_CLASSNAME[result.confidenceBand.label], + CONFIDENCE_BADGE_CLASS[result.confidenceBand.label], )} > {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 575fb70f5..7362f40c6 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { useTranslations } from "@/lib/i18n/context"; +import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { cn } from "@/lib/utils"; import type { DailyBriefing as DailyBriefingPayload, @@ -251,23 +252,3 @@ export function DailyBriefing({ ); } -/** - * Bucketed relative-time using i18n translation keys so every surface - * shares vocabulary. Buckets: <1m, <1h, <24h, else days. Mirrors the - * helper in `` so the two cards format the same way. - */ -function formatRelativeTime( - iso: string, - t: (key: string, params?: Record) => string, -): string { - const target = new Date(iso).getTime(); - if (Number.isNaN(target)) return ""; - const diffMs = Date.now() - target; - if (diffMs < 60_000) return t("insights.relativeJustNow"); - const minutes = Math.floor(diffMs / 60_000); - if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes }); - const hours = Math.floor(minutes / 60); - if (hours < 24) return t("insights.relativeHoursAgo", { count: hours }); - const days = Math.floor(hours / 24); - return t("insights.relativeDaysAgo", { count: days }); -} diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 52a58a0a6..b5fccd6d8 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; +import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { cn } from "@/lib/utils"; import { SuggestedPrompts } from "./suggested-prompts"; import { @@ -134,26 +135,6 @@ function resolveGreetingKey(now: Date): string { return "insights.heroGreetingNight"; } -/** - * Bucketed relative-time using i18n translation keys so every surface - * shares vocabulary. Mirrors the helper in ``. - */ -function formatRelativeTime( - iso: string, - t: (key: string, params?: Record) => string, -): string { - const target = new Date(iso).getTime(); - if (Number.isNaN(target)) return ""; - const diffMs = Date.now() - target; - if (diffMs < 60_000) return t("insights.relativeJustNow"); - const minutes = Math.floor(diffMs / 60_000); - if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes }); - const hours = Math.floor(minutes / 60); - if (hours < 24) return t("insights.relativeHoursAgo", { count: hours }); - const days = Math.floor(hours / 24); - return t("insights.relativeDaysAgo", { count: days }); -} - export function HeroStrip({ briefing, updatedAt, diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 9c86acd12..4c14fdab9 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -4,6 +4,7 @@ import { Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; /** * v1.4.20 phase B3 — single-sentence AI annotation rendered directly @@ -30,15 +31,6 @@ interface TrendAnnotationProps { confidence?: TrendAnnotationConfidenceBand; } -const CONFIDENCE_BADGE_CLASSNAME: Record< - TrendAnnotationConfidenceBand, - string -> = { - high: "border-dracula-green/40 bg-dracula-green/10 text-dracula-green", - moderate: "border-dracula-orange/40 bg-dracula-orange/10 text-dracula-orange", - low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground", -}; - const CONFIDENCE_LABEL_KEY: Record = { high: "insights.trendAnnotation.confidenceHigh", moderate: "insights.trendAnnotation.confidenceModerate", @@ -86,7 +78,7 @@ export function TrendAnnotation({ {t(CONFIDENCE_LABEL_KEY[confidence])} diff --git a/src/lib/i18n/relative-time.ts b/src/lib/i18n/relative-time.ts new file mode 100644 index 000000000..64b23dad8 --- /dev/null +++ b/src/lib/i18n/relative-time.ts @@ -0,0 +1,25 @@ +/** + * v1.4.20 phase D reconcile — shared relative-time helper. + * + * Formats an ISO timestamp as a localised "just now" / "N minutes ago" + * string via i18n keys. Three Insights surfaces (hero strip, daily + * briefing, history rail) carried byte-identical copies of this helper; + * lift the body here so a future i18n key change lands once. + * + * Buckets: <1m → just-now, <1h → minutes, <24h → hours, else days. + */ +export function formatRelativeTime( + iso: string, + t: (key: string, params?: Record) => string, +): string { + const target = new Date(iso).getTime(); + if (Number.isNaN(target)) return ""; + const diffMs = Date.now() - target; + if (diffMs < 60_000) return t("insights.relativeJustNow"); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t("insights.relativeHoursAgo", { count: hours }); + const days = Math.floor(hours / 24); + return t("insights.relativeDaysAgo", { count: days }); +}