- {#each stats.daily as d (d.day)}
-
-
-
-
+ {:else if section === 'audience'}
+
+ Visitors
+
+
{stats.visitors.today}Today
+
{stats.visitors.d7}Last 7 days
+
{stats.visitors.d30}Last 30 days
+
+
+
+ Returning visitors (30d)
+
+
{stats.retention.new}New (1 day)
+
{stats.retention['2-3']}2–3 days
+
{stats.retention['4-7']}4–7 days
+
{stats.retention['8+']}8+ days
+
+
+
+ Accounts
+
+
{stats.accounts.total}Total
+
{stats.accounts.new_today}New today
+
{stats.accounts.new_7d}New this week
+
{stats.accounts.active_7d}Active this week
+
+
+
+ Reading funnel
+
+
{stats.funnel.summary_viewed}Summaries read
+
{stats.funnel.source_click}→ Source (from summary)
+
{stats.funnel.source_rate}%Summary → source rate
+
{stats.funnel.full_story}Straight to source
+
+
+
+ Emotional mix & friction
+
+
{stats.emotional_mix.not_today}Not today
+
{stats.emotional_mix.less_like_this}Less like this
+
{stats.emotional_mix.hide_topic}Hide topic
+
{stats.replace.used}Replaces
+
{stats.paywall.paywalled_source_open}Paywalled opens
+
+
+
+ Sharing
+
+ {#each Object.entries(stats.shares) as [kind, n]}
+
{n}{SHARE_LABEL[kind] ?? kind}
{/each}
- visits opens
- {:else}No data yet.
{/if}
-
-
- {#if stats.sources?.length}
-
- Source health
- {healthy} healthy · {ailing} backing off · {stats.sources.length} active
-
- {#each stats.sources as s (s.id)}
- - 0}>
- {s.name}
- {s.served}
-
- {#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}
-
-
- {/each}
-
- “served” = accepted articles live from that source · times in your local zone
- {/if}
+
+ Daily trend
+ {#if stats.daily.length}
+ {@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))}
+
+ {#each stats.daily as d (d.day)}
+
+
+
+
+ {/each}
+
+ visits opens
+ {:else}No data yet.
{/if}
+
-
+ {:else if section === 'feedback'}
Feedback {#if feedback.length}({feedback.length}){/if}
{#if feedback.length}
+
+
+ {#each fbCats as cat (cat)}
+
+ {/each}
+
- {#each feedback as f (f.id)}
+ {#each shownFeedback as f (f.id)}
-
{f.category}
{fdate(f.created_at)}
- {#if f.contact_email}
{f.contact_email}{/if}
+ {#if f.contact_email}
Reply ↩{:else}
anonymous{/if}
{f.message}
{/each}
{:else}No feedback yet.
{/if}
-
+ {/if}
{/if}
@@ -250,14 +322,35 @@
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
.back svg { width: 17px; height: 17px; display: block; }
.page { padding: 22px 20px 70px; }
- h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 2px; }
- .sub { color: var(--muted); font-size: 0.9rem; margin: 0 0 22px; }
- h2 { font-size: 1.1rem; margin: 30px 0 12px; }
+ h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 14px; }
+ h2 { font-size: 1.1rem; margin: 28px 0 12px; }
.muted { color: var(--muted); }
+ /* Sticky section tabs */
+ .tabs {
+ display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 22px;
+ position: sticky; top: 64px; z-index: 15; background: var(--bg);
+ padding: 10px 0; border-bottom: 1px solid var(--line);
+ }
+ .tabs a {
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
+ border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px;
+ }
+ .tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
+ .tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
+ .tabs .badge { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 0 7px; font-size: 0.74rem; }
+ .tabs a.active .badge { background: rgba(255,255,255,0.25); color: #fff; }
+
+ /* Attention Needed — soft amber/blue, never alarming red unless truly broken */
+ .attn-strip { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
+ .attn { border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; border-left: 3px solid; }
+ .attn.warn { background: #fdf3e3; color: #875a16; border-color: #e0a648; }
+ .attn.info { background: #eef4f8; color: var(--accent-deep); border-color: var(--accent); }
+ .attn.ok { background: #eef5ee; color: #3f7048; border-left: 3px solid #6aa86a; border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; }
+
.cards { display: flex; flex-wrap: wrap; gap: 12px; }
.stat {
- flex: 1 1 120px; background: var(--surface); border: 1px solid var(--line);
+ flex: 1 1 130px; background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 3px;
}
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
@@ -270,8 +363,7 @@
ul.bars li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; position: relative; }
ul.bars .lbl {
grid-column: 1; z-index: 1; font-size: 0.86rem; color: var(--ink);
- text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- padding: 4px 0;
+ text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 0;
}
a.lbl { text-decoration: none; }
a.lbl:hover { color: var(--accent-deep); }
@@ -292,17 +384,33 @@
.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;
+
+ /* Filter chips (sources + feedback) */
+ .filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; }
+ .filterchips .chip {
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
+ border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer; text-transform: capitalize;
}
- 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; }
+ .filterchips .chip:hover { border-color: var(--accent); }
+ .filterchips .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
+
+ /* Source health table */
+ .tablewrap { overflow-x: auto; }
+ .srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }
+ .srctable th {
+ text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
+ color: var(--muted); font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--line);
+ }
+ .srctable td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: baseline; }
+ .srctable .num { text-align: right; font-variant-numeric: tabular-nums; }
+ .srctable .dim { color: var(--muted); white-space: nowrap; font-size: 0.82rem; }
+ .srctable .sname { font-weight: 600; color: var(--ink); }
+ .srctable .sname .cat { display: block; font-weight: 400; font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
+ .srctable tr.warn { background: #fdf3e3; }
+ .srctable tr.flag { background: #eef4f8; }
+ .srctable .status .bad { color: #875a16; }
+ .srctable .status .flagtxt { color: var(--accent-deep); }
+ .srctable .status .good { color: #3f7048; }
.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; }
@@ -311,5 +419,6 @@
.fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
.fhead .when { color: var(--muted); }
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
+ .fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
diff --git a/goodnews/queries.py b/goodnews/queries.py
index 0b2d1e3..b7d55d8 100644
--- a/goodnews/queries.py
+++ b/goodnews/queries.py
@@ -233,17 +233,45 @@ def content_stats(conn: sqlite3.Connection) -> dict:
"SELECT brief_date, (SELECT COUNT(*) FROM daily_brief_items WHERE brief_id=daily_briefs.id) n "
"FROM daily_briefs ORDER BY brief_date DESC LIMIT 1"
).fetchone()
+ with_image = scalar(
+ "SELECT COUNT(*) FROM article_scores s JOIN articles a ON a.id=s.article_id "
+ "WHERE s.accepted=1 AND a.duplicate_of IS NULL AND a.image_url IS NOT NULL AND a.image_url!=''"
+ )
+ summaries = scalar("SELECT COUNT(*) FROM article_summaries")
+ summaries_with_image = scalar(
+ "SELECT COUNT(*) FROM article_summaries m JOIN articles a ON a.id=m.article_id "
+ "WHERE a.image_url IS NOT NULL AND a.image_url!=''"
+ )
+ brief_with_image = 0
+ if brief:
+ brief_with_image = scalar(
+ "SELECT COUNT(*) FROM daily_brief_items bi JOIN articles a ON a.id=bi.article_id "
+ "JOIN daily_briefs b ON b.id=bi.brief_id "
+ "WHERE b.brief_date=? AND a.image_url IS NOT NULL AND a.image_url!=''",
+ (brief["brief_date"],),
+ )
return {
"served": served,
"total": scalar("SELECT COUNT(*) FROM articles"),
"rejected": scalar("SELECT COUNT(*) FROM article_scores WHERE accepted=0"),
"accepted_7d": accepted_7d,
"added_24h": scalar("SELECT COUNT(*) FROM articles WHERE discovered_at >= datetime('now','-1 day')"),
- "summaries": scalar("SELECT COUNT(*) FROM article_summaries"),
+ "added_7d": scalar("SELECT COUNT(*) FROM articles WHERE discovered_at >= datetime('now','-7 days')"),
+ "summaries": summaries,
+ "summaries_with_image": summaries_with_image,
+ "with_image": with_image,
+ # Accepted, imageless articles whose enrichment was tried recently and
+ # came up empty (no usable og:image) — the image "misses" to watch.
+ "recent_enrich_fail": scalar(
+ "SELECT COUNT(*) FROM article_scores s JOIN articles a ON a.id=s.article_id "
+ "WHERE s.accepted=1 AND a.duplicate_of IS NULL AND (a.image_url IS NULL OR a.image_url='') "
+ "AND a.image_checked_at >= datetime('now','-1 day')"
+ ),
"active_sources": scalar("SELECT COUNT(*) FROM sources WHERE active=1"),
"total_sources": scalar("SELECT COUNT(*) FROM sources"),
"latest_brief_date": brief["brief_date"] if brief else None,
"latest_brief_size": brief["n"] if brief else 0,
+ "brief_with_image": brief_with_image,
}
@@ -258,7 +286,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
"""
SELECT
s.id, s.name, s.default_category AS category, s.active,
- s.consecutive_failures AS failures,
+ s.consecutive_failures AS failures, s.review_flag,
s.poll_interval_minutes AS interval_minutes,
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
(SELECT MAX(r.finished_at) FROM ingest_runs r
@@ -279,6 +307,34 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in rows]
+def _attention(content: dict, sources: list[dict], feedback_7d: int) -> list[dict]:
+ """The 'Attention Needed' strip: what an operator should look at, soft-toned
+ (warn = act soon, info = worth a glance). Derived from the same data shown
+ elsewhere, so it never disagrees with the detail sections."""
+ items: list[dict] = []
+ n = lambda c: "" if c == 1 else "s" # noqa: E731 — tiny pluralizer
+
+ resting = [s for s in sources if (s.get("failures") or 0) > 0]
+ if resting:
+ items.append({"level": "warn", "text": f"{len(resting)} source{n(len(resting))} backing off after failures"})
+ flagged = [s for s in sources if s.get("review_flag")]
+ if flagged:
+ items.append({"level": "warn", "text": f"{len(flagged)} source{n(len(flagged))} flagged for review"})
+
+ served = content.get("served") or 0
+ with_image = content.get("with_image") or 0
+ if served and (with_image / served) < 0.70:
+ items.append({"level": "info", "text": f"Image coverage at {round(100 * with_image / served)}% (aim for 70%+)"})
+
+ brief_size = content.get("latest_brief_size") or 0
+ if brief_size < 7:
+ items.append({"level": "info", "text": f"Today's brief has only {brief_size} item{n(brief_size)}"})
+
+ if feedback_7d:
+ items.append({"level": "info", "text": f"{feedback_7d} feedback message{n(feedback_7d)} in the last 7 days"})
+ return items
+
+
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
"""Aggregate, non-personal usage stats for the admin dashboard."""
since = f"-{days} days"
@@ -376,10 +432,16 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
"FROM events WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
)]
+ content = content_stats(conn)
+ sources = source_health(conn)
+ feedback_7d = scalar("SELECT COUNT(*) FROM feedback WHERE created_at >= datetime('now','-7 days')")
+
return {
"days": days,
- "content": content_stats(conn),
- "sources": source_health(conn),
+ "content": content,
+ "sources": sources,
+ "attention": _attention(content, sources, feedback_7d),
+ "feedback_7d": feedback_7d,
"visitors": visitors,
"returning": loyalty.get("returning", 0),
"once": loyalty.get("once", 0),
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index b9fe091..6bfeb98 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -47,3 +47,28 @@ def test_source_health_orders_failing_first_and_computes_next_due(tmp_path):
assert flaky["next_due_at"] is not None
good = next(s for s in sh if s["name"] == "Good")
assert good["served"] == 1 and good["next_due_at"] is None # never attempted → due now
+
+
+def test_attention_flags_resting_flagged_and_brief():
+ from goodnews import queries
+ content = {"served": 100, "with_image": 90, "latest_brief_size": 5}
+ sources = [
+ {"failures": 3, "review_flag": 0},
+ {"failures": 0, "review_flag": 1},
+ {"failures": 0, "review_flag": 0},
+ ]
+ items = queries._attention(content, sources, feedback_7d=2)
+ texts = " | ".join(i["text"] for i in items)
+ assert "1 source backing off" in texts # one resting
+ assert "1 source flagged" in texts # one flagged
+ assert "brief has only 5" in texts # brief < 7
+ assert "2 feedback" in texts # recent feedback
+ # 90/100 = 90% image coverage → no coverage warning
+ assert "Image coverage" not in texts
+
+
+def test_attention_clear_when_healthy():
+ from goodnews import queries
+ content = {"served": 100, "with_image": 95, "latest_brief_size": 7}
+ sources = [{"failures": 0, "review_flag": 0}]
+ assert queries._attention(content, sources, feedback_7d=0) == []