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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 13:51:41 -04:00
1 parent 0a37e625bb
commit 575befb83c
7 files changed
+445 -5

No files matched your search

+15
View File
@@ -518,6 +518,21 @@
-webkit-backdrop-filter: blur(var(--overlay-backdrop-blur));
}
.popularity-modal__backdrop {
z-index: var(--z-overlay-popover);
padding: var(--space-md);
}
.popularity-modal {
width: 100%;
max-width: var(--popularity-modal-max-width);
}
.popularity-modal__chart {
width: 100%;
height: var(--popularity-modal-chart-height);
}
.popover-panel[data-variant="zap"],
.popover__panel[data-variant="zap"] {
background: var(--color-overlay-veil);
+2
View File
@@ -80,6 +80,8 @@
--blur-modal-alert: 14px;
--blur-thumbnail-blur: 18px;
--modal-nav-backdrop-blur: 6px;
--popularity-modal-max-width: 28rem;
--popularity-modal-chart-height: 130px;
--app-header-centerline-offset: calc(
var(--space-sm) + (var(--space-2xl-plus) / 2)
+6
View File
@@ -187,6 +187,12 @@ export function createVideoMoreMenuPanel({
dataset: { eventId: metadata.id, context },
});
appendMenuAction(doc, list, {
text: "Popularity",
action: "view-stats",
dataset: { eventId: metadata.id, context },
});
const baseBoostDataset = {
eventId: metadata.id,
author: metadata.pubkey,
+22
View File
@@ -1162,6 +1162,28 @@ export default class MoreMenuController {
.catch(() => this.callbacks.showError("Failed to copy the link."));
break;
}
case "view-stats": {
const targetVideo =
video || (context === "modal" ? currentVideo : null) || currentVideo;
if (!targetVideo) {
this.callbacks.showError("No video selected.");
break;
}
// Dismiss the menu, then open the public popularity chart (lazy-loaded).
try {
this.activePopover?.close?.();
} catch (error) {
// ignore
}
try {
const { openPopularityModal } = await import("../../viewCountChart.js");
openPopularityModal({ video: targetVideo });
} catch (error) {
userLogger.warn("[MoreMenu] Failed to open popularity modal:", error);
this.callbacks.showError("Couldn’t open popularity.");
}
break;
}
case "remove-history": {
await this.callbacks.handleRemoveHistoryAction(dataset);
break;
+302
View File
@@ -0,0 +1,302 @@
// js/viewCountChart.js
//
// Public "Popularity" view: a views-over-time chart for a video, opened from the
// ⋯ menu. View data is public (kind-30079 events), so any viewer can see it.
// The chart buckets view events by the same dedupe window the counter uses (one
// view per viewer per day), and updates live as more view events stream in.
import {
listVideoViewEventsWithDefaultClient,
subscribeVideoViewEventsWithDefaultClient,
} from "./nostrViewEventsFacade.js";
import { resolveVideoPointer } from "./utils/videoPointer.js";
import { formatViewCount } from "./viewCounter.js";
import { devLogger } from "./utils/logger.js";
export const VIEW_CHART_WINDOW_SECONDS = 86400; // 1 day, matches the counter dedupe window
const SVG_NS = "http://www.w3.org/2000/svg";
// ---- Pure data ---------------------------------------------------------------
// Bucket raw view events into a cumulative time series. Dedupes by
// (viewer pubkey, day bucket) so it reflects the same "one view per viewer per
// window" semantics the counter displays. Returns ordered buckets + the total.
export function buildViewCountTimeSeries(
events,
{ windowSeconds = VIEW_CHART_WINDOW_SECONDS } = {},
) {
const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS);
const seen = new Set();
const perBucket = new Map();
for (const event of Array.isArray(events) ? events : []) {
const createdAt = Number(event?.created_at);
const pubkey = typeof event?.pubkey === "string" ? event.pubkey : "";
if (!Number.isFinite(createdAt) || createdAt <= 0 || !pubkey) {
continue;
}
const bucket = Math.floor(createdAt / w);
const key = `${pubkey}:${bucket}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
perBucket.set(bucket, (perBucket.get(bucket) || 0) + 1);
}
const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b);
const series = [];
let cumulative = 0;
for (const bucket of buckets) {
const count = perBucket.get(bucket) || 0;
cumulative += count;
series.push({ bucketStart: bucket * w, count, cumulative });
}
return { series, total: cumulative };
}
function formatDay(seconds) {
try {
return new Date(seconds * 1000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} catch (error) {
return "";
}
}
// ---- SVG chart ---------------------------------------------------------------
// Cumulative views-over-time as a token-colored SVG area+line chart. Uses
// currentColor (set via a text-* utility on the container) so it honors theme
// tokens — no raw colors.
export function buildViewCountChartSvg(doc, series, { width = 320, height = 120 } = {}) {
const pad = { top: 8, right: 8, bottom: 18, left: 8 };
const svg = doc.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", "100%");
svg.setAttribute("role", "img");
svg.setAttribute("preserveAspectRatio", "none");
svg.classList.add("text-accent");
const points = Array.isArray(series) ? series : [];
if (points.length === 0) {
svg.setAttribute("aria-label", "No views yet");
return svg;
}
const innerW = Math.max(1, width - pad.left - pad.right);
const innerH = Math.max(1, height - pad.top - pad.bottom);
const maxY = points[points.length - 1].cumulative || 1;
const minX = points[0].bucketStart;
const maxX = points[points.length - 1].bucketStart;
const spanX = maxX - minX || 1;
const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW;
const y = (cumulative) => pad.top + (1 - cumulative / maxY) * innerH;
// A single bucket can't draw a line — draw a flat baseline to its value.
const coords =
points.length === 1
? [
[pad.left, y(points[0].cumulative)],
[pad.left + innerW, y(points[0].cumulative)],
]
: points.map((p) => [x(p.bucketStart), y(p.cumulative)]);
const linePoints = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
const baseY = pad.top + innerH;
const areaPoints =
`${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` +
linePoints +
` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`;
const area = doc.createElementNS(SVG_NS, "polygon");
area.setAttribute("points", areaPoints);
area.setAttribute("fill", "currentColor");
area.setAttribute("fill-opacity", "0.15");
area.setAttribute("stroke", "none");
svg.appendChild(area);
const line = doc.createElementNS(SVG_NS, "polyline");
line.setAttribute("points", linePoints);
line.setAttribute("fill", "none");
line.setAttribute("stroke", "currentColor");
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-linejoin", "round");
line.setAttribute("stroke-linecap", "round");
svg.appendChild(line);
svg.setAttribute(
"aria-label",
`${maxY} total views from ${formatDay(minX)} to ${formatDay(maxX)}`,
);
return svg;
}
// ---- Modal -------------------------------------------------------------------
function pointerForVideo(video) {
if (!video || typeof video !== "object") {
return null;
}
const tags = Array.isArray(video.tags) ? video.tags : [];
const dTag = tags.find((t) => Array.isArray(t) && t[0] === "d");
const dValue = dTag && typeof dTag[1] === "string" ? dTag[1] : "";
return resolveVideoPointer({
kind: video.kind,
pubkey: video.pubkey,
videoRootId: video.videoRootId,
dTag: dValue,
fallbackEventId: video.id,
relay: video.relay,
});
}
function el(doc, tag, className, text) {
const node = doc.createElement(tag);
if (className) node.className = className;
if (typeof text === "string") node.textContent = text;
return node;
}
// Opens the popularity modal for a video. Dependencies are injectable for tests.
export function openPopularityModal({
document: doc = typeof document !== "undefined" ? document : null,
video,
listViewEvents = listVideoViewEventsWithDefaultClient,
subscribeViewEvents = subscribeVideoViewEventsWithDefaultClient,
mount = null,
} = {}) {
if (!doc) return null;
const pointerInfo = pointerForVideo(video);
if (!pointerInfo?.pointer) {
return null;
}
const pointer = pointerInfo.pointer;
const root = mount || doc.getElementById("uiOverlay") || doc.body;
const backdrop = el(doc, "div", "ds-overlay-backdrop popularity-modal__backdrop");
const dialog = el(doc, "div", "popularity-modal card p-md");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", "Video popularity");
const header = el(doc, "div", "flex items-center justify-between gap-3 mb-2");
header.appendChild(el(doc, "h2", "text-lg font-semibold text-text", "Popularity"));
const closeBtn = el(doc, "button", "icon-button", "");
closeBtn.type = "button";
closeBtn.setAttribute("aria-label", "Close");
closeBtn.textContent = "✕";
header.appendChild(closeBtn);
dialog.appendChild(header);
const totalLine = el(doc, "p", "text-sm text-muted mb-3", "Loading views…");
dialog.appendChild(totalLine);
const chartHost = el(doc, "div", "popularity-modal__chart");
dialog.appendChild(chartHost);
const note = el(
doc,
"p",
"text-2xs text-muted mt-2",
"Public view data · updates as more views load.",
);
dialog.appendChild(note);
backdrop.appendChild(dialog);
root.appendChild(backdrop);
// ---- data + render ----
const eventsById = new Map();
let closed = false;
let unsubscribe = null;
let rerenderTimer = null;
const addEvent = (event) => {
const id = typeof event?.id === "string" ? event.id : "";
if (!id || eventsById.has(id)) return false;
eventsById.set(id, event);
return true;
};
const render = () => {
if (closed) return;
const { series, total } = buildViewCountTimeSeries([...eventsById.values()]);
totalLine.textContent =
total > 0
? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}`
: "No views recorded yet.";
chartHost.textContent = "";
chartHost.appendChild(buildViewCountChartSvg(doc, series));
};
const scheduleRender = () => {
if (rerenderTimer) return;
rerenderTimer = setTimeout(() => {
rerenderTimer = null;
render();
}, 250);
};
const close = () => {
if (closed) return;
closed = true;
if (rerenderTimer) {
clearTimeout(rerenderTimer);
rerenderTimer = null;
}
if (typeof unsubscribe === "function") {
try {
unsubscribe();
} catch (error) {
// best effort
}
}
doc.removeEventListener("keydown", onKeydown, true);
backdrop.remove();
};
function onKeydown(event) {
if (event.key === "Escape") {
event.stopPropagation();
close();
}
}
closeBtn.addEventListener("click", close);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) close();
});
doc.addEventListener("keydown", onKeydown, true);
// Initial fetch + live subscription.
Promise.resolve()
.then(() => listViewEvents(pointer, {}))
.then((events) => {
if (closed) return;
(Array.isArray(events) ? events : []).forEach(addEvent);
render();
})
.catch((error) => {
devLogger.warn("[popularity] Failed to load view events:", error);
if (!closed) {
totalLine.textContent = "Couldn’t load view data.";
}
});
try {
unsubscribe = subscribeViewEvents(pointer, {
onEvent: (event) => {
if (closed) return;
if (addEvent(event)) {
scheduleRender();
}
},
});
} catch (error) {
devLogger.warn("[popularity] Failed to subscribe to view events:", error);
}
return { close };
}
+87
View File
@@ -0,0 +1,87 @@
// #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");
});
+11 -5
View File
@@ -431,11 +431,17 @@ toward freshness and looked identical. Gave each a structural identity:
render-time filter alongside the existing author/community blacklist, and expose
it in the moderation UI (and eventually the #23 admin tab).
### 26. Video popularity / view-count chart (public, three-dots menu)
- [ ] Surface a video's popularity from the view counter as a **chart of views over
time** that updates dynamically as more view events load in. Put it in the
video's **three-dots (⋯) menu** — view data is public, so any viewer can see it.
Depends on the #4 view-counter audit being trustworthy first.
### 26. Video popularity / view-count chart (public, three-dots menu) — DONE 2026-06-24
- [x] **Shipped.** A "Popularity" item in the ⋯ menu opens a public views-over-time
chart (`js/viewCountChart.js`). `buildViewCountTimeSeries` buckets the 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 — no chart lib. The
modal fetches via `listVideoViewEventsWithDefaultClient`, shows the total,
and **updates live** by subscribing to `subscribeVideoViewEventsWithDefaultClient`
(debounced re-render); cleans up on close (Esc/backdrop/✕). Lazy-loaded from
the menu handler. Tests: `tests/view-count-chart.test.mjs`. Built on the
trustworthy #4 counts.
- [ ] FUTURE: feed the same data into a **creator dashboard** showing the creator
their videos' ranking + performance over time (separate, larger effort).