Files
socialmag/dashboard.html
juxtapoandClaude Opus 5 15f3388e5e v0.6: cross-platform feed on the dashboard, home units, napkin
dashboard.html — Feed and Timeline swap places; Feed is every platform's
capture now, not Twitter intel, so rows carry their own platform glyph and an
unknown platform renders neutral rather than being dropped. A row whose ts is
really first_seen reads "seen X ago": saying "just now" for a post of unknown
age is a lie that sorts it to the top.

dashboard.json — regenerated by the refresher; _yt_caveat dropped, the
activate fix made it stale.

deploy/home — dash, openbridge, refresh service and timer for this desktop.
Refresh every 30min with --skip-foreground, not kdmirah's 10min full pass:
there is a human at this box and the metrics table only needs enough
resolution to watch engagement move. No Restart= on the oneshot — a down
browser is a waiting state the refresher records and exits 0 for.

NAPKIN.md — the whiteboard: measured delay budget, the background-render
table, the three landmines earned (a DOM signal is a rendering question, %z
cannot come back out of struct_time, a pure time sort is not a feed), and the
open calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 01:58:45 +08:00

927 lines
31 KiB
HTML

<meta charset="utf-8">
<title>socialmag console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;450;500;600&display=swap">
<style>
/* socialmag console — dark by commitment (the brief asks for dark), so every
surface and every color is painted explicitly and no theme block flips it. */
:root {
--bg: #0F1218;
--surface: #161A22;
--surface-2: #1C212B;
--line: #272D3A;
--line-soft: #1F2531;
--text: #E5E9F1;
--text-dim: #99A1B3;
--text-faint:#6B7488;
--accent: #D8A657; /* brass — belongs to no platform */
--accent-dim:#8A6B37;
--ok: #46BE84;
--bad: #F0555F;
--idle: #566072;
--threads: #FFFFFF; /* brief says #000000; invisible on this ground —
drawn as a black chip with a white ring instead */
--fb: #1877F2;
--ig: #E4405F;
--yt: #FF0000;
--tiktok: #00F2EA;
--twitter: #1DA1F2;
--r-sm: 4px;
--r-md: 8px;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0 16px 56px;
}
/* ── header ────────────────────────────────────────────────────────── */
header.top {
position: sticky;
top: 0;
z-index: 10;
background: linear-gradient(var(--bg) 72%, rgba(15, 18, 24, 0));
padding: 20px 0 14px;
}
.brandline {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.wordmark {
font-family: var(--mono);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.wordmark::before {
content: "$ ";
color: var(--accent-dim);
font-weight: 400;
}
.badge {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
border: 1px solid var(--accent-dim);
border-radius: 999px;
padding: 2px 9px;
}
.freshness {
margin-left: auto;
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
}
.pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(70, 190, 132, 0.55);
}
.pulse.live { animation: ping 2.6s ease-out infinite; }
.pulse.cold { background: var(--idle); animation: none; }
@keyframes ping {
0% { box-shadow: 0 0 0 0 rgba(70, 190, 132, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(70, 190, 132, 0); }
100% { box-shadow: 0 0 0 0 rgba(70, 190, 132, 0); }
}
@media (prefers-reduced-motion: reduce) {
.pulse.live { animation: none; }
}
/* ── platform strip ────────────────────────────────────────────────── */
.strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 14px 0 4px;
scrollbar-width: thin;
}
.pchip {
flex: 1 0 auto;
min-width: 116px;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
}
.glyph {
width: 22px; height: 22px;
flex: 0 0 22px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
color: #0B0E13;
letter-spacing: -0.03em;
}
.glyph.threads { background: #000; color: #fff; box-shadow: inset 0 0 0 1px var(--threads); }
.glyph.fb { background: var(--fb); color: #fff; }
.glyph.ig { background: var(--ig); color: #fff; }
.glyph.yt { background: var(--yt); color: #fff; }
.glyph.tiktok { background: var(--tiktok); color: #04201F; }
.glyph.twitter { background: var(--twitter); color: #06222F; }
.pchip .pname {
font-size: 12.5px;
font-weight: 500;
line-height: 1.2;
}
.pchip .pstate {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
}
.dot {
margin-left: auto;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--idle);
flex: 0 0 8px;
}
.dot.in { background: var(--ok); }
.dot.out { background: var(--bad); }
/* ── stats ─────────────────────────────────────────────────────────── */
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--line-soft);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
overflow: hidden;
margin-top: 18px;
}
.stat {
background: var(--surface);
padding: 14px 16px;
}
.stat .k {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.stat .v {
font-family: var(--mono);
font-size: 26px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1.25;
margin-top: 4px;
}
.stat .v.zero { color: var(--text-faint); }
/* ── layout ────────────────────────────────────────────────────────── */
main {
display: grid;
grid-template-columns: 1fr;
gap: 26px;
margin-top: 26px;
align-items: start;
}
@media (min-width: 900px) {
main { grid-template-columns: minmax(0, 1fr) 320px; gap: 30px; }
}
.shead {
display: flex;
align-items: baseline;
gap: 10px;
padding-bottom: 9px;
border-bottom: 1px solid var(--line);
margin-bottom: 14px;
}
.shead h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
text-wrap: balance;
}
.shead .tool {
font-family: var(--mono);
font-size: 10.5px;
color: var(--accent-dim);
}
.shead .count {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
/* ── timeline ──────────────────────────────────────────────────────── */
.timeline { display: flex; flex-direction: column; gap: 10px; }
.card {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 11px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-left: 2px solid var(--stripe, var(--line));
border-radius: 0 var(--r-md) var(--r-md) 0;
}
.card .body { min-width: 0; }
.card .meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
}
.card .who {
color: var(--text-dim);
font-weight: 500;
}
.card .text {
margin-top: 5px;
font-size: 13.5px;
color: var(--text);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.card .text.quiet { color: var(--text-dim); }
.kind {
text-transform: uppercase;
letter-spacing: 0.07em;
font-size: 9.5px;
padding: 1px 6px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
color: var(--text-dim);
}
.kind.sent { border-color: var(--accent-dim); color: var(--accent); }
.kind.error { border-color: #6C2B31; color: var(--bad); }
.engagement {
display: flex;
gap: 14px;
margin-top: 8px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.engagement span b {
color: var(--text-dim);
font-weight: 500;
}
.ts { margin-left: auto; white-space: nowrap; }
/* ── rail ──────────────────────────────────────────────────────────── */
.rail { display: flex; flex-direction: column; gap: 26px; }
/* ── empty / notice ────────────────────────────────────────────────── */
.empty {
padding: 22px 16px;
border: 1px dashed var(--line);
border-radius: var(--r-md);
color: var(--text-faint);
font-size: 12.5px;
}
.empty code {
font-family: var(--mono);
font-size: 11.5px;
color: var(--accent-dim);
}
.notice {
margin-top: 16px;
padding: 11px 14px;
border: 1px solid #5C3A1C;
background: #1E1810;
border-radius: var(--r-md);
color: #E3B679;
font-size: 12.5px;
}
.notice code { font-family: var(--mono); font-size: 11.5px; }
/* Local-JSON mode is information, not failure — the orange above stays for
real errors. */
.notice.info {
border-color: #2C3B4E;
background: #141A22;
color: var(--text-dim);
}
.notice.info code { color: var(--accent-dim); }
/* ── clickable cards ───────────────────────────────────────────────── */
a.cardlink {
display: block;
text-decoration: none;
color: inherit;
border-radius: 0 var(--r-md) var(--r-md) 0;
}
a.cardlink:hover .card,
a.cardlink:focus-visible .card {
background: var(--surface-2);
border-color: var(--line);
}
footer.foot {
margin-top: 34px;
padding-top: 14px;
border-top: 1px solid var(--line-soft);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
display: flex;
gap: 8px 18px;
flex-wrap: wrap;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
<div class="wrap">
<header class="top">
<div class="brandline">
<span class="wordmark">socialmag</span>
<span class="badge">6 platforms</span>
<span class="freshness">
<span class="pulse cold" id="pulse"></span>
<span id="fresh">connecting to store…</span>
</span>
</div>
<div class="strip" id="strip"></div>
</header>
<div class="stats">
<div class="stat">
<div class="k">Posts sent</div>
<div class="v zero" id="stat-posts">0</div>
</div>
<div class="stat">
<div class="k">Total views</div>
<div class="v zero" id="stat-views">—</div>
</div>
<div class="stat">
<div class="k">Platforms active</div>
<div class="v zero" id="stat-active">0<span style="color:var(--text-faint);font-size:15px">/6</span></div>
</div>
</div>
<div id="notice"></div>
<main>
<section>
<div class="shead">
<h2>Feed</h2>
<span class="tool">capture</span>
<span class="count" id="feed-count"></span>
</div>
<div id="feed"></div>
</section>
<aside class="rail">
<section>
<div class="shead">
<h2>Timeline</h2>
<span class="tool">create_post · get_notifications</span>
<span class="count" id="tl-count"></span>
</div>
<div class="timeline" id="timeline"></div>
</section>
</aside>
</main>
<footer class="foot">
<span>display only — the posting loop writes, this page reads</span>
<span>collections: platforms · posts · notifications · feed</span>
</footer>
</div>
<script>
(() => {
"use strict";
// Platform registry — id, label, glyph mark, stripe color. Order is the
// order socialmag's server.py registers its modules.
const PLATFORMS = [
{ id: "threads", name: "Threads", mark: "T", color: "#FFFFFF" },
{ id: "fb", name: "Facebook", mark: "f", color: "#1877F2" },
{ id: "ig", name: "Instagram", mark: "IG", color: "#E4405F" },
{ id: "yt", name: "YouTube", mark: "YT", color: "#FF0000" },
{ id: "tiktok", name: "TikTok", mark: "TT", color: "#00F2EA" },
{ id: "twitter", name: "Twitter", mark: "X", color: "#1DA1F2" }
];
const BY_ID = Object.fromEntries(PLATFORMS.map(p => [p.id, p]));
// Aliases the writer loop might use instead of the canonical ids.
const ALIAS = {
facebook: "fb", instagram: "ig", youtube: "yt",
x: "twitter", tiktok: "tiktok", threads: "threads"
};
const norm = id => BY_ID[id] ? id : (ALIAS[String(id || "").toLowerCase()] || null);
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null, mode: null };
const $ = id => document.getElementById(id);
// Escapes " as well — these strings go into href/title attributes now.
const esc = s => String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
// Scraped URLs are untrusted input. Anything that isn't http(s) — a
// javascript: href, a relative path that never got absolutised — renders as
// a plain unclickable card rather than a live link.
const safeUrl = u => {
if (!u) return null;
try {
const p = new URL(String(u), location.href);
return (p.protocol === "http:" || p.protocol === "https:") ? p.href : null;
} catch { return null; }
};
const linkWrap = (url, inner, platform) => {
const href = safeUrl(url);
if (!href) return inner;
const pd = platform ? ` data-platform="${esc(platform)}"` : "";
return `<a class="cardlink" href="${esc(href)}"${pd} target="_blank" rel="noopener noreferrer">${inner}</a>`;
};
// ── tab reuse ───────────────────────────────────────────────────────
// target="_blank" spawns a tab per click. socialmag already knows how to
// reuse a warm tab per platform (open_link), but a static page can't speak
// MCP — openbridge.py exposes that one call over localhost.
//
// Progressive enhancement, deliberately: if the bridge isn't running, the
// click is left alone and the anchor opens a new tab as before. The page is
// never worse than it was without this.
const BRIDGE = "http://127.0.0.1:8322";
let bridgeUp = null; // null = unknown, true/false = probed
fetch(BRIDGE + "/health", { cache: "no-store" })
.then(r => { bridgeUp = r.ok; })
.catch(() => { bridgeUp = false; });
document.addEventListener("click", ev => {
const a = ev.target.closest && ev.target.closest("a.cardlink[data-platform]");
if (!a) return;
// Respect the browser's own gestures — ctrl/cmd/middle-click means the
// person explicitly asked for a new tab.
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ev.button !== 0) return;
if (bridgeUp === false) return;
ev.preventDefault();
const url = a.getAttribute("href");
const platform = a.getAttribute("data-platform");
const q = `?platform=${encodeURIComponent(platform)}&url=${encodeURIComponent(url)}`;
fetch(BRIDGE + "/open" + q, { cache: "no-store" })
.then(r => r.json().then(body => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
bridgeUp = true;
if (!ok) {
// The bridge answered and refused. Don't silently open a tab instead —
// say why, since that's a real error the person should see.
notice(`Couldn't reuse the ${esc(platform)} tab:
<code>${esc(body && body.error || "unknown error")}</code>`);
}
})
.catch(() => {
// Bridge went away mid-session: fall back for this click and every
// click after it.
bridgeUp = false;
window.open(url, "_blank", "noopener");
});
});
// ── time ────────────────────────────────────────────────────────────
// Rows carry ts / time / createdAt / checkedAt depending on which tool
// produced them; anything unparseable sorts last and renders as "—".
const parseTime = row => {
const raw = row.ts ?? row.time ?? row.createdAt ?? row.timestamp ?? null;
if (raw == null) return null;
if (typeof raw === "number") return raw > 1e12 ? raw : raw * 1000;
const t = Date.parse(raw);
return Number.isNaN(t) ? null : t;
};
const ago = ms => {
if (ms == null) return "—";
const s = Math.max(0, (Date.now() - ms) / 1000);
if (s < 45) return "just now";
if (s < 3600) return Math.round(s / 60) + "m ago";
if (s < 86400) return Math.round(s / 3600) + "h ago";
if (s < 604800) return Math.round(s / 86400) + "d ago";
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Some rows carry no platform clock — a Threads payload that arrived without
// taken_at, a notification whose only marker was "1d". The store fills `ts`
// with first_seen and flags it. Saying "just now" for a post of unknown age
// is a small lie that sorts it to the top, so say what the stamp actually is.
const stamp = row => row._tsIsFirstSeen
? "seen " + ago(parseTime(row))
: ago(parseTime(row));
// Engagement counts arrive as scraped strings ("1.2K") or numbers.
const countNum = v => {
if (typeof v === "number") return v;
const s = String(v || "").trim().replace(/,/g, "");
const m = s.match(/^([\d.]+)\s*([KMB])?$/i);
if (!m) return 0;
const mult = { k: 1e3, m: 1e6, b: 1e9 }[(m[2] || "").toLowerCase()] || 1;
return parseFloat(m[1]) * mult;
};
const showCount = v => (v === 0 || v === "0" || v == null) ? "0" : String(v);
const compact = n =>
n >= 1e6 ? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M" :
n >= 1e3 ? (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "K" : String(n);
// ── platform strip ──────────────────────────────────────────────────
function loginState(doc) {
if (!doc) return "unknown";
if (typeof doc.loggedIn === "boolean") return doc.loggedIn ? "in" : "out";
if (doc.error) return "unknown";
const s = String(doc.status ?? "").toLowerCase();
if (["true", "in", "ok", "logged_in", "logged in", "yes"].includes(s)) return "in";
if (["false", "out", "logged_out", "logged out", "no"].includes(s)) return "out";
return "unknown";
}
const STATE_LABEL = { in: "logged in", out: "signed out", unknown: "unknown" };
function renderStrip() {
$("strip").innerHTML = PLATFORMS.map(p => {
const st = loginState(state.platforms[p.id]);
return `<div class="pchip" title="${esc(p.name)} — ${STATE_LABEL[st]}">
<span class="glyph ${p.id}">${esc(p.mark)}</span>
<span>
<span class="pname">${esc(p.name)}</span><br>
<span class="pstate">${STATE_LABEL[st]}</span>
</span>
<span class="dot ${st === "unknown" ? "" : st}"></span>
</div>`;
}).join("");
}
// ── timeline ────────────────────────────────────────────────────────
function timelineRows() {
const rows = [];
for (const d of state.posts) {
const pid = norm(d.platform) || norm(d.id);
rows.push({
kind: (d.status && String(d.status).toLowerCase() !== "ok") ? "error" : "sent",
pid,
who: pid ? BY_ID[pid].name : (d.platform || "unknown platform"),
text: d.text || d.detail || "",
detail: d.detail && d.status && String(d.status).toLowerCase() !== "ok" ? d.detail : "",
t: parseTime(d),
url: d.url || d.link || null,
stats: { views: d.views, likes: d.likes, replies: d.replies, reposts: d.reposts ?? d.retweets }
});
}
for (const d of state.notifications) {
const pid = norm(d.platform) || "threads";
rows.push({
kind: "activity",
pid,
who: BY_ID[pid] ? BY_ID[pid].name : String(d.platform),
text: d.text || "",
t: parseTime(d),
tsIsFirstSeen: !!d._tsIsFirstSeen,
url: d.url || d.link || null,
stats: {}
});
}
rows.sort((a, b) => (b.t ?? -Infinity) - (a.t ?? -Infinity));
return rows;
}
function engagementHTML(s) {
const parts = [];
if (s.views != null) parts.push(`<span><b>${esc(showCount(s.views))}</b> views</span>`);
if (s.likes != null) parts.push(`<span><b>${esc(showCount(s.likes))}</b> likes</span>`);
if (s.replies != null) parts.push(`<span><b>${esc(showCount(s.replies))}</b> replies</span>`);
if (s.reposts != null) parts.push(`<span><b>${esc(showCount(s.reposts))}</b> reposts</span>`);
return parts.length ? `<div class="engagement">${parts.join("")}</div>` : "";
}
const KIND_LABEL = { sent: "posted", error: "failed", activity: "activity" };
function renderTimeline() {
const rows = timelineRows();
$("tl-count").textContent = rows.length ? rows.length + " entries" : "";
if (!rows.length) {
$("timeline").innerHTML = `<div class="empty">
Nothing here yet. <code>create_post</code> writes to <code>posts</code>
and <code>get_notifications</code> writes to <code>notifications</code> —
both land in this timeline, newest first.
</div>`;
return;
}
$("timeline").innerHTML = rows.map(r => {
const p = r.pid ? BY_ID[r.pid] : null;
const stripe = p ? p.color : "var(--line)";
const glyph = p
? `<span class="glyph ${p.id}">${esc(p.mark)}</span>`
: `<span class="glyph" style="background:var(--surface-2);color:var(--text-faint)">?</span>`;
return linkWrap(r.url, `<article class="card" style="--stripe:${stripe}">
${glyph}
<div class="body">
<div class="meta">
<span class="who">${esc(r.who)}</span>
<span class="kind ${r.kind === "activity" ? "" : r.kind}">${KIND_LABEL[r.kind]}</span>
<span class="ts">${esc(r.tsIsFirstSeen ? "seen " + ago(r.t) : ago(r.t))}</span>
</div>
<div class="text${r.kind === "activity" ? " quiet" : ""}">${esc(r.text) || "<span style='color:var(--text-faint)'>(no text)</span>"}</div>
${r.detail ? `<div class="text quiet" style="font-size:12px">${esc(r.detail)}</div>` : ""}
${engagementHTML(r.stats)}
</div>
</article>`, r.pid);
}).join("");
}
// ── feed — every platform capture writes here ───────────────────────
function renderFeed() {
const rows = state.feed.slice().sort((a, b) => (parseTime(b) ?? -Infinity) - (parseTime(a) ?? -Infinity));
$("feed-count").textContent = rows.length ? rows.length : "";
if (!rows.length) {
$("feed").innerHTML = `<div class="empty">
Nothing captured yet. <code>capture</code> reads the platform's own JSON
— the app's GraphQL responses on X and Threads, the prefetched payload
inside the Instagram document — and writes it to <code>feed</code>.
</div>`;
return;
}
// Rows carry their own platform now, so a mixed feed still says which is
// which. A row from a platform the page does not know renders with a
// neutral glyph rather than being dropped.
$("feed").innerHTML = rows.map(t => {
const pid = norm(t.platform) || "twitter";
const p = BY_ID[pid];
const stripe = p ? p.color : "var(--line)";
const glyph = p
? `<span class="glyph ${p.id}">${esc(p.mark)}</span>`
: `<span class="glyph" style="background:var(--surface-2);color:var(--text-faint)">?</span>`;
return linkWrap(t.url, `<article class="card" style="--stripe:${stripe}">
${glyph}
<div class="body">
<div class="meta">
<span class="who">${esc(t.author || "unknown")}</span>
<span class="ts">${esc(stamp(t))}</span>
</div>
<div class="text">${esc(t.text || "") || "<span style='color:var(--text-faint)'>(no text)</span>"}</div>
${engagementHTML({
views: t.views,
likes: t.likes,
replies: t.replies,
reposts: t.reposts ?? t.retweets
})}
</div>
</article>`, pid);
}).join("");
}
// ── stats ───────────────────────────────────────────────────────────
function renderStats() {
const sent = state.posts.filter(d => !d.status || String(d.status).toLowerCase() === "ok").length;
const views = state.posts.reduce((n, d) => n + countNum(d.views), 0);
const active = PLATFORMS.filter(p => loginState(state.platforms[p.id]) === "in").length;
const set = (id, txt, zero) => {
const el = $(id);
el.innerHTML = txt;
el.classList.toggle("zero", zero);
};
set("stat-posts", String(sent), sent === 0);
set("stat-views", views > 0 ? compact(Math.round(views)) : "—", views === 0);
set("stat-active",
`${active}<span style="color:var(--text-faint);font-size:15px">/6</span>`, active === 0);
}
function renderAll() {
renderStrip();
renderStats();
renderTimeline();
renderFeed();
}
function setFreshness(text, live) {
$("fresh").textContent = text;
$("pulse").className = "pulse " + (live ? "live" : "cold");
}
function notice(html, cls) {
$("notice").innerHTML = html
? `<div class="notice${cls ? " " + cls : ""}">${html}</div>` : "";
}
// Mode-aware freshness. This used to be inlined in the ticker and rewrote the
// line to "live · …" unconditionally, so local mode claimed "live" one tick
// after load.
function tickFreshness() {
if (!state.lastData || !state.mode) return;
const s = Math.round((Date.now() - state.lastData) / 1000);
const when = s < 60 ? (s < 3 ? "just now" : s + "s ago") : ago(state.lastData);
setFreshness(state.mode + " · updated " + when, state.mode === "live");
}
// ── first paint: the layout, with empty states, before any store call ─
renderAll();
// ── store ───────────────────────────────────────────────────────────
const docsOf = snap => snap.docs.map(d => Object.assign({ id: d.id }, d.data()));
// ── local JSON mode ─────────────────────────────────────────────────
// When the artifact store isn't there (served from a plain file server, or
// opened as a local file), the page reads dashboard.json beside itself and
// re-polls it. Same schema as the db collections.
const LOCAL_POLL_MS = 10000;
function applyLocal(data) {
state.platforms = {};
const plats = data.platforms || {};
if (Array.isArray(plats)) {
for (const row of plats) {
const id = norm(row.id || row.platform);
if (id) state.platforms[id] = row;
}
} else {
for (const [k, v] of Object.entries(plats)) {
const id = norm(k);
if (id) state.platforms[id] = (v && typeof v === "object") ? v : { loggedIn: v };
}
}
state.posts = data.posts || [];
state.notifications = data.notifications || [];
state.feed = data.feed || [];
state.lastData = Date.now();
renderAll();
tickFreshness();
}
async function pollLocal(first) {
let res;
try {
res = await fetch("dashboard.json", { cache: "no-store" });
} catch (e) {
if (first) {
setFreshness("local · can't read dashboard.json", false);
notice(`Couldn't fetch <code>dashboard.json</code>. A page opened as
<code>file://</code> can't fetch its neighbours — serve the folder over
HTTP instead (<code>python3 -m http.server</code>).`);
}
return;
}
if (!res.ok) {
if (first) {
setFreshness(`local · no dashboard.json (HTTP ${res.status})`, false);
notice(`No <code>dashboard.json</code> next to this page
(HTTP <code>${res.status}</code>). Write one with the same shape as the
<code>platforms · posts · notifications · feed</code> collections.`);
}
return;
}
let data;
try { data = await res.json(); }
catch (e) {
if (first) {
setFreshness("local · dashboard.json is not valid JSON", false);
notice(`<code>dashboard.json</code> loaded but didn't parse:
<code>${esc(e.message)}</code>.`);
}
return;
}
if (first) {
notice(`Local mode — reading <code>dashboard.json</code> from disk, refreshed
every ${LOCAL_POLL_MS / 1000}s. The artifact store isn't available here.`, "info");
}
applyLocal(data);
}
(async () => {
const db = window.claude && await window.claude.use("db");
if (!db) {
state.mode = "local";
setFreshness("local · loading…", false);
await pollLocal(true);
setInterval(() => pollLocal(false), LOCAL_POLL_MS);
return;
}
state.mode = "live";
setFreshness("live", true);
const onErr = where => e => {
setFreshness("store error", false);
notice(`Lost the <code>${where}</code> subscription: <code>${esc(e.code)}</code>.
Reload the page to resubscribe.`);
};
const touch = () => { state.lastData = Date.now(); };
// One live subscription per collection. Snapshots deliver other writers'
// rows as they land, and fall back to a ~30s refresh on their own.
db.collection("platforms").onSnapshot(snap => {
state.platforms = {};
for (const d of snap.docs) {
const id = norm(d.id);
if (id) state.platforms[id] = d.data();
}
touch(); renderStrip(); renderStats();
}, onErr("platforms"));
db.collection("posts").limit(200).onSnapshot(snap => {
state.posts = docsOf(snap);
touch(); renderTimeline(); renderStats();
}, onErr("posts"));
db.collection("notifications").limit(200).onSnapshot(snap => {
state.notifications = docsOf(snap);
touch(); renderTimeline();
}, onErr("notifications"));
db.collection("feed").limit(60).onSnapshot(snap => {
state.feed = docsOf(snap);
touch(); renderFeed();
}, onErr("feed"));
})();
// Relative timestamps and the freshness line go stale on their own; retick
// them on the same cadence as the local poll. The data itself arrives from
// the subscriptions (live) or pollLocal (local).
setInterval(() => {
renderTimeline();
renderFeed();
tickFreshness();
}, LOCAL_POLL_MS);
})();
</script>