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;
+2 -2
View File
@@ -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;
@@ -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<string | null>(null);
function emptyEditForm() {
@@ -14,8 +16,8 @@
description: '',
sourceIdSet: new Set<string>(),
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 @@
</div>
<div class="cadence-block">
<div class="field-label">Recap cadence</div>
<select disabled value="continuous">
<option value="continuous">Continuous</option>
<select bind:value={newEvent.recapIntervalHours}>
<option value={null}>Off no recap</option>
<option value={1}>Every hour</option>
<option value={3}>Every 3 hours</option>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Every 24 hours</option>
</select>
<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.
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.
</p>
</div>
<div class="add-actions">
@@ -171,22 +172,27 @@
<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 bind:value={editForm.recapIntervalHours}>
<option value={null}>Off no recap</option>
<option value={1}>Every hour</option>
<option value={3}>Every 3 hours</option>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Every 24 hours</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.
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.
</p>
</div>
<label class="spillover-toggle edit-spillover">
<input type="checkbox" bind:checked={editForm.isSpillover} />
Show in "More »" instead of its own nav tab
</label>
<div class="add-actions">
<button onclick={cancelEdit}>Cancel</button>
<button class="primary" onclick={saveEdit}>Save</button>
@@ -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`}
</div>
</div>
<label class="spillover-toggle">
@@ -350,18 +356,13 @@
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;
}
.edit-spillover {
display: flex;
margin-top: 12px;
}
.spillover-toggle {
display: flex;
align-items: center;
+1 -1
View File
@@ -80,7 +80,7 @@ export interface TrackedEventPublic {
id: string;
name: string;
active: boolean;
cadence: string;
recapIntervalHours: number | null;
isSpillover: boolean;
}
+2 -4
View File
@@ -86,8 +86,7 @@ let events = [
name: "Iran war",
description: "Ongoing conflict coverage, sourced primarily from Telegram channels for speed.",
sourceIds: ["src-2"],
cadence: "daily",
cadenceTime: "18:00",
recapIntervalHours: 24,
active: true,
isSpillover: false,
retentionOverrideDays: null
@@ -97,8 +96,7 @@ let events = [
name: "Fed rate decisions",
description: "",
sourceIds: ["src-1"],
cadence: "continuous",
cadenceTime: null,
recapIntervalHours: 1,
active: true,
isSpillover: false,
retentionOverrideDays: 7
+2 -2
View File
@@ -217,8 +217,8 @@ const tags = [
];
const events = [
{ id: "evt-iran", name: "Iran war", active: true, cadence: "daily" },
{ id: "evt-fed", name: "Fed rate decisions", active: true, cadence: "continuous" }
{ id: "evt-iran", name: "Iran war", active: true, recapIntervalHours: 24, isSpillover: false },
{ id: "evt-fed", name: "Fed rate decisions", active: true, recapIntervalHours: 1, isSpillover: false }
];
module.exports = { articles, tags, events };