diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index c1c3c64..516220d 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -1,6 +1,7 @@
@@ -63,183 +88,230 @@ {:else if !stats}

Loading…

{:else} -

Aggregate, anonymous — last {stats.days} days. No personal data.

+ -
-
{stats.visitors.today}Visitors today
-
{stats.visitors.d7}Last 7 days
-
{stats.visitors.d30}Last 30 days
-
- - {#if stats.content} -
-

Content served

-
-
{stats.content.served}Articles live
-
{stats.content.accepted_7d}Fresh (last 7d)
-
{stats.content.added_24h}Ingested (24h)
-
{stats.content.summaries}Summaries
-
{stats.content.active_sources}Active sources
-
{stats.content.latest_brief_size}In today's brief
-
-
- {/if} - -
-

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
-
-
- -
-

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
-
-
- -
-

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.replace.none}No replacement
-
{stats.paywall.paywall_replace}Paywall replaces
-
{stats.paywall.paywalled_source_open}Paywalled opens
-
-
- -
-

Most-opened articles

- {#if stats.top_articles.length} - - {:else}

No opens yet.

{/if} -
+ + {:else} +
✓ All clear — nothing needs attention right now.
+ {/if} -
-
-

Popular groupings

- {#if stats.top_groupings.length} -
    - {#each stats.top_groupings as g (g.tag)} -
  • - {g.tag.replace('-', ' ')} - - {g.opens} -
  • - {/each} -
- {:else}

No data yet.

{/if} -
- -
-

Popular topics

- {#if stats.top_topics.length} -
    - {#each stats.top_topics as t (t.topic)} -
  • - {t.topic} - - {t.opens} -
  • - {/each} -
- {:else}

No data yet.

{/if} -
-
- -
-

Sharing

+

Pulse

- {#each Object.entries(stats.shares) as [kind, n]} -
{n}{SHARE_LABEL[kind] ?? kind}
+
{stats.visitors.today}Visitors today
+
{stats.visitors.d7}Visitors (7d)
+
{stats.visitors.d30}Visitors (30d)
+
{stats.content.served}Articles live
+
{stats.content.accepted_7d}Fresh (7d)
+
{stats.content.latest_brief_size}In today's brief
+
{healthy}/{sources.length}Sources healthy
+
{stats.accounts.total}Accounts
+
+ + {:else if section === 'content'} +

Corpus

+
+
{stats.content.served}Articles live
+
{stats.content.rejected}Filtered out
+
{stats.content.added_24h}Ingested (24h)
+
{stats.content.added_7d}Ingested (7d)
+
{stats.content.accepted_7d}Fresh & live (7d)
+
{stats.content.latest_brief_size}In today's brief
+
+ +

Coverage

+
+
{coverage(stats.content.with_image, stats.content.served)}%Articles with image ({stats.content.with_image}/{stats.content.served})
+
{coverage(stats.content.summaries, stats.content.served)}%Summaries ({stats.content.summaries}/{stats.content.served})
+
{coverage(stats.content.summaries_with_image, stats.content.summaries)}%Summary pages w/ image
+
{stats.content.brief_with_image}/{stats.content.latest_brief_size}Brief w/ image
+
{stats.content.recent_enrich_fail}Image misses (24h)
+
+ +
+

Most-opened articles

+ {#if stats.top_articles.length} +
    + {#each stats.top_articles as a (a.id)} +
  • + {a.title} + + {a.opens} +
  • + {/each} +
+ {:else}

No opens yet.

{/if} +
+ +
+
+

Popular groupings

+ {#if stats.top_groupings.length} +
    + {#each stats.top_groupings as g (g.tag)} +
  • + {g.tag.replace('-', ' ')} + + {g.opens} +
  • + {/each} +
+ {:else}

No data yet.

{/if} +
+
+

Popular topics

+ {#if stats.top_topics.length} +
    + {#each stats.top_topics as t (t.topic)} +
  • + {t.topic} + + {t.opens} +
  • + {/each} +
+ {:else}

No data yet.

{/if} +
+
+ + {:else if section === 'sources'} +

Source health

+

{healthy} healthy · {resting} resting · {flagged} flagged · {sources.length} active

+
+ {#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged']] as [key, label] (key)} + {/each}
-
+
+ + + + + + {#each shownSources as s (s.id)} + 0} class:flag={s.review_flag}> + + + + + + + + {/each} + +
SourceServedLast successNext pollFailsStatus
{s.name}{#if s.category}{s.category}{/if}{s.served}{s.last_success_at ? fwhen(s.last_success_at) : '—'}{fwhen(s.next_due_at)}{s.failures || ''} + {#if s.failures > 0}⚠ resting + {:else if s.review_flag}⚑ review + {:else}✓ ok{/if} +
+
+

“served” = accepted articles live from that source · times in your local zone

-
-

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)} -
- - -
+ {: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) == []