commit 1914e869fcf6d6537fe61a71c1c8844541880eb4
parent df5f33f38301aa839aa8c2b52a5aeee1d7f9569a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 29 Jun 2026 09:16:07 -0400
Merge refine/chart-presets: recent site-based combined presets
Diffstat:
6 files changed, 101 insertions(+), 89 deletions(-)
diff --git a/common/components/charts/MomentumChart.tsx b/common/components/charts/MomentumChart.tsx
@@ -89,8 +89,8 @@ export function MomentumChart({
<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}
+ scale={isSymlog ? (symlog as unknown as "linear") : "linear"}
+ domain={[0, domainMax]}
ticks={ticks}
tickFormatter={(v: number) => compactNumber(v)}
tickLine={false}
@@ -105,7 +105,7 @@ export function MomentumChart({
key={s.id}
dataKey={s.id}
name={s.label}
- type="monotone"
+ type="linear"
stroke={s.color}
strokeWidth={1.75}
dot={false}
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -8,7 +8,7 @@ import type { MetricKey } from "./homepageSummary";
// Chart control axes (each a small segmented button group on the landing).
export type Breakdown = "site" | "channel";
export type Bucket = "day" | "week" | "month" | "cumulative";
-export type Range = "2w" | "90d" | "12mo" | "all";
+export type Range = "2w" | "6w" | "90d" | "12mo" | "all";
export type Metric = MetricKey; // "transcribed" | "downloaded"
export type Display = "share" | "counts";
@@ -65,7 +65,7 @@ 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";
+export type PresetId = "recent" | "cumulative" | "alltime";
// The seven control fields a preset pins. (hidden/soloId/excludeTop are
// per-view filters, not part of a preset's identity.)
@@ -86,74 +86,62 @@ export type ChartState = PresetState & {
excludeTop: boolean; // drop the largest site and rescale (site breakdown only)
};
+// Presets are deliberately simple: always per-site, always one combined line
+// chart. The recent views use a ~6-week window where the three sites are
+// comparable (the May megaspike sits outside it), so plain linear lines read
+// clearly. All-time is the one tamed historical view (symlog so the dominant
+// site doesn't slam it). The full toolbox (channel / small multiples / ranked /
+// share / indexed) still lives in Customize.
export const PRESETS: Record<
PresetId,
{ label: string; hint: string; state: PresetState }
> = {
- momentum: {
- label: "Momentum",
- hint: "Recent activity, each site on a symlog axis",
+ recent: {
+ label: "Recent",
+ hint: "The last six weeks, week by week",
state: {
metric: "transcribed",
breakdown: "site",
chartType: "line",
- bucket: "day",
- range: "90d",
- scale: "symlog",
+ bucket: "week",
+ range: "6w",
+ scale: "linear",
valueMode: "counts",
},
},
- growth: {
- label: "Growth",
- hint: "The archive accumulating over all time",
+ cumulative: {
+ label: "Cumulative",
+ hint: "Running totals over the last six weeks",
state: {
metric: "transcribed",
breakdown: "site",
- chartType: "area",
+ chartType: "line",
bucket: "cumulative",
- range: "all",
+ range: "6w",
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",
+ alltime: {
+ label: "All-time",
+ hint: "Every week since launch, on a symlog axis",
state: {
metric: "transcribed",
breakdown: "site",
- chartType: "small",
+ chartType: "line",
bucket: "week",
range: "all",
- scale: "linear",
+ scale: "symlog",
valueMode: "counts",
},
},
};
-export const PRESET_ORDER: PresetId[] = [
- "momentum",
- "growth",
- "leaderboard",
- "compare",
-];
+export const PRESET_ORDER: PresetId[] = ["recent", "cumulative", "alltime"];
export const DEFAULT_STATE: ChartState = {
- ...PRESETS.momentum.state,
- preset: "momentum",
+ ...PRESETS.recent.state,
+ preset: "recent",
hidden: new Set(),
soloId: null,
excludeTop: false,
diff --git a/common/lib/homepageChartData.ts b/common/lib/homepageChartData.ts
@@ -19,6 +19,7 @@ type Gran = "day" | "week" | "month";
// day series; week/month on their own).
const WINDOW: Record<ChartState["range"], Record<Gran, number | null>> = {
"2w": { day: 14, week: 2, month: 1 },
+ "6w": { day: 42, week: 6, month: 2 },
"90d": { day: 90, week: 13, month: 3 },
"12mo": { day: 365, week: 52, month: 12 },
all: { day: null, week: null, month: null },
@@ -155,15 +156,17 @@ export function buildTimeSeries(
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);
+ // Indexed rebases the absolute running total to 100 at the window start.
if (indexed) {
- const base = win.find((v) => v > 0) ?? 0;
- return win.map((v) =>
- base <= 0 || v < base ? null : (v / base) * 100,
- );
+ const cum = runningSum(full).slice(start);
+ const base = cum.find((v) => v > 0) ?? 0;
+ return cum.map((v) => (base <= 0 || v < base ? null : (v / base) * 100));
}
- return win;
+ // Cumulative is window-RELATIVE: accumulate only within the window (starts
+ // at 0), so a recent slice isn't dominated by a site's historical total.
+ // (For range "all" the window is the whole series, so it's the true total.)
+ if (cumulative) return runningSum(full.slice(start));
+ return full.slice(start);
};
const series: ChartSeries[] = list.map((e) => ({
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -8,19 +8,24 @@
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).
+ non-stacked one) and lights the **Custom** chip. The presets are deliberately simple —
+ **always per-site, always one combined line chart** — and lean on a recent window so
+ the May megaspike (15.8k in a day, Jeralyzer's backfill) doesn't dominate. The three:
+ - **Recent** (default) — one line per site over the last six weeks, weekly, linear.
+ In that window the three sites are *comparable* (recent totals ~290/420/440 vs
+ all-time 27.5k/710/500), so a plain combined line chart reads clearly with no site
+ slammed and the spike excluded.
+ - **Cumulative** — running totals over the same recent window. The running sum is
+ **window-relative** (starts at 0 at the window edge), so it isn't dominated by a
+ site's historical total the way an absolute cumulative is.
+ - **All-time** — every week since launch on a **symlog** y-axis: the one tamed
+ historical view, where symlog keeps the dominant site from slamming the small ones.
+- **Skew-handling tools for the one-dominant-site data.** The full toolbox stays in
+ Customize: by-channel, ranked leaderboard, small multiples, the linear⇄symlog toggle,
+ 100% **Share**, **Indexed** (every series rebased to 100 at its window start), and
+ **Exclude <top site>** (drops the leader and rescales). Clicking a line isolates it.
+ (Fixed a recharts quirk where a conditionally-`undefined` y-axis `scale` collapsed
+ linear line charts to two points — the scale is now always explicit.)
- **"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
diff --git a/homepage/app/components/CustomizePanel.tsx b/homepage/app/components/CustomizePanel.tsx
@@ -85,6 +85,7 @@ export function CustomizePanel({
onChange={(v) => onPatch({ range: v })}
options={[
{ value: "2w", label: "2w" },
+ { value: "6w", label: "6w" },
{ value: "90d", label: "90d" },
{ value: "12mo", label: "12mo" },
{ value: "all", label: "All" },
diff --git a/homepage/e2e/landing.spec.ts b/homepage/e2e/landing.spec.ts
@@ -1,7 +1,8 @@
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.
+// The landing's cross-site chart: the three site-based presets, plus the
+// power-user Customize toolbox (chart types, scale, exclude-outlier, indexed)
+// and its guard rails. Asserts against the live recharts SVG.
const pressedPreset = (page: Page) =>
page.locator('[aria-label="Chart preset"] button[aria-pressed="true"]');
@@ -11,43 +12,58 @@ const yTicks = (page: Page) =>
.locator(".recharts-yAxis .recharts-cartesian-axis-tick-value")
.allTextContents();
+const fullLines = (page: Page) =>
+ // Lines that actually span the data (guards against the scale-undefined glitch
+ // that collapsed lines to 1-2 points).
+ page.evaluate(
+ () =>
+ [...document.querySelectorAll(".recharts-line-curve")].filter(
+ (p) => (p.getAttribute("d") || "").length > 30,
+ ).length,
+ );
+
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 ({
+test("Recent is the default: three full site lines on a linear axis", async ({
page,
}) => {
- await expect(pressedPreset(page)).toHaveText("Momentum");
+ await expect(pressedPreset(page)).toHaveText("Recent");
await expect(page.locator(".recharts-line-curve")).toHaveCount(3);
+ expect(await fullLines(page)).toBe(3);
+ // Linear (not symlog): no "1K"/"10K" decade ticks.
+ const ticks = (await yTicks(page)).map((t) => t.trim());
+ expect(ticks).not.toContain("10K");
+});
+test("all three presets draw site lines; All-time is symlog", async ({ page }) => {
+ for (const name of ["Recent", "Cumulative", "All-time"]) {
+ await page.getByRole("button", { name, exact: true }).click();
+ await expect(pressedPreset(page)).toHaveText(name);
+ await expect(page.locator(".recharts-line-curve")).toHaveCount(3);
+ expect(await fullLines(page)).toBe(3);
+ }
+ // All-time is the tamed symlog view.
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 still reaches the full toolbox (ranked / small / channel)", async ({
+ page,
+}) => {
+ await page.getByRole("button", { name: "Customize" }).click();
+ await page.getByRole("button", { name: "By channel", exact: true }).click();
+ await page.getByRole("button", { name: "Ranked", exact: true }).click();
+ await expect(page.locator(".recharts-bar-rectangle").first()).toBeVisible();
+ await page.getByRole("button", { name: "Small", exact: true }).click();
+ await expect(page.locator(".recharts-responsive-container").first()).toBeVisible();
});
test("Customize greys invalid option combinations", async ({ page }) => {
await page.getByRole("button", { name: "Customize" }).click();
- // Line chart (Momentum default): Share invalid, Symlog valid.
+ // Default Recent is a line chart: 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.
@@ -56,7 +72,7 @@ test("Customize greys invalid option combinations", async ({ page }) => {
await expect(page.getByRole("button", { name: "Share", exact: true })).toBeEnabled();
});
-test("exclude-outlier drops the leading site", async ({ page }) => {
+test("exclude-outlier drops a site line", 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();
@@ -66,9 +82,7 @@ test("exclude-outlier drops the leading site", async ({ page }) => {
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).
+ await expect(pressedPreset(page)).toHaveCount(0); // custom
const ys = await page.evaluate(() =>
[...document.querySelectorAll(".recharts-line-curve")]
.map((p) => {
@@ -85,6 +99,7 @@ test("Indexed rebases every series to a shared baseline", async ({ page }) => {
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 page.getByRole("button", { name: "Customize" }).click();
+ await page.getByRole("button", { name: "Small", exact: true }).click();
await expect(page.locator(".recharts-responsive-container")).toHaveCount(3);
});