Dashboard
{#if error}
{error}
{:else if !stats}
Loading…
{:else}
{#if section === 'overview' || section === 'content' || section === 'audience'}
Window
{#each [7, 30, 90] as d (d)}
{/each}
{/if}
{#if section === 'overview'}
{#if (stats.attention ?? []).length}
{#each stats.attention as a}
{a.text}
{/each}
{:else}
✓ All clear — nothing needs attention right now.
{/if}
Pulse
{stats.visitors.today}Visitors today
{stats.visitors.d7}Visitors (7d)
{stats.visitors.d30}Visitors ({range}d)
{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
{#if stats.client_errors}
0}>
{stats.client_errors.today}Load errors today
{/if}
{#if clientErrors.length}
Recent load errors (last {clientErrors.length})
{#each clientErrors as e (e.created_at + e.reason)}
-
{fdate(e.created_at)}
{e.reason || '—'}{#if e.bot}bot{/if}
{e.path || '/'}
{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}
{/each}
{/if}
{: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'}
{#if pendingCandidates.length}
Candidate queue ({pendingCandidates.length})
{#each pendingCandidates as c (c.id)}
-
{#if c._editing}
(e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} />
{:else}
{c.name || c.feed_url}
{c.status}
{/if}
{c.feed_url}
{#if c.preview}
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
{#if c.preview.classified}
model-checked{:else}
quick estimate{/if}
{#if c.preview.examples_accepted?.length}
e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}
{/if}
{/if}
{#if c._err}{c._err}
{/if}
{/each}
{/if}
{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total
| Source | Served | Accept | Dup | Media |
Last success | Next poll | Fails | Status | Actions |
{#each shownSources as s (s.id)}
{@const st = sstatus(s)}
0} class:flag={s.review_flag} class:paused={st !== 'active'}>
|
{s.name}{#if s.category}{s.category}{/if}
{#if s.review_flag && s.review_reason}“{s.review_reason}”{/if}
|
{s.served} |
{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'} |
{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'} |
{s.image_coverage != null ? s.image_coverage + '%' : '—'}{#if s.paywalled} 🔒{/if}
|
{s.last_success_at ? fwhen(s.last_success_at) : '—'} |
{st === 'active' ? fwhen(s.next_due_at) : '—'} |
{s.failures || ''} |
{#if st === 'retired'}retired
{:else if st === 'paused'}paused
{:else if rateLimited(s)}rate-limited · rests until {fwhen(s.retry_after_at)}{#if s.review_flag} · review{/if}
{:else if s.failures > 0}⚠ resting{#if s.review_flag} · review{/if}
{:else if s.review_flag}⚑ review
{:else}✓ ok{/if}
{#if !s.content_visible}· hidden{/if}
|
{#if st === 'retired'}
{:else}
{/if}
|
{#if s._checking || s._check || s._checkErr}
|
{#if s._checking}
Checking {s.name}…
{:else if s._checkErr}
Couldn't preview: {s._checkErr}
{:else if s._check}
{s._check.accepted ?? 0}/{s._check.sampled ?? 0} would pass{#if s._check.acceptance_rate != null} · {Math.round(s._check.acceptance_rate * 100)}%{/if}{#if s._check.recent_7d != null} · {s._check.recent_7d} in last 7d{/if}
checked just now
{#if s._check.examples_accepted?.length}would accept: {s._check.examples_accepted.slice(0, 3).join(' · ')} {/if}
{#if s._check.examples_rejected?.length}would skip: {s._check.examples_rejected.slice(0, 3).map((r) => r.title).join(' · ')} {/if}
{/if}
|
{/if}
{#if s._showArts}
|
{#if s._artSummary}
{s._artSummary.total} ingested · {s._artSummary.accepted} accepted ·
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image ·
{s._artSummary.duplicates} dup ·
paywall rule: {s._artSummary.paywalled ? 'ON (domain)' : 'off'}
{/if}
{#each ART_FILTERS as [key, label] (key)}
{/each}
{#if s._artErr}{s._artErr} {/if}
{#if s._arts?.length}
{#each s._arts as a (a.id)}
-
{a.title}
{#if a.accepted === 1} accepted{:else if a.accepted === 0} rejected{/if}
{#if a.paywalled} 🔒{/if}
{#if !a.has_image} no img{/if}
{#if a.duplicate} dup{/if}
{#if a.topic} {a.topic}{/if}
{a.published_at ? fdate(a.published_at) : ''}
{#if a.reason}{a.reason} {/if}
{/each}
{#if s._artMore}{/if}
{:else if !s._artBusy}
No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.
{/if}
{#if s._artBusy && !s._arts?.length}Loading articles… {/if}
|
{/if}
{:else}
| {srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'} |
{/each}
“served” = accepted, non-duplicate articles live · accept/dup % is of all ingested · pausing stops polling but keeps existing articles live · times in your local zone
{:else if section === 'audience'}
export audience CSV ({range}d) ↓
Visitors
{stats.visitors.today}Today
{stats.visitors.d7}Last 7 days
{stats.visitors.d30}Last {range} days
Returning visitors ({range}d)
{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}
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}({unreadCount} unread / {feedback.length}){/if}
{#if feedback.length}
{#each fbCats as cat (cat)}
{/each}
{#if shownFeedback.length}
{#each shownFeedback as f (f.id)}
-
{#if !f.read_at}{/if}
{f.category}
{fdate(f.created_at)}
{#if f.contact_email}{f.contact_email}{:else}anonymous{/if}
{f.message}
{#if f.replies?.length}
{#each f.replies as r (r.id)}
↪ Replied · {fwhen(r.sent_at)}
{#if r.message_html}
{@html r.message_html}
{:else}
{r.message}
{/if}
{/each}
{/if}
{#if replyingId === f.id}
{#if replyErr}
{replyErr}
{/if}
{/if}
{#if f.contact_email && replyingId !== f.id}
{:else if !f.contact_email}
No reply address
{/if}
{/each}
{:else}Nothing in this filter.
{/if}
{:else}No feedback yet.
{/if}
{:else if section === 'games'}
Daily Word pool
Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
effect for future puzzles and can be restored any time.
{#if wpResult && wpResult.word}
{#if !wpResult.variant}
Must be 5 or 6 letters
{:else if wpResult.in_pool}
In the pool
{:else if wpResult.removed}
Removed
{:else if wpResult.in_dictionary}
Valid {wpResult.variant}-letter word
{:else}
Not in the guess dictionary
{/if}
{/if}
{#if wpMsg}{wpMsg}
{/if}
{#if wpPool}
{#each ['5', '6'] as v (v)}
{v === '6' ? 'Long Word' : 'Daily Word'} · {v} letters · {wpPool[v].total} words
{wpPool[v].curated} curated + {wpPool[v].added.length} added by you
{#if wpPool[v].added.length}
{#each wpPool[v].added as w (w)}
- {w}
{/each}
{:else}
No words added yet — the curated pool is in use.
{/if}
{#if wpPool[v].removed && wpPool[v].removed.length}
Removed ({wpPool[v].removed.length}) — tap to restore:
{#each wpPool[v].removed as w (w)}
- {w}
{/each}
{/if}
{/each}
{/if}
Import a vetted list
Paste words (any separators) or upload a .txt/.csv. Each is checked —
real word · 5–6 letters · in the guess dictionary — and duplicates are ignored.
{#if wpImportResult}
{#if wpImportResult.error}
{wpImportResult.error}
{:else}
Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.
{#if wpImportResult.rejected.length}
{wpImportResult.rejected.length} rejected — see why
{#each wpImportResult.rejected as r (r.word)}
- {r.word} — {r.reason}
{/each}
{/if}
{/if}
{/if}
Word Search themes
Add a theme and its words — the system splits them across the three sizes and places
them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI
suggest one that fits.
{#if wsMsg}{wsMsg}
{/if}
{#if wsThemes.length}
{#each wsThemes as t (t.id)}
-
{t.theme}
{t.count} words
{/each}
{:else}No custom themes yet — the daily rotation uses the built-in ones.
{/if}
{/if}
{/if}