mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
0a37e625bb
commit
575befb83c
7 files changed
+445
-5
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user