commit dd3b14ce1c9fcf25e9d929c90975bd73265e8cd3
parent 0dcb798573b0f236eefaafbc55a97111a6569e4c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 31 May 2026 14:16:55 -0400
Date filter
Diffstat:
8 files changed, 387 insertions(+), 13 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -55,6 +55,7 @@ import {
} from "../lib/chartConfig";
import { stringifyChart } from "../lib/chartShare";
import { LayerSwatch } from "./LayerSwatch";
+import { ymdToInput, inputToYmd } from "../lib/ymd";
import type { DisplaySummary } from "../lib/transcripts";
import {
DEFAULT_GROUP_FALLBACK_ID,
@@ -181,6 +182,9 @@ export default function TranscriptSearch() {
const [committedNav, setCommittedNav] = useState(false);
const [committedNd, setCommittedNd] = useState(false);
const [committedNu, setCommittedNu] = useState(false);
+ // Inclusive upload-date bounds ("YYYYMMDD"); "" => unbounded on that end.
+ const [committedDateFrom, setCommittedDateFrom] = useState("");
+ const [committedDateTo, setCommittedDateTo] = useState("");
const committedChannelsKey = useMemo(
() => Array.from(committedExcludedChannels).sort().join(" "),
[committedExcludedChannels],
@@ -209,6 +213,8 @@ export default function TranscriptSearch() {
const [draftNav, setDraftNav] = useState(false);
const [draftNd, setDraftNd] = useState(false);
const [draftNu, setDraftNu] = useState(false);
+ const [draftDateFrom, setDraftDateFrom] = useState("");
+ const [draftDateTo, setDraftDateTo] = useState("");
const [hydrated, setHydrated] = useState(false);
const [profiles, setProfiles] = useState<Record<string, FilterSnapshot>>({});
@@ -460,6 +466,8 @@ export default function TranscriptSearch() {
const passesFilter = useMemo(() => {
return (t: Summary) => {
if (committedChannels.has(t.channel)) return false;
+ if (committedDateFrom && t.uploadDate < committedDateFrom) return false;
+ if (committedDateTo && t.uploadDate > committedDateTo) return false;
if (t.isLivestream ? committedNol : committedNov) return false;
if (t.ageRestricted ? committedNar : committedNaa) return false;
// Availability is a three-way split: deleted / unlisted / available.
@@ -476,6 +484,8 @@ export default function TranscriptSearch() {
};
}, [
committedChannels,
+ committedDateFrom,
+ committedDateTo,
committedNov,
committedNol,
committedNaa,
@@ -485,7 +495,7 @@ export default function TranscriptSearch() {
committedNu,
]);
- const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${committedNu ? 1 : 0}`;
+ const filterKey = `${committedChannelsKey}|${committedNov ? 1 : 0}|${committedNol ? 1 : 0}|${committedNaa ? 1 : 0}|${committedNar ? 1 : 0}|${committedNav ? 1 : 0}|${committedNd ? 1 : 0}|${committedNu ? 1 : 0}|${committedDateFrom}|${committedDateTo}`;
const globalScopeSlugs = useMemo<string[]>(() => {
if (!transcripts) return [];
@@ -638,7 +648,9 @@ export default function TranscriptSearch() {
draftNar !== committedNar ||
draftNav !== committedNav ||
draftNd !== committedNd ||
- draftNu !== committedNu;
+ draftNu !== committedNu ||
+ draftDateFrom !== committedDateFrom ||
+ draftDateTo !== committedDateTo;
const committedSnapshot = useMemo<FilterSnapshot>(() => {
const deltas = buildSnapshotDeltas(
@@ -654,6 +666,8 @@ export default function TranscriptSearch() {
if (committedNav) snap.nav = true;
if (committedNd) snap.nd = true;
if (committedNu) snap.nu = true;
+ if (committedDateFrom) snap.dateFrom = committedDateFrom;
+ if (committedDateTo) snap.dateTo = committedDateTo;
snap.query = stringifyRoot(committedRoot);
return snap;
}, [
@@ -667,6 +681,8 @@ export default function TranscriptSearch() {
committedNav,
committedNd,
committedNu,
+ committedDateFrom,
+ committedDateTo,
committedRoot,
]);
@@ -685,6 +701,8 @@ export default function TranscriptSearch() {
if (draftNav) snap.nav = true;
if (draftNd) snap.nd = true;
if (draftNu) snap.nu = true;
+ if (draftDateFrom) snap.dateFrom = draftDateFrom;
+ if (draftDateTo) snap.dateTo = draftDateTo;
snap.query = stringifyRoot(draftRoot);
return snap;
}, [
@@ -698,6 +716,8 @@ export default function TranscriptSearch() {
draftNav,
draftNd,
draftNu,
+ draftDateFrom,
+ draftDateTo,
draftRoot,
]);
@@ -710,6 +730,8 @@ export default function TranscriptSearch() {
setCommittedNav(draftNav);
setCommittedNd(draftNd);
setCommittedNu(draftNu);
+ setCommittedDateFrom(draftDateFrom);
+ setCommittedDateTo(draftDateTo);
stripAllFilterParamsFromUrl();
}, [
draftExcludedChannels,
@@ -720,6 +742,8 @@ export default function TranscriptSearch() {
draftNav,
draftNd,
draftNu,
+ draftDateFrom,
+ draftDateTo,
]);
// ─── Hydration ────────────────────────────────────────────────────────────
@@ -762,6 +786,8 @@ export default function TranscriptSearch() {
let initialNav = false;
let initialNd = false;
let initialNu = false;
+ let initialDateFrom = "";
+ let initialDateTo = "";
if (hasShareV1(search)) {
const sel = parseShareV1(search, channelOptions);
@@ -777,6 +803,8 @@ export default function TranscriptSearch() {
initialNav = !sel.available;
initialNd = !sel.deleted;
initialNu = !sel.unlisted;
+ initialDateFrom = sel.dateFrom ?? "";
+ initialDateTo = sel.dateTo ?? "";
} else if (urlHasAnyFilterParam()) {
const excluded = new Set(params.getAll("ch"));
initialExcluded = excluded;
@@ -809,6 +837,8 @@ export default function TranscriptSearch() {
initialNav = snapshot?.nav === true;
initialNd = snapshot?.nd === true;
initialNu = snapshot?.nu === true;
+ initialDateFrom = snapshot?.dateFrom ?? "";
+ initialDateTo = snapshot?.dateTo ?? "";
if (!resolvedRoot && snapshot?.query) {
resolvedRoot = parseRoot(snapshot.query);
}
@@ -828,6 +858,8 @@ export default function TranscriptSearch() {
setDraftNav(initialNav);
setDraftNd(initialNd);
setDraftNu(initialNu);
+ setDraftDateFrom(initialDateFrom);
+ setDraftDateTo(initialDateTo);
setCommittedExcludedChannels(initialExcluded);
setCommittedNov(initialNov);
setCommittedNol(initialNol);
@@ -836,6 +868,8 @@ export default function TranscriptSearch() {
setCommittedNav(initialNav);
setCommittedNd(initialNd);
setCommittedNu(initialNu);
+ setCommittedDateFrom(initialDateFrom);
+ setCommittedDateTo(initialDateTo);
setHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelOptions.length === 0, hydrated]);
@@ -940,6 +974,8 @@ export default function TranscriptSearch() {
setDraftNav(snapshot?.nav === true);
setDraftNd(snapshot?.nd === true);
setDraftNu(snapshot?.nu === true);
+ setDraftDateFrom(snapshot?.dateFrom ?? "");
+ setDraftDateTo(snapshot?.dateTo ?? "");
if (snapshot?.query) {
const parsed = parseRoot(snapshot.query);
if (parsed) setDraftRoot(parsed);
@@ -964,6 +1000,8 @@ export default function TranscriptSearch() {
setCommittedNav(snapshot?.nav === true);
setCommittedNd(snapshot?.nd === true);
setCommittedNu(snapshot?.nu === true);
+ setCommittedDateFrom(snapshot?.dateFrom ?? "");
+ setCommittedDateTo(snapshot?.dateTo ?? "");
if (snapshot?.query) {
const parsed = parseRoot(snapshot.query);
if (parsed) setCommittedRoot(parsed);
@@ -1149,6 +1187,8 @@ export default function TranscriptSearch() {
deleted: !committedNd,
unlisted: !committedNu,
tracks: new Set(),
+ dateFrom: committedDateFrom || undefined,
+ dateTo: committedDateTo || undefined,
};
const params = buildShareSearchParams(selection, {
q: "",
@@ -1181,6 +1221,8 @@ export default function TranscriptSearch() {
committedNar,
committedNav,
committedNd,
+ committedDateFrom,
+ committedDateTo,
committedRoot,
]);
@@ -1213,6 +1255,10 @@ export default function TranscriptSearch() {
}
const filters: ChartFilters = {};
if (excludedAny && selectedSlugs.length) filters.channels = selectedSlugs;
+ // Reuse the search's date range so the chart opens scoped to the same
+ // window. ChartFilters uses the same inclusive "YYYYMMDD" bounds.
+ if (committedDateFrom) filters.dateFrom = committedDateFrom;
+ if (committedDateTo) filters.dateTo = committedDateTo;
const config = chartFromSearch(
templateId,
stringifyRoot(committedRoot),
@@ -1223,7 +1269,14 @@ export default function TranscriptSearch() {
`/charts?addChart=${enc}${mode === "replace" ? "&chartMode=replace" : ""}`,
);
},
- [committedRoot, channelOptions, committedExcludedChannels, channelSlugByName],
+ [
+ committedRoot,
+ channelOptions,
+ committedExcludedChannels,
+ channelSlugByName,
+ committedDateFrom,
+ committedDateTo,
+ ],
);
const setSelected = useCallback(
@@ -1585,6 +1638,35 @@ export default function TranscriptSearch() {
Deleted
</label>
</div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-zinc-500">
+ Date
+ </span>
+ <label className="flex items-center gap-1.5 select-none">
+ From
+ <input
+ type="date"
+ data-testid="date-from"
+ value={ymdToInput(draftDateFrom)}
+ onChange={(e) =>
+ setDraftDateFrom(inputToYmd(e.target.value) ?? "")
+ }
+ className="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ </label>
+ <label className="flex items-center gap-1.5 select-none">
+ To
+ <input
+ type="date"
+ data-testid="date-to"
+ value={ymdToInput(draftDateTo)}
+ onChange={(e) =>
+ setDraftDateTo(inputToYmd(e.target.value) ?? "")
+ }
+ className="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ </label>
+ </div>
</div>
</details>
)}
diff --git a/common/components/charts/FilterBar.tsx b/common/components/charts/FilterBar.tsx
@@ -2,19 +2,10 @@
import { PLATFORM_VALUES, type Platform } from "../../lib/platform";
import type { ChartFilters } from "../../lib/chartConfig";
+import { ymdToInput, inputToYmd } from "../../lib/ymd";
export type ChannelOption = { slug: string; name: string };
-// "20260513" <-> "2026-05-13" for <input type="date">.
-function ymdToInput(ymd?: string): string {
- if (!ymd || !/^\d{8}$/.test(ymd)) return "";
- return `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
-}
-function inputToYmd(v: string): string | undefined {
- const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
- return m ? `${m[1]}${m[2]}${m[3]}` : undefined;
-}
-
const field = "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm";
const label = "text-xs font-medium text-zinc-500";
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -27,6 +27,9 @@ export type FilterSnapshot = {
nav?: boolean;
nd?: boolean;
nu?: boolean;
+ // Inclusive upload-date bounds, "YYYYMMDD". Absent => unbounded on that end.
+ dateFrom?: string;
+ dateTo?: string;
mode?: SearchMode;
tracks?: string[];
query?: string;
@@ -80,6 +83,12 @@ function parseSnapshot(raw: unknown): FilterSnapshot | null {
if (typeof r.nar === "boolean") snap.nar = r.nar;
if (typeof r.nav === "boolean") snap.nav = r.nav;
if (typeof r.nd === "boolean") snap.nd = r.nd;
+ if (typeof r.dateFrom === "string" && /^\d{8}$/.test(r.dateFrom)) {
+ snap.dateFrom = r.dateFrom;
+ }
+ if (typeof r.dateTo === "string" && /^\d{8}$/.test(r.dateTo)) {
+ snap.dateTo = r.dateTo;
+ }
if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode;
if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice();
if (typeof r.query === "string") snap.query = r.query;
@@ -187,6 +196,8 @@ export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean {
if ((a.nar ?? false) !== (b.nar ?? false)) return false;
if ((a.nav ?? false) !== (b.nav ?? false)) return false;
if ((a.nd ?? false) !== (b.nd ?? false)) return false;
+ if ((a.dateFrom ?? "") !== (b.dateFrom ?? "")) return false;
+ if ((a.dateTo ?? "") !== (b.dateTo ?? "")) return false;
if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false;
if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false;
if ((a.query ?? "") !== (b.query ?? "")) return false;
diff --git a/common/components/shareUrl.ts b/common/components/shareUrl.ts
@@ -18,6 +18,9 @@ export type ShareSelection = {
unlisted: boolean;
deleted: boolean;
tracks: Set<string>;
+ // Inclusive upload-date bounds, "YYYYMMDD". Absent => unbounded on that end.
+ dateFrom?: string;
+ dateTo?: string;
};
// Legacy filter keys auto-written by older versions of this app. Listed here
@@ -43,6 +46,8 @@ export const FILTER_URL_KEYS_V1 = [
"fa",
"fav",
"fk",
+ "fdf",
+ "fdt",
] as const;
export function hasShareV1(search: string): boolean {
@@ -66,6 +71,8 @@ export function parseShareV1(
const audience = new Set(p.getAll("fa"));
const availability = new Set(p.getAll("fav"));
const tracks = new Set(p.getAll("fk"));
+ const dfRaw = p.get("fdf");
+ const dtRaw = p.get("fdt");
return {
selectedChannels,
videos: types.has("v"),
@@ -76,6 +83,8 @@ export function parseShareV1(
unlisted: availability.has("u"),
deleted: availability.has("d"),
tracks,
+ dateFrom: dfRaw && /^\d{8}$/.test(dfRaw) ? dfRaw : undefined,
+ dateTo: dtRaw && /^\d{8}$/.test(dtRaw) ? dtRaw : undefined,
};
}
@@ -95,6 +104,8 @@ export function buildShareSearchParams(
if (committed.unlisted) p.append("fav", "u");
if (committed.deleted) p.append("fav", "d");
for (const tk of Array.from(committed.tracks).sort()) p.append("fk", tk);
+ if (committed.dateFrom) p.set("fdf", committed.dateFrom);
+ if (committed.dateTo) p.set("fdt", committed.dateTo);
if (opts.q) p.set("q", opts.q);
if (opts.mode === "subs") p.set("m", "subs");
if (opts.regex) p.set("re", "1");
diff --git a/common/lib/ymd.ts b/common/lib/ymd.ts
@@ -0,0 +1,18 @@
+// Conversions between the compact "YYYYMMDD" date keys used throughout the
+// export data (e.g. `DisplaySummary.uploadDate`) and the "YYYY-MM-DD" strings
+// that `<input type="date">` expects. Shared by the charts FilterBar and the
+// search date filter.
+
+// "20260513" -> "2026-05-13" for <input type="date">. Returns "" for anything
+// that isn't a well-formed 8-digit key.
+export function ymdToInput(ymd?: string): string {
+ if (!ymd || !/^\d{8}$/.test(ymd)) return "";
+ return `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
+}
+
+// "2026-05-13" -> "20260513". Returns undefined when the input is empty or
+// malformed (e.g. a cleared date input).
+export function inputToYmd(v: string): string | undefined {
+ const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ return m ? `${m[1]}${m[2]}${m[3]}` : undefined;
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Date-range filter on the public site's search.** The exported site's search filters gained a **Date** row (From / To pickers) that restricts results to videos uploaded within an inclusive range. The range saves in profiles and shareable search links and carries over to "Chart this search", reusing the same date mechanism the charts dashboard already uses. No editor-chrome change; it ships in every built site.
- **Bulk checkbox transcribe/retry now queue like every other batch.** Selecting videos in the channel's list and clicking **Transcribe** (or **Retry download**) used to fire one queued single-video job per selection onto the channel's *platform* queue — diverging from "Transcribe missing"/"Download", which submit one batch job on the shared `transcription`/platform queue. The checkbox actions now submit a **single** batch job through the same path (so bulk and the stage buttons serialize together instead of contending), and the resulting job shows up in the page's running-jobs list. The selection bar gained the matching controls: a **queue** selector for each action (defaulting to `transcription` for transcribe and the platform queue for retry), a **Parallel** concurrency input for transcribe, and an **Abort on error** toggle for retry. Default-queue resolution for all batch features now lives in one shared helper (`common/lib/queueKeys.ts`) so they can't drift apart again. "Mark untranscribable" is unchanged (instant metadata write).
- **Shard controls overhauled: "Save" button, 1-based `i / N` inputs, remaining-only transcribe slices, and always-on logging.** The shard inputs now read **`i / N`** (this shard's number first, 1-based — a 2-way split is `1/2` and `2/2`) and each box has its own hover title so they're no longer easy to mix up. A new **Save** button persists a slice to `shard-<op>.json` *without* starting the job — handy for dividing the remaining videos across machines (that share an identical synced copy) up front, and for confirming the slice actually saved; the saved-shard pill updates immediately. **Transcribe** sharding now splits only the videos that still need a transcript (e.g. 6 untranscribed videos split 2 ways → 3 each) instead of slicing the whole channel's video list. And every Download/Transcribe/Availability run now logs whether it's running a computed/saved shard slice or the full set, so a run that silently ignored shard inputs is no longer indistinguishable from one that honored it.
- **Shard configs now show up immediately, and a shard job's progress bar totals only its slice.** Previously, setting a shard (N/i) and running **Download videos** / **Transcribe missing** did persist the slice to `shard-*.json`, but the saved-shard indicator never updated until you manually reloaded the whole page — so it looked like nothing saved. The indicator on the Download/Transcribe/Diagnostics controls (and channel counts, buckets, and failed-lists generally) now refresh as soon as a run finishes — including after a cancel — without a reload. The download slice is still taken over the *missing* set and snapshotted to the file, so a resume run with the same N/i reuses that exact slice instead of re-slicing the now-smaller set. A sharded job's progress bar on `/jobs/active` now totals only its own slice rather than the whole channel.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [0.3.1] - 2026-05-31
+- **Date-range filter in search.** The filters section has a new **Date** row with From / To pickers that limit results to videos uploaded within the range (inclusive bounds; leave either end blank for open-ended). It works the same way the charts date range does and applies to the browse list and every search layer. The range saves in profiles and the working snapshot, rides along in shared search links, and carries over to **"Chart this search"** so the chart opens scoped to the same window.
+
## [0.3.0] - 2026-05-30
- **Per-site builds (major change).** The export is now built and deployed once per site from a shared index, so one editor can publish several independent sites (e.g. Jeralyzer and Rekietalyzer) over the same channel pool. Each deployed site shows only its own channels and carries its own branding (title, header, tagline, footer social links), channel groups, and charts dashboard — a channel shared between sites is downloaded once and never duplicated. The site is selected at build time via the `SITE_ID` env var; the heavy per-channel transcript/subs data is generated once and shared, while each site gets a filtered summaries/subs/stats bundle composed into its own static output. No user-visible change for a single-site install beyond the branding now coming from that site's config.
- **Twitch.tv videos.** Twitch is now a supported platform: Twitch videos play inline via a Twitch embed, and "Twitch" appears as an option in the charts platform filter and series grouping.
diff --git a/export/e2e/search-date-filter.spec.ts b/export/e2e/search-date-filter.spec.ts
@@ -0,0 +1,257 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+
+// Self-contained fixtures with videos spread across distinct upload dates so
+// the search date-range filter (common/components/TranscriptSearch.tsx) has
+// something to slice. We search in the "Title / channel" (metadata) scope —
+// every title contains "report", so the date range is the only variable.
+
+const CHANNEL = "Test Channel";
+const CHANNEL_SLUG = "test-channel";
+
+type Vid = { id: string; title: string; uploadDate: string };
+
+const VIDS: Vid[] = [
+ { id: "vid-2024", title: "Report from 2024", uploadDate: "20240101" },
+ { id: "vid-2025", title: "Report from 2025", uploadDate: "20250601" },
+ { id: "vid-2026", title: "Report from 2026", uploadDate: "20260301" },
+];
+
+function summary(v: Vid) {
+ return {
+ slug: `${CHANNEL_SLUG}/${v.id}`,
+ id: v.id,
+ channelSlug: CHANNEL_SLUG,
+ title: v.title,
+ uploadDate: v.uploadDate,
+ date: `${v.uploadDate.slice(0, 4)}-${v.uploadDate.slice(4, 6)}-${v.uploadDate.slice(6, 8)}`,
+ duration: "5:00",
+ channel: CHANNEL,
+ isLivestream: false,
+ ageRestricted: false,
+ isDeleted: false,
+ isUnlisted: false,
+ platform: "youtube" as const,
+ webpageUrl: `https://example.com/${v.id}`,
+ };
+}
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+}
+
+async function installRoutes(page: Page) {
+ const list = VIDS.map(summary);
+ await page.route("**/summaries/manifest.json", async (route) => {
+ await fulfillJson(route, {
+ version: 3,
+ totalCount: list.length,
+ pageSize: 1000,
+ pageCount: 1,
+ generatedAt: new Date().toISOString(),
+ channels: [{ name: CHANNEL, count: list.length, groupId: "default" }],
+ groups: [{ id: "default", name: "All channels", selectedByDefault: true }],
+ defaultGroupId: "default",
+ });
+ });
+ await page.route("**/summaries/page-*.json", async (route) => {
+ await fulfillJson(route, list);
+ });
+ // Metadata-scope search reads titles from the summaries; transcripts are
+ // never fetched, but stub the routes so nothing falls through to the dev
+ // server.
+ await page.route(/\/transcripts\/[^/]+\/manifest\.json$/, async (route) => {
+ const slugToPage: Record<string, number> = {};
+ for (const v of VIDS) slugToPage[v.id] = 0;
+ await fulfillJson(route, {
+ version: 1,
+ channelSlug: CHANNEL_SLUG,
+ pageCount: 1,
+ maxPageBytes: 8388608,
+ generatedAt: new Date().toISOString(),
+ slugToPage,
+ });
+ });
+ await page.route(/\/transcripts\/[^/]+\/page-\d+\.json$/, async (route) => {
+ await fulfillJson(
+ route,
+ list.map((s) => ({ slug: s.slug, id: s.id, cues: [] })),
+ );
+ });
+ // No live chat.
+ await page.route("**/subs/manifest.json", async (route) => {
+ await fulfillJson(route, {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: new Date().toISOString(),
+ });
+ });
+}
+
+function leafInput(page: Page) {
+ return page.locator('[data-testid^="leaf-query-"]').first();
+}
+function scopeSelect(page: Page) {
+ return page.locator('[data-testid^="leaf-scope-"]').first();
+}
+
+async function waitForHydration(page: Page) {
+ await page.getByTestId("query-builder").waitFor();
+}
+
+// Commit a metadata-scope query that matches every fixture title.
+async function searchReports(page: Page) {
+ await scopeSelect(page).selectOption({ label: "Title / channel" });
+ const input = leafInput(page);
+ await input.click();
+ await input.fill("report");
+ await input.press("Enter");
+ await page.waitForURL(/[?&]qt=/);
+}
+
+// Set the From/To date inputs (YYYY-MM-DD; "" clears) and commit the filters.
+async function setDates(page: Page, from: string, to: string) {
+ await page.getByTestId("date-from").fill(from);
+ await page.getByTestId("date-to").fill(to);
+ await page.getByTestId("search-submit").click();
+}
+
+// The result-card header button carries "<title> <channel>"; the channel
+// suffix disambiguates it from the metadata-match snippet button, which can
+// also contain the title text.
+function card(page: Page, titleRe: RegExp) {
+ return page.getByRole("button", {
+ name: new RegExp(`${titleRe.source}.*Test Channel`),
+ });
+}
+
+test.describe("export search — date-range filter", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ await page.goto("/");
+ await waitForHydration(page);
+ });
+
+ test("range narrows results to the window with inclusive bounds", async ({
+ page,
+ }) => {
+ await searchReports(page);
+ // All three visible before any date filter.
+ await expect(card(page, /Report from 2024/)).toBeVisible();
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+ await expect(card(page, /Report from 2026/)).toBeVisible();
+
+ // From is exactly on the 2025 video's date → inclusive, it stays.
+ await setDates(page, "2025-06-01", "2025-12-31");
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+ await expect(card(page, /Report from 2024/)).toHaveCount(0);
+ await expect(card(page, /Report from 2026/)).toHaveCount(0);
+ });
+
+ test("To-only bound is inclusive", async ({ page }) => {
+ await searchReports(page);
+ // To is exactly on the 2024 video's date → it stays; later videos drop.
+ await setDates(page, "", "2024-01-01");
+ await expect(card(page, /Report from 2024/)).toBeVisible();
+ await expect(card(page, /Report from 2025/)).toHaveCount(0);
+ await expect(card(page, /Report from 2026/)).toHaveCount(0);
+ });
+
+ test("From-only keeps that date and everything later", async ({ page }) => {
+ await searchReports(page);
+ await setDates(page, "2025-06-01", "");
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+ await expect(card(page, /Report from 2026/)).toBeVisible();
+ await expect(card(page, /Report from 2024/)).toHaveCount(0);
+ });
+
+ test("clearing both bounds restores all matches", async ({ page }) => {
+ await searchReports(page);
+ await setDates(page, "2025-06-01", "2025-12-31");
+ await expect(card(page, /Report from 2024/)).toHaveCount(0);
+
+ await setDates(page, "", "");
+ await expect(card(page, /Report from 2024/)).toBeVisible();
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+ await expect(card(page, /Report from 2026/)).toBeVisible();
+ });
+
+ test("range persists across a reload (working snapshot)", async ({ page }) => {
+ await searchReports(page);
+ await setDates(page, "2025-06-01", "2025-12-31");
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+
+ await page.reload();
+ await waitForHydration(page);
+
+ await expect(page.getByTestId("date-from")).toHaveValue("2025-06-01");
+ await expect(page.getByTestId("date-to")).toHaveValue("2025-12-31");
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+ await expect(card(page, /Report from 2024/)).toHaveCount(0);
+ await expect(card(page, /Report from 2026/)).toHaveCount(0);
+ });
+});
+
+test.describe("export search — date range in share + chart handoff", () => {
+ test.use({ permissions: ["clipboard-read", "clipboard-write"] });
+
+ test.beforeEach(async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await installRoutes(page);
+ await page.goto("/");
+ await waitForHydration(page);
+ });
+
+ test("share link carries the date range and round-trips", async ({ page }) => {
+ await searchReports(page);
+ await setDates(page, "2025-06-01", "2025-12-31");
+
+ await page
+ .getByTestId("profiles-row")
+ .getByRole("button", { name: "Share current search" })
+ .click();
+ await expect(
+ page.getByRole("button", { name: "Link copied!" }),
+ ).toBeVisible();
+
+ const clip = await page.evaluate(() => navigator.clipboard.readText());
+ const url = new URL(clip);
+ expect(url.searchParams.get("fdf")).toBe("20250601");
+ expect(url.searchParams.get("fdt")).toBe("20251231");
+
+ // Loading the shared link reproduces the range and the filtered results.
+ await page.goto(`${url.pathname}${url.search}`);
+ await waitForHydration(page);
+ await expect(page.getByTestId("date-from")).toHaveValue("2025-06-01");
+ await expect(page.getByTestId("date-to")).toHaveValue("2025-12-31");
+ await expect(card(page, /Report from 2025/)).toBeVisible();
+ await expect(card(page, /Report from 2024/)).toHaveCount(0);
+ await expect(card(page, /Report from 2026/)).toHaveCount(0);
+ });
+
+ test("'Chart this search' carries the date range into the chart config", async ({
+ page,
+ }) => {
+ await searchReports(page);
+ await setDates(page, "2025-06-01", "2025-12-31");
+
+ const reqP = page.waitForRequest((r) => r.url().includes("addChart"));
+ await page.getByRole("button", { name: /Chart this search/ }).click();
+ await page
+ .getByTestId("chart-this-search-menu")
+ .getByRole("button", { name: "Mentions over time" })
+ .click();
+
+ const req = await reqP;
+ const addChart = new URL(req.url()).searchParams.get("addChart") ?? "";
+ const config = JSON.parse(addChart);
+ // chartShare encodes ChartFilters under `f` with short keys df/dt.
+ expect(config.f?.df).toBe("20250601");
+ expect(config.f?.dt).toBe("20251231");
+ });
+});