commit a8c21966adf25ca0fcbea8a47f76d3d85d2f7183
parent 47700598931cb926e5027b5ca10cfa916d0ecfb1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 29 Jun 2026 00:48:54 -0400
Rebuild hub charts: presets + symlog explorer for the skewed data
The single five-control chart didn't fit the real data: 3 public sites with
one (Jeralyzer) at 96% of transcripts, 38 channels with a long tail, bursty
activity, and a default (Day · 2-week) that landed on the emptiest slice.
Lead with four data-tuned preset chips + an inline Customize matrix:
- Momentum (default): per-site lines over 90d on a d3 scaleSymlog y-axis, so
the small sites stay visible instead of flat-lining (symlog, not log, for
the zero-days).
- Growth: cumulative stacked area over all time.
- Leaderboard: top-12 channels as ranked horizontal bars (symlog value axis).
- Compare: small multiples, one panel per site at its own scale.
Plus the skew tools the data needs: linear⇄symlog toggle, indexed-to-baseline
(% growth, composes with symlog), and exclude-top-site. chart type is the
authoritative control; Symlog/Share/Indexed grey out when invalid.
A shared framework-free pipeline (homepageChartData.ts) feeds every chart; the
plot moves onto an opaque "instrument panel" surface for data contrast. Added
d3-scale to common. No summary-schema change — all derived client-side.
Verified: typecheck (common + homepage), static export build, and a new
homepage Playwright suite (e2e/landing.spec.ts, 6/6) covering each preset, the
symlog small-site visibility, the guard rails, exclude-outlier, indexed
baseline, and mobile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
18 files changed, 1303 insertions(+), 252 deletions(-)
diff --git a/common/components/charts/ChartMessage.tsx b/common/components/charts/ChartMessage.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from "react";
+
+// Shared empty/placeholder panel so every chart's "no data" state matches.
+export function ChartMessage({ children }: { children: ReactNode }) {
+ return (
+ <div className="flex h-[320px] items-center justify-center rounded-xl border border-[var(--border)] px-6 text-center text-sm text-[var(--muted-foreground)]">
+ {children}
+ </div>
+ );
+}
diff --git a/common/components/charts/ChartPanel.tsx b/common/components/charts/ChartPanel.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import type { HomepageSummary } from "../../lib/homepageSummary";
+import type { ChartState } from "../../lib/homepageChart";
+import { MomentumChart } from "./MomentumChart";
+import { SmallMultiples } from "./SmallMultiples";
+import { LeaderboardChart } from "./LeaderboardChart";
+import { CrossSiteChart } from "./CrossSiteChart";
+
+// Routes a ChartState to the right renderer. Keeps HomeLanding about controls
+// and presets; each chart owns its own axis/scale concerns.
+export function ChartPanel({
+ summary,
+ state,
+ onSelectSeries,
+}: {
+ summary: HomepageSummary;
+ state: ChartState;
+ onSelectSeries?: (id: string) => void;
+}) {
+ switch (state.chartType) {
+ case "line":
+ return (
+ <MomentumChart
+ summary={summary}
+ state={state}
+ onSelectSeries={onSelectSeries}
+ />
+ );
+ case "small":
+ return <SmallMultiples summary={summary} state={state} />;
+ case "barH":
+ return <LeaderboardChart summary={summary} state={state} />;
+ case "area":
+ case "bar":
+ default:
+ return <CrossSiteChart summary={summary} state={state} />;
+ }
+}
diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx
@@ -18,122 +18,26 @@ import {
ChartTooltipContent,
type ChartConfig,
} from "../ui/chart";
-import type { BucketSeries, HomepageSummary } from "../../lib/homepageSummary";
-import {
- seriesColor,
- OTHER_COLOR,
- weekLabel,
- monthLabel,
- type Breakdown,
- type Bucket,
- type Range,
- type Metric,
- type Display,
-} from "../../lib/homepageChart";
-
-const TOP_CHANNELS = 8;
-
-type Gran = "day" | "week" | "month";
-
-// How many trailing buckets each range shows, per granularity.
-const WINDOW: Record<Range, Record<Gran, number | null>> = {
- "2w": { day: 14, week: 2, month: 1 },
- "90d": { day: 90, week: 13, month: 3 },
- "12mo": { day: 365, week: 52, month: 12 },
- all: { day: null, week: null, month: null },
-};
-
-function runningSum(v: number[]): number[] {
- let acc = 0;
- return v.map((x) => (acc += x));
-}
-
-type Series = { id: string; label: string; color: string; values: number[] };
+import { ChartMessage } from "./ChartMessage";
+import type { HomepageSummary } from "../../lib/homepageSummary";
+import { type ChartState } from "../../lib/homepageChart";
+import { buildTimeSeries } from "../../lib/homepageChartData";
+// The stacked renderer: area (cumulative/Growth) or bars, optionally 100%-share.
+// Stacking shows per-series composition *and* the combined total at once — the
+// honest view when the series add up to a meaningful whole. Skew-handling for
+// non-stacked views lives in the line / small-multiples / leaderboard charts.
export function CrossSiteChart({
summary,
- breakdown,
- bucket,
- range,
- metric,
- display,
- hidden,
+ state,
}: {
summary: HomepageSummary;
- breakdown: Breakdown;
- bucket: Bucket;
- range: Range;
- metric: Metric;
- display: Display;
- hidden: ReadonlySet<string>;
+ state: ChartState;
}) {
- const { rows, series, labels } = useMemo(() => {
- const ms = summary.series[metric];
- const cumulative = bucket === "cumulative";
- // Day + cumulative bin on the day series; week/month on their own.
- const gran: Gran = bucket === "week" ? "week" : bucket === "month" ? "month" : "day";
- const src: BucketSeries = ms[gran];
- const labelOf = gran === "month" ? monthLabel : weekLabel;
-
- const win = WINDOW[range][gran];
- const start = win == null ? 0 : Math.max(0, src.buckets.length - win);
- const len = src.buckets.length;
- const pad = (vals?: number[]) =>
- Array.from({ length: len }, (_, i) => vals?.[i] ?? 0);
- const transform = (vals?: number[]) => {
- const full = pad(vals);
- return (cumulative ? runningSum(full) : full).slice(start);
- };
-
- let series: Series[];
- if (breakdown === "site") {
- series = summary.sites
- .map((s, i) => ({ s, color: seriesColor(i) }))
- .filter(({ s }) => !hidden.has(s.siteId))
- .map(({ s, color }) => ({
- id: s.siteId,
- label: s.siteTitle,
- color,
- values: transform(src.bySite[s.siteId]),
- }));
- } else {
- const nameOf = new Map(summary.channels.map((c) => [c.slug, c.name]));
- const totalOf = (slug: string) =>
- (src.byChannel[slug] ?? []).reduce((a, b) => a + b, 0);
- const sorted = Object.keys(src.byChannel).sort(
- (a, b) => totalOf(b) - totalOf(a) || a.localeCompare(b),
- );
- const top = sorted.slice(0, TOP_CHANNELS);
- const rest = sorted.slice(TOP_CHANNELS);
- series = top.map((slug, i) => ({
- id: slug,
- label: nameOf.get(slug) ?? slug,
- color: seriesColor(i),
- values: transform(src.byChannel[slug]),
- }));
- if (rest.length) {
- const otherFull = pad();
- for (const slug of rest) {
- const p = pad(src.byChannel[slug]);
- for (let i = 0; i < p.length; i++) otherFull[i] += p[i];
- }
- series.push({
- id: "__other",
- label: `Other (${rest.length})`,
- color: OTHER_COLOR,
- values: (cumulative ? runningSum(otherFull) : otherFull).slice(start),
- });
- }
- }
-
- const labels = src.buckets.slice(start).map(labelOf);
- const rows = labels.map((x, i) => {
- const row: Record<string, string | number> = { x };
- for (const s of series) row[s.id] = s.values[i] ?? 0;
- return row;
- });
- return { rows, series, labels };
- }, [summary, breakdown, bucket, range, metric, hidden]);
+ const { labels, series } = useMemo(
+ () => buildTimeSeries(summary, state),
+ [summary, state],
+ );
const config = useMemo<ChartConfig>(() => {
const c: ChartConfig = {};
@@ -141,29 +45,35 @@ export function CrossSiteChart({
return c;
}, [series]);
+ const rows = useMemo(
+ () =>
+ labels.map((x, i) => {
+ const row: Record<string, string | number> = { x };
+ for (const s of series) row[s.id] = (s.values[i] as number) ?? 0;
+ return row;
+ }),
+ [labels, series],
+ );
+
if (labels.length === 0) {
- return (
- <div className="flex h-[320px] items-center justify-center rounded-xl border border-zinc-200 dark:border-zinc-800 text-sm text-zinc-500">
- No data for this metric yet.
- </div>
- );
+ return <ChartMessage>No data for this view yet.</ChartMessage>;
}
if (series.length === 0) {
return (
- <div className="flex h-[320px] items-center justify-center rounded-xl border border-zinc-200 dark:border-zinc-800 text-sm text-zinc-500">
- All sites hidden — re-enable one from the cards below.
- </div>
+ <ChartMessage>
+ Every series is hidden — re-enable one from the cards below.
+ </ChartMessage>
);
}
- const isArea = bucket === "cumulative";
- const isShare = display === "share";
+ const isArea = state.chartType === "area";
+ const isShare = state.valueMode === "share";
const stackOffset = isShare ? "expand" : undefined;
- const showLegend = breakdown === "channel" && series.length > 1;
+ const showLegend = state.breakdown === "channel" && series.length > 1;
const margin = { top: 8, right: 12, bottom: 4, left: 4 };
const axes = [
- <CartesianGrid key="grid" vertical={false} strokeDasharray="3 3" />,
+ <CartesianGrid key="grid" vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" />,
<XAxis
key="x"
dataKey="x"
@@ -171,20 +81,19 @@ export function CrossSiteChart({
axisLine={false}
tickMargin={8}
minTickGap={24}
+ tick={{ fill: "var(--chart-axis)", fontSize: 12 }}
/>,
<YAxis
key="y"
tickLine={false}
axisLine={false}
width={isShare ? 40 : 44}
- // Share = 100% stacked: pin the domain so the stack fills the height (and
- // give it fractional ticks). Counts: integer auto-scale.
+ // Share = 100% stacked: pin [0,1] so the stack fills the height.
domain={isShare ? [0, 1] : undefined}
ticks={isShare ? [0, 0.25, 0.5, 0.75, 1] : undefined}
- allowDecimals={isShare ? true : false}
- tickFormatter={
- isShare ? (v: number) => `${Math.round(v * 100)}%` : undefined
- }
+ allowDecimals={isShare}
+ tickFormatter={isShare ? (v: number) => `${Math.round(v * 100)}%` : undefined}
+ tick={{ fill: "var(--chart-axis)", fontSize: 12 }}
/>,
<ChartTooltip key="tip" content={<ChartTooltipContent />} />,
showLegend ? (
diff --git a/common/components/charts/LeaderboardChart.tsx b/common/components/charts/LeaderboardChart.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useMemo } from "react";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ LabelList,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { scaleSymlog } from "d3-scale";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "../ui/chart";
+import { ChartMessage } from "./ChartMessage";
+import type { HomepageSummary } from "../../lib/homepageSummary";
+import {
+ SYMLOG_CONSTANT,
+ compactNumber,
+ symlogTicks,
+ type ChartState,
+} from "../../lib/homepageChart";
+import { buildRanking } from "../../lib/homepageChartData";
+
+const TOP_N = 12;
+
+// Ranked horizontal bars — an aggregate over the window, not a time series. With
+// one bar at ~27k and a floor in the hundreds, a symlog value axis keeps the
+// small channels readable next to the leader (linear via the scale toggle).
+export function LeaderboardChart({
+ summary,
+ state,
+}: {
+ summary: HomepageSummary;
+ state: ChartState;
+}) {
+ const { rows, max } = useMemo(
+ () => buildRanking(summary, state, TOP_N),
+ [summary, state],
+ );
+ const symlog = useMemo(() => scaleSymlog().constant(SYMLOG_CONSTANT), []);
+
+ if (rows.length === 0) {
+ return <ChartMessage>No data for this view yet.</ChartMessage>;
+ }
+
+ const config: ChartConfig = { value: { label: "Transcripts" } };
+ const isSymlog = state.scale === "symlog";
+ const domainMax = Math.max(10, max);
+ const ticks = isSymlog ? symlogTicks(domainMax) : undefined;
+
+ return (
+ <ChartContainer config={config} className="aspect-auto h-[420px] w-full">
+ <BarChart
+ data={rows}
+ layout="vertical"
+ margin={{ top: 4, right: 52, bottom: 4, left: 8 }}
+ accessibilityLayer
+ >
+ <CartesianGrid horizontal={false} stroke="var(--chart-grid)" strokeDasharray="3 3" />
+ <XAxis
+ type="number"
+ scale={isSymlog ? (symlog as unknown as "linear") : undefined}
+ domain={[0, domainMax]}
+ ticks={ticks}
+ tickFormatter={(v: number) => compactNumber(v)}
+ tickLine={false}
+ axisLine={false}
+ tick={{ fill: "var(--chart-axis)", fontSize: 12 }}
+ />
+ <YAxis
+ type="category"
+ dataKey="label"
+ width={132}
+ tickLine={false}
+ axisLine={false}
+ tick={{ fill: "var(--chart-axis)", fontSize: 12 }}
+ />
+ <ChartTooltip content={<ChartTooltipContent />} />
+ <Bar dataKey="value" radius={[0, 3, 3, 0]} isAnimationActive={false}>
+ {rows.map((r) => (
+ <Cell key={r.id} fill={r.color} />
+ ))}
+ <LabelList
+ dataKey="value"
+ position="right"
+ formatter={(v: number) => compactNumber(v)}
+ className="fill-[var(--muted-foreground)] text-xs tabular-nums"
+ />
+ </Bar>
+ </BarChart>
+ </ChartContainer>
+ );
+}
diff --git a/common/components/charts/MomentumChart.tsx b/common/components/charts/MomentumChart.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useMemo } from "react";
+import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
+import { scaleSymlog } from "d3-scale";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "../ui/chart";
+import { ChartMessage } from "./ChartMessage";
+import type { HomepageSummary } from "../../lib/homepageSummary";
+import {
+ SYMLOG_CONSTANT,
+ compactNumber,
+ symlogTicks,
+ type ChartState,
+} from "../../lib/homepageChart";
+import { buildTimeSeries } from "../../lib/homepageChartData";
+
+// One line per series over a trailing window — the only chart that can carry a
+// symlog y-axis (lines, not stacked, so a non-additive axis is meaningful). With
+// one site dwarfing the rest, symlog lifts the small sites off the floor; symlog
+// (not log) because the daily series is full of zeros. Also renders the indexed
+// (% growth) value mode, where every series starts at 100.
+export function MomentumChart({
+ summary,
+ state,
+ onSelectSeries,
+}: {
+ summary: HomepageSummary;
+ state: ChartState;
+ onSelectSeries?: (id: string) => void;
+}) {
+ const { labels, series, max } = useMemo(
+ () => buildTimeSeries(summary, state),
+ [summary, state],
+ );
+
+ const config = useMemo<ChartConfig>(() => {
+ const c: ChartConfig = {};
+ for (const s of series) c[s.id] = { label: s.label, color: s.color };
+ return c;
+ }, [series]);
+
+ const rows = useMemo(
+ () =>
+ labels.map((x, i) => {
+ const row: Record<string, string | number | null> = { x };
+ for (const s of series) row[s.id] = s.values[i] ?? null;
+ return row;
+ }),
+ [labels, series],
+ );
+
+ // recharts mutates the scale's domain/range; the constant is what we own.
+ const symlog = useMemo(() => scaleSymlog().constant(SYMLOG_CONSTANT), []);
+
+ if (series.length === 0 || rows.length === 0) {
+ return (
+ <ChartMessage>
+ {state.soloId || state.hidden.size > 0
+ ? "Every series is hidden — re-enable one from the cards below."
+ : "No data for this view yet."}
+ </ChartMessage>
+ );
+ }
+
+ const isSymlog = state.scale === "symlog";
+ const domainMax = Math.max(10, max);
+ const ticks = isSymlog ? symlogTicks(domainMax) : undefined;
+
+ return (
+ <ChartContainer
+ config={config}
+ className="aspect-auto h-[320px] w-full sm:h-[380px]"
+ >
+ <LineChart data={rows} margin={{ top: 8, right: 12, bottom: 4, left: 4 }} accessibilityLayer>
+ <CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" />
+ <XAxis
+ dataKey="x"
+ tickLine={false}
+ axisLine={false}
+ tickMargin={8}
+ minTickGap={28}
+ tick={{ fill: "var(--chart-axis)", fontSize: 12 }}
+ />
+ <YAxis
+ type="number"
+ // d3 symlog instance ⇒ skew-taming axis; otherwise linear auto-scale.
+ scale={isSymlog ? (symlog as unknown as "linear") : undefined}
+ domain={isSymlog ? [0, domainMax] : undefined}
+ ticks={ticks}
+ tickFormatter={(v: number) => compactNumber(v)}
+ tickLine={false}
+ axisLine={false}
+ width={44}
+ allowDecimals={false}
+ tick={{ fill: "var(--chart-axis)", fontSize: 12 }}
+ />
+ <ChartTooltip content={<ChartTooltipContent />} />
+ {series.map((s) => (
+ <Line
+ key={s.id}
+ dataKey={s.id}
+ name={s.label}
+ type="monotone"
+ stroke={s.color}
+ strokeWidth={1.75}
+ dot={false}
+ connectNulls
+ isAnimationActive={false}
+ onClick={onSelectSeries ? () => onSelectSeries(s.id) : undefined}
+ style={onSelectSeries ? { cursor: "pointer" } : undefined}
+ />
+ ))}
+ </LineChart>
+ </ChartContainer>
+ );
+}
diff --git a/common/components/charts/SmallMultiples.tsx b/common/components/charts/SmallMultiples.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useMemo } from "react";
+import { Area, AreaChart, YAxis } from "recharts";
+import { scaleSymlog } from "d3-scale";
+import { ChartContainer, type ChartConfig } from "../ui/chart";
+import { ChartMessage } from "./ChartMessage";
+import type { HomepageSummary } from "../../lib/homepageSummary";
+import {
+ SYMLOG_CONSTANT,
+ compactNumber,
+ symlogTicks,
+ type ChartState,
+} from "../../lib/homepageChart";
+import { buildTimeSeries, type ChartSeries } from "../../lib/homepageChartData";
+
+// One mini-panel per series, each on its OWN y-scale — so the outlier can't
+// crush the others by construction (the honest per-shape comparison). With three
+// sites this is a clean 3-up; the panel scale follows the chart's linear/symlog.
+export function SmallMultiples({
+ summary,
+ state,
+}: {
+ summary: HomepageSummary;
+ state: ChartState;
+}) {
+ const { labels, series } = useMemo(
+ () => buildTimeSeries(summary, state),
+ [summary, state],
+ );
+
+ if (series.length === 0) {
+ return <ChartMessage>No data for this view yet.</ChartMessage>;
+ }
+
+ return (
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {series.map((s) => (
+ <Panel key={s.id} s={s} labels={labels} scale={state.scale} />
+ ))}
+ </div>
+ );
+}
+
+function Panel({
+ s,
+ labels,
+ scale,
+}: {
+ s: ChartSeries;
+ labels: string[];
+ scale: ChartState["scale"];
+}) {
+ const config = useMemo<ChartConfig>(
+ () => ({ v: { label: s.label, color: s.color } }),
+ [s.label, s.color],
+ );
+ const rows = useMemo(
+ () => labels.map((x, i) => ({ x, v: s.values[i] ?? null })),
+ [labels, s.values],
+ );
+ const symlog = useMemo(() => scaleSymlog().constant(SYMLOG_CONSTANT), []);
+
+ const max = s.values.reduce<number>((m, v) => (v != null && v > m ? v : m), 0);
+ // The panel's headline number is the window total (the per-bucket latest is
+ // often 0 for the current partial bucket).
+ const total = s.values.reduce<number>((sum, v) => sum + (v ?? 0), 0);
+ const isSymlog = scale === "symlog";
+ const domainMax = Math.max(10, max);
+
+ return (
+ <div className="rounded-lg border border-[var(--border)] p-3">
+ <div className="mb-1 flex items-baseline justify-between gap-2">
+ <span className="flex items-center gap-1.5 truncate text-sm text-[var(--foreground)]">
+ <span
+ className="inline-block h-2 w-2 shrink-0 rounded-[2px]"
+ style={{ backgroundColor: s.color }}
+ />
+ {s.label}
+ </span>
+ <span className="font-mono text-xs tabular-nums text-[var(--muted-foreground)]">
+ {compactNumber(Math.round(total))}
+ </span>
+ </div>
+ <ChartContainer config={config} className="aspect-auto h-[120px] w-full">
+ <AreaChart data={rows} margin={{ top: 4, right: 4, bottom: 0, left: 0 }} accessibilityLayer>
+ <YAxis
+ type="number"
+ hide
+ scale={isSymlog ? (symlog as unknown as "linear") : undefined}
+ domain={isSymlog ? [0, domainMax] : [0, "auto"]}
+ ticks={isSymlog ? symlogTicks(domainMax) : undefined}
+ allowDecimals={false}
+ />
+ <Area
+ dataKey="v"
+ type="monotone"
+ stroke={s.color}
+ fill={s.color}
+ fillOpacity={0.18}
+ strokeWidth={1.5}
+ connectNulls
+ isAnimationActive={false}
+ />
+ </AreaChart>
+ </ChartContainer>
+ </div>
+ );
+}
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -49,3 +49,210 @@ export function dayLabel(yyyymmdd: string): string {
const d = Number(yyyymmdd.slice(6, 8));
return `${MONTHS[m - 1] ?? ""} ${d}, ${y}`;
}
+
+// ---------------------------------------------------------------------------
+// Chart redesign: presets + a power-user control matrix.
+//
+// The landing leads with a few named *presets* (each a full, valid control
+// state tuned to the real data), with an inline "Customize" panel exposing the
+// raw controls. Because one site (Jeralyzer) dwarfs the others, the model adds
+// an explicit chart type, a linear⇄symlog scale, an indexed/share value mode,
+// and an exclude-outlier toggle — the tools that keep the small sites legible.
+// ---------------------------------------------------------------------------
+
+export type ChartType = "line" | "area" | "bar" | "barH" | "small";
+export type Scale = "linear" | "symlog";
+// How values are expressed: raw counts, 100%-stacked share, or indexed to 100
+// at each series' window start (% growth). Supersedes the old `Display`.
+export type ValueMode = "counts" | "share" | "indexed";
+export type PresetId = "momentum" | "growth" | "leaderboard" | "compare";
+
+// The seven control fields a preset pins. (hidden/soloId/excludeTop are
+// per-view filters, not part of a preset's identity.)
+export type PresetState = {
+ metric: Metric;
+ breakdown: Breakdown;
+ chartType: ChartType;
+ bucket: Bucket;
+ range: Range;
+ scale: Scale;
+ valueMode: ValueMode;
+};
+
+export type ChartState = PresetState & {
+ preset: PresetId | "custom";
+ hidden: ReadonlySet<string>;
+ soloId: string | null; // isolate one series (overrides hidden)
+ excludeTop: boolean; // drop the largest site and rescale (site breakdown only)
+};
+
+export const PRESETS: Record<
+ PresetId,
+ { label: string; hint: string; state: PresetState }
+> = {
+ momentum: {
+ label: "Momentum",
+ hint: "Recent activity, each site on a symlog axis",
+ state: {
+ metric: "transcribed",
+ breakdown: "site",
+ chartType: "line",
+ bucket: "day",
+ range: "90d",
+ scale: "symlog",
+ valueMode: "counts",
+ },
+ },
+ growth: {
+ label: "Growth",
+ hint: "The archive accumulating over all time",
+ state: {
+ metric: "transcribed",
+ breakdown: "site",
+ chartType: "area",
+ bucket: "cumulative",
+ range: "all",
+ scale: "linear",
+ valueMode: "counts",
+ },
+ },
+ leaderboard: {
+ label: "Leaderboard",
+ hint: "Top channels ranked by transcripts",
+ state: {
+ metric: "transcribed",
+ breakdown: "channel",
+ chartType: "barH",
+ bucket: "day", // ignored by barH (it sums over the range)
+ range: "all",
+ scale: "symlog",
+ valueMode: "counts",
+ },
+ },
+ compare: {
+ label: "Compare",
+ hint: "Each site at its own independent scale",
+ state: {
+ metric: "transcribed",
+ breakdown: "site",
+ chartType: "small",
+ bucket: "week",
+ range: "all",
+ scale: "linear",
+ valueMode: "counts",
+ },
+ },
+};
+
+export const PRESET_ORDER: PresetId[] = [
+ "momentum",
+ "growth",
+ "leaderboard",
+ "compare",
+];
+
+export const DEFAULT_STATE: ChartState = {
+ ...PRESETS.momentum.state,
+ preset: "momentum",
+ hidden: new Set(),
+ soloId: null,
+ excludeTop: false,
+};
+
+export function isStacked(t: ChartType): boolean {
+ return t === "area" || t === "bar";
+}
+
+// Coerce a state to a self-consistent one. chartType is authoritative: scale
+// and valueMode yield to it (never the reverse), so there are no cascades.
+// - share needs a stacked chart; indexed needs a line.
+// - symlog needs a non-stacked chart (it composes with indexed, which is a
+// line — symlog keeps the growth multiples comparable across series).
+// - excludeTop only applies to the site breakdown.
+export function normalize(s: ChartState): ChartState {
+ const stacked = isStacked(s.chartType);
+ let valueMode = s.valueMode;
+ if (valueMode === "share" && !stacked) valueMode = "counts";
+ if (valueMode === "indexed" && s.chartType !== "line") valueMode = "counts";
+ let scale = s.scale;
+ if (scale === "symlog" && stacked) scale = "linear";
+ const excludeTop = s.breakdown === "site" ? s.excludeTop : false;
+ return { ...s, valueMode, scale, excludeTop };
+}
+
+// Whether a given option in the Customize matrix is currently invalid (greyed),
+// so the UI can explain *why* a combination isn't available.
+export function isDisabled(
+ key: keyof PresetState | "excludeTop",
+ value: string,
+ s: ChartState,
+): boolean {
+ const stacked = isStacked(s.chartType);
+ if (key === "valueMode" && value === "share") return !stacked;
+ if (key === "valueMode" && value === "indexed") return s.chartType !== "line";
+ if (key === "scale" && value === "symlog") return stacked;
+ if (key === "excludeTop") return s.breakdown !== "site";
+ return false;
+}
+
+const CORE_KEYS: (keyof PresetState)[] = [
+ "metric",
+ "breakdown",
+ "chartType",
+ "bucket",
+ "range",
+ "scale",
+ "valueMode",
+];
+
+// Which preset (if any) a state currently matches — drives the active chip.
+export function presetOf(s: ChartState): PresetId | "custom" {
+ for (const id of PRESET_ORDER) {
+ const p = PRESETS[id].state;
+ if (CORE_KEYS.every((k) => s[k] === p[k])) return id;
+ }
+ return "custom";
+}
+
+export function applyPreset(prev: ChartState, id: PresetId): ChartState {
+ return {
+ ...prev,
+ ...PRESETS[id].state,
+ preset: id,
+ hidden: new Set(),
+ soloId: null,
+ excludeTop: false,
+ };
+}
+
+// Apply a control change: merge, clear stale solo on a breakdown switch,
+// normalize, then recompute which preset (or "custom") we landed on.
+export function applyPatch(
+ prev: ChartState,
+ patch: Partial<ChartState>,
+): ChartState {
+ let next: ChartState = { ...prev, ...patch };
+ if ("breakdown" in patch) next.soloId = null;
+ next = normalize(next);
+ next.preset = presetOf(next);
+ return next;
+}
+
+// --- symlog axis helpers (shared by the line + leaderboard charts) ---
+
+// Half-width of symlog's near-linear region around 0. Tuned for a ~1–100/day
+// floor against a ~16k spike; a visual knob, not load-bearing.
+export const SYMLOG_CONSTANT = 10;
+
+const compactFmt = new Intl.NumberFormat("en", { notation: "compact" });
+export function compactNumber(n: number): string {
+ return compactFmt.format(n);
+}
+
+// Powers of ten within [10, max], plus 0 — the symlog tick set (0 is why we use
+// symlog over log: the data is full of zero buckets).
+export function symlogTicks(max: number): number[] {
+ const ticks = [0];
+ for (let t = 10; t <= max; t *= 10) ticks.push(t);
+ return ticks;
+}
diff --git a/common/lib/homepageChartData.ts b/common/lib/homepageChartData.ts
@@ -0,0 +1,223 @@
+// Pure (React-free) data pipeline shared by every cross-site chart. Turns the
+// build-time summary's BucketSeries arrays into the rows each chart type needs —
+// applying the window, cumulative/indexed transforms, exclude-top / solo / hide
+// filters, and channel "Other" grouping — so the chart components stay thin and
+// the skew-handling logic lives in one tested place.
+
+import type { BucketSeries, HomepageSummary } from "./homepageSummary";
+import {
+ seriesColor,
+ OTHER_COLOR,
+ weekLabel,
+ monthLabel,
+ type ChartState,
+} from "./homepageChart";
+
+type Gran = "day" | "week" | "month";
+
+// Trailing buckets shown per range, by granularity (day/cumulative bin on the
+// day series; week/month on their own).
+const WINDOW: Record<ChartState["range"], Record<Gran, number | null>> = {
+ "2w": { day: 14, week: 2, month: 1 },
+ "90d": { day: 90, week: 13, month: 3 },
+ "12mo": { day: 365, week: 52, month: 12 },
+ all: { day: null, week: null, month: null },
+};
+
+// Channels beyond this fold into one "Other" band on time-series charts.
+const TOP_CHANNELS_SERIES = 8;
+
+export type ChartSeries = {
+ id: string;
+ label: string;
+ color: string;
+ // null = before a series' indexed baseline (so the line starts cleanly at 100)
+ values: (number | null)[];
+};
+
+export type RankRow = {
+ id: string;
+ label: string;
+ color: string;
+ value: number;
+};
+
+function runningSum(v: number[]): number[] {
+ let acc = 0;
+ return v.map((x) => (acc += x));
+}
+
+function granOf(bucket: ChartState["bucket"]): Gran {
+ return bucket === "week" ? "week" : bucket === "month" ? "month" : "day";
+}
+
+function pad(vals: number[] | undefined, len: number): number[] {
+ return Array.from({ length: len }, (_, i) => vals?.[i] ?? 0);
+}
+
+function windowTotal(
+ vals: number[] | undefined,
+ len: number,
+ start: number,
+): number {
+ const p = pad(vals, len);
+ let s = 0;
+ for (let i = start; i < len; i++) s += p[i];
+ return s;
+}
+
+function sourceAndWindow(summary: HomepageSummary, state: ChartState) {
+ const gran = granOf(state.bucket);
+ const src: BucketSeries = summary.series[state.metric][gran];
+ const len = src.buckets.length;
+ const win = WINDOW[state.range][gran];
+ const start = win == null ? 0 : Math.max(0, len - win);
+ return { gran, src, len, start };
+}
+
+type Entry = { id: string; label: string; color: string; full: number[] };
+
+// One entry per site (in summary order) or per channel (sorted by window total).
+function entries(
+ summary: HomepageSummary,
+ state: ChartState,
+ src: BucketSeries,
+ len: number,
+ start: number,
+): Entry[] {
+ if (state.breakdown === "site") {
+ return summary.sites.map((s, i) => ({
+ id: s.siteId,
+ label: s.siteTitle,
+ color: seriesColor(i),
+ full: pad(src.bySite[s.siteId], len),
+ }));
+ }
+ const nameOf = new Map(summary.channels.map((c) => [c.slug, c.name]));
+ const slugs = Object.keys(src.byChannel).sort(
+ (a, b) =>
+ windowTotal(src.byChannel[b], len, start) -
+ windowTotal(src.byChannel[a], len, start) || a.localeCompare(b),
+ );
+ return slugs.map((slug, i) => ({
+ id: slug,
+ label: nameOf.get(slug) ?? slug,
+ color: seriesColor(i),
+ full: pad(src.byChannel[slug], len),
+ }));
+}
+
+// Time-series rows for line / area / bar / small-multiples charts.
+export function buildTimeSeries(
+ summary: HomepageSummary,
+ state: ChartState,
+): { labels: string[]; series: ChartSeries[]; max: number } {
+ const { gran, src, len, start } = sourceAndWindow(summary, state);
+ const cumulative = state.bucket === "cumulative";
+ const indexed = state.valueMode === "indexed";
+ const labelOf = gran === "month" ? monthLabel : weekLabel;
+ const labels = src.buckets.slice(start).map(labelOf);
+
+ let list = entries(summary, state, src, len, start);
+
+ // Drop the largest site (by window total) and let the rest rescale.
+ if (state.excludeTop && list.length > 1) {
+ let topIdx = 0;
+ let topVal = -1;
+ list.forEach((e, i) => {
+ const t = windowTotal(e.full, len, start);
+ if (t > topVal) {
+ topVal = t;
+ topIdx = i;
+ }
+ });
+ list = list.filter((_, i) => i !== topIdx);
+ }
+
+ // Channel breakdown: fold the long tail into one "Other" band.
+ if (state.breakdown === "channel" && list.length > TOP_CHANNELS_SERIES) {
+ const top = list.slice(0, TOP_CHANNELS_SERIES);
+ const rest = list.slice(TOP_CHANNELS_SERIES);
+ const otherFull = new Array(len).fill(0);
+ for (const e of rest)
+ for (let i = 0; i < len; i++) otherFull[i] += e.full[i];
+ top.push({
+ id: "__other",
+ label: `Other (${rest.length})`,
+ color: OTHER_COLOR,
+ full: otherFull,
+ });
+ list = top;
+ }
+
+ // Solo overrides hide; otherwise apply the hidden set.
+ if (state.soloId) list = list.filter((e) => e.id === state.soloId);
+ else list = list.filter((e) => !state.hidden.has(e.id));
+
+ const transform = (full: number[]): (number | null)[] => {
+ const arr = cumulative || indexed ? runningSum(full) : full;
+ const win = arr.slice(start);
+ if (indexed) {
+ const base = win.find((v) => v > 0) ?? 0;
+ return win.map((v) =>
+ base <= 0 || v < base ? null : (v / base) * 100,
+ );
+ }
+ return win;
+ };
+
+ const series: ChartSeries[] = list.map((e) => ({
+ id: e.id,
+ label: e.label,
+ color: e.color,
+ values: transform(e.full),
+ }));
+
+ let max = 0;
+ for (const s of series)
+ for (const v of s.values) if (v != null && v > max) max = v;
+
+ return { labels, series, max };
+}
+
+// Aggregate ranking (window totals) for the horizontal leaderboard. Colors are
+// re-keyed by rank so the leader is chart-1, and the long tail is truncated.
+export function buildRanking(
+ summary: HomepageSummary,
+ state: ChartState,
+ topN: number,
+): { rows: RankRow[]; max: number } {
+ const { src, len, start } = sourceAndWindow(summary, state);
+ const list = entries(summary, state, src, len, start)
+ .map((e) => ({
+ id: e.id,
+ label: e.label,
+ value: windowTotal(e.full, len, start),
+ }))
+ .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
+ const rows: RankRow[] = list
+ .slice(0, topN)
+ .map((r, i) => ({ ...r, color: seriesColor(i) }));
+ const max = rows.reduce((m, r) => Math.max(m, r.value), 0);
+ return { rows, max };
+}
+
+// The current top site by window total — used to label the exclude-outlier
+// toggle ("Exclude Jeralyzer").
+export function topSite(
+ summary: HomepageSummary,
+ state: ChartState,
+): { id: string; label: string } | null {
+ if (state.breakdown !== "site") return null;
+ const { src, len, start } = sourceAndWindow(summary, state);
+ let top: { id: string; label: string } | null = null;
+ let topVal = -1;
+ for (const s of summary.sites) {
+ const t = windowTotal(src.bySite[s.siteId], len, start);
+ if (t > topVal) {
+ topVal = t;
+ top = { id: s.siteId, label: s.siteTitle };
+ }
+ }
+ return top;
+}
diff --git a/common/package.json b/common/package.json
@@ -8,6 +8,7 @@
"@tanstack/react-query": "^5.99.1",
"@tanstack/react-virtual": "^3.13.0",
"clsx": "^2.1.1",
+ "d3-scale": "^4.0.2",
"execa": "^9.6.1",
"fs-extra": "^11.3.4",
"lmdb": "^3.5.4",
@@ -24,6 +25,7 @@
"react-dom": "19.2.4"
},
"devDependencies": {
+ "@types/d3-scale": "^4.0.9",
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.19.39",
"@types/react": "^19.2.14",
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -1,5 +1,38 @@
# Homepage Changelog
+## 2026-06-29
+
+- **Charts rebuilt around presets + a power-user explorer.** The single five-control
+ chart is replaced by named **preset chips** that each set a full, data-tuned view,
+ with an inline **"Customize"** disclosure exposing the raw matrix (Metric /
+ Breakdown / Chart type / Bucket / Range / Scale / Values + an exclude-outlier
+ toggle). Selecting a preset sets the controls; touching any control reconciles the
+ state (chart type is authoritative — Symlog is greyed on a stacked chart, Share on a
+ non-stacked one) and lights the **Custom** chip. The four presets:
+ - **Momentum** (default) — one line per site over 90 days on a **symlog** y-axis, so
+ the small sites (Rekietalyzer, Hasanalyzer) stay legible next to Jeralyzer instead
+ of flat-lining; symlog (not log) because the daily series is full of zeros.
+ - **Growth** — cumulative stacked area over all time (the 0 → ~29k archive story).
+ - **Leaderboard** — top-12 channels as ranked horizontal bars on a symlog value axis.
+ - **Compare** — small multiples, one mini-panel per site at its **own** scale.
+- **Skew-handling tools for the one-dominant-site data.** Beyond the symlog scale
+ toggle and small multiples, Customize adds **Indexed** (every series rebased to 100
+ at its window start — % growth, composes with symlog so the multiples stay
+ comparable) and **Exclude <top site>** (drops the leader and rescales). Clicking a
+ line isolates it. The default view is now Momentum · 90d (the old Day · 2-week
+ default landed on June, the emptiest slice).
+- **"Instrument panel" chart styling.** The plot sits on an opaque, slightly-lifted
+ surface (`.panel-instrument`) that occludes the page glow/grain for data contrast,
+ with crisp themed gridlines/axes and a hairline brass top edge; the in-panel brass
+ glow is gone. Brass identity stays in the preset chips, the active control state, and
+ the series palette.
+- New shared, framework-free pipeline (`common/lib/homepageChartData.ts`) feeds every
+ chart (`common/components/charts/{ChartPanel,MomentumChart,SmallMultiples,
+ LeaderboardChart,CrossSiteChart}.tsx`); symlog uses d3 `scaleSymlog` (added `d3-scale`
+ to `common`). New homepage Playwright suite (`e2e/landing.spec.ts`) covers every
+ preset, the symlog small-site visibility, the guard rails, exclude-outlier, indexed,
+ and mobile. No summary-schema change — everything derives client-side.
+
## 2026-06-28
- **The home page is now the cross-site landing.** The thin branding hero and the
diff --git a/homepage/app/components/CustomizePanel.tsx b/homepage/app/components/CustomizePanel.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useState } from "react";
+import {
+ isDisabled,
+ type ChartState,
+} from "yt-dlp-transcript-common/lib/homepageChart";
+import { Segmented } from "./Segmented";
+
+// The power-user matrix, hidden behind a disclosure. Each control is a Segmented
+// group; invalid options are greyed via isDisabled (e.g. Symlog is unavailable
+// on a stacked chart, Share on a non-stacked one) so the UI shows *why* rather
+// than silently flipping a choice. The exclude-outlier toggle drops the top site.
+export function CustomizePanel({
+ state,
+ onPatch,
+ topSiteLabel,
+}: {
+ state: ChartState;
+ onPatch: (patch: Partial<ChartState>) => void;
+ topSiteLabel: string | null;
+}) {
+ const [open, setOpen] = useState(false);
+ const dis = (key: Parameters<typeof isDisabled>[0], value: string) =>
+ isDisabled(key, value, state);
+
+ return (
+ <div className="flex flex-col gap-3">
+ <button
+ type="button"
+ aria-expanded={open}
+ onClick={() => setOpen((o) => !o)}
+ className="inline-flex w-fit items-center gap-1.5 font-mono text-[0.6875rem] uppercase tracking-[0.18em] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
+ >
+ <span className="text-[var(--accent)]">{open ? "–" : "+"}</span>
+ Customize
+ </button>
+
+ {open && (
+ <div className="flex flex-wrap items-center gap-2.5">
+ <Segmented
+ label="Metric"
+ value={state.metric}
+ onChange={(v) => onPatch({ metric: v })}
+ options={[
+ { value: "transcribed", label: "Transcribed" },
+ { value: "downloaded", label: "Downloaded" },
+ ]}
+ />
+ <Segmented
+ label="Breakdown"
+ value={state.breakdown}
+ onChange={(v) => onPatch({ breakdown: v })}
+ options={[
+ { value: "site", label: "By site" },
+ { value: "channel", label: "By channel" },
+ ]}
+ />
+ <Segmented
+ label="Chart type"
+ value={state.chartType}
+ onChange={(v) => onPatch({ chartType: v })}
+ options={[
+ { value: "line", label: "Lines" },
+ { value: "area", label: "Area" },
+ { value: "bar", label: "Bars" },
+ { value: "barH", label: "Ranked" },
+ { value: "small", label: "Small" },
+ ]}
+ />
+ <Segmented
+ label="Bucket"
+ value={state.bucket}
+ onChange={(v) => onPatch({ bucket: v })}
+ options={[
+ { value: "day", label: "Day" },
+ { value: "week", label: "Week" },
+ { value: "month", label: "Month" },
+ { value: "cumulative", label: "Cumulative" },
+ ]}
+ />
+ <Segmented
+ label="Range"
+ value={state.range}
+ onChange={(v) => onPatch({ range: v })}
+ options={[
+ { value: "2w", label: "2w" },
+ { value: "90d", label: "90d" },
+ { value: "12mo", label: "12mo" },
+ { value: "all", label: "All" },
+ ]}
+ />
+ <Segmented
+ label="Scale"
+ value={state.scale}
+ onChange={(v) => onPatch({ scale: v })}
+ options={[
+ { value: "linear", label: "Linear" },
+ { value: "symlog", label: "Symlog", disabled: dis("scale", "symlog") },
+ ]}
+ />
+ <Segmented
+ label="Values"
+ value={state.valueMode}
+ onChange={(v) => onPatch({ valueMode: v })}
+ options={[
+ { value: "counts", label: "Counts" },
+ { value: "share", label: "Share", disabled: dis("valueMode", "share") },
+ { value: "indexed", label: "Indexed", disabled: dis("valueMode", "indexed") },
+ ]}
+ />
+ {topSiteLabel && (
+ <button
+ type="button"
+ aria-pressed={state.excludeTop}
+ onClick={() => onPatch({ excludeTop: !state.excludeTop })}
+ className={
+ "rounded-full border px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " +
+ (state.excludeTop
+ ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-strong)]"
+ : "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]")
+ }
+ >
+ {state.excludeTop ? `Excluding ${topSiteLabel}` : `Exclude ${topSiteLabel}`}
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/homepage/app/components/HomeLanding.tsx b/homepage/app/components/HomeLanding.tsx
@@ -1,76 +1,50 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSummary";
-import type {
- Breakdown,
- Bucket,
- Range,
- Metric,
- Display,
+import {
+ DEFAULT_STATE,
+ applyPreset,
+ applyPatch,
+ type ChartState,
+ type PresetId,
} from "yt-dlp-transcript-common/lib/homepageChart";
-import { CrossSiteChart } from "yt-dlp-transcript-common/components/charts/CrossSiteChart";
+import { topSite } from "yt-dlp-transcript-common/lib/homepageChartData";
+import { ChartPanel } from "yt-dlp-transcript-common/components/charts/ChartPanel";
import { KpiHeader } from "./KpiHeader";
import { SiteGrid } from "./SiteGrid";
+import { PresetBar } from "./PresetBar";
+import { CustomizePanel } from "./CustomizePanel";
-// A compact segmented control — the "instrument panel" driving the single
-// landing chart. Touch-friendly and wraps on small screens.
-function Segmented<T extends string>({
- label,
- value,
- options,
- onChange,
-}: {
- label: string;
- value: T;
- options: { value: T; label: string }[];
- onChange: (v: T) => void;
-}) {
- return (
- <div
- role="group"
- aria-label={label}
- className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--panel)] p-0.5 backdrop-blur-sm"
- >
- {options.map((o) => {
- const active = o.value === value;
- return (
- <button
- key={o.value}
- type="button"
- aria-pressed={active}
- onClick={() => onChange(o.value)}
- className={
- "rounded-full px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " +
- (active
- ? "bg-[var(--accent-soft)] text-[var(--accent-strong)] shadow-[inset_0_0_0_1px_var(--accent-soft)]"
- : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]")
- }
- >
- {o.label}
- </button>
- );
- })}
- </div>
- );
-}
-
+// The cross-site landing: headline KPIs, then one chart driven by named presets
+// (with an inline Customize matrix for power users), then the site grid. All of
+// it is one ChartState — presets set a full valid state; touching a control
+// reconciles + recomputes which preset (or "custom") we're on.
export function HomeLanding({ summary }: { summary: HomepageSummary }) {
- const [breakdown, setBreakdown] = useState<Breakdown>("site");
- const [bucket, setBucket] = useState<Bucket>("day");
- const [range, setRange] = useState<Range>("2w");
- const [metric, setMetric] = useState<Metric>("transcribed");
- const [display, setDisplay] = useState<Display>("counts");
- const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
+ const [state, setState] = useState<ChartState>(DEFAULT_STATE);
+ const selectPreset = useCallback(
+ (id: PresetId) => setState((p) => applyPreset(p, id)),
+ [],
+ );
+ const patch = useCallback(
+ (p: Partial<ChartState>) => setState((s) => applyPatch(s, p)),
+ [],
+ );
const toggleSite = useCallback((siteId: string) => {
- setHidden((prev) => {
- const next = new Set(prev);
+ setState((s) => {
+ const next = new Set(s.hidden);
if (next.has(siteId)) next.delete(siteId);
else next.add(siteId);
- return next;
+ return applyPatch(s, { hidden: next });
});
}, []);
+ // Click a line to isolate it (click again to clear).
+ const selectSeries = useCallback((id: string) => {
+ setState((s) => applyPatch(s, { soloId: s.soloId === id ? null : id }));
+ }, []);
+
+ const top = useMemo(() => topSite(summary, state), [summary, state]);
return (
<div className="flex flex-col gap-14 sm:gap-20">
@@ -79,73 +53,22 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
</div>
<section
- className="reveal flex flex-col gap-5"
+ className="reveal flex flex-col gap-4"
style={{ "--d": "200ms" } as React.CSSProperties}
>
- <div className="flex flex-wrap items-center gap-2.5">
- <Segmented
- label="Metric"
- value={metric}
- onChange={setMetric}
- options={[
- { value: "transcribed", label: "Transcribed" },
- { value: "downloaded", label: "Downloaded" },
- ]}
- />
- <Segmented
- label="Breakdown"
- value={breakdown}
- onChange={setBreakdown}
- options={[
- { value: "site", label: "By site" },
- { value: "channel", label: "By channel" },
- ]}
- />
- <Segmented
- label="Bucket"
- value={bucket}
- onChange={setBucket}
- options={[
- { value: "day", label: "Day" },
- { value: "week", label: "Week" },
- { value: "month", label: "Month" },
- { value: "cumulative", label: "Cumulative" },
- ]}
- />
- <Segmented
- label="Range"
- value={range}
- onChange={setRange}
- options={[
- { value: "2w", label: "2w" },
- { value: "90d", label: "90d" },
- { value: "12mo", label: "12mo" },
- { value: "all", label: "All" },
- ]}
- />
- <Segmented
- label="Display"
- value={display}
- onChange={setDisplay}
- options={[
- { value: "counts", label: "Counts" },
- { value: "share", label: "Share" },
- ]}
- />
- </div>
-
- <div className="relative overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-3 pt-5 backdrop-blur-sm sm:p-5">
- <div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-[var(--accent-soft)] to-transparent opacity-40" />
- <CrossSiteChart
+ <PresetBar active={state.preset} onSelect={selectPreset} />
+ <div className="panel-instrument rounded-2xl p-3 pt-4 sm:p-5">
+ <ChartPanel
summary={summary}
- breakdown={breakdown}
- bucket={bucket}
- range={range}
- metric={metric}
- display={display}
- hidden={hidden}
+ state={state}
+ onSelectSeries={selectSeries}
/>
</div>
+ <CustomizePanel
+ state={state}
+ onPatch={patch}
+ topSiteLabel={top?.label ?? null}
+ />
</section>
<section
@@ -154,9 +77,9 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
>
<SiteGrid
sites={summary.sites}
- metric={metric}
- breakdown={breakdown}
- hidden={hidden}
+ metric={state.metric}
+ breakdown={state.breakdown}
+ hidden={state.hidden}
onToggle={toggleSite}
/>
</section>
diff --git a/homepage/app/components/PresetBar.tsx b/homepage/app/components/PresetBar.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import {
+ PRESET_ORDER,
+ PRESETS,
+ type PresetId,
+} from "yt-dlp-transcript-common/lib/homepageChart";
+
+// The primary control: named preset chips that each set a full, valid chart
+// state. A "Custom" chip lights up once any Customize control diverges from a
+// preset.
+export function PresetBar({
+ active,
+ onSelect,
+}: {
+ active: PresetId | "custom";
+ onSelect: (id: PresetId) => void;
+}) {
+ return (
+ <div role="group" aria-label="Chart preset" className="flex flex-wrap items-center gap-2">
+ {PRESET_ORDER.map((id) => {
+ const on = active === id;
+ return (
+ <button
+ key={id}
+ type="button"
+ aria-pressed={on}
+ title={PRESETS[id].hint}
+ onClick={() => onSelect(id)}
+ className={
+ "rounded-full border px-4 py-2 text-sm font-medium transition-all duration-150 " +
+ (on
+ ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent-strong)]"
+ : "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]")
+ }
+ >
+ {PRESETS[id].label}
+ </button>
+ );
+ })}
+ {active === "custom" && (
+ <span className="rounded-full border border-dashed border-[var(--border-strong)] px-4 py-2 text-sm text-[var(--faint)]">
+ Custom
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/homepage/app/components/Segmented.tsx b/homepage/app/components/Segmented.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+// A compact segmented control — the unit the Customize matrix is built from.
+// Touch-friendly, wraps on small screens, and can grey out individual options
+// (a disabled option that happens to be active still shows, just unclickable).
+export function Segmented<T extends string>({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: { value: T; label: string; disabled?: boolean }[];
+ onChange: (v: T) => void;
+}) {
+ return (
+ <div
+ role="group"
+ aria-label={label}
+ className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--panel)] p-0.5 backdrop-blur-sm"
+ >
+ {options.map((o) => {
+ const active = o.value === value;
+ const disabled = !!o.disabled && !active;
+ return (
+ <button
+ key={o.value}
+ type="button"
+ aria-pressed={active}
+ disabled={disabled}
+ onClick={() => {
+ if (!disabled) onChange(o.value);
+ }}
+ className={
+ "rounded-full px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " +
+ (active
+ ? "bg-[var(--accent-soft)] text-[var(--accent-strong)] shadow-[inset_0_0_0_1px_var(--accent-soft)]"
+ : disabled
+ ? "cursor-not-allowed text-[var(--faint)] opacity-50"
+ : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]")
+ }
+ >
+ {o.label}
+ </button>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -30,6 +30,12 @@
--chart-3: #f2935b;
--chart-4: #c08cf0;
--chart-5: #f178b6;
+ /* "Instrument panel": an opaque, slightly-lifted plot surface that occludes
+ the page glow/grain for maximum data contrast, plus crisp axis/grid inks. */
+ --chart-surface: #16110a;
+ --chart-grid: rgba(233, 220, 197, 0.08);
+ --chart-axis: #8a8170;
+ --chart-tooltip-bg: #1b150d;
}
@theme inline {
@@ -92,6 +98,17 @@ body::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
+/* The chart "instrument panel": opaque so the atmospheric glow/grain stops at
+ the plot edge (the data zone reads as a calm instrument, not the warm room),
+ with a hairline brass-tinted top edge so brass still signs the panel. No
+ backdrop-blur — the surface is solid by design. */
+.panel-instrument {
+ background: var(--chart-surface);
+ border: 1px solid var(--border-strong);
+ border-top-color: var(--accent-soft);
+ box-shadow: inset 0 1px 0 rgba(233, 220, 197, 0.04);
+}
+
/* A faint hairline grid layer the page can opt into for the "ledger" feel. */
.grid-ledger {
background-image:
diff --git a/homepage/e2e/landing.spec.ts b/homepage/e2e/landing.spec.ts
@@ -0,0 +1,90 @@
+import { test, expect, type Page } from "@playwright/test";
+
+// The landing's cross-site chart: presets, the symlog skew-handling, and the
+// Customize guard rails. Asserts against the live recharts SVG.
+
+const pressedPreset = (page: Page) =>
+ page.locator('[aria-label="Chart preset"] button[aria-pressed="true"]');
+
+const yTicks = (page: Page) =>
+ page
+ .locator(".recharts-yAxis .recharts-cartesian-axis-tick-value")
+ .allTextContents();
+
+test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.waitForSelector(".recharts-surface");
+});
+
+test("Momentum is the default: symlog lines keep the small sites visible", async ({
+ page,
+}) => {
+ await expect(pressedPreset(page)).toHaveText("Momentum");
+ await expect(page.locator(".recharts-line-curve")).toHaveCount(3);
+
+ const ticks = (await yTicks(page)).map((t) => t.trim());
+ for (const t of ["0", "10", "100", "1K", "10K"]) expect(ticks).toContain(t);
+
+ // The two small sites must lift off the floor (symlog), not lie flat.
+ const heights = await page.evaluate(() =>
+ [...document.querySelectorAll(".recharts-line-curve")].map(
+ (p) => (p as SVGPathElement).getBBox().height,
+ ),
+ );
+ expect(Math.min(heights[1], heights[2])).toBeGreaterThan(25);
+});
+
+test("each preset renders its own chart type", async ({ page }) => {
+ await page.getByRole("button", { name: "Growth", exact: true }).click();
+ await expect(page.locator(".recharts-area-area")).toHaveCount(3);
+
+ await page.getByRole("button", { name: "Leaderboard", exact: true }).click();
+ await expect(page.locator(".recharts-bar-rectangle")).toHaveCount(12);
+
+ await page.getByRole("button", { name: "Compare", exact: true }).click();
+ await expect(page.locator(".recharts-responsive-container")).toHaveCount(3);
+});
+
+test("Customize greys invalid option combinations", async ({ page }) => {
+ await page.getByRole("button", { name: "Customize" }).click();
+ // Line chart (Momentum default): Share invalid, Symlog valid.
+ await expect(page.getByRole("button", { name: "Share", exact: true })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Symlog", exact: true })).toBeEnabled();
+ // Switch to a stacked Area: Symlog invalid, Share valid.
+ await page.getByRole("button", { name: "Area", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Symlog", exact: true })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Share", exact: true })).toBeEnabled();
+});
+
+test("exclude-outlier drops the leading site", async ({ page }) => {
+ await page.getByRole("button", { name: "Customize" }).click();
+ await expect(page.locator(".recharts-line-curve")).toHaveCount(3);
+ await page.getByRole("button", { name: /^Exclude / }).click();
+ await expect(page.locator(".recharts-line-curve")).toHaveCount(2);
+});
+
+test("Indexed rebases every series to a shared baseline", async ({ page }) => {
+ await page.getByRole("button", { name: "Customize" }).click();
+ await page.getByRole("button", { name: "Indexed", exact: true }).click();
+ // No preset matches a custom state.
+ await expect(pressedPreset(page)).toHaveCount(0);
+ // Every line begins at the same y-pixel (value 100).
+ const ys = await page.evaluate(() =>
+ [...document.querySelectorAll(".recharts-line-curve")]
+ .map((p) => {
+ const m = (p.getAttribute("d") || "").match(
+ /^M\s*[-\d.]+[, ]\s*([-\d.]+)/,
+ );
+ return m ? parseFloat(m[1]) : null;
+ })
+ .filter((v): v is number => v != null),
+ );
+ expect(ys.length).toBeGreaterThanOrEqual(2);
+ expect(Math.max(...ys) - Math.min(...ys)).toBeLessThan(2);
+});
+
+test("mobile: small multiples stack to one column", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.getByRole("button", { name: "Compare", exact: true }).click();
+ await expect(page.locator(".recharts-responsive-container")).toHaveCount(3);
+});
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -0,0 +1,34 @@
+import { defineConfig, devices } from "@playwright/test";
+
+// Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted
+// source — the landing reads its build-time summary from public/ on disk, which
+// is present in dev. Kill any stale dev server on the port between runs.
+const PORT = Number(process.env.PORT ?? 3032);
+const baseURL = `http://localhost:${PORT}`;
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30_000,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? "github" : "list",
+ outputDir: "test-results/",
+ fullyParallel: false,
+ workers: 1,
+ webServer: {
+ command: `pnpm exec next dev --port ${PORT}`,
+ url: baseURL,
+ timeout: 120_000,
+ reuseExistingServer: !process.env.CI,
+ },
+ use: {
+ baseURL,
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"], viewport: { width: 1100, height: 1100 } },
+ },
+ ],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ d3-scale:
+ specifier: ^4.0.2
+ version: 4.0.2
execa:
specifier: ^9.6.1
version: 9.6.1
@@ -63,6 +66,9 @@ importers:
specifier: ^3.6.0
version: 3.6.0
devDependencies:
+ '@types/d3-scale':
+ specifier: ^4.0.9
+ version: 4.0.9
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4