From 199177a3d223d1c348ad2713bc84b204f7a2e7ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 18:16:34 +0000 Subject: [PATCH] Replace cadence lock with a real off/1h/3h/6h/12h/24h recap interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undoes the previous lock — some tracked items (a commit feed, a torrent feed) are just organizing sources under a nav entry and never want an AI recap at all, so the control needs to be both live and able to express "never." Replaces the old cadence ('continuous'/'daily'/'hourly'/'custom') + cadenceTime model with a single recapIntervalHours: 1 | 3 | 6 | 12 | 24 | null field (null = off), which also simplifies eventsRecap.ts's isDue() down to one comparison. Off by default for new items. The "Show in More »" toggle is now available both inline on the row and inside the edit panel. --- backend/src/api/public.ts | 4 +- backend/src/queue/eventsRecap.ts | 27 ++---- backend/src/storage/db/events.ts | 15 ++-- backend/src/storage/db/index.ts | 6 +- backend/src/storage/db/types.ts | 4 +- frontend/src/lib/adminTypes.ts | 4 +- .../src/lib/components/admin/EventsTab.svelte | 85 ++++++++++--------- frontend/src/lib/types.ts | 2 +- mock-backend/admin-data.js | 6 +- mock-backend/data.js | 4 +- 10 files changed, 70 insertions(+), 87 deletions(-) diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index 13dff2c..b5c4cf9 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -46,10 +46,10 @@ export async function registerPublicRoutes(app: FastifyInstance) { }); app.get('/api/events', async () => { - // Public fields only — sourceIds, cadenceTime etc. stay admin-only. + // Public fields only — sourceIds, keywords etc. stay admin-only. return eventsDb .listEvents() - .map((e) => ({ id: e.id, name: e.name, active: e.active, cadence: e.cadence, isSpillover: e.isSpillover })); + .map((e) => ({ id: e.id, name: e.name, active: e.active, recapIntervalHours: e.recapIntervalHours, isSpillover: e.isSpillover })); }); // Drives the site nav — admin-editable (add/remove/reorder) via /api/admin/categories, diff --git a/backend/src/queue/eventsRecap.ts b/backend/src/queue/eventsRecap.ts index 3541e37..f66bc08 100644 --- a/backend/src/queue/eventsRecap.ts +++ b/backend/src/queue/eventsRecap.ts @@ -5,29 +5,14 @@ import { publishEventRecap } from '../pipeline/publish.js'; import type { GlobalSettings } from '../storage/db/types.js'; import { logger } from '../storage/db/logs.js'; +// null means recaps are off for this item — e.g. one just organizing a commit or +// torrent RSS feed under its own nav entry, with nothing that needs periodically +// summarizing. Individual items still publish immediately regardless (see +// priorityQueue.ts); this only gates the periodic AI wrap-up below. function isDue(event: ReturnType[number]): boolean { - const now = new Date(); + if (event.recapIntervalHours === null) return false; const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null; - - // "Continuous" no longer means "recap every tick" — individual items matching this - // event now publish immediately regardless of cadence (see priorityQueue.ts), so the - // recap job's only remaining purpose is the periodic AI wrap-up. Treated the same as - // hourly so an ongoing event still gets occasional recaps without spamming a - // near-duplicate one on every synthesis tick. - if (event.cadence === 'continuous' || event.cadence === 'hourly') { - return !last || now.getTime() - last.getTime() >= 3600_000; - } - - if (event.cadence === 'daily') { - if (!event.cadenceTime) return false; - const [h, m] = event.cadenceTime.split(':').map(Number); - const scheduledToday = new Date(now); - scheduledToday.setHours(h, m, 0, 0); - const alreadyRecappedToday = last && last.toDateString() === now.toDateString(); - return now >= scheduledToday && !alreadyRecappedToday; - } - - return false; + return !last || Date.now() - last.getTime() >= event.recapIntervalHours * 3600_000; } /** diff --git a/backend/src/storage/db/events.ts b/backend/src/storage/db/events.ts index b711865..e2b12ab 100644 --- a/backend/src/storage/db/events.ts +++ b/backend/src/storage/db/events.ts @@ -9,8 +9,7 @@ function rowToEvent(row: any): TrackedEvent { description: row.description, sourceIds: JSON.parse(row.source_ids), keywords: JSON.parse(row.keywords), - cadence: row.cadence, - cadenceTime: row.cadence_time, + recapIntervalHours: row.recap_interval_hours, active: !!row.active, isSpillover: !!row.is_spillover, retentionOverrideDays: row.retention_override_days, @@ -53,16 +52,15 @@ 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, is_spillover, retention_override_days, last_recap_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)` + `INSERT INTO tracked_events (id, name, description, source_ids, keywords, recap_interval_hours, active, is_spillover, retention_override_days, last_recap_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)` ).run( id, input.name ?? 'Untitled event', input.description ?? '', JSON.stringify(input.sourceIds ?? []), JSON.stringify(input.keywords ?? []), - input.cadence ?? 'continuous', - input.cadenceTime ?? null, + input.recapIntervalHours ?? null, input.active === false ? 0 : 1, input.isSpillover ? 1 : 0, input.retentionOverrideDays ?? null, @@ -76,14 +74,13 @@ 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=?, is_spillover=?, retention_override_days=?, last_recap_at=? WHERE id=?` + `UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, recap_interval_hours=?, active=?, is_spillover=?, retention_override_days=?, last_recap_at=? WHERE id=?` ).run( merged.name, merged.description, JSON.stringify(merged.sourceIds), JSON.stringify(merged.keywords), - merged.cadence, - merged.cadenceTime, + merged.recapIntervalHours, merged.active ? 1 : 0, merged.isSpillover ? 1 : 0, merged.retentionOverrideDays, diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 0dc2e0c..c8b182f 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -130,8 +130,7 @@ export function migrate() { description TEXT NOT NULL DEFAULT '', source_ids TEXT NOT NULL DEFAULT '[]', -- JSON keywords TEXT NOT NULL DEFAULT '[]', -- JSON string array — empty means "match everything from source_ids" - cadence TEXT NOT NULL DEFAULT 'continuous', - cadence_time TEXT, + recap_interval_hours INTEGER, -- hours between AI recaps; NULL = recaps off for this item active INTEGER NOT NULL DEFAULT 1, is_spillover INTEGER NOT NULL DEFAULT 0, retention_override_days INTEGER, @@ -332,6 +331,9 @@ export function migrate() { if (!hasColumn('tracked_events', 'is_spillover')) { db.exec('ALTER TABLE tracked_events ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0'); } + if (!hasColumn('tracked_events', 'recap_interval_hours')) { + db.exec('ALTER TABLE tracked_events ADD COLUMN recap_interval_hours INTEGER'); + } 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 3bf285d..572fe81 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -178,8 +178,8 @@ export interface TrackedEvent { sourceIds: string[]; /** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds", the original behavior. */ keywords: string[]; - cadence: 'continuous' | 'daily' | 'hourly' | 'custom'; - cadenceTime: string | null; + /** Hours between AI recaps, or null to turn recaps off entirely — e.g. an item that's just organizing a commit or torrent RSS feed under one nav entry, with nothing that needs periodically summarizing. Individual articles still publish immediately either way (see priorityQueue.ts); this only gates eventsRecap.ts's periodic wrap-up. */ + recapIntervalHours: 1 | 3 | 6 | 12 | 24 | null; active: boolean; /** Collapses into the "More »" nav tab instead of getting its own top-level tab — same idea as Category.isSpillover. */ isSpillover: boolean; diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 7880117..ba1b649 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -166,8 +166,8 @@ export interface AdminTrackedEvent { sourceIds: string[]; /** Only items whose title/summary/body contain at least one of these (case-insensitive) qualify for this event — empty means "match everything from sourceIds". */ keywords: string[]; - cadence: 'continuous' | 'daily' | 'hourly' | 'custom'; - cadenceTime: string | null; + /** Hours between AI recaps, or null to turn recaps off entirely for this item. */ + recapIntervalHours: 1 | 3 | 6 | 12 | 24 | 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 a245a67..e91ee52 100644 --- a/frontend/src/lib/components/admin/EventsTab.svelte +++ b/frontend/src/lib/components/admin/EventsTab.svelte @@ -5,7 +5,9 @@ let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props(); let events = $state([...initial]); let showAdd = $state(false); - let newEvent = $state({ name: '' }); + // Off by default — plenty of items (a commit feed, a torrent feed) exist just to + // organize sources under one nav entry and never need an AI recap. + let newEvent = $state({ name: '', recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'] }); let editingId = $state(null); function emptyEditForm() { @@ -14,8 +16,8 @@ description: '', sourceIdSet: new Set(), keywordsText: '', - cadence: 'continuous' as AdminTrackedEvent['cadence'], - cadenceTime: null as string | null, + recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'], + isSpillover: false, retentionOverrideDays: null as number | null }; } @@ -25,8 +27,6 @@ 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({ @@ -34,13 +34,12 @@ description: '', sourceIds: [], keywords: [], - cadence: 'continuous', - cadenceTime: null, + recapIntervalHours: newEvent.recapIntervalHours, isSpillover: false, retentionOverrideDays: null }); events = [...events, created]; - newEvent = { name: '' }; + newEvent = { name: '', recapIntervalHours: null }; showAdd = false; } @@ -68,8 +67,8 @@ description: event.description, sourceIdSet: new Set(event.sourceIds), keywordsText: event.keywords.join(', '), - cadence: event.cadence, - cadenceTime: event.cadenceTime, + recapIntervalHours: event.recapIntervalHours, + isSpillover: event.isSpillover, retentionOverrideDays: event.retentionOverrideDays }; } @@ -85,9 +84,6 @@ 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 @@ -99,8 +95,8 @@ description: editForm.description, sourceIds: [...editForm.sourceIdSet], keywords, - cadence: editForm.cadence, - cadenceTime: editForm.cadenceTime, + recapIntervalHours: editForm.recapIntervalHours, + isSpillover: editForm.isSpillover, retentionOverrideDays: editForm.retentionOverrideDays }); events = events.map((e) => (e.id === editingId ? updated : e)); @@ -120,14 +116,19 @@
Recap cadence
- + + + + + +

- 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. + How often an AI recap is written summarizing this item's coverage, on top of its + individual articles (which publish immediately either way). Off by default — leave it + off for something you're just organizing under its own nav entry (a commit feed, a + torrent feed) with nothing that needs summarizing.

@@ -171,22 +172,27 @@
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. + How often an AI recap is written summarizing this item's coverage, on top of its + individual articles (which publish immediately either way). Off by default — leave + it off for something you're just organizing under its own nav entry (a commit feed, + a torrent feed) with nothing that needs summarizing.

+ +
@@ -202,7 +208,7 @@ · matching {event.keywords.map((k) => `"${k}"`).join(', ')} {/if} · - {event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence} + {event.recapIntervalHours === null ? 'no recaps' : `recap every ${event.recapIntervalHours}h`}