mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
charts: dual-line popularity chart — views (red) + zaps (orange) over time (#47 §5.9)
The per-video Popularity modal now plots a second, orange zaps-over-time line beside the red views line, with a legend and — new — VISIBLE x-axis date ticks (the old chart only put the range in the aria-label). - buildZapSatsTimeSeries: buckets cumulative SATS from 9735 receipts + verified bitvid tallies, deduped by payment_hash (same rule as the badge store) so the chart and badge agree. - buildPopularityChartSvg: two token-colored cumulative lines on a shared time x-axis, each with its OWN y scale (independent per-series — D5), plus date-tick <text> labels. Views = .text-accent, zaps = .text-zap (new --color-zap utility). Legend is plain DOM in the modal; headline gains "· N sats zapped". - js/zapEventsFacade.js: lists a pointer's [9735, ZAP_TALLY_KIND] events via the L1 SubscriptionManager for the chart; modal re-renders on onZapTotalsChanged. - All flag-gated (FEATURE_ZAP_TALLY); the existing views-only chart + its test are untouched (buildViewCountChartSvg kept). Verified live earlier that imports resolve (app builds/runs). Tests: tests/zap-time-series-chart.test.mjs (5) + existing view-count-chart green. Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
68bdee8da5
commit
974fdc836d
5 files changed
+504
-8
No files matched your search
@@ -2923,6 +2923,34 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Zap orange as a currentColor source for the popularity chart's zaps line
|
||||
+ legend swatch (mirrors how the views line uses .text-accent). */
|
||||
.text-zap {
|
||||
color: var(--color-zap);
|
||||
}
|
||||
|
||||
.popularity-legend {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-block-start: var(--space-2xs);
|
||||
}
|
||||
|
||||
.popularity-legend__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3xs);
|
||||
}
|
||||
|
||||
.popularity-legend__swatch {
|
||||
inline-size: var(--space-sm);
|
||||
block-size: var(--size-3px, 0.1875rem);
|
||||
border-radius: var(--radius-full);
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
/* #43: always-visible active-identity pill. Rendered after the header in
|
||||
flow and pulled up with a negative block-start margin so it sits half-on /
|
||||
half-off the header divider — every pane shows who you're acting as. */
|
||||
|
||||
@@ -471,12 +471,16 @@ tallies simply stop being counted. No data migration.
|
||||
- [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify.
|
||||
- [x] `lint` + `build` + suites green; pushed to `unstable`.
|
||||
- [ ] **Remaining v1 work:**
|
||||
- [ ] **Profile-zap counting:** the store queries `#a`/`#e` only — add
|
||||
`#p` batches so a profile-only tally (no video pointer) is fetched,
|
||||
and a channel-page total UI surface (D2 chose video+profile).
|
||||
- [ ] **Profile-zap counting (remaining):** the store queries `#a`/`#e`
|
||||
only — add `#p` batches so a profile-only tally (no video pointer) is
|
||||
fetched, and a channel-page total UI surface (D2 chose video+profile).
|
||||
- [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess`
|
||||
so retried shares publish tallies too. (commit afed041f)
|
||||
- [ ] **Popularity chart §5.9:** orange zaps line + legend + date axis.
|
||||
- [x] **Popularity chart §5.9 DONE:** orange zaps-over-time line beside
|
||||
the red views line, legend (Views/Zaps), visible date-axis ticks,
|
||||
and a "· N sats zapped" headline. buildZapSatsTimeSeries (dedup by
|
||||
payment_hash) + buildPopularityChartSvg + zapEventsFacade. Tests:
|
||||
tests/zap-time-series-chart.test.mjs.
|
||||
- [ ] Live end-to-end on unstable (real zap → tally on relay → second
|
||||
browser counts it); rollout per §9.
|
||||
|
||||
|
||||
+287
-4
@@ -15,7 +15,20 @@ import {
|
||||
subscribeToVideoViewCount,
|
||||
unsubscribeFromVideoViewCount,
|
||||
} from "./viewCounter.js";
|
||||
import { listVideoZapEventsWithDefaultClient } from "./zapEventsFacade.js";
|
||||
import {
|
||||
getVideoZapTotalSnapshot,
|
||||
requestVideoZapTotal,
|
||||
onZapTotalsChanged,
|
||||
ZAP_RECEIPT_KIND,
|
||||
} from "./zapTotals.js";
|
||||
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
verifyBitvidZapTally,
|
||||
extractBolt11Fields,
|
||||
} from "./payments/zapReceiptValidator.js";
|
||||
import { VIEW_COUNT_BACKFILL_MAX_DAYS } from "./config.js";
|
||||
import { FEATURE_ZAP_TALLY } from "./constants.js";
|
||||
import { devLogger } from "./utils/logger.js";
|
||||
|
||||
export const VIEW_CHART_WINDOW_SECONDS = 86400; // 1 day, matches the counter dedupe window
|
||||
@@ -58,6 +71,73 @@ export function buildViewCountTimeSeries(
|
||||
return { series, total: cumulative };
|
||||
}
|
||||
|
||||
// Read the authoritative sats for one zap event (9735 receipt or bitvid tally),
|
||||
// plus a dedup key (payment_hash preferred, else event id). Returns null when
|
||||
// the event isn't a countable zap (invalid tally, no amount, …).
|
||||
function readZapEvent(event, tools) {
|
||||
const getSats = tools?.nip57?.getSatoshisAmountFromBolt11;
|
||||
if (event?.kind === ZAP_TALLY_KIND) {
|
||||
const verdict = verifyBitvidZapTally(event, { getSats });
|
||||
if (!verdict?.ok || !(verdict.sats > 0)) {
|
||||
return null;
|
||||
}
|
||||
return { sats: verdict.sats, dedup: verdict.paymentHash || event.id };
|
||||
}
|
||||
if (event?.kind === ZAP_RECEIPT_KIND && event?.id) {
|
||||
const bolt11 =
|
||||
(Array.isArray(event.tags)
|
||||
? event.tags.find((t) => Array.isArray(t) && t[0] === "bolt11")?.[1]
|
||||
: "") || "";
|
||||
let sats = 0;
|
||||
try {
|
||||
const parsed = typeof getSats === "function" ? Number(getSats(bolt11)) : NaN;
|
||||
sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
|
||||
} catch (error) {
|
||||
sats = 0;
|
||||
}
|
||||
if (sats <= 0) {
|
||||
return null;
|
||||
}
|
||||
const dedup = extractBolt11Fields(bolt11).paymentHash || event.id;
|
||||
return { sats, dedup };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bucket zap events into a cumulative SATS-over-time series, deduped by
|
||||
// payment_hash across 9735s + tallies (same rule as the zap-totals store), so
|
||||
// the chart and the badge agree. Returns ordered buckets + the total.
|
||||
export function buildZapSatsTimeSeries(
|
||||
events,
|
||||
{ windowSeconds = VIEW_CHART_WINDOW_SECONDS, tools = null } = {},
|
||||
) {
|
||||
const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS);
|
||||
const seenPayments = new Set();
|
||||
const perBucket = new Map();
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
const createdAt = Number(event?.created_at);
|
||||
if (!Number.isFinite(createdAt) || createdAt <= 0) {
|
||||
continue;
|
||||
}
|
||||
const read = readZapEvent(event, tools);
|
||||
if (!read || seenPayments.has(read.dedup)) {
|
||||
continue;
|
||||
}
|
||||
seenPayments.add(read.dedup);
|
||||
const bucket = Math.floor(createdAt / w);
|
||||
perBucket.set(bucket, (perBucket.get(bucket) || 0) + read.sats);
|
||||
}
|
||||
const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b);
|
||||
const series = [];
|
||||
let cumulative = 0;
|
||||
for (const bucket of buckets) {
|
||||
const sats = perBucket.get(bucket) || 0;
|
||||
cumulative += sats;
|
||||
series.push({ bucketStart: bucket * w, sats, cumulative });
|
||||
}
|
||||
return { series, total: cumulative };
|
||||
}
|
||||
|
||||
function formatDay(seconds) {
|
||||
try {
|
||||
return new Date(seconds * 1000).toLocaleDateString(undefined, {
|
||||
@@ -138,6 +218,128 @@ export function buildViewCountChartSvg(doc, series, { width = 320, height = 120
|
||||
return svg;
|
||||
}
|
||||
|
||||
// Draw one cumulative series (area + line) into `svg` using a color class on a
|
||||
// wrapping <g> (currentColor). x()/y() map data → pixels. Independent per-series
|
||||
// Y scaling (D5): the caller passes this series' own maxY.
|
||||
function drawSeries(doc, svg, points, { x, y, baseY, colorClass }) {
|
||||
if (!points.length) return;
|
||||
const coords =
|
||||
points.length === 1
|
||||
? [
|
||||
[x(points[0].bucketStart), y(points[0].cumulative)],
|
||||
[x(points[points.length - 1].bucketStart) + 1, 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 areaPoints =
|
||||
`${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` +
|
||||
linePoints +
|
||||
` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`;
|
||||
|
||||
const group = doc.createElementNS(SVG_NS, "g");
|
||||
if (colorClass) group.classList.add(colorClass);
|
||||
|
||||
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");
|
||||
group.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");
|
||||
group.appendChild(line);
|
||||
|
||||
svg.appendChild(group);
|
||||
}
|
||||
|
||||
// Popularity chart with TWO cumulative series on a shared TIME x-axis, each with
|
||||
// its OWN y scale (views in counts, zaps in sats — independent per-series
|
||||
// scaling shows each trend honestly; the legend names the units). Adds VISIBLE
|
||||
// x-axis date ticks. Views = accent (red), zaps = --color-zap (orange).
|
||||
export function buildPopularityChartSvg(
|
||||
doc,
|
||||
{ views = [], zaps = [] } = {},
|
||||
{ width = 320, height = 132 } = {},
|
||||
) {
|
||||
const pad = { top: 8, right: 8, bottom: 26, 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");
|
||||
|
||||
const viewsPts = Array.isArray(views) ? views : [];
|
||||
const zapsPts = Array.isArray(zaps) ? zaps : [];
|
||||
if (!viewsPts.length && !zapsPts.length) {
|
||||
svg.setAttribute("aria-label", "No views or zaps yet");
|
||||
return svg;
|
||||
}
|
||||
|
||||
const innerW = Math.max(1, width - pad.left - pad.right);
|
||||
const innerH = Math.max(1, height - pad.top - pad.bottom);
|
||||
const baseY = pad.top + innerH;
|
||||
|
||||
// Shared time domain across both series.
|
||||
const allX = [...viewsPts, ...zapsPts].map((p) => p.bucketStart);
|
||||
const minX = Math.min(...allX);
|
||||
const maxX = Math.max(...allX);
|
||||
const spanX = maxX - minX || 1;
|
||||
const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW;
|
||||
|
||||
// Per-series y scale (each fills the height against its own max).
|
||||
const makeY = (pts) => {
|
||||
const maxY = (pts[pts.length - 1]?.cumulative) || 1;
|
||||
return (cumulative) => pad.top + (1 - cumulative / maxY) * innerH;
|
||||
};
|
||||
|
||||
drawSeries(doc, svg, viewsPts, {
|
||||
x,
|
||||
y: makeY(viewsPts),
|
||||
baseY,
|
||||
colorClass: "text-accent",
|
||||
});
|
||||
drawSeries(doc, svg, zapsPts, {
|
||||
x,
|
||||
y: makeY(zapsPts),
|
||||
baseY,
|
||||
colorClass: "text-zap",
|
||||
});
|
||||
|
||||
// Visible x-axis date ticks (min → max, a few evenly spaced) so the reader
|
||||
// knows the time range — the old chart only put dates in the aria-label.
|
||||
const tickCount = spanX > 1 ? 3 : 1;
|
||||
for (let i = 0; i < tickCount; i += 1) {
|
||||
const t = tickCount === 1 ? minX : minX + (spanX * i) / (tickCount - 1);
|
||||
const label = doc.createElementNS(SVG_NS, "text");
|
||||
const px = x(t);
|
||||
label.setAttribute("x", px.toFixed(1));
|
||||
label.setAttribute("y", (height - 8).toFixed(1));
|
||||
label.setAttribute("font-size", "9");
|
||||
label.setAttribute("fill", "currentColor");
|
||||
label.setAttribute("fill-opacity", "0.6");
|
||||
label.setAttribute(
|
||||
"text-anchor",
|
||||
i === 0 ? "start" : i === tickCount - 1 ? "end" : "middle",
|
||||
);
|
||||
label.textContent = formatDay(t);
|
||||
svg.appendChild(label);
|
||||
}
|
||||
|
||||
const viewsTotal = viewsPts[viewsPts.length - 1]?.cumulative || 0;
|
||||
const zapsTotal = zapsPts[zapsPts.length - 1]?.cumulative || 0;
|
||||
svg.setAttribute(
|
||||
"aria-label",
|
||||
`${viewsTotal} views and ${zapsTotal} sats zapped from ${formatDay(minX)} to ${formatDay(maxX)}`,
|
||||
);
|
||||
return svg;
|
||||
}
|
||||
|
||||
// ---- Modal -------------------------------------------------------------------
|
||||
|
||||
function pointerForVideo(video) {
|
||||
@@ -173,6 +375,11 @@ export function openPopularityModal({
|
||||
video,
|
||||
listViewEvents = listVideoViewEventsWithDefaultClient,
|
||||
subscribeViewEvents = subscribeVideoViewEventsWithDefaultClient,
|
||||
listZapEvents = listVideoZapEventsWithDefaultClient,
|
||||
getTools = () =>
|
||||
(typeof globalThis !== "undefined" &&
|
||||
(globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ || globalThis.NostrTools)) ||
|
||||
null,
|
||||
mount = null,
|
||||
} = {}) {
|
||||
if (!doc) return null;
|
||||
@@ -216,11 +423,28 @@ export function openPopularityModal({
|
||||
const chartHost = el(doc, "div", "popularity-modal__chart");
|
||||
dialog.appendChild(chartHost);
|
||||
|
||||
// Legend: red = Views, orange = Zaps (sats). Token colors via .text-accent /
|
||||
// .text-zap on the swatch (currentColor). Only shown when the feature is on.
|
||||
const legend = el(doc, "div", "popularity-legend");
|
||||
const legendItem = (colorClass, label) => {
|
||||
const item = el(doc, "span", "popularity-legend__item");
|
||||
item.appendChild(el(doc, "span", `popularity-legend__swatch ${colorClass}`));
|
||||
item.appendChild(el(doc, "span", "", label));
|
||||
return item;
|
||||
};
|
||||
legend.appendChild(legendItem("text-accent", "Views"));
|
||||
if (FEATURE_ZAP_TALLY) {
|
||||
legend.appendChild(legendItem("text-zap", "Zaps (sats)"));
|
||||
}
|
||||
dialog.appendChild(legend);
|
||||
|
||||
const note = el(
|
||||
doc,
|
||||
"p",
|
||||
"text-2xs text-muted mt-2",
|
||||
"Public view data · updates as more views load.",
|
||||
FEATURE_ZAP_TALLY
|
||||
? "Public view + zap data · updates as more load."
|
||||
: "Public view data · updates as more views load.",
|
||||
);
|
||||
dialog.appendChild(note);
|
||||
|
||||
@@ -229,8 +453,10 @@ export function openPopularityModal({
|
||||
|
||||
// ---- data + render ----
|
||||
const eventsById = new Map();
|
||||
const zapEventsById = new Map();
|
||||
let closed = false;
|
||||
let unsubscribe = null;
|
||||
let unsubscribeZapTotals = null;
|
||||
let countToken = null;
|
||||
let counterTotal = null;
|
||||
let rerenderTimer = null;
|
||||
@@ -242,6 +468,13 @@ export function openPopularityModal({
|
||||
return true;
|
||||
};
|
||||
|
||||
const addZapEvent = (event) => {
|
||||
const id = typeof event?.id === "string" ? event.id : "";
|
||||
if (!id || zapEventsById.has(id)) return false;
|
||||
zapEventsById.set(id, event);
|
||||
return true;
|
||||
};
|
||||
|
||||
// The headline number must match what the card / counter shows everywhere
|
||||
// (the authoritative, deduped, cached viewCounter total) — NOT a separate count
|
||||
// re-derived from a one-off relay list (which varies by relay response and
|
||||
@@ -254,17 +487,33 @@ export function openPopularityModal({
|
||||
if (!Number.isFinite(total)) {
|
||||
return;
|
||||
}
|
||||
totalLine.textContent =
|
||||
const viewsText =
|
||||
total > 0
|
||||
? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}`
|
||||
: "No views recorded yet.";
|
||||
// Zap total from the shared store (matches the badge), appended when on.
|
||||
if (FEATURE_ZAP_TALLY) {
|
||||
let zapSats = 0;
|
||||
try {
|
||||
zapSats = getVideoZapTotalSnapshot(pointer) || 0;
|
||||
} catch (error) {
|
||||
zapSats = 0;
|
||||
}
|
||||
totalLine.textContent =
|
||||
zapSats > 0 ? `${viewsText} · ${formatViewCount(zapSats)} sats zapped` : viewsText;
|
||||
} else {
|
||||
totalLine.textContent = viewsText;
|
||||
}
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
if (closed) return;
|
||||
const { series } = buildViewCountTimeSeries([...eventsById.values()]);
|
||||
const { series: views } = buildViewCountTimeSeries([...eventsById.values()]);
|
||||
const { series: zaps } = FEATURE_ZAP_TALLY
|
||||
? buildZapSatsTimeSeries([...zapEventsById.values()], { tools: getTools() })
|
||||
: { series: [] };
|
||||
chartHost.textContent = "";
|
||||
chartHost.appendChild(buildViewCountChartSvg(doc, series));
|
||||
chartHost.appendChild(buildPopularityChartSvg(doc, { views, zaps }));
|
||||
renderHeadline();
|
||||
};
|
||||
|
||||
@@ -297,6 +546,13 @@ export function openPopularityModal({
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
if (typeof unsubscribeZapTotals === "function") {
|
||||
try {
|
||||
unsubscribeZapTotals();
|
||||
} catch (error) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
doc.removeEventListener("keydown", onKeydown, true);
|
||||
backdrop.remove();
|
||||
if (activePopularityModal === handle) {
|
||||
@@ -362,6 +618,33 @@ export function openPopularityModal({
|
||||
devLogger.warn("[popularity] Failed to subscribe to view events:", error);
|
||||
}
|
||||
|
||||
// Zaps-over-time (orange line): fetch the pointer's zap events (9735 + tally)
|
||||
// for the chart, and (headline) request the shared total + re-render when the
|
||||
// zap-totals store changes. Gated by the feature flag.
|
||||
if (FEATURE_ZAP_TALLY) {
|
||||
try {
|
||||
requestVideoZapTotal(pointer); // primes the store for the headline
|
||||
unsubscribeZapTotals = onZapTotalsChanged(() => {
|
||||
if (!closed) renderHeadline();
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[popularity] Failed to subscribe to zap totals:", error);
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => listZapEvents(pointer, { since }))
|
||||
.then((events) => {
|
||||
if (closed) return;
|
||||
let added = false;
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
if (addZapEvent(event)) added = true;
|
||||
}
|
||||
if (added) renderChart();
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn("[popularity] Failed to load zap events:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const handle = { close };
|
||||
activePopularityModal = handle;
|
||||
return handle;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// js/zapEventsFacade.js
|
||||
//
|
||||
// Lists the zap events for a video/profile pointer so the Popularity chart can
|
||||
// draw a zaps-over-time series (docs/zap-tally-plan.md §5.9). Fetches BOTH real
|
||||
// NIP-57 receipts (kind 9735) and bitvid tallies (ZAP_TALLY_KIND); the chart's
|
||||
// buildZapSatsTimeSeries verifies tallies + dedups by payment_hash, so this just
|
||||
// needs to return the raw events. Routed through the L1 SubscriptionManager
|
||||
// (relay caps + in-flight dedupe), matching the zap-totals store.
|
||||
|
||||
import { nostrClient } from "./nostrClientFacade.js";
|
||||
import { ZAP_RECEIPT_KIND } from "./zapTotals.js";
|
||||
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
|
||||
import { devLogger } from "./utils/logger.js";
|
||||
|
||||
const LIMIT = 500;
|
||||
|
||||
// pointer: the tag-style ["a"|"e", value, relay?] array resolveVideoPointer
|
||||
// produces (or an { type, value } object).
|
||||
function pointerFilterKey(pointer) {
|
||||
const type = Array.isArray(pointer)
|
||||
? pointer[0]
|
||||
: pointer && typeof pointer === "object"
|
||||
? pointer.type
|
||||
: "";
|
||||
const value = Array.isArray(pointer)
|
||||
? pointer[1]
|
||||
: pointer && typeof pointer === "object"
|
||||
? pointer.value
|
||||
: "";
|
||||
if ((type === "a" || type === "e") && typeof value === "string" && value.trim()) {
|
||||
return { tag: `#${type}`, value: value.trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listVideoZapEventsWithDefaultClient(
|
||||
pointer,
|
||||
{ since, client = nostrClient } = {},
|
||||
) {
|
||||
const pf = pointerFilterKey(pointer);
|
||||
if (!pf) {
|
||||
return [];
|
||||
}
|
||||
const manager =
|
||||
typeof client?.getSubscriptionManager === "function"
|
||||
? client.getSubscriptionManager()
|
||||
: null;
|
||||
const relays = Array.isArray(client?.relays) ? client.relays : [];
|
||||
if (!manager || typeof manager.list !== "function" || !relays.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [ZAP_RECEIPT_KIND, ZAP_TALLY_KIND],
|
||||
[pf.tag]: [pf.value],
|
||||
limit: LIMIT,
|
||||
};
|
||||
if (Number.isFinite(since) && since > 0) {
|
||||
filter.since = Math.floor(since);
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await manager.list({ relays, filters: [filter] });
|
||||
return Array.isArray(events) ? events : [];
|
||||
} catch (error) {
|
||||
devLogger.warn("[zapEvents] Failed to list zap events:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default listVideoZapEventsWithDefaultClient;
|
||||
@@ -0,0 +1,110 @@
|
||||
// #47 §5.9: the popularity chart gains a zaps-over-time (orange) series with a
|
||||
// visible date axis. buildZapSatsTimeSeries buckets cumulative SATS from 9735
|
||||
// receipts + verified tallies, deduped by payment_hash (same rule as the badge
|
||||
// store). buildPopularityChartSvg draws two token-colored lines + date ticks.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-zap-sats-series
|
||||
// given: "zap events (9735) across days, incl. a duplicate payment_hash"
|
||||
// when: "buildZapSatsTimeSeries buckets them"
|
||||
// then: "cumulative sats per day; a repeated payment counts once"
|
||||
// - id: SCN-popularity-svg
|
||||
// given: "views + zaps cumulative series"
|
||||
// when: "buildPopularityChartSvg renders"
|
||||
// then: "two <polyline> series (accent + zap colors) and visible date <text> ticks"
|
||||
// observable_outcomes:
|
||||
// - "series buckets/cumulative/total"
|
||||
// - "SVG polyline count, series color classes, date-tick text present"
|
||||
// determinism_controls:
|
||||
// - "in-test bech32 bolt11 vector; injected getSats; JSDOM"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { bech32 } from "../vendor/crypto-helpers.bundle.min.js";
|
||||
import {
|
||||
buildZapSatsTimeSeries,
|
||||
buildPopularityChartSvg,
|
||||
} from "../js/viewCountChart.js";
|
||||
|
||||
const lenWords = (n) => [n >> 5, n & 31];
|
||||
function bolt11WithPaymentHash(fillByte) {
|
||||
const pWords = bech32.toWords(new Uint8Array(32).fill(fillByte));
|
||||
const words = [0, 0, 0, 0, 0, 0, 0, 1, ...lenWords(pWords.length), ...pWords, ...new Array(104).fill(0)];
|
||||
return bech32.encode("lnbc", words, 2000);
|
||||
}
|
||||
const DAY = 86400;
|
||||
const receipt = (id, createdAt, fillByte) => ({
|
||||
id,
|
||||
kind: 9735,
|
||||
created_at: createdAt,
|
||||
tags: [["bolt11", bolt11WithPaymentHash(fillByte)]],
|
||||
});
|
||||
const tools = { nip57: { getSatoshisAmountFromBolt11: () => 500 } };
|
||||
|
||||
test("buildZapSatsTimeSeries: cumulative sats per day", () => {
|
||||
const events = [
|
||||
receipt("a", 10 * DAY + 100, 0x11),
|
||||
receipt("b", 11 * DAY + 100, 0x22),
|
||||
receipt("c", 11 * DAY + 200, 0x33),
|
||||
];
|
||||
const { series, total } = buildZapSatsTimeSeries(events, { tools });
|
||||
assert.equal(total, 1500, "3 × 500 sats");
|
||||
assert.equal(series.length, 2, "two day buckets");
|
||||
assert.equal(series[0].sats, 500);
|
||||
assert.equal(series[1].sats, 1000);
|
||||
assert.equal(series[1].cumulative, 1500, "cumulative across days");
|
||||
});
|
||||
|
||||
test("buildZapSatsTimeSeries: a repeated payment_hash is counted once", () => {
|
||||
const events = [
|
||||
receipt("a", 10 * DAY, 0xab),
|
||||
receipt("b", 10 * DAY, 0xab), // same payment_hash (same bolt11 fill) → dup
|
||||
];
|
||||
const { total } = buildZapSatsTimeSeries(events, { tools });
|
||||
assert.equal(total, 500, "deduped by payment_hash, not 1000");
|
||||
});
|
||||
|
||||
test("buildZapSatsTimeSeries: empty/garbage → empty series", () => {
|
||||
assert.deepEqual(buildZapSatsTimeSeries([], { tools }), { series: [], total: 0 });
|
||||
assert.deepEqual(buildZapSatsTimeSeries(null, { tools }), { series: [], total: 0 });
|
||||
});
|
||||
|
||||
test("buildPopularityChartSvg: two colored lines + visible date ticks", () => {
|
||||
const doc = new JSDOM("<!DOCTYPE html><body></body>").window.document;
|
||||
const views = [
|
||||
{ bucketStart: 10 * DAY, cumulative: 3 },
|
||||
{ bucketStart: 12 * DAY, cumulative: 8 },
|
||||
];
|
||||
const zaps = [
|
||||
{ bucketStart: 11 * DAY, cumulative: 500 },
|
||||
{ bucketStart: 12 * DAY, cumulative: 2100 },
|
||||
];
|
||||
const svg = buildPopularityChartSvg(doc, { views, zaps });
|
||||
|
||||
const polylines = svg.querySelectorAll("polyline");
|
||||
assert.equal(polylines.length, 2, "one line per series");
|
||||
assert.ok(svg.querySelector("g.text-accent polyline"), "views line uses the accent color");
|
||||
assert.ok(svg.querySelector("g.text-zap polyline"), "zaps line uses the zap color");
|
||||
|
||||
const ticks = svg.querySelectorAll("text");
|
||||
assert.ok(ticks.length >= 2, "visible x-axis date ticks rendered");
|
||||
assert.ok(
|
||||
Array.from(ticks).every((t) => t.textContent && t.textContent.length > 0),
|
||||
"each tick has a formatted date label",
|
||||
);
|
||||
assert.match(svg.getAttribute("aria-label"), /views and .* sats zapped/);
|
||||
});
|
||||
|
||||
test("buildPopularityChartSvg: no data → labelled empty, no lines", () => {
|
||||
const doc = new JSDOM("<!DOCTYPE html><body></body>").window.document;
|
||||
const svg = buildPopularityChartSvg(doc, { views: [], zaps: [] });
|
||||
assert.equal(svg.querySelectorAll("polyline").length, 0);
|
||||
assert.match(svg.getAttribute("aria-label"), /No views or zaps/i);
|
||||
});
|
||||
Reference in new issue
Block a user