diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index 453c77e..13dff2c 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -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, diff --git a/backend/src/storage/db/events.ts b/backend/src/storage/db/events.ts index 70ab9f4..b711865 100644 --- a/backend/src/storage/db/events.ts +++ b/backend/src/storage/db/events.ts @@ -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 { 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 { 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): 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): TrackedEv merged.cadence, merged.cadenceTime, merged.active ? 1 : 0, + merged.isSpillover ? 1 : 0, merged.retentionOverrideDays, merged.lastRecapAt, id diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 364981a..0dc2e0c 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -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'); } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 322d9d8..3bf285d 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -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; diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 9714f50..7880117 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -169,6 +169,7 @@ export interface AdminTrackedEvent { cadence: 'continuous' | 'daily' | 'hourly' | 'custom'; cadenceTime: string | null; active: boolean; + isSpillover: boolean; retentionOverrideDays: number | null; } diff --git a/frontend/src/lib/components/admin/EventsTab.svelte b/frontend/src/lib/components/admin/EventsTab.svelte index db7c18d..a245a67 100644 --- a/frontend/src/lib/components/admin/EventsTab.svelte +++ b/frontend/src/lib/components/admin/EventsTab.svelte @@ -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(null); function emptyEditForm() { @@ -14,8 +14,8 @@ description: '', sourceIdSet: new Set(), 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 @@
- {events.length} tracked events - + {events.length} tracked items +
{#if showAdd}
- - +
+
+
Recap cadence
+ - {#if newEvent.cadence === 'daily'} - - {/if} +

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

@@ -126,15 +143,7 @@ {#if editingId === event.id}
- - - {#if editForm.cadence === 'daily'} - - {/if} +
@@ -156,10 +165,28 @@

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.

+
+
Recap cadence
+ + {#if editForm.cadence === 'daily' && editForm.cadenceTime} + at {editForm.cadenceTime} + {/if} +

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

+
+
@@ -178,6 +205,10 @@ {event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence}
+ toggleActive(event)} role="button" tabindex="0"> {event.active ? 'Active' : 'Paused'} @@ -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; + } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 48d269c..5464ae0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -81,6 +81,7 @@ export interface TrackedEventPublic { name: string; active: boolean; cadence: string; + isSpillover: boolean; } export interface Category { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ad2eaa9..7d2a36a 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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 { diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte index c75cfe8..6d695ef 100644 --- a/frontend/src/routes/admin/settings/+page.svelte +++ b/frontend/src/routes/admin/settings/+page.svelte @@ -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' } diff --git a/frontend/src/routes/event/[id]/+page.svelte b/frontend/src/routes/event/[id]/+page.svelte index ce674b9..57f14bb 100644 --- a/frontend/src/routes/event/[id]/+page.svelte +++ b/frontend/src/routes/event/[id]/+page.svelte @@ -7,7 +7,7 @@
{data.name} - Tracked event — periodically recapped by AI + Tracked item — periodically recapped by AI
diff --git a/frontend/src/routes/more/+page.svelte b/frontend/src/routes/more/+page.svelte index dbefa81..b1f1945 100644 --- a/frontend/src/routes/more/+page.svelte +++ b/frontend/src/routes/more/+page.svelte @@ -11,7 +11,7 @@
- {#each data.sections as section (section.category.id)} + {#each data.categorySections as section (section.category.id)} {#if section.articles.length > 0}
{section.category.name} @@ -23,6 +23,18 @@
{/if} {/each} + {#each data.eventSections as section (section.event.id)} + {#if section.articles.length > 0} +
+ {section.event.name} +
+ {#each section.articles as article (article.id)} + + {/each} +
+
+ {/if} + {/each}