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:
@@ -76,6 +76,17 @@
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
||||
|
||||
// Show the brief's date in the VIEWER's local timezone (from its UTC freshness
|
||||
// stamp), so everyone sees their own correct date — never "tomorrow."
|
||||
function localDateLabel(b) {
|
||||
const ts = b?.generated_at;
|
||||
if (ts) {
|
||||
const d = new Date(ts.replace(' ', 'T') + 'Z');
|
||||
if (!isNaN(d)) return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
}
|
||||
return b?.brief_date ?? '';
|
||||
}
|
||||
let filtersOn = $derived(prefsActive());
|
||||
let currentMood = $derived(moods.find((m) => m.key === selected));
|
||||
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
||||
@@ -89,7 +100,7 @@
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? (brief?.brief_date ?? '')
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: currentTag ? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user