From 575befb83cb57fc7f1f309ed3c81c763be3c57ad Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:51:41 -0400 Subject: [PATCH] =?UTF-8?q?feed:=20add=20public=20popularity=20(views-over?= =?UTF-8?q?-time)=20chart=20in=20the=20=E2=8B=AF=20menu=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- css/tailwind.source.css | 15 ++ css/tokens.css | 2 + js/ui/components/videoMenuRenderers.js | 6 + js/ui/moreMenuController.js | 22 ++ js/viewCountChart.js | 302 +++++++++++++++++++++++++ tests/view-count-chart.test.mjs | 87 +++++++ todo/TODO_2026-06-20_pre-launch.md | 16 +- 7 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 js/viewCountChart.js create mode 100644 tests/view-count-chart.test.mjs diff --git a/css/tailwind.source.css b/css/tailwind.source.css index b01d760b..8a69aaf5 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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); diff --git a/css/tokens.css b/css/tokens.css index 3d4c9475..7cc22a09 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -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) diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index e9f26aeb..e71651bf 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -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, diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 823afc77..3dbadf03 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -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; diff --git a/js/viewCountChart.js b/js/viewCountChart.js new file mode 100644 index 00000000..e2ed6272 --- /dev/null +++ b/js/viewCountChart.js @@ -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 }; +} diff --git a/tests/view-count-chart.test.mjs b/tests/view-count-chart.test.mjs new file mode 100644 index 00000000..9e01f400 --- /dev/null +++ b/tests/view-count-chart.test.mjs @@ -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(""); + 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(""); + 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"); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 477bbc06..a1df816c 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -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).