zaps: make the card + modal zap counter always visible (0 sats when none)

Diagnosis (live, verified): the zap-totals pipeline is correct end to end —
a seeded receipt queries and sums to the right sats — but of 310 kind-9735
receipts reachable on the relays, ZERO reference any bitvid video, so every
badge was legitimately hidden. That reads as "broken" and profile zaps to a
Lightning address never attach to a video, so hidden-when-zero left the
feature invisible in practice.

Show the counter always, like the view counter it sits beside: "0 sats"
until a receipt lands, then the summed total. Card binder and modal render
no longer gate on sats>0; the badge is revealed on bind/open and re-hidden
on modal close (no video open). Live-checked: 158/158 cards render the
badge. Tests spec-corrected with integrity notes.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 14:43:22 -04:00
1 parent 213cb97ee8
commit 8d45f25962
4 files changed
+35 -29

No files matched your search

+4 -5
View File
@@ -81,17 +81,16 @@ export function createModalCoordinator(deps) {
if (!el || !pointer) {
return;
}
// Always visible while a video is open (mirrors the view counter); a zero
// is a real value, not noise to hide.
const render = () => {
const sats = getVideoZapTotalSnapshot(pointer);
const value = Number.isFinite(sats) && sats > 0 ? sats : 0;
const text = el.querySelector("[data-zap-total-text]");
if (Number.isFinite(sats) && sats > 0) {
if (text) {
text.textContent = `${formatViewCount(sats)} sats`;
text.textContent = `${formatViewCount(value)} sats`;
}
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
};
requestVideoZapTotal(pointer);
render();
+6 -6
View File
@@ -23,15 +23,15 @@ export function createZapTotalBinder({ formatSats = defaultFormatSats } = {}) {
const entries = new Map(); // pointerKey → { pointer, elements:Set }
let unsubscribe = null;
// Always visible, mirroring the view counter (a zero is a real, informative
// value — "this hasn't been zapped yet" — not noise to hide). The wrapper
// starts hidden in markup and is revealed on first bind so cards without a
// pointer stay clean.
const renderElement = (el, sats) => {
const wrapper = el.closest?.(".video-card__zaps") || null;
if (Number.isFinite(sats) && sats > 0) {
el.textContent = formatSats(sats);
const value = Number.isFinite(sats) && sats > 0 ? sats : 0;
el.textContent = formatSats(value);
wrapper?.classList?.remove("hidden");
} else {
el.textContent = "";
wrapper?.classList?.add("hidden");
}
};
const renderKey = (key) => {
+13 -6
View File
@@ -13,10 +13,10 @@
// - id: SCN-modal-open-no-missing-delegate
// given: "a coordinator bound to an app stub exposing only app.js's real modal delegates"
// when: "subscribeModalViewCount runs (the modal-open path) and teardown runs (close path)"
// then: "no TypeError; the zap badge renders from the shared store and clears on teardown"
// then: "no TypeError; the zap badge is visible from open (0 sats) and updates to the summed total; hidden on teardown"
// observable_outcomes:
// - "subscribeModalViewCount does not throw (the user-facing 'video will not open' bug)"
// - "badge hidden at zero, revealed with sats after a receipt batch, cleared on teardown"
// - "badge visible at open showing 0 sats, updates to the summed total, hidden on teardown (no video open)"
// determinism_controls:
// - "JSDOM; scripted subscription-manager double; store flushed manually"
// anti_cheat_rationale:
@@ -86,7 +86,13 @@ test("modal open path survives binding and drives the zap badge", async () => {
app.subscribeModalViewCount(pointer, `a:${A1}`);
const badge = dom.window.document.getElementById("videoZapTotal");
assert.equal(badge.classList.contains("hidden"), true, "zero total stays hidden");
// Always visible while a video is open (mirrors the view counter); 0 sats
// until a receipt lands.
assert.equal(badge.classList.contains("hidden"), false, "visible while open");
assert.match(
badge.querySelector("[data-zap-total-text]").textContent,
/^0 sats$/,
);
receipts = [
{
@@ -96,13 +102,14 @@ test("modal open path survives binding and drives the zap badge", async () => {
},
];
await zapTotalsStore.flush();
assert.equal(badge.classList.contains("hidden"), false, "receipts reveal the badge");
assert.equal(badge.classList.contains("hidden"), false, "stays visible");
assert.match(
badge.querySelector("[data-zap-total-text]").textContent,
/21 sats/,
);
// Modal close path (also used to throw via this.teardownModalZapTotal).
// Modal close path re-hides the badge (no video open) — also used to throw
// via the missing this.teardownModalZapTotal delegate.
app.teardownModalViewCountSubscription();
assert.equal(badge.classList.contains("hidden"), true, "teardown clears the badge");
assert.equal(badge.classList.contains("hidden"), true, "teardown hides the badge");
});
+12 -12
View File
@@ -1,24 +1,24 @@
// #47 follow-up: video cards show the zap total (sats, orange, right-aligned)
// next to the view count. The binder fills [data-zap-total] from the shared
// zapTotals cache, hides the badge while the total is zero/unknown (no "0
// sats" noise), and reveals/updates every bound card when a receipt batch
// lands (the shared change signal).
// next to the view count. The badge is ALWAYS visible once bound (mirroring the
// view counter — a zero is a real, informative value), showing "0 sats" until a
// receipt batch lands, then the summed total.
//
// test_integrity_note:
// change_type: ["new_tests"]
// change_type: ["spec_correction"]
// scenarios:
// - id: SCN-card-zap-badge
// given: "cards bound to pointers with zero and nonzero cached totals"
// given: "cards bound to pointers with zero then nonzero cached totals"
// when: "bind() runs and a later zapTotals change fires"
// then: "nonzero → formatted sats + revealed wrapper; zero → hidden; change signal updates in place; detached cards pruned"
// then: "badge visible from bind showing 0 sats; updates to the summed total in place; detached cards pruned"
// observable_outcomes:
// - "badge textContent and .hidden class per state"
// - "badge textContent per state; wrapper visible after bind"
// determinism_controls:
// - "JSDOM; the real zapTotals singleton driven through its store test hook is avoided — the binder is exercised against the exported cache APIs via a scripted store double at module boundary"
// - "JSDOM; the real zapTotals singleton driven through its store test hook; scripted subscription-manager list double"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
// if_true_explain_spec_basis: "hidden-when-zero replaced with always-visible (matches the view counter the feature sits beside; the maintainer wants a persistent counter). Equally strict: still asserts exact textContent per state."
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
@@ -63,8 +63,8 @@ test("binder: zero stays hidden, receipts reveal and format, prune drops detache
// Tag-style ARRAY pointer — the shape VideoListView actually passes.
const pointerInfo = { key: `a:${A1}`, pointer: ["a", A1] };
binder.bind(card, pointerInfo);
assert.equal(wrapper.classList.contains("hidden"), true, "unknown total stays hidden");
assert.equal(badge.textContent, "");
assert.equal(wrapper.classList.contains("hidden"), false, "visible from bind");
assert.match(badge.textContent, /^0 sats$/, "shows 0 sats until receipts arrive");
// A receipt batch lands: 2100 sats via the zap request's amount tag.
receipts = [
@@ -79,7 +79,7 @@ test("binder: zero stays hidden, receipts reveal and format, prune drops detache
];
await zapTotalsStore.flush();
assert.equal(wrapper.classList.contains("hidden"), false, "revealed once nonzero");
assert.equal(wrapper.classList.contains("hidden"), false, "stays visible");
assert.match(badge.textContent, /2,?100 sats|2\.1K sats/);
// Detached cards are dropped by prune (no leak, no stale writes).