Account

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

{/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} {: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'}

Add a source

e.key === 'Enter' && addCandidate()} />
{#if addErr}

{addErr}

{/if}

Fetches a sample (safely) and stages it as a candidate — nothing is added live until you Promote.

{#if pendingCandidates.length}

Candidate queue ({pendingCandidates.length})

{/if}

Sources export CSV ↓

{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total

{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused'], ['retired', 'Retired']] as [key, label] (key)} {/each}
{#if srcSearch.trim()}{shownSources.length} match{shownSources.length === 1 ? '' : 'es'}{/if}
{#each shownSources as s (s.id)} {@const st = sstatus(s)} 0} class:flag={s.review_flag} class:paused={st !== 'active'}> {#if s._checking || s._check || s._checkErr} {/if} {#if s._showArts} {/if} {:else} {/each}
SourceServedAcceptDupMedia Last successNext pollFailsStatusActions
{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} 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 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}
{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}

“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} {: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.

= WS_NEEDED}> {wsValid.length} / {WS_NEEDED} valid{#if wsInvalid.length} · {wsInvalid.length} skipped{/if} {#if wsEditId}{/if}
{#if wsInvalid.length}

Skipped (not 4–8 letters): {wsInvalid.join(', ')}

{/if}
{#if wsMsg}

{wsMsg}

{/if} {#if wsThemes.length} {:else}

No custom themes yet — the daily rotation uses the built-in ones.

{/if} {/if} {/if}
{#if flagging} {/if}