From 974fdc836d1fc011578509d84b8f06ef87fff318 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:27:00 -0400 Subject: [PATCH] =?UTF-8?q?charts:=20dual-line=20popularity=20chart=20?= =?UTF-8?q?=E2=80=94=20views=20(red)=20+=20zaps=20(orange)=20over=20time?= =?UTF-8?q?=20(#47=20=C2=A75.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- css/tailwind.source.css | 28 +++ docs/zap-tally-plan.md | 12 +- js/viewCountChart.js | 291 ++++++++++++++++++++++++++- js/zapEventsFacade.js | 71 +++++++ tests/zap-time-series-chart.test.mjs | 110 ++++++++++ 5 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 js/zapEventsFacade.js create mode 100644 tests/zap-time-series-chart.test.mjs diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 4392010a..1cf66059 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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. */ diff --git a/docs/zap-tally-plan.md b/docs/zap-tally-plan.md index 068dbb1a..ae6a2b29 100644 --- a/docs/zap-tally-plan.md +++ b/docs/zap-tally-plan.md @@ -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. diff --git a/js/viewCountChart.js b/js/viewCountChart.js index c7c337d3..8de8ef0f 100644 --- a/js/viewCountChart.js +++ b/js/viewCountChart.js @@ -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 (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; diff --git a/js/zapEventsFacade.js b/js/zapEventsFacade.js new file mode 100644 index 00000000..659240ef --- /dev/null +++ b/js/zapEventsFacade.js @@ -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; diff --git a/tests/zap-time-series-chart.test.mjs b/tests/zap-time-series-chart.test.mjs new file mode 100644 index 00000000..11b65a0e --- /dev/null +++ b/tests/zap-time-series-chart.test.mjs @@ -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 series (accent + zap colors) and visible date 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("").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("").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); +});