Rename Tracked Events to Tracked Items, add More>> spillover, lock recap cadence

- Tracked items get the same isSpillover flag as categories: a per-item "More"
  checkbox collapses it into the "More »" nav tab instead of its own top-level
  tab, mirroring Category.isSpillover end to end (schema, CRUD, public API,
  nav partitioning, /more page).
- Renamed user-facing "Tracked events" text to "Tracked items" (admin tab,
  toolbar, buttons, placeholders, /event/:id subtitle) — the underlying
  TrackedEvent/events data model and routes are unchanged.
- Locked the per-item recap cadence dropdown (previously a live
  Continuous/Daily/Hourly picker) and moved it to its own labeled section with
  an explanatory hint: reading eventsRecap.ts confirmed Continuous and Hourly
  currently behave identically, so the live choice was more confusing than
  useful. New items are fixed to Continuous; existing items show their real
  cadence read-only.
This commit is contained in:
Claude
2026-07-26 17:59:56 +00:00
parent 20f9267594
commit 1068a4a5f7
13 changed files with 143 additions and 45 deletions
+3 -1
View File
@@ -47,7 +47,9 @@ export async function registerPublicRoutes(app: FastifyInstance) {
app.get('/api/events', async () => {
// Public fields only — sourceIds, cadenceTime etc. stay admin-only.
return eventsDb.listEvents().map((e) => ({ id: e.id, name: e.name, active: e.active, cadence: e.cadence }));
return eventsDb
.listEvents()
.map((e) => ({ id: e.id, name: e.name, active: e.active, cadence: e.cadence, isSpillover: e.isSpillover }));
});
// Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories,
+6 -3
View File
@@ -12,6 +12,7 @@ function rowToEvent(row: any): TrackedEvent {
cadence: row.cadence,
cadenceTime: row.cadence_time,
active: !!row.active,
isSpillover: !!row.is_spillover,
retentionOverrideDays: row.retention_override_days,
lastRecapAt: row.last_recap_at,
createdAt: row.created_at
@@ -52,8 +53,8 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
const id = `evt-${randomUUID()}`;
const now = new Date().toISOString();
db.prepare(
`INSERT INTO tracked_events (id, name, description, source_ids, keywords, cadence, cadence_time, active, retention_override_days, last_recap_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`
`INSERT INTO tracked_events (id, name, description, source_ids, keywords, cadence, cadence_time, active, is_spillover, retention_override_days, last_recap_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`
).run(
id,
input.name ?? 'Untitled event',
@@ -63,6 +64,7 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
input.cadence ?? 'continuous',
input.cadenceTime ?? null,
input.active === false ? 0 : 1,
input.isSpillover ? 1 : 0,
input.retentionOverrideDays ?? null,
now
);
@@ -74,7 +76,7 @@ export function updateEvent(id: string, patch: Partial<TrackedEvent>): TrackedEv
if (!existing) return null;
const merged = { ...existing, ...patch };
db.prepare(
`UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, cadence=?, cadence_time=?, active=?, retention_override_days=?, last_recap_at=? WHERE id=?`
`UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, cadence=?, cadence_time=?, active=?, is_spillover=?, retention_override_days=?, last_recap_at=? WHERE id=?`
).run(
merged.name,
merged.description,
@@ -83,6 +85,7 @@ export function updateEvent(id: string, patch: Partial<TrackedEvent>): TrackedEv
merged.cadence,
merged.cadenceTime,
merged.active ? 1 : 0,
merged.isSpillover ? 1 : 0,
merged.retentionOverrideDays,
merged.lastRecapAt,
id
+4
View File
@@ -133,6 +133,7 @@ export function migrate() {
cadence TEXT NOT NULL DEFAULT 'continuous',
cadence_time TEXT,
active INTEGER NOT NULL DEFAULT 1,
is_spillover INTEGER NOT NULL DEFAULT 0,
retention_override_days INTEGER,
last_recap_at TEXT,
created_at TEXT NOT NULL
@@ -328,6 +329,9 @@ export function migrate() {
if (!hasColumn('tracked_events', 'keywords')) {
db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'");
}
if (!hasColumn('tracked_events', 'is_spillover')) {
db.exec('ALTER TABLE tracked_events ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('merged_articles', 'is_recap')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
}
+2
View File
@@ -181,6 +181,8 @@ export interface TrackedEvent {
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
cadenceTime: string | null;
active: boolean;
/** Collapses into the "More »" nav tab instead of getting its own top-level tab — same idea as Category.isSpillover. */
isSpillover: boolean;
retentionOverrideDays: number | null;
lastRecapAt: string | null;
createdAt: string;
+1
View File
@@ -169,6 +169,7 @@ export interface AdminTrackedEvent {
cadence: 'continuous' | 'daily' | 'hourly' | 'custom';
cadenceTime: string | null;
active: boolean;
isSpillover: boolean;
retentionOverrideDays: number | null;
}
@@ -5,7 +5,7 @@
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
let events = $state([...initial]);
let showAdd = $state(false);
let newEvent = $state({ name: '', cadence: 'daily' as AdminTrackedEvent['cadence'], cadenceTime: '18:00' });
let newEvent = $state({ name: '' });
let editingId = $state<string | null>(null);
function emptyEditForm() {
@@ -14,8 +14,8 @@
description: '',
sourceIdSet: new Set<string>(),
keywordsText: '',
cadence: 'daily' as AdminTrackedEvent['cadence'],
cadenceTime: '18:00',
cadence: 'continuous' as AdminTrackedEvent['cadence'],
cadenceTime: null as string | null,
retentionOverrideDays: null as number | null
};
}
@@ -25,6 +25,8 @@
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
}
// Cadence is fixed at Continuous for every new item — see the "Recap cadence" hint
// below for why the dropdown is locked rather than a live choice right now.
async function handleAdd() {
if (!newEvent.name) return;
const created = await addEvent({
@@ -32,12 +34,13 @@
description: '',
sourceIds: [],
keywords: [],
cadence: newEvent.cadence,
cadenceTime: newEvent.cadence === 'daily' ? newEvent.cadenceTime : null,
cadence: 'continuous',
cadenceTime: null,
isSpillover: false,
retentionOverrideDays: null
});
events = [...events, created];
newEvent = { name: '', cadence: 'daily', cadenceTime: '18:00' };
newEvent = { name: '' };
showAdd = false;
}
@@ -46,6 +49,13 @@
events = events.map((e) => (e.id === event.id ? updated : e));
}
// Same "More »" collapse as Category.isSpillover (see MergeTab.svelte) — an item
// marked here loses its own top-nav tab and shows up on /more instead.
async function toggleSpillover(event: AdminTrackedEvent) {
const updated = await updateEvent(event.id, { isSpillover: !event.isSpillover });
events = events.map((e) => (e.id === event.id ? updated : e));
}
async function handleDelete(id: string) {
await deleteEvent(id);
events = events.filter((e) => e.id !== id);
@@ -59,7 +69,7 @@
sourceIdSet: new Set(event.sourceIds),
keywordsText: event.keywords.join(', '),
cadence: event.cadence,
cadenceTime: event.cadenceTime ?? '18:00',
cadenceTime: event.cadenceTime,
retentionOverrideDays: event.retentionOverrideDays
};
}
@@ -75,6 +85,9 @@
editForm.sourceIdSet = next;
}
// Cadence/cadenceTime are read-only in this form (disabled select below) and are
// passed straight through unchanged, so editing name/sources/keywords never
// accidentally shifts an item's recap timing.
async function saveEdit() {
if (!editingId || !editForm.name) return;
const keywords = editForm.keywordsText
@@ -87,7 +100,7 @@
sourceIds: [...editForm.sourceIdSet],
keywords,
cadence: editForm.cadence,
cadenceTime: editForm.cadence === 'daily' ? editForm.cadenceTime : null,
cadenceTime: editForm.cadenceTime,
retentionOverrideDays: editForm.retentionOverrideDays
});
events = events.map((e) => (e.id === editingId ? updated : e));
@@ -96,22 +109,26 @@
</script>
<div class="toolbar">
<span class="count">{events.length} tracked events</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New event</button>
<span class="count">{events.length} tracked items</span>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New item</button>
</div>
{#if showAdd}
<div class="add-panel">
<div class="add-grid">
<input placeholder="Event name (e.g. Iran war)" bind:value={newEvent.name} />
<select bind:value={newEvent.cadence}>
<input placeholder="Item name (e.g. Iran war)" bind:value={newEvent.name} />
</div>
<div class="cadence-block">
<div class="field-label">Recap cadence</div>
<select disabled value="continuous">
<option value="continuous">Continuous</option>
<option value="daily">Daily</option>
<option value="hourly">Hourly</option>
</select>
{#if newEvent.cadence === 'daily'}
<input type="text" placeholder="18:00" bind:value={newEvent.cadenceTime} />
{/if}
<p class="hint">
Controls how often this item's AI recap is written, on top of its individual articles
(which publish immediately either way). Locked for now — Continuous and Hourly
currently behave identically (roughly once an hour, as long as new coverage keeps
arriving), so every new item uses Continuous.
</p>
</div>
<div class="add-actions">
<button onclick={() => (showAdd = false)}>Cancel</button>
@@ -126,15 +143,7 @@
{#if editingId === event.id}
<div class="edit-panel">
<div class="add-grid">
<input placeholder="Event name" bind:value={editForm.name} />
<select bind:value={editForm.cadence}>
<option value="continuous">Continuous</option>
<option value="daily">Daily</option>
<option value="hourly">Hourly</option>
</select>
{#if editForm.cadence === 'daily'}
<input type="text" placeholder="18:00" bind:value={editForm.cadenceTime} />
{/if}
<input placeholder="Item name" bind:value={editForm.name} />
</div>
<textarea placeholder="Description (optional)" bind:value={editForm.description} rows="2"></textarea>
@@ -156,10 +165,28 @@
<input placeholder="e.g. 🇮🇷, Tehran, IRGC" bind:value={editForm.keywordsText} />
<p class="hint">
Comma-separated words, phrases, or emoji — only items from the sources above whose
title/summary/body contain at least one qualify for this event's recap. Leave blank to
title/summary/body contain at least one qualify for this item's recap. Leave blank to
include everything from the assigned sources.
</p>
<div class="cadence-block">
<div class="field-label">Recap cadence</div>
<select disabled value={editForm.cadence}>
<option value="continuous">Continuous</option>
<option value="daily">Daily</option>
<option value="hourly">Hourly</option>
</select>
{#if editForm.cadence === 'daily' && editForm.cadenceTime}
<span class="cadence-time">at {editForm.cadenceTime}</span>
{/if}
<p class="hint">
Controls how often this item's AI recap is written, on top of its individual
articles (which publish immediately either way). Locked for now — Continuous and
Hourly currently behave identically (roughly once an hour, as long as new coverage
keeps arriving); Daily instead waits for the one fixed time shown above.
</p>
</div>
<div class="add-actions">
<button onclick={cancelEdit}>Cancel</button>
<button class="primary" onclick={saveEdit}>Save</button>
@@ -178,6 +205,10 @@
{event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence}
</div>
</div>
<label class="spillover-toggle">
<input type="checkbox" checked={event.isSpillover} onchange={() => toggleSpillover(event)} />
More
</label>
<span class="badge" class:active={event.active} onclick={() => toggleActive(event)} role="button" tabindex="0">
{event.active ? 'Active' : 'Paused'}
</span>
@@ -314,4 +345,32 @@
.edit-panel .hint {
margin: 6px 0 10px;
}
.cadence-block {
margin-top: 12px;
padding-top: 12px;
border-top: 0.5px solid var(--border);
}
.cadence-block select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cadence-time {
font-size: 12px;
color: var(--text-secondary);
margin-left: 8px;
}
.cadence-block .hint {
margin-top: 6px;
}
.spillover-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.spillover-toggle input {
width: auto;
}
</style>
+1
View File
@@ -81,6 +81,7 @@ export interface TrackedEventPublic {
name: string;
active: boolean;
cadence: string;
isSpillover: boolean;
}
export interface Category {
+7 -4
View File
@@ -51,20 +51,23 @@
// list) — those collapse into a single trailing "More »" tab instead, so the nav
// doesn't get too wide or wrap once there are more than a handful of categories.
//
// A tracked event is a displayed category too, just backed by a source+keyword
// A tracked item is a displayed category too, just backed by a source+keyword
// filter instead of manual per-source category checkboxes, and periodically
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
// appended after the regular categories.
// appended after the regular categories, unless marked spillover — same "More »"
// collapse as an overflow category, see /more's +page.ts.
const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
const primaryEvents = $derived(data.events.filter((e) => !e.isSpillover));
const spilloverEvents = $derived(data.events.filter((e) => e.isSpillover));
const navItems = $derived([
...primaryCategories.map((cat) => ({
label: cat.name,
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
})),
...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
...primaryEvents.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
...(spilloverCategories.length > 0 || spilloverEvents.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
]);
function isActive(href: string): boolean {
@@ -14,7 +14,7 @@
{ id: 'merge', label: 'Sources & Merge' },
{ id: 'models', label: 'Models' },
{ id: 'retention', label: 'Retention' },
{ id: 'events', label: 'Tracked events' },
{ id: 'events', label: 'Tracked items' },
{ id: 'widgets', label: 'Widgets' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="head">
<span class="title">{data.name}</span>
<span class="sub">Tracked event — periodically recapped by AI</span>
<span class="sub">Tracked item — periodically recapped by AI</span>
</div>
<InfiniteFeed initial={data.initial} filters={data.filters} pageSize={data.pageSize} />
+13 -1
View File
@@ -11,7 +11,7 @@
</div>
<div class="sections">
{#each data.sections as section (section.category.id)}
{#each data.categorySections as section (section.category.id)}
{#if section.articles.length > 0}
<section class="cat-section">
<a class="cat-name" href={`/category/${slugify(section.category.name)}`}>{section.category.name}</a>
@@ -23,6 +23,18 @@
</section>
{/if}
{/each}
{#each data.eventSections as section (section.event.id)}
{#if section.articles.length > 0}
<section class="cat-section">
<a class="cat-name" href={`/event/${section.event.id}`}>{section.event.name}</a>
<div class="list">
{#each section.articles as article (article.id)}
<ArticleListRow {article} />
{/each}
</div>
</section>
{/if}
{/each}
</div>
<style>
+16 -7
View File
@@ -4,19 +4,28 @@ import { getFeed } from '$lib/api';
const PREVIEW_COUNT = 5;
// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
// category (see MergeTab.svelte's "More" toggle) with its few newest articles, the
// category name itself linking through to the full /category/:slug page. Mirrors
// category (see MergeTab.svelte's "More" toggle) or spillover tracked item (see
// EventsTab.svelte's "More" toggle) with its few newest articles, the name itself
// linking through to the full /category/:slug or /event/:id page. Mirrors
// category/[name]/+page.ts's parent()-based category access rather than a second fetch.
export const load: PageLoad = async ({ fetch, parent }) => {
const { categories } = await parent();
const spillover = categories.filter((c) => c.isSpillover);
const { categories, events } = await parent();
const spilloverCategories = categories.filter((c) => c.isSpillover);
const spilloverEvents = events.filter((e) => e.isSpillover);
const sections = await Promise.all(
spillover.map(async (category) => ({
const categorySections = await Promise.all(
spilloverCategories.map(async (category) => ({
category,
articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch)
}))
);
return { sections };
const eventSections = await Promise.all(
spilloverEvents.map(async (event) => ({
event,
articles: await getFeed({ eventId: event.id, limit: PREVIEW_COUNT }, fetch)
}))
);
return { categorySections, eventSections };
};
+2
View File
@@ -89,6 +89,7 @@ let events = [
cadence: "daily",
cadenceTime: "18:00",
active: true,
isSpillover: false,
retentionOverrideDays: null
},
{
@@ -99,6 +100,7 @@ let events = [
cadence: "continuous",
cadenceTime: null,
active: true,
isSpillover: false,
retentionOverrideDays: 7
}
];