Files
bitvid/tests/view-count-chart.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 575befb83c feed: add public popularity (views-over-time) chart in the ⋯ menu (#26)
A "Popularity" item in the video ⋯ menu opens a public views-over-time chart
(view data is public). Builds on the now-trustworthy view counts (#4).

- buildViewCountTimeSeries buckets raw kind-30079 view events by the counter's
  dedupe window (one view per viewer per day) into a cumulative series.
- buildViewCountChartSvg renders a token-colored (currentColor / text-accent)
  SVG area+line chart — no charting library, no raw colors.
- The modal fetches via listVideoViewEventsWithDefaultClient, shows the total,
  and updates LIVE by subscribing to view events (debounced re-render); cleans
  up on close (Esc / backdrop / ✕). Lazy-loaded from the menu handler so it
  only ships when opened.

New size tokens for the modal; reuses .ds-overlay-backdrop for the scrim.

Tests: tests/view-count-chart.test.mjs (cumulative dedupe, junk filtering,
SVG line/area + empty/single-point).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 13:51:41 -04:00

88 lines
3.0 KiB
JavaScript

// #26: the popularity chart buckets view events into a cumulative time series,
// deduped by (viewer, day) like the counter, and renders a token-colored SVG.
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import {
buildViewCountTimeSeries,
buildViewCountChartSvg,
VIEW_CHART_WINDOW_SECONDS,
} from "../js/viewCountChart.js";
const W = VIEW_CHART_WINDOW_SECONDS;
const ev = (id, pubkey, day, secInDay = 10) => ({
id,
pubkey,
created_at: day * W + secInDay,
});
test("cumulative series: one view per (viewer, day), summed over time", () => {
const events = [
ev("1", "alice", 100),
ev("2", "alice", 100, 50), // same viewer+day → deduped
ev("3", "bob", 100),
ev("4", "alice", 101), // alice again next day → counts
ev("5", "carol", 102),
];
const { series, total } = buildViewCountTimeSeries(events);
assert.equal(total, 4, "alice(d100)+bob(d100)+alice(d101)+carol(d102)");
assert.deepEqual(
series.map((s) => [s.bucketStart / W, s.count, s.cumulative]),
[
[100, 2, 2],
[101, 1, 3],
[102, 1, 4],
],
);
});
test("ignores events without a valid pubkey or created_at", () => {
const events = [
ev("1", "alice", 100),
{ id: "2", created_at: 100 * W }, // no pubkey
{ id: "3", pubkey: "bob" }, // no created_at
{ id: "4", pubkey: "carol", created_at: -5 },
];
const { total } = buildViewCountTimeSeries(events);
assert.equal(total, 1);
});
test("empty input yields an empty series", () => {
assert.deepEqual(buildViewCountTimeSeries([]), { series: [], total: 0 });
assert.deepEqual(buildViewCountTimeSeries(null), { series: [], total: 0 });
});
test("SVG renders a line+area for a multi-point series, token-colored", () => {
const dom = new JSDOM("<!DOCTYPE html><body></body>");
const doc = dom.window.document;
const { series } = buildViewCountTimeSeries([
ev("1", "a", 100),
ev("2", "b", 101),
ev("3", "c", 102),
]);
const svg = buildViewCountChartSvg(doc, series, { width: 300, height: 100 });
assert.equal(svg.tagName.toLowerCase(), "svg");
assert.ok(svg.classList.contains("text-accent"), "uses the accent token via currentColor");
assert.ok(svg.querySelector("polyline"), "has the cumulative line");
assert.ok(svg.querySelector("polygon"), "has the area fill");
assert.equal(svg.querySelector("polyline").getAttribute("stroke"), "currentColor");
});
test("SVG handles empty + single-point series without throwing", () => {
const dom = new JSDOM("<!DOCTYPE html><body></body>");
const doc = dom.window.document;
const empty = buildViewCountChartSvg(doc, [], { width: 300, height: 100 });
assert.equal(empty.querySelector("polyline"), null, "no line when there are no views");
const single = buildViewCountChartSvg(
doc,
buildViewCountTimeSeries([ev("1", "a", 100)]).series,
{ width: 300, height: 100 },
);
assert.ok(single.querySelector("polyline"), "single point draws a flat baseline");
});