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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface TrackedEventPublic {
|
||||
name: string;
|
||||
active: boolean;
|
||||
cadence: string;
|
||||
isSpillover: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user