Fazekit

Code

"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>
  );
}

More in AI

View all →