Replace cadence lock with a real off/1h/3h/6h/12h/24h recap interval

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.
This commit is contained in:
Claude
2026-07-26 18:16:34 +00:00
parent 1068a4a5f7
commit 199177a3d2
10 changed files with 70 additions and 87 deletions
+2 -2
View File
@@ -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,
+6 -21
View File
@@ -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<typeof eventsDb.listActiveEvents>[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;
}
/**
+6 -9
View File
@@ -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>): 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<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=?, 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,
+4 -2
View File
@@ -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');
}
+2 -2
View File
@@ -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;