Dashboard: content + source-health; per-viewer local dates

* Date fix: introduce GOODNEWS_TZ (goodnews/localtime.py) so the brief's "today"
  rolls over in a pinned zone (Eastern) instead of UTC — robust to host-clock
  resets. The home page now formats the brief's date in each VISITOR's local
  timezone (from its UTC freshness stamp), so nobody ever sees "tomorrow."

* Admin "Content served": articles live, fresh (7d), ingested (24h), summaries,
  active sources, today's brief size — queries.content_stats().

* Admin "Source health": per active source, the failure streak, last error,
  accepted contribution, and computed next-poll time (so backoff / "resting
  until" is visible), via queries.source_health() reusing the feeds backoff
  math. Failing sources sort to the top; times render in the viewer's zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-06 19:34:22 +00:00
parent 452e5a3fe4
commit d87347b032
7 changed files with 227 additions and 6 deletions
+60
View File
@@ -26,6 +26,15 @@
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
catch { return s; }
}
// A UTC "YYYY-MM-DD HH:MM:SS" stamp → the viewer's local date + time.
function fwhen(s) {
if (!s) return 'now';
const d = new Date(s.replace(' ', 'T') + 'Z');
if (isNaN(d)) return s;
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
let healthy = $derived((stats?.sources ?? []).filter((s) => !s.failures).length);
let ailing = $derived((stats?.sources ?? []).filter((s) => s.failures > 0).length);
const SHARE_LABEL = {
share_ub: 'Copied UB link',
@@ -62,6 +71,20 @@
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
</div>
{#if stats.content}
<section>
<h2>Content served</h2>
<div class="cards">
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (last 7d)</span></div>
<div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
<div class="stat"><span class="n">{stats.content.summaries}</span><span class="l">Summaries</span></div>
<div class="stat"><span class="n">{stats.content.active_sources}</span><span class="l">Active sources</span></div>
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
</div>
</section>
{/if}
<section>
<h2>Accounts</h2>
<div class="cards">
@@ -177,6 +200,29 @@
{:else}<p class="muted">No data yet.</p>{/if}
</section>
{#if stats.sources?.length}
<section>
<h2>Source health</h2>
<p class="sub2">{healthy} healthy · {ailing} backing off · {stats.sources.length} active</p>
<ul class="srclist">
{#each stats.sources as s (s.id)}
<li class:warn={s.failures > 0}>
<span class="sname" title={s.last_error || ''}>{s.name}</span>
<span class="sserved">{s.served}</span>
<span class="sstatus">
{#if s.failures > 0}
{s.failures} fail{s.failures > 1 ? 's' : ''} · resting until {fwhen(s.next_due_at)}
{:else}
✓ next poll {fwhen(s.next_due_at)}
{/if}
</span>
</li>
{/each}
</ul>
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
</section>
{/if}
<section>
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
{#if feedback.length}
@@ -243,6 +289,20 @@
.legend .sw.visits { background: var(--accent-soft); }
.legend .sw.opens { background: var(--accent); }
.sub2 { color: var(--muted); font-size: 0.84rem; margin: 0 0 12px; }
.legend2 { color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; font-style: italic; }
ul.srclist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
ul.srclist li {
display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: 12px;
padding: 7px 10px; border-radius: 8px; font-size: 0.86rem;
}
ul.srclist li:nth-child(odd) { background: var(--surface); }
ul.srclist li.warn { background: #fbeaea; }
ul.srclist .sname { color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
ul.srclist .sserved { color: var(--muted); font-variant-numeric: tabular-nums; }
ul.srclist .sstatus { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
ul.srclist li.warn .sstatus { color: #9a3b3b; }
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }