refactor(charts): single HealthChartDynamic re-export

This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:51:26 +02:00
1 parent 8808561581
commit 235e52cb95
3 files changed
+237 -63

No files matched your search

@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.28 R3c (BK-F-M6) — pin the carved-out mobile rail tray's
* presentational contract. The carve-out is pure render: the parent
* owns open/closed state + the rail content; this component just
* paints two side-sheets with the right slots when open.
*
* The shadcn `<Sheet>` portals its content out of the React tree, so
* we mock the primitives down to plain wrappers (same pattern as
* `coach-settings-sheet.test.tsx`) to keep the slot assertions
* reachable in static markup.
*/
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-slot="mock-sheet">{children}</div> : null,
SheetContent: ({
children,
"data-slot": dataSlot,
className,
}: {
children: React.ReactNode;
"data-slot"?: string;
className?: string;
}) => (
<div data-slot={dataSlot} className={className}>
{children}
</div>
),
SheetHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
}));
import { MobileRailTray } from "../mobile-rail-tray";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseProps = {
historyOpen: true,
onHistoryOpenChange: () => undefined,
historyRail: <span>history-content</span>,
sourcesOpen: true,
onSourcesOpenChange: () => undefined,
sourcesRail: <span>sources-content</span>,
};
describe("<MobileRailTray>", () => {
it("renders both trays with their data-slot identifiers when open", () => {
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toContain('data-slot="coach-drawer-history-tray"');
expect(html).toContain('data-slot="coach-drawer-sources-tray"');
});
it("forwards the rail content slots verbatim", () => {
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toContain("history-content");
expect(html).toContain("sources-content");
});
it("hides the history tray on lg+ and the sources tray on xl+", () => {
// Matches the breakpoints documented inline: the history rail
// mounts inline at lg+, so the tray is `lg:hidden`. The sources
// rail mounts inline at xl+ only, so the tray is `xl:hidden`.
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toMatch(
/data-slot="coach-drawer-history-tray"[^>]*class="[^"]*lg:hidden/,
);
expect(html).toMatch(
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
);
});
it("renders both header titles localised", () => {
const enHtml = render(<MobileRailTray {...baseProps} />, "en");
expect(enHtml).toContain("Conversations");
expect(enHtml).toContain("What I can see");
const deHtml = render(<MobileRailTray {...baseProps} />, "de");
expect(deHtml).toContain("Unterhaltungen");
expect(deHtml).toContain("Worauf ich zugreife");
});
it("does not render either tray when its open prop is false", () => {
// The mock's `Sheet` returns `null` when `open === false`. Pin
// that contract so the carve-out stays gated on `historyOpen` /
// `sourcesOpen` and never paints orphan overlays.
const html = render(
<MobileRailTray
{...baseProps}
historyOpen={false}
sourcesOpen={false}
/>,
);
expect(html).not.toContain('data-slot="coach-drawer-history-tray"');
expect(html).not.toContain('data-slot="coach-drawer-sources-tray"');
});
});
@@ -30,6 +30,7 @@ import { CoachInput } from "./coach-input";
import { CoachSettingsSheet } from "./coach-settings-sheet";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
import { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
import type {
@@ -488,74 +489,52 @@ export function CoachDrawer({
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
{/* v1.4.20 phase B4 — mobile-only rail trays. Each renders a
slide-in panel from the matching edge and surfaces the same
HistoryRail / SourcesRail instance the desktop layout uses,
so a `>=lg` viewport never shows them but a `<lg` viewport
can summon either rail without leaving the message thread. */}
<Sheet open={historyTrayOpen} onOpenChange={setHistoryTrayOpen}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
)}
</div>
</SheetContent>
</Sheet>
{/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so
it doesn't conflict with the existing left/right rail
trays (those are <lg / <xl only; the settings sheet works
on every viewport). */}
it doesn't conflict with the existing rail trays (those are
<lg / <xl only; the settings sheet works on every
viewport). */}
<CoachSettingsSheet
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
<Sheet open={sourcesTrayOpen} onOpenChange={setSourcesTrayOpen}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
// Sources rail is xl+ inline; on lg it's only available
// via this tray to match the narrowed drawer cap.
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.sourcesTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">
{sourcesRail ?? (
<SourcesRail
scope={{
sources: scope.sources,
window: effectiveWindow,
}}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)}
</div>
</SheetContent>
</Sheet>
{/* v1.4.28 R3c (BK-F-M6) — mobile-only rail trays carved out of
the drawer shell. Each tray surfaces the same HistoryRail /
SourcesRail instance the desktop layout uses; the parent
still owns the open/closed state + the rail callbacks so
the carve-out is pure render. */}
<MobileRailTray
historyOpen={historyTrayOpen}
onHistoryOpenChange={setHistoryTrayOpen}
historyRail={
historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
)
}
sourcesOpen={sourcesTrayOpen}
onSourcesOpenChange={setSourcesTrayOpen}
sourcesRail={
sourcesRail ?? (
<SourcesRail
scope={{
sources: scope.sources,
window: effectiveWindow,
}}
onScopeChange={(next) => {
setScope((prev) => ({ ...prev, sources: next.sources }));
setWindowOverride(
next.window === savedDefaultWindow ? null : next.window,
);
}}
/>
)
}
/>
</SheetContent>
</Sheet>
);
@@ -0,0 +1,87 @@
"use client";
import type { ReactNode } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.20 phase B4 — mobile-only rail trays for the Coach drawer.
*
* The desktop layout pins the history rail (lg+) and the sources rail
* (xl+) alongside the message thread. Below those breakpoints the
* rails are reachable via chevron triggers in the thread's sub-header
* strip; each trigger opens the matching side-sheet here.
*
* v1.4.28 R3c (BK-F-M6) — carved out of `<CoachDrawer>` (~560 LOC).
* The parent still owns the rail content + the open/closed state for
* each tray so this component stays a thin presentational shell that
* can be unit-tested in isolation without standing up the drawer's
* snapshot fetch + scope-picker hooks.
*
* The component takes pre-rendered rail nodes rather than the raw
* rail components so the caller controls how each rail's
* `onSelect` / `onScopeChange` callbacks close the tray (some flows
* dismiss the tray after a selection; the contract for that lives in
* the parent, not here).
*/
export interface MobileRailTrayProps {
historyOpen: boolean;
onHistoryOpenChange: (next: boolean) => void;
historyRail: ReactNode;
sourcesOpen: boolean;
onSourcesOpenChange: (next: boolean) => void;
sourcesRail: ReactNode;
}
export function MobileRailTray({
historyOpen,
onHistoryOpenChange,
historyRail,
sourcesOpen,
onSourcesOpenChange,
sourcesRail,
}: MobileRailTrayProps) {
const { t } = useTranslations();
return (
<>
{/* History tray — slides in from the left edge. `lg:hidden`
because the history rail is inline on lg+. */}
<Sheet open={historyOpen} onOpenChange={onHistoryOpenChange}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">{historyRail}</div>
</SheetContent>
</Sheet>
{/* Sources tray — slides in from the right edge. `xl:hidden`
because the sources rail is inline on xl+. */}
<Sheet open={sourcesOpen} onOpenChange={onSourcesOpenChange}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.sourcesTitle")}
</SheetTitle>
</SheetHeader>
<div className="h-full min-h-0 overflow-y-auto">{sourcesRail}</div>
</SheetContent>
</Sheet>
</>
);
}