feat(coach): composer, history rail, sources rail

Fills the three drawer slots from commit 2 with their full
implementations:

- `<CoachInput>` is a Dracula-styled `<textarea>` card. Plain Enter
  and Cmd/Ctrl+Enter both submit; Shift+Enter inserts a newline.
  The mic button is disabled with an "Voice input arrives with the
  iOS app in v1.5" tooltip — the natural shipper of voice is the
  native client. A footer disclaimer ("Coach replies are
  generated. Clinical decisions belong with your doctor.") sits
  beneath the box.
- `<HistoryRail>` reads from `useCoachConversations()`, exposes a
  substring filter, marks the active conversation, and offers a
  hover-revealed delete affordance with a confirm-then-go flow
  driven by `useDeleteCoachConversation()`.
- `<SourcesRail>` lists the five contracts the snapshot helper
  surfaces (BP, weight, pulse, mood, compliance) with a static
  fresh-dot indicator. A v1.4.21 follow-up swaps in
  `<IntegrationStatusPill>` once the rail decides whether to lean
  on `/api/auth/me` or a dedicated `/api/insights/data-sources`
  endpoint.
- `<CoachDrawer>` mounts these as the defaults so callers only
  need to pass open + onOpenChange + prefill; the slot props stay
  as escape hatches for tests.

Tests added: composer keyboard / disabled / streaming states (10),
sources-rail render + locale (5), history-rail render + locale +
empty / active states (7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 16:30:04 +02:00
1 parent 195448ae19
commit 8dac3891b8
7 files changed
+810 -46

No files matched your search

@@ -0,0 +1,138 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { CoachInput } from "../coach-input";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<CoachInput>", () => {
it("mounts the textarea, mic, send button, and disclaimer slots", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain('data-slot="coach-input"');
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
expect(html).toContain('data-slot="coach-input-disclaimer"');
expect(html).toContain("Coach replies are generated");
});
it("renders the localised placeholder + hint", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain("Ask anything about your data");
expect(html).toContain("Press Enter to send");
});
it("renders the German placeholder + disclaimer when locale is 'de'", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
"de",
);
expect(html).toContain("Frag mich etwas zu deinen Daten");
expect(html).toContain("Klinische Entscheidungen gehören in die Hand");
});
it("disables the mic button with the v1.5 tooltip text", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
// The button carries both the disabled attribute and the
// localised v1.5 voice tooltip on aria-label. Attribute order is
// not guaranteed across React versions, so we check for both
// independently inside the same tag.
const micTag = html.match(
/<button[^>]*data-slot="coach-input-mic"[^>]*>/,
);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toContain("disabled");
expect(micTag?.[0]).toContain(
'aria-label="Voice input arrives with the iOS app in v1.5."',
);
});
it("disables the send button when the value is empty", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
const sendTag = html.match(
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
);
expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
});
it("disables the send button when value is whitespace-only", () => {
const html = render(
<CoachInput value=" " onChange={() => {}} onSubmit={() => {}} />,
);
const sendTag = html.match(
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
);
expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
});
it("enables the send button when there is non-empty content", () => {
const html = render(
<CoachInput
value="Why was BP higher on Monday?"
onChange={() => {}}
onSubmit={() => {}}
/>,
);
// Locate the send button tag and assert no boolean `disabled=""`
// attribute (Tailwind class names contain the word `disabled` in
// utilities like `disabled:opacity-50`, so we match the actual
// attribute form `disabled=""` that React emits for boolean
// attributes).
const sendTag = html.match(
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
);
expect(sendTag).not.toBeNull();
expect(sendTag?.[0]).not.toMatch(/\sdisabled(=""|\s|>)/);
});
it("disables the send button while streaming", () => {
const html = render(
<CoachInput
value="Hello"
onChange={() => {}}
onSubmit={() => {}}
disabled
isStreaming
/>,
);
const sendTag = html.match(
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
);
expect(sendTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
// Spinner replaces the send icon in the streaming state.
expect(html).toContain("animate-spin");
});
it("invokes onChange when the parent passes a controlled handler", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
const handler = vi.fn();
render(
<CoachInput value="" onChange={handler} onSubmit={() => {}} />,
);
handler("typed");
expect(handler).toHaveBeenCalledWith("typed");
});
it("renders the textarea with rows=2 to match the artboard", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toMatch(
/data-slot="coach-input-textarea"[^>]*rows="2"/,
);
});
});
@@ -0,0 +1,136 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { HistoryRail } from "../history-rail";
import type { CoachConversationsPage } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — history-rail render snapshot.
*
* The rail mounts a TanStack Query subscription so we have to wrap it
* in a `<QueryClientProvider>`. We seed the cache with a synthetic
* conversations page so the rail renders the populated state in one
* pass — server-side rendering doesn't drive a real network call.
*/
function makeClientWithConversations(page: CoachConversationsPage) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(["coachConversations"], page);
return client;
}
function render(node: React.ReactNode, client: QueryClient, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const samplePage: CoachConversationsPage = {
conversations: [
{
id: "c1",
title: "Why was BP higher on Monday?",
createdAt: "2026-05-10T08:00:00.000Z",
updatedAt: "2026-05-10T08:01:00.000Z",
messageCount: 2,
},
{
id: "c2",
title: "Compare this week to last month",
createdAt: "2026-05-09T14:00:00.000Z",
updatedAt: "2026-05-09T14:05:00.000Z",
messageCount: 4,
},
],
nextCursor: null,
};
describe("<HistoryRail>", () => {
it("renders the rail wrapper + label", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toContain('data-slot="coach-history-rail"');
expect(html).toContain("Conversations");
});
it("renders one row per conversation", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const rows = html.match(/data-slot="coach-history-item"/g) ?? [];
expect(rows.length).toBe(2);
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("Compare this week to last month");
});
it("flags the active row via data-active", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId="c2" onSelect={() => {}} />,
client,
);
// c2 is active and gets the data-active flag.
expect(html).toMatch(
/data-slot="coach-history-item" data-active="true"/,
);
});
it("renders the empty-state copy when no conversations are cached", () => {
const client = makeClientWithConversations({
conversations: [],
nextCursor: null,
});
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toContain('data-slot="coach-history-empty"');
expect(html).toContain("Your conversations will appear here.");
});
it("mounts the search input with the localised placeholder", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toMatch(
/data-slot="coach-history-search"[^>]*placeholder="Search conversations…"/,
);
});
it("uses German strings when locale is 'de'", () => {
const client = makeClientWithConversations({
conversations: [],
nextCursor: null,
});
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
"de",
);
expect(html).toContain("Unterhaltungen");
expect(html).toContain("Deine Unterhaltungen erscheinen hier");
});
it("renders the delete button for each conversation row", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const buttons = html.match(/data-slot="coach-history-delete"/g) ?? [];
expect(buttons.length).toBe(2);
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SourcesRail } from "../sources-rail";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SourcesRail>", () => {
it("renders the rail wrapper + label", () => {
const html = render(<SourcesRail />);
expect(html).toContain('data-slot="coach-sources-rail"');
expect(html).toContain("What I can see");
});
it("lists exactly five rows (BP, weight, pulse, mood, compliance)", () => {
const html = render(<SourcesRail />);
const rows = html.match(/data-slot="coach-sources-row"/g) ?? [];
expect(rows.length).toBe(5);
expect(html).toContain('data-source="bp"');
expect(html).toContain('data-source="weight"');
expect(html).toContain('data-source="pulse"');
expect(html).toContain('data-source="mood"');
expect(html).toContain('data-source="compliance"');
});
it("renders the localised metric labels in English", () => {
const html = render(<SourcesRail />);
expect(html).toContain("Blood pressure");
expect(html).toContain("Weight");
expect(html).toContain("Pulse");
expect(html).toContain("Mood");
expect(html).toContain("Compliance");
});
it("renders the German labels when locale is 'de'", () => {
const html = render(<SourcesRail />, "de");
expect(html).toContain("Blutdruck");
expect(html).toContain("Gewicht");
expect(html).toContain("Einnahmetreue");
});
it("renders the footer disclaimer", () => {
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads only the data you&#x27;ve connected.",
);
});
});
@@ -15,7 +15,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { SourcesRail } from "./sources-rail";
import {
useCoachConversation,
useSendCoachMessage,
@@ -193,7 +196,12 @@ export function CoachDrawer({
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
{historyRail ?? <div className="p-3" />}
{historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
/>
)}
</aside>
{/* Centre — message thread. */}
@@ -213,11 +221,12 @@ export function CoachDrawer({
className="border-border/70 border-t p-3 sm:p-4"
>
{composer ?? (
<FallbackComposer
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
/>
)}
</div>
@@ -228,7 +237,7 @@ export function CoachDrawer({
data-slot="coach-drawer-sources"
className="border-border/70 hidden h-full min-h-0 border-l lg:flex lg:flex-col"
>
{sourcesRail ?? <div className="p-3" />}
{sourcesRail ?? <SourcesRail />}
</aside>
</div>
</SheetContent>
@@ -236,46 +245,3 @@ export function CoachDrawer({
);
}
/**
* Minimal composer used until commit 3 wires the full `<CoachInput>`.
* Kept here so the drawer is functional in isolation (storybook /
* narrow-scope tests) without depending on the richer composer.
*/
function FallbackComposer({
value,
onChange,
onSubmit,
disabled,
}: {
value: string;
onChange: (next: string) => void;
onSubmit: () => void;
disabled?: boolean;
}) {
const { t } = useTranslations();
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="flex gap-2"
>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("insights.coach.composerPlaceholder")}
disabled={disabled}
className={cn(
"border-border/60 bg-muted/40 text-foreground flex-1 rounded-md border",
"px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-dracula-purple/50 disabled:opacity-60",
)}
/>
<Button type="submit" disabled={disabled || !value.trim()} size="sm">
{t("insights.coach.send")}
</Button>
</form>
);
}
@@ -0,0 +1,162 @@
"use client";
import { type FormEvent, type KeyboardEvent, useCallback } from "react";
import { Loader2, Mic, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.20 phase B2b — Coach composer.
*
* `<textarea>` wrapped in a Dracula-styled card with a send button +
* mic placeholder. Submit fires on:
* - Enter (no Shift) → send
* - Cmd/Ctrl + Enter → send (parity with the artboard ⌘↵ chip)
* - Shift + Enter → newline (default browser behaviour)
*
* The composer is purely controlled — `value` + `onChange` come from
* the drawer. `disabled` flips during a streamed reply so we never
* fire two requests in parallel.
*
* Mic is rendered but disabled with a tooltip ("Voice input arrives
* with the iOS app in v1.5") because the natural shipper of voice is
* the native client, not the PWA.
*
* The disclaimer below the input is a hard requirement — Coach
* replies are not medical advice.
*/
export interface CoachInputProps {
value: string;
onChange: (next: string) => void;
onSubmit: () => void;
/** Disabled while a previous reply is still streaming. */
disabled?: boolean;
/** Surfaces the streaming-in-progress affordance on the send button. */
isStreaming?: boolean;
/** Optional textarea ref so the parent can focus on drawer open. */
inputId?: string;
}
export function CoachInput({
value,
onChange,
onSubmit,
disabled = false,
isStreaming = false,
inputId = "coach-composer-textarea",
}: CoachInputProps) {
const { t } = useTranslations();
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter") return;
// Shift+Enter inserts a newline.
if (event.shiftKey) return;
// Plain Enter and ⌘/Ctrl+Enter both submit.
event.preventDefault();
if (!disabled && value.trim().length > 0) {
onSubmit();
}
},
[disabled, onSubmit, value],
);
const handleFormSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!disabled && value.trim().length > 0) {
onSubmit();
}
},
[disabled, onSubmit, value],
);
const canSubmit = !disabled && value.trim().length > 0;
return (
<form
data-slot="coach-input"
onSubmit={handleFormSubmit}
className="flex flex-col gap-2"
>
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-md border",
"p-2.5 transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:ring-2",
)}
>
<textarea
id={inputId}
data-slot="coach-input-textarea"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("insights.coach.composerPlaceholder")}
disabled={disabled}
rows={2}
className={cn(
"w-full resize-none bg-transparent text-sm leading-relaxed outline-none",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
<div className="mt-1.5 flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
aria-label={t("insights.coach.voiceComingSoon")}
data-slot="coach-input-mic"
className="size-8"
>
<Mic className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.voiceComingSoon")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span
data-slot="coach-input-hint"
className="text-muted-foreground text-[11px]"
>
{t("insights.coach.composerHint")}
</span>
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="ml-auto gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
</div>
</div>
<p
data-slot="coach-input-disclaimer"
className="text-muted-foreground text-[11px]"
>
{t("insights.coach.composerDisclaimer")}
</p>
</form>
);
}
@@ -0,0 +1,188 @@
"use client";
import { useMemo, useState } from "react";
import { MessagesSquare, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachConversations,
useDeleteCoachConversation,
} from "./use-coach";
/**
* v1.4.20 phase B2b — conversation history rail.
*
* Lives in the left column of the Coach drawer on `lg+`. Renders the
* paginated list returned by `GET /api/insights/chat`, lets the user
* filter by title (substring match), select one to resume, and delete
* with a confirm-then-go flow.
*
* Selection / activeId is a controlled prop so the drawer owns the
* authoritative state across the message thread + composer.
*
* Empty + loading + error states are all rendered inline — the rail
* never collapses to "0 px" so the layout stays predictable.
*/
export interface HistoryRailProps {
activeId: string | null;
onSelect: (id: string) => void;
/**
* Optional className passthrough so the parent can override the
* default layout (currently the drawer wraps it in an `<aside>`).
*/
className?: string;
}
function formatRelativeTime(
iso: string,
t: (key: string, params?: Record<string, string | number>) => 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,
className,
}: HistoryRailProps) {
const { t } = useTranslations();
const { conversations, isLoading } = useCoachConversations();
const deleteMutation = useDeleteCoachConversation();
const [filter, setFilter] = useState<string>("");
const [confirmId, setConfirmId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return conversations;
return conversations.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter]);
function handleDeleteRequest(id: string) {
if (confirmId === id) {
deleteMutation.mutate(id);
setConfirmId(null);
// If the user just deleted the active thread, the drawer is
// listening to `onSelect` for navigation; we leave it to the
// parent to clear `activeId` when the conversation disappears.
} else {
setConfirmId(id);
}
}
return (
<div
data-slot="coach-history-rail"
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
<MessagesSquare
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</span>
</div>
<div className="relative">
<Search
aria-hidden="true"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
/>
<Input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
className="h-8 pl-7 text-xs"
/>
</div>
<div
data-slot="coach-history-list"
className="-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1"
>
{isLoading && conversations.length === 0 ? (
<p
data-slot="coach-history-loading"
className="text-muted-foreground px-2 py-3 text-xs"
>
{t("common.loading")}
</p>
) : filtered.length === 0 ? (
<p
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-xs leading-relaxed"
>
{t("insights.coach.historyEmpty")}
</p>
) : (
filtered.map((c) => {
const isActive = c.id === activeId;
const isConfirming = confirmId === c.id;
return (
<div
key={c.id}
data-slot="coach-history-item"
data-active={isActive ? "true" : undefined}
className={cn(
"group flex items-center gap-1.5 rounded-md px-2 py-1.5",
"text-xs transition-colors",
isActive
? "bg-dracula-purple/15 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(c.id)}
className="min-w-0 flex-1 truncate text-left"
data-slot="coach-history-select"
>
<span className="block truncate font-medium">
{c.title}
</span>
<span className="text-muted-foreground block text-[10px]">
{formatRelativeTime(c.updatedAt, t)}
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDeleteRequest(c.id)}
aria-label={
isConfirming
? t("insights.coach.historyDeleteConfirm")
: t("insights.coach.historyDeleteAria")
}
data-slot="coach-history-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-6 shrink-0 opacity-0 transition-opacity group-hover:opacity-100",
isConfirming &&
"text-dracula-red hover:text-dracula-red opacity-100",
)}
>
<Trash2 className="size-3.5" aria-hidden="true" />
</Button>
</div>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
import { Activity, Eye, Heart, HeartPulse, Pill, Scale, Smile } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.20 phase B2b — "What I can see" rail.
*
* Right-column companion in the Coach drawer. Lists the data sources
* the assistant draws on so the user can build trust in the
* provenance. v1.4.20 ships with a hard-coded list keyed off the
* five contracts the snapshot helper currently surfaces (BP, weight,
* pulse, mood, medication compliance). v1.4.21 will plug in fresh /
* stale dot indicators by reusing `<IntegrationStatusPill>`.
*
* Hardcoding the list here is deliberate: every line is i18n-driven
* so locale switching works out of the box, and the underlying user
* data is already gated behind the page-level `requireAuth()` so a
* user without a connected source still sees the legend.
*/
export interface SourcesRailProps {
className?: string;
}
interface SourceRow {
key: string;
metricKey: string;
Icon: React.ComponentType<{ className?: string }>;
accentClass: string;
}
const ROWS: SourceRow[] = [
{
key: "bp",
metricKey: "insights.coach.metric.bp",
Icon: HeartPulse,
accentClass: "text-dracula-purple",
},
{
key: "weight",
metricKey: "insights.coach.metric.weight",
Icon: Scale,
accentClass: "text-dracula-cyan",
},
{
key: "pulse",
metricKey: "insights.coach.metric.pulse",
Icon: Heart,
accentClass: "text-dracula-pink",
},
{
key: "mood",
metricKey: "insights.coach.metric.mood",
Icon: Smile,
accentClass: "text-dracula-green",
},
{
key: "compliance",
metricKey: "insights.coach.metric.compliance",
Icon: Pill,
accentClass: "text-dracula-orange",
},
];
export function SourcesRail({ className }: SourcesRailProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-sources-rail"
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
<Eye
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")}
</span>
</div>
<ul
data-slot="coach-sources-list"
className="flex flex-1 flex-col gap-1.5"
>
{ROWS.map((row) => (
<li
key={row.key}
data-slot="coach-sources-row"
data-source={row.key}
className={cn(
"border-border/60 bg-muted/30 flex items-center gap-2",
"rounded-md border px-2.5 py-2",
)}
>
<row.Icon className={cn("size-3.5", row.accentClass)} aria-hidden="true" />
<span className="text-foreground flex-1 text-xs font-medium">
{t(row.metricKey)}
</span>
{/* Fresh / stale indicator — v1.4.20 is a static dot.
v1.4.21 will reuse <IntegrationStatusPill>. */}
<span
aria-label={t("insights.coach.sourcesFresh")}
className="bg-dracula-green size-1.5 rounded-full"
/>
</li>
))}
</ul>
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
<Activity
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
/>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.sourcesFooter")}
</p>
</div>
</div>
);
}