"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ArrowUp, BarChart3, CalendarRange, LayoutDashboard, LineChart as LineIcon, Menu, MessageSquare, Percent, Square, Store, Trash2, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnswerCard } from "./answer-card";
import { Board } from "./board";
import { SALES, SUGGESTIONS } from "./data";
import { isAsyncIterable, makeFmt, narrativeFor, parseQuestion, runQuery, schemaOf, scriptedStream } from "./engine";
import { Sidebar } from "./sidebar";
import type { Answer, Query, Result, SaleRow } from "./types";
import { BrandMark, focusRing, ROOT_VARS } from "./ui";
export type { SaleRow, Result, Query };
export type InsightAnalystAppProps = {
/** Weekly store × category rows. Default: 1,152 seeded rows (6 stores × 8 categories × 24 weeks). */
rows?: SaleRow[];
title?: string;
suggestions?: string[];
currency?: string;
/** Number formatting locale. */
locale?: string;
/** Date formatting locale for the English copy. */
dateLocale?: string;
/** Replace the narrative writer (e.g. a real model). Receives the question and the computed result. */
onAsk?: (question: string, result: Result) => AsyncIterable<string> | Promise<string>;
onPin?: (answer: { id: string; question: string; result: Result }) => void;
onQuery?: (sql: string, result: Result) => void;
className?: string;
};
const SUGGESTION_ICONS = [TrendingUp, BarChart3, LineIcon, Percent, Store, CalendarRange];
let seq = 0;
const nid = () => `q${++seq}`;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const plain = (t: string) => t.replace(/\{\{([^|}]+)\|[^}]+\}\}/g, "$1");
type Ghost = { from: DOMRect; to: DOMRect; root: DOMRect; key: number };
export function InsightAnalystApp({
rows = SALES,
title = "Northwind Insights",
suggestions = SUGGESTIONS,
currency = "PLN",
locale = "pl-PL",
dateLocale = "en-GB",
onAsk,
onPin,
onQuery,
className,
}: InsightAnalystAppProps) {
const reduced = useReducedMotion() ?? false;
const fmt = React.useMemo(() => makeFmt(currency, locale, dateLocale), [currency, locale, dateLocale]);
const schema = React.useMemo(() => schemaOf(rows), [rows]);
const firstWeekStart = React.useMemo(() => rows.reduce((a, r) => (r.weekStart < a ? r.weekStart : a), schema.latestStart), [rows, schema.latestStart]);
const [answers, setAnswers] = React.useState<Answer[]>([]);
const [view, setView] = React.useState<"ask" | "board">("ask");
const [busy, setBusy] = React.useState(false);
const [draft, setDraft] = React.useState("");
const [hl, setHl] = React.useState<{ id: string; key: string } | null>(null);
const [drawer, setDrawer] = React.useState(false);
const [ghost, setGhost] = React.useState<Ghost | null>(null);
const [bump, setBump] = React.useState(0);
const [announce, setAnnounce] = React.useState("");
const rootRef = React.useRef<HTMLDivElement>(null);
const logRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const boardTabRef = React.useRef<HTMLButtonElement>(null);
const menuRef = React.useRef<HTMLButtonElement>(null);
const drawerRef = React.useRef<HTMLDivElement>(null);
const runRef = React.useRef(0);
const currentRef = React.useRef<string | null>(null);
const stickRef = React.useRef(true);
const autoScrollRef = React.useRef(-1);
const toBottom = (el: HTMLElement) => {
el.scrollTop = el.scrollHeight;
autoScrollRef.current = el.scrollTop;
};
const patch = (id: string, p: Partial<Answer>) => setAnswers((as) => as.map((a) => (a.id === id ? { ...a, ...p } : a)));
/* -------------------------------- asking -------------------------------- */
const run = async (question: string, preset?: Query) => {
const q = question.trim();
if (!q) return;
const runId = ++runRef.current;
const id = nid();
currentRef.current = id;
setView("ask");
setDraft("");
stickRef.current = true;
const parsed = preset ? ({ ok: true, query: preset } as const) : parseQuestion(q, schema);
if (!parsed.ok) {
setAnswers((as) => [...as, { id, question: q, phase: "clarify", result: null, sqlChars: 0, narrative: "", reason: parsed.reason, pinned: false }]);
setAnnounce(`${parsed.reason} Suggestions are available.`);
return;
}
const result = runQuery(rows, parsed.query, schema);
setAnswers((as) => [...as, { id, question: q, phase: "plan", result, sqlChars: 0, narrative: "", pinned: false }]);
setBusy(true);
setAnnounce("Analysing your question");
const alive = () => runRef.current === runId;
try {
await sleep(reduced ? 0 : 650);
if (!alive()) return;
patch(id, { phase: "sql" });
if (!reduced) {
const len = result.sql.length;
for (let c = 0; c < len; c += 5) {
await sleep(14);
if (!alive()) return;
patch(id, { sqlChars: c });
}
}
patch(id, { sqlChars: result.sql.length, phase: "run" });
await sleep(reduced ? 0 : 520);
if (!alive()) return;
onQuery?.(result.sql, result);
patch(id, { phase: "narrate" });
await sleep(reduced ? 0 : 450);
let source: AsyncIterable<string>;
if (onAsk) {
const r = onAsk(q, result);
source = isAsyncIterable(r)
? r
: (async function* () {
yield await r;
})();
} else source = scriptedStream(narrativeFor(result, fmt), { whole: reduced, delay: 26 });
let text = "";
for await (const chunk of source) {
if (!alive()) return;
text += chunk;
patch(id, { narrative: text });
}
if (!alive()) return;
patch(id, { phase: "done", narrative: text });
setAnnounce(`${result.title}. ${plain(text)}`);
} catch (err) {
if (!alive()) return;
patch(id, { phase: "error", error: err instanceof Error ? err.message : "The analyst could not write an answer." });
setAnnounce("The analyst could not answer. Retry is available.");
} finally {
if (alive()) {
setBusy(false);
currentRef.current = null;
}
}
};
const stop = () => {
const id = currentRef.current;
runRef.current++;
setBusy(false);
currentRef.current = null;
if (id) setAnswers((as) => as.map((a) => (a.id === id && a.result ? { ...a, phase: "done", sqlChars: a.result.sql.length, narrative: a.narrative || "Stopped before the summary was written." } : a)));
setAnnounce("Stopped");
};
const retry = (id: string) => {
const a = answers.find((x) => x.id === id);
if (!a) return;
setAnswers((as) => as.filter((x) => x.id !== id));
void run(a.question, a.result?.query);
};
const togglePin = (id: string, rect: DOMRect | null) => {
const a = answers.find((x) => x.id === id);
if (!a || !a.result) return;
const pinned = !a.pinned;
patch(id, { pinned });
if (pinned) {
onPin?.({ id, question: a.question, result: a.result });
const to = boardTabRef.current?.getBoundingClientRect();
const root = rootRef.current?.getBoundingClientRect();
if (rect && to && root && !reduced) setGhost({ from: rect, to, root, key: Date.now() });
else setBump((b) => b + 1);
setAnnounce("Pinned to board");
} else setAnnounce("Removed from board");
};
const focusPrompt = () => {
setView("ask");
setDrawer(false);
window.setTimeout(() => inputRef.current?.focus(), 0);
};
const openAnswer = (id: string) => {
setView("ask");
setDrawer(false);
window.setTimeout(() => document.getElementById(`answer-${id}`)?.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" }), 30);
};
/* ------------------------------- effects -------------------------------- */
// Keep the log pinned to the bottom while answers stream, unless the user scrolled up.
React.useEffect(() => {
const el = logRef.current;
if (el && stickRef.current) toBottom(el);
}, [answers]);
const isEmpty = answers.length === 0;
// Charts measure themselves after mount, so also follow size changes of the log content.
React.useEffect(() => {
const el = logRef.current;
const inner = el?.firstElementChild;
if (!el || !inner) return;
const ro = new ResizeObserver(() => {
if (stickRef.current) toBottom(el);
});
ro.observe(inner);
return () => ro.disconnect();
}, [view, isEmpty]);
// Global shortcuts: "/" focuses the prompt, Escape stops or closes.
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
// Only react when focus is inside the app or nowhere in particular (page body).
if (t && t !== document.body && !root.contains(t)) return;
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
if (e.key === "/" && !typing) {
e.preventDefault();
focusPrompt();
} else if (e.key === "Escape") {
if (drawer) {
setDrawer(false);
menuRef.current?.focus();
} else if (busy) stop();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
// Drawer: move focus in, trap Tab, return focus on close.
React.useEffect(() => {
if (!drawer) return;
const el = drawerRef.current;
const q = () => Array.from(el?.querySelectorAll<HTMLElement>("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? []);
q()[0]?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const f = q();
if (f.length === 0) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
el?.addEventListener("keydown", onKey);
return () => el?.removeEventListener("keydown", onKey);
}, [drawer]);
const pinnedCount = answers.filter((a) => a.pinned).length;
const empty = answers.length === 0;
const sidebarProps = {
title,
answers,
schema,
rowCount: rows.length,
firstWeekStart,
fmt,
onSelect: openAnswer,
onNew: focusPrompt,
onAsk: (t: string) => {
setDrawer(false);
void run(t);
},
};
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
<aside aria-label="Sessions and dataset" className="hidden w-64 shrink-0 border-r bg-muted/25 lg:block">
<Sidebar {...sidebarProps} />
</aside>
<div className="flex min-w-0 flex-1 flex-col" inert={drawer ? true : undefined}>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-4">
<button ref={menuRef} type="button" onClick={() => setDrawer(true)} aria-label="Open menu" className={cn("grid size-9 place-items-center rounded-lg hover:bg-accent lg:hidden", focusRing)}>
<Menu className="size-4.5" aria-hidden />
</button>
<BrandMark className="hidden size-7 sm:grid lg:hidden" />
<div role="tablist" aria-label="View" className="relative flex rounded-xl bg-muted p-1">
{(
[
{ id: "ask", label: "Ask", icon: MessageSquare },
{ id: "board", label: "Board", icon: LayoutDashboard },
] as const
).map((t) => (
<button
key={t.id}
ref={t.id === "board" ? boardTabRef : undefined}
type="button"
role="tab"
aria-selected={view === t.id}
onClick={() => setView(t.id)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
setView(view === "ask" ? "board" : "ask");
}
}}
className={cn("relative inline-flex h-7 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-medium transition-colors", view === t.id ? "text-foreground" : "text-muted-foreground hover:text-foreground", focusRing)}
>
{view === t.id && <motion.span layoutId="ia-view-pill" className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<t.icon className="relative size-3.5" aria-hidden />
<span className="relative">{t.label}</span>
{t.id === "board" && pinnedCount > 0 && (
<motion.span
key={bump}
initial={bump && !reduced ? { scale: 1.7 } : false}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14 }}
className="relative grid h-4.5 min-w-4.5 place-items-center rounded-full bg-[var(--ia-1)] px-1 text-[10.5px] font-semibold text-white tabular-nums"
aria-label={`${pinnedCount} pinned`}
>
{pinnedCount}
</motion.span>
)}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-1.5">
<span className="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] text-muted-foreground md:inline-flex">
<span className="size-1.5 rounded-full bg-[var(--ia-pos)]" aria-hidden /> {fmt.int(rows.length)} rows · {schema.stores.length} stores
</span>
{!empty && view === "ask" && (
<button
type="button"
onClick={() => {
stop();
setAnswers((as) => as.filter((a) => a.pinned));
setAnnounce("Conversation cleared. Pinned answers stay on the board.");
}}
className={cn("inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] text-muted-foreground hover:bg-accent hover:text-foreground", focusRing)}
>
<Trash2 className="size-3.5" aria-hidden />
<span className="hidden sm:inline">Clear</span>
<span className="sr-only sm:hidden">Clear conversation</span>
</button>
)}
</div>
</header>
{view === "ask" ? (
<div className="flex min-h-0 flex-1 flex-col">
<div
ref={logRef}
role="log"
aria-label="Conversation"
aria-live="off"
onScroll={(e) => {
const el = e.currentTarget;
// Scroll events arrive a frame late; ignore the ones we caused ourselves.
if (Math.abs(el.scrollTop - autoScrollRef.current) < 2) return;
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain [overflow-anchor:none]"
>
{empty ? (
<div className="mx-auto flex min-h-full max-w-2xl flex-col justify-center px-4 py-8">
<motion.div initial={reduced ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="text-center">
<div aria-hidden className="mx-auto mb-5 flex h-16 w-28 items-end justify-center gap-1.5">
{[0.45, 0.7, 0.55, 0.95, 0.8].map((h, i) => (
<motion.span
key={i}
className="w-4 rounded-t-md bg-gradient-to-t from-indigo-600 to-sky-400"
initial={reduced ? false : { height: 0 }}
animate={{ height: `${h * 100}%` }}
transition={{ type: "spring", stiffness: 120, damping: 14, delay: 0.1 + i * 0.07 }}
/>
))}
</div>
<h1 className="text-balance text-2xl font-semibold tracking-tight sm:text-[28px]">Ask anything about your stores</h1>
<p className="mt-2 text-[13.5px] text-muted-foreground">
{fmt.int(rows.length)} rows of weekly sales · {schema.stores.length} stores · {schema.categories.length} categories. Every answer shows its query.
</p>
</motion.div>
<ul className="mt-7 grid gap-2 sm:grid-cols-2" aria-label="Suggested questions">
{suggestions.map((s, i) => {
const Icon = SUGGESTION_ICONS[i % SUGGESTION_ICONS.length];
return (
<motion.li key={s} initial={reduced ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: reduced ? 0 : 0.2 + i * 0.05 }}>
<button
type="button"
onClick={() => void run(s)}
className={cn("group flex w-full items-center gap-3 rounded-xl border bg-card px-3.5 py-3 text-left text-[13px] shadow-sm transition-all hover:-translate-y-px hover:border-[var(--ia-1)]/40 hover:shadow-md", focusRing)}
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-[var(--ia-1)]/10 text-[var(--ia-1)]">
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">{s}</span>
</button>
</motion.li>
);
})}
</ul>
</div>
) : (
<div className="mx-auto max-w-3xl space-y-6 px-3 py-5 sm:px-5">
{answers.map((a) => (
<AnswerCard
key={a.id}
answer={a}
fmt={fmt}
highlight={hl && hl.id === a.id ? hl.key : null}
onHighlight={(key) => setHl(key ? { id: a.id, key } : null)}
onFollowUp={(label, query) => void run(label, query)}
onPin={togglePin}
onRetry={retry}
clarifyChips={suggestions.slice(0, 3)}
/>
))}
</div>
)}
</div>
<form
className="shrink-0 border-t bg-background/80 p-3 backdrop-blur sm:px-5"
onSubmit={(e) => {
e.preventDefault();
if (busy) return;
void run(draft);
}}
>
<div className="mx-auto flex max-w-3xl items-center gap-2 rounded-2xl border bg-card p-1.5 pl-3.5 shadow-sm focus-within:ring-2 focus-within:ring-ring">
<label htmlFor="ia-prompt" className="sr-only">
Ask a question about your sales
</label>
<input
ref={inputRef}
id="ia-prompt"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="e.g. Top 3 stores by margin last quarter"
autoComplete="off"
className="h-9 min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-muted-foreground"
/>
<kbd className="hidden rounded border bg-muted px-1.5 font-mono text-[10.5px] text-muted-foreground sm:block" aria-hidden>
/
</kbd>
{busy ? (
<button type="button" onClick={stop} aria-label="Stop answering" className={cn("grid size-9 place-items-center rounded-xl bg-foreground text-background", focusRing)}>
<Square className="size-3.5 fill-current" aria-hidden />
</button>
) : (
<button type="submit" disabled={!draft.trim()} aria-label="Ask" className={cn("grid size-9 place-items-center rounded-xl bg-primary text-primary-foreground transition-opacity disabled:opacity-40", focusRing)}>
<ArrowUp className="size-4" aria-hidden />
</button>
)}
</div>
</form>
</div>
) : (
<div className="min-h-0 flex-1">
<Board answers={answers} fmt={fmt} onUnpin={(id) => togglePin(id, null)} onOpen={openAnswer} onBrowse={focusPrompt} />
</div>
)}
</div>
{/* Mobile drawer */}
<AnimatePresence>
{drawer && (
<>
<motion.div key="scrim" className="absolute inset-0 z-30 bg-black/40 lg:hidden" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setDrawer(false)} aria-hidden />
<motion.div
key="drawer"
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label="Menu"
className="absolute inset-y-0 left-0 z-40 w-[82%] max-w-72 border-r bg-background shadow-2xl lg:hidden"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 380, damping: 38 }}
>
<Sidebar
{...sidebarProps}
onClose={() => {
setDrawer(false);
menuRef.current?.focus();
}}
/>
</motion.div>
</>
)}
</AnimatePresence>
{/* Pin flight */}
{ghost && (
<motion.div
key={ghost.key}
aria-hidden
className="pointer-events-none absolute left-0 top-0 z-50 rounded-2xl border-2 border-[var(--ia-1)] bg-[var(--ia-1)]/15 shadow-xl"
initial={{ x: ghost.from.left - ghost.root.left, y: ghost.from.top - ghost.root.top, width: ghost.from.width, height: Math.min(ghost.from.height, 420), opacity: 0.95 }}
animate={{ x: ghost.to.left - ghost.root.left, y: ghost.to.top - ghost.root.top, width: ghost.to.width, height: ghost.to.height, opacity: 0.35, borderRadius: 10 }}
transition={{ type: "spring", stiffness: 140, damping: 22 }}
onAnimationComplete={() => {
setGhost(null);
setBump((b) => b + 1);
}}
/>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
</MotionConfig>
);
}