Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit ff43837c4aafab58729486df34a6a8518d03c9e9
parent a353c5523f64965b3c2214914f0e38a2146e5704
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 26 May 2026 23:26:26 -0400

query builder for charts

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcommon/components/charts/ChartConfigEditor.tsx | 92+++++++++++++++++++++++--------------------------------------------------------
Mcommon/components/charts/ChartsDashboard.tsx | 61+++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcommon/lib/chartConfig.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 2+-
Mexport/CHANGELOG.md | 2++
Mexport/e2e/charts.spec.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
7 files changed, 343 insertions(+), 94 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -48,6 +48,12 @@ import { } from "./exportAdvancedStorage"; import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; import QueryBuilder from "./QueryBuilder"; +import { + chartFromSearch, + CHART_THIS_SEARCH_TEMPLATES, + type ChartFilters, +} from "../lib/chartConfig"; +import { stringifyChart } from "../lib/chartShare"; import { LayerSwatch } from "./LayerSwatch"; import type { DisplaySummary } from "../lib/transcripts"; import { @@ -1178,6 +1184,48 @@ export default function TranscriptSearch() { committedRoot, ]); + // name → slug, so "Chart this search" can translate the search's channel + // selection (held by name) into the chart's slug-based channel filter. The + // summaries manifest only carries names, so derive the mapping from the + // loaded summaries (which carry both). + const channelSlugByName = useMemo(() => { + const m = new Map<string, string>(); + if (summaries) + for (const s of summaries) + if (s.channel && !m.has(s.channel)) m.set(s.channel, s.channelSlug); + return m; + }, [summaries]); + + const handleChartThisSearch = useCallback( + (templateId: string, mode: "add" | "replace") => { + if (!isNodeActive(committedRoot)) return; + // Carry the channel selection: when some channels are excluded, pass the + // selected ones (mapped to slugs) as the chart's channel filter. + const selectedSlugs: string[] = []; + let excludedAny = false; + for (const name of channelOptions) { + if (committedExcludedChannels.has(name)) { + excludedAny = true; + continue; + } + const slug = channelSlugByName.get(name); + if (slug) selectedSlugs.push(slug); + } + const filters: ChartFilters = {}; + if (excludedAny && selectedSlugs.length) filters.channels = selectedSlugs; + const config = chartFromSearch( + templateId, + stringifyRoot(committedRoot), + filters, + ); + const enc = encodeURIComponent(stringifyChart(config)); + window.location.assign( + `/charts?addChart=${enc}${mode === "replace" ? "&chartMode=replace" : ""}`, + ); + }, + [committedRoot, channelOptions, committedExcludedChannels, channelSlugByName], + ); + const setSelected = useCallback( (targets: ReadonlyArray<string>, selected: boolean) => { setDraftExcludedChannels((prev) => { @@ -1244,6 +1292,8 @@ export default function TranscriptSearch() { onDelete={handleDeleteProfile} onRevert={handleRevertProfile} onShareCurrentSearch={handleShareCurrentSearch} + onChartThisSearch={handleChartThisSearch} + chartDisabled={!hasActiveQuery} /> <form onSubmit={(e) => { @@ -1927,6 +1977,8 @@ function ProfilesRow({ onDelete, onRevert, onShareCurrentSearch, + onChartThisSearch, + chartDisabled, }: { profileNames: string[]; activeProfileName: string | null; @@ -1939,6 +1991,8 @@ function ProfilesRow({ onDelete: () => void; onRevert: () => void; onShareCurrentSearch: () => void; + onChartThisSearch: (templateId: string, mode: "add" | "replace") => void; + chartDisabled: boolean; }) { const showRevert = activeProfileName != null && profileDirty; return ( @@ -2013,18 +2067,95 @@ function ProfilesRow({ > Delete </button> + <div className="ml-auto flex items-center gap-x-3"> + <ChartThisSearchMenu + onChart={onChartThisSearch} + disabled={chartDisabled} + /> + <button + type="button" + onClick={onShareCurrentSearch} + title={ + shareCopied + ? "Copied!" + : "Copy link encoding the current search + filters" + } + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + > + {shareCopied ? "Link copied!" : "Share current search"} + </button> + </div> + </div> + ); +} + +// Dropdown beside "Share current search": pick a starting chart template and +// whether to add it to the existing board or open it as a single-chart board, +// then jump to /charts with the current query pre-filled. +function ChartThisSearchMenu({ + onChart, + disabled, +}: { + onChart: (templateId: string, mode: "add" | "replace") => void; + disabled: boolean; +}) { + const [open, setOpen] = useState(false); + const [addToBoard, setAddToBoard] = useState(true); + const ref = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + return ( + <div className="relative" ref={ref}> <button type="button" - onClick={onShareCurrentSearch} + disabled={disabled} + onClick={() => setOpen((v) => !v)} title={ - shareCopied - ? "Copied!" - : "Copy link encoding the current search + filters" + disabled + ? "Enter a search to chart it" + : "Build a chart from this search" } - className="ml-auto text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" + className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-40 disabled:no-underline" > - {shareCopied ? "Link copied!" : "Share current search"} + Chart this search ▾ </button> + {open && !disabled && ( + <div + data-testid="chart-this-search-menu" + className="absolute right-0 z-10 mt-1 flex w-60 flex-col rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1 shadow-lg" + > + {CHART_THIS_SEARCH_TEMPLATES.map((t) => ( + <button + key={t.id} + type="button" + onClick={() => { + setOpen(false); + onChart(t.id, addToBoard ? "add" : "replace"); + }} + className="rounded px-2 py-1.5 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800" + > + {t.label} + </button> + ))} + <label className="mt-1 flex items-center gap-2 border-t border-zinc-200 dark:border-zinc-800 px-2 py-1.5 text-xs text-zinc-500"> + <input + type="checkbox" + checked={addToBoard} + onChange={(e) => setAddToBoard(e.target.checked)} + /> + Add to existing board + </label> + </div> + )} </div> ); } diff --git a/common/components/charts/ChartConfigEditor.tsx b/common/components/charts/ChartConfigEditor.tsx @@ -11,17 +11,22 @@ import type { YMetric, } from "../../lib/chartConfig"; import { - newGroup, - newLeaf, + emptyRoot, parseRoot, stringifyRoot, - forEachLeaf, - type LayerScope, + type GroupNode, } from "../../lib/searchQuery"; +import type { GroupState, LeafState } from "../../lib/searchEval"; +import QueryBuilder from "../QueryBuilder"; const sel = "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"; const lbl = "flex flex-col gap-1 text-xs font-medium text-zinc-500"; +// The chart editor has no live search evaluation, so the builder renders without +// per-leaf/group count badges. +const NO_LEAF_STATES: ReadonlyMap<string, LeafState> = new Map(); +const NO_GROUP_STATES: ReadonlyMap<string, GroupState> = new Map(); + const METRIC_FIELDS: { value: MetricField; label: string }[] = [ { value: "duration", label: "Duration" }, { value: "viewCount", label: "Views" }, @@ -31,24 +36,6 @@ const METRIC_FIELDS: { value: MetricField; label: string }[] = [ { value: "cueCount", label: "Cue count" }, ]; -// Pull the first leaf's query/scope/regex out of a serialized tree for editing. -function firstLeaf(tree?: string): { - query: string; - scope: LayerScope; - useRegex: boolean; -} { - const fallback = { query: "", scope: "transcripts" as LayerScope, useRegex: false }; - if (!tree) return fallback; - const root = parseRoot(tree); - let found = fallback; - if (root) - forEachLeaf(root, (l) => { - if (!found.query && l.query) - found = { query: l.query, scope: l.scope, useRegex: l.useRegex }; - }); - return found; -} - export function ChartConfigEditor({ initial, onSave, @@ -59,13 +46,13 @@ export function ChartConfigEditor({ onCancel: () => void; }) { const [draft, setDraft] = useState<ChartConfig>(initial); - const seed = firstLeaf(initial.search?.queryTree); const [mode, setMode] = useState<"metadata" | "search">( initial.search ? "search" : "metadata", ); - const [term, setTerm] = useState(seed.query); - const [scope, setScope] = useState<LayerScope>(seed.scope); - const [useRegex, setUseRegex] = useState(seed.useRegex); + // The full query builder owns the chart's search tree while editing. + const [searchRoot, setSearchRoot] = useState<GroupNode>( + () => parseRoot(initial.search?.queryTree ?? "") ?? emptyRoot(), + ); const patch = (p: Partial<ChartConfig>) => setDraft((d) => ({ ...d, ...p })); const patchX = (x: XAxisField) => patch({ x }); @@ -74,13 +61,10 @@ export function ChartConfigEditor({ const save = () => { let search: ChartConfig["search"]; if (mode === "search") { - // Keep the search block even with an empty term, so the card prompts the + // Keep the search block even with an empty query, so the card prompts the // user to fill one in rather than silently reverting to a metadata chart. - const root = newGroup({ - children: [newLeaf({ query: term.trim(), scope, useRegex })], - }); search = { - queryTree: stringifyRoot(root), + queryTree: stringifyRoot(searchRoot), metric: draft.search?.metric ?? "matchedVideos", }; } else { @@ -287,38 +271,9 @@ export function ChartConfigEditor({ )} {mode === "search" && ( - <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3"> + <fieldset className="flex flex-col gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3"> <legend className="px-1 text-xs font-semibold">Search</legend> - <label className={lbl}> - Term - <input - className={sel} - value={term} - placeholder={useRegex ? "e.g. tim\\s+pool" : "e.g. bitcoin"} - onChange={(e) => setTerm(e.target.value)} - /> - </label> - <label className="flex items-center gap-2 self-end pb-1 text-sm"> - <input - type="checkbox" - checked={useRegex} - onChange={(e) => setUseRegex(e.target.checked)} - /> - Regex - </label> - <label className={lbl}> - In - <select - className={sel} - value={scope} - onChange={(e) => setScope(e.target.value as LayerScope)} - > - <option value="transcripts">Transcripts</option> - <option value="metadata">Title / channel</option> - <option value="chat">Live chat</option> - </select> - </label> - <label className={lbl}> + <label className={`${lbl} self-start`}> Plot <select className={sel} @@ -336,9 +291,16 @@ export function ChartConfigEditor({ <option value="totalHits">Total hits</option> </select> </label> - <p className="w-full text-xs text-zinc-500"> - Each matched video counts once, or by its hit count. The X axis, - grouping, and filters above still apply. + <QueryBuilder + root={searchRoot} + onChange={setSearchRoot} + leafStates={NO_LEAF_STATES} + groupStates={NO_GROUP_STATES} + /> + <p className="text-xs text-zinc-500"> + Build the query with any number of layers (AND/OR/NOT). Each matched + video counts once, or by its hit count. The X axis, grouping, and + filters above still apply. </p> </fieldset> )} diff --git a/common/components/charts/ChartsDashboard.tsx b/common/components/charts/ChartsDashboard.tsx @@ -11,7 +11,11 @@ import { type GalleryGroup, type GalleryTemplate, } from "../../lib/chartConfig"; -import { stringifyDashboard, parseDashboard } from "../../lib/chartShare"; +import { + stringifyDashboard, + parseDashboard, + parseChart, +} from "../../lib/chartShare"; import { ChartCard } from "./ChartCard"; import { ChartConfigEditor } from "./ChartConfigEditor"; import { FilterBar, type ChannelOption } from "./FilterBar"; @@ -62,24 +66,53 @@ export function ChartsDashboard({ useEffect(() => { if (!syncUrl || hydrated.current) return; hydrated.current = true; - const fromUrl = new URLSearchParams(window.location.search).get("dash"); + const search = new URLSearchParams(window.location.search); + + // Resolve the base board first (?dash= → localStorage → editor default), + // so a "Chart this search" handoff can append onto it. + let base = initialDashboard; + const fromUrl = search.get("dash"); if (fromUrl) { const parsed = parseDashboard(fromUrl); - if (parsed && parsed.charts.length) { - setDashboard(parsed); - return; + if (parsed && parsed.charts.length) base = parsed; + } else { + try { + const saved = window.localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = parseDashboard(saved); + if (parsed && parsed.charts.length) base = parsed; + } + } catch { + // ignore storage errors (private mode, etc.) } } - try { - const saved = window.localStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = parseDashboard(saved); - if (parsed && parsed.charts.length) setDashboard(parsed); - } - } catch { - // ignore storage errors (private mode, etc.) + + // "Chart this search" handoff: a single serialized chart to add to (or, in + // replace mode, become) the board. Open it in the editor and persist it. + const addChart = search.get("addChart"); + const chart = addChart ? parseChart(addChart) : null; + if (chart) { + const fresh: ChartConfig = { ...chart, id: newChart().id }; + const next = + search.get("chartMode") === "replace" + ? { version: 1 as const, title: "Charted search", filters: {}, charts: [fresh] } + : { ...base, charts: [...base.charts, fresh] }; + dirty.current = true; + setDashboard(next); + setEditing(fresh); + search.delete("addChart"); + search.delete("chartMode"); + const qs = search.toString(); + window.history.replaceState( + window.history.state, + "", + qs ? `${window.location.pathname}?${qs}` : window.location.pathname, + ); + return; } - }, [syncUrl]); + + if (base !== initialDashboard) setDashboard(base); + }, [syncUrl, initialDashboard]); // Persist edits to localStorage + ?dash= (viewer only), once dirty. useEffect(() => { diff --git a/common/lib/chartConfig.ts b/common/lib/chartConfig.ts @@ -296,6 +296,54 @@ export const PRESET_GROUPS: GalleryGroup[] = [ { id: "overview", title: "Overview", templates: OVERVIEW_TEMPLATES }, ]; +// ─── "Chart this search" handoff ─── +// The starting templates offered by the search page's "Chart this search" menu. +// Each reuses a SEARCH_TEMPLATES config as the shape (x axis / type / metric / +// grouping); the live query tree is swapped in by chartFromSearch(). +export const CHART_THIS_SEARCH_TEMPLATES: { + id: string; + label: string; + base: ChartConfig; +}[] = [ + { + id: "mentions-over-time", + label: "Mentions over time", + base: SEARCH_TEMPLATES[0].config, + }, + { + id: "matching-videos-per-month", + label: "Matching videos per month", + base: SEARCH_TEMPLATES[1].config, + }, + { + id: "matches-by-channel", + label: "Matches by channel", + base: SEARCH_TEMPLATES[2].config, + }, +]; + +// Build a chart from a "Chart this search" template + a live serialized query +// tree (+ optional carried filters). Keeps the template's x/type/metric/group, +// swaps in the real query, and assigns a fresh id. +export function chartFromSearch( + templateId: string, + queryTree: string, + filters: ChartFilters = {}, +): ChartConfig { + const tpl = + CHART_THIS_SEARCH_TEMPLATES.find((t) => t.id === templateId) ?? + CHART_THIS_SEARCH_TEMPLATES[0]; + return newChart({ + ...tpl.base, + id: nextChartId(), + filters, + search: { + queryTree, + metric: tpl.base.search?.metric ?? "matchedVideos", + }, + }); +} + // YYYYMMDD for `yearsBack` years before today. Used to scope the default // search charts to a recent window so they don't search the whole corpus on // load. Computed at build time (baked into chart-templates.json). diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## [Unreleased] -- **Charts authoring + stats dataset.** A new **Charts** tab lets you author the default chart dashboard that ships to the viewer — add/edit/remove charts and configure axes, metrics, series, filters, and search-derived series with a live preview; edits save automatically and bake into the next export build. A new **Build stats dataset** action on the Build page extracts per-video stats (views, likes, comments, follower count, duration, categories, language, cue counts) into `export/public/stats/` for the charts to read; it's incremental (mtime short-circuit) and also runs automatically as part of the static export build. +- **Charts authoring + stats dataset.** A new **Charts** tab lets you author the default chart dashboard that ships to the viewer — add/edit/remove charts and configure axes, metrics, series, filters, and search-derived series with a live preview; edits save automatically and bake into the next export build. Search-derived charts now use the full layered query builder (AND/OR/NOT, nesting, per-layer scope/regex), matching the search page. A new **Build stats dataset** action on the Build page extracts per-video stats (views, likes, comments, follower count, duration, categories, language, cue counts) into `export/public/stats/` for the charts to read; it's incremental (mtime short-circuit) and also runs automatically as part of the static export build. - **Per-operation progress bars on `/jobs/active`.** Each running download/transcription now shows its own live progress bar parsed from the tool's shell output — yt-dlp's download percent (and fragment count) and whisper's transcribed position against the audio length. Batch jobs (Transcribe missing, Download from playlist, Sync, …) list a bar per in-flight video underneath the batch's overall bar; standalone single-video jobs get one too. The screen now polls about once a second so the bars advance live. - **"Drain" (soft-cancel) for batch jobs.** Alongside the existing Cancel (which immediately kills everything), running batches now offer **Drain**: it lets the in-flight operations finish, starts no new ones, then completes the job normally and releases the queue so the next batch can run. Hard Cancel still works at any time, including mid-drain. - **"Build static export" moved from the Build page to the top of the Deploy page**, co-locating it with the export changelog preview, "Cut release", and "Deploy static export" so the whole build → review → cut → deploy sequence lives on one page. diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- **Advanced query builder when charting search matches.** A chart's search source now uses the same layered query builder as the main search page — combine any number of layers with AND/OR/NOT, nest groups, and set each layer's scope and regex — instead of the old single term box. Simple one-term searches still collapse to a single input. +- **"Chart this search."** A new control beside "Share current search" turns the query you're looking at into a chart in one click: pick a starting template (mentions over time / matching videos per month / matches by channel) and jump to the Charts tab with the query pre-filled and ready to edit. Choose whether it's added to your existing board or opens as a single-chart board, and your selected channels carry over as the chart's channel filter. - **Charts.** A new **Charts** tab (a prominent primary nav item alongside Search) visualises the library. Build charts from video metadata — upload date (binned by week/month/quarter/year, with optional cumulative running totals), engagement metrics (views, likes, comments, follower count), duration distributions, categories, languages — or from **search matches**, plotting how many videos match a term (or total hits) over time. Each chart has labelled axes and hover tooltips. Pick the chart type (line/bar/stacked/area/pie), split any chart into series by channel/platform/language, and apply a dashboard-wide filter bar (date range, platform, channel, has-transcript). The editor sets the data source with a clear Metadata/Search toggle, and search terms can be plain text or **regex**. A chart's date/channel/platform filters narrow the search scope (not just the displayed results), so a date range keeps a search chart light. The default board leads with example search charts, date-limited to the recent ~2 years so they load quickly out of the box. Charts open with an editor-authored default dashboard plus a grouped gallery of templates — including ready-made search examples — that you can fork and customise. Your board auto-saves locally and survives reloads; **Reset to default** restores the editor's dashboard. **Share** copies a link that restores the whole board, and every chart can be exported as a PNG (download or copy to clipboard) or its data as CSV. Engagement metrics come from a new stats dataset built alongside the search index. ## [0.2.2] - 2026-05-26 diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts @@ -63,7 +63,8 @@ test.describe("charts", () => { await page.goto("/charts"); await page.getByRole("button", { name: "New blank" }).click(); await page.getByRole("button", { name: "Search matches" }).click(); - await page.getByPlaceholder("e.g. bitcoin").fill("alpha"); + // The chart editor now embeds the full query builder. + await page.getByTestId("query-builder").getByRole("searchbox").fill("alpha"); await page.getByRole("button", { name: "Save", exact: true }).click(); // "alpha" appears once per fixture transcript → 3 videos, 3 hits. await expect(page.getByText(/3 videos · 3 hits/)).toBeVisible({ @@ -75,8 +76,9 @@ test.describe("charts", () => { await page.goto("/charts"); await page.getByRole("button", { name: "New blank" }).click(); await page.getByRole("button", { name: "Search matches" }).click(); - await page.getByRole("checkbox", { name: "Regex" }).check(); - await page.getByPlaceholder(/tim/).fill("alpha|beta"); + const builder = page.getByTestId("query-builder"); + await builder.getByRole("checkbox", { name: "Regex" }).check(); + await builder.getByRole("searchbox").fill("alpha|beta"); await page.getByRole("button", { name: "Save", exact: true }).click(); // "alpha" and "beta" each appear once per fixture transcript → 3 videos, 6 hits. await expect(page.getByText(/3 videos · 6 hits/)).toBeVisible({ @@ -100,12 +102,12 @@ test.describe("charts", () => { }) => { await page.goto("/charts"); await page.getByRole("button", { name: "New blank" }).click(); - // Metadata mode by default: Y-metric shown, no term input. + // Metadata mode by default: Y-metric shown, no query builder. await expect(page.getByText("Y metric")).toBeVisible(); - await expect(page.getByPlaceholder("e.g. bitcoin")).toHaveCount(0); - // Switch to Search: term input shown, Y-metric gone. + await expect(page.getByTestId("query-builder")).toHaveCount(0); + // Switch to Search: query builder shown, Y-metric gone. await page.getByRole("button", { name: "Search matches" }).click(); - await expect(page.getByPlaceholder("e.g. bitcoin")).toBeVisible(); + await expect(page.getByTestId("query-builder")).toBeVisible(); await expect(page.getByText("Y metric")).toHaveCount(0); }); @@ -120,7 +122,7 @@ test.describe("charts", () => { await expect(page.getByText("Search examples")).toBeVisible(); // Adding a search starter (empty term) opens the editor to fill it in. await page.getByRole("button", { name: "Mentions of a term over time" }).click(); - await expect(page.getByPlaceholder("e.g. bitcoin")).toBeVisible(); + await expect(page.getByTestId("query-builder")).toBeVisible(); }); test("viewer edits persist across a reload and Reset clears them", async ({ @@ -143,4 +145,75 @@ test.describe("charts", () => { await expect(page.getByRole("heading", { name: "PERSISTED CHART" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Uploads per month" })).toBeVisible(); }); + + // The search page builds this kind of param via "Chart this search"; here we + // verify the charts page consumes it (opens the editor on the pre-filled chart). + const addChartParam = (mode?: "replace") => { + const chart = JSON.stringify({ + t: "Charted: alpha", + ty: "line", + x: { k: "t", b: "month" }, + y: { a: "count" }, + s: { + q: '{"k":"g","o":"AND","c":[{"k":"l","q":"alpha","s":"transcripts"}]}', + m: "totalHits", + }, + }); + return `?addChart=${encodeURIComponent(chart)}${mode === "replace" ? "&chartMode=replace" : ""}`; + }; + + test("addChart handoff adds a pre-filled chart to the board", async ({ + page, + }) => { + await page.goto(`/charts${addChartParam()}`); + // Editor opens on the new chart with the query pre-filled in the builder. + await expect( + page.getByTestId("query-builder").getByRole("searchbox"), + ).toHaveValue("alpha"); + // The default board's charts remain (added, not replaced). + await expect( + page.getByRole("heading", { name: "Uploads per month" }), + ).toBeVisible(); + // The handoff params are stripped from the URL after consumption. + await expect + .poll(() => page.evaluate(() => window.location.search)) + .not.toContain("addChart"); + }); + + test("addChart handoff with replace mode shows only the charted search", async ({ + page, + }) => { + await page.goto(`/charts${addChartParam("replace")}`); + await expect( + page.getByTestId("query-builder").getByRole("searchbox"), + ).toHaveValue("alpha"); + // Replace mode drops the default board. + await expect( + page.getByRole("heading", { name: "Uploads per month" }), + ).toHaveCount(0); + }); + + // Full round-trip: "Chart this search" on the search page → /charts editor. + // installChartRoutes also mocks the search page's data, so both ends work. + test("Chart this search hands the query off from the search page", async ({ + page, + }) => { + await page.goto("/"); + const search = page.getByPlaceholder("Search transcripts..."); + await expect(search).toBeVisible(); + await search.fill("alpha"); + await search.press("Enter"); + await expect(page).toHaveURL(/[?&]qt=/); + + await page.getByRole("button", { name: /Chart this search/ }).click(); + await page + .getByTestId("chart-this-search-menu") + .getByRole("button", { name: "Mentions over time" }) + .click(); + + await expect(page).toHaveURL(/\/charts/); + await expect( + page.getByTestId("query-builder").getByRole("searchbox"), + ).toHaveValue("alpha"); + }); });