Rearchitect tracked events: a real displayed category, recap is additive

Previously a tracked event withheld all matching items from ever
publishing individually — they sat unclustered until the recap job fired,
which then bundled them into one article and discarded the raw items.
Visitors saw nothing from an event until a recap happened, and never saw
the underlying pieces at all.

Matching items now publish through the exact same pipeline as everything
else (individually or merged with same-story coverage via the normal
clustering pipeline), just tagged with the event's id via a new
publishDirect/publishCluster opts.eventId. An item whose source is
assigned to an event but doesn't match its keyword filter still
publishes normally, just without the tag, instead of being dropped.

The recap is now a periodic *additional* AI-written summary of everything
already published under the event since the last recap (new
synthesizeRecap prompt + publishEventRecap, reading MergedArticle bodies
rather than raw feed items) — never a replacement. Added a new
MergedArticle.isRecap flag so the two are visually distinguishable.

Frontend: a tracked event is now a real displayed category — new
/event/[id] page (mirrors /category/[name]), active events appended to
the site nav, and a "🧵 AI Recap" marker on recap articles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 23:25:36 +00:00
parent 97d3ed366c
commit fbb879effe
14 changed files with 256 additions and 74 deletions
+67 -6
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto';
import type { InferenceProvider } from '../inference/provider.js';
import type { Cluster } from './clustering.js';
import { synthesizeArticle } from './synthesis.js';
import { synthesizeArticle, synthesizeRecap } from './synthesis.js';
import { selectBestImage, faviconUrlFor } from './image-selection.js';
import { downloadAndStore, promoteToPublished, storeMediaBuffer } from '../storage/media/index.js';
import { downloadMessageMedia, downloadChannelAvatar } from '../telegram/client.js';
@@ -16,7 +16,8 @@ import type {
TweetMediaItem,
QuotedTweet,
TelegramMediaItem,
TelegramMediaRef
TelegramMediaRef,
TrackedEvent
} from '../storage/db/types.js';
const FOLLOW_UP_LOOKBACK_DAYS = 3;
@@ -233,7 +234,11 @@ async function resolveQuotedTweet(
* tag-based thread detection — but these earlier articles aren't retroactively
* rewritten or merged with anything after the fact.
*/
export async function publishDirect(item: ContentItem, settings: GlobalSettings): Promise<MergedArticle> {
export async function publishDirect(
item: ContentItem,
settings: GlobalSettings,
opts: { eventId?: string } = {}
): Promise<MergedArticle> {
const category = uniqueCategories([item]);
const storedMediaIds: string[] = [];
@@ -315,7 +320,7 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
telegramMessage,
category,
geo: item.geo,
eventId: item.eventId,
eventId: opts.eventId ?? item.eventId,
sourceCount: 1,
sources: [
{
@@ -334,7 +339,8 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
threadId: randomUUID(),
previousArticleId: null,
nextArticleId: null,
topStories: anyPushesToTopStories([item])
topStories: anyPushesToTopStories([item]),
isRecap: false
});
for (const id of storedMediaIds) promoteToPublished(id, article.id);
@@ -428,7 +434,8 @@ export async function publishCluster(
threadId,
previousArticleId,
nextArticleId: null,
topStories: anyPushesToTopStories(items)
topStories: anyPushesToTopStories(items),
isRecap: false
});
if (storedMediaId) {
@@ -437,3 +444,57 @@ export async function publishCluster(
return article;
}
/**
* Builds the periodic AI recap for a tracked event — a standalone summary article of
* everything published under this event since the last recap, additive alongside those
* individual articles rather than replacing or consuming them (see eventsRecap.ts).
* Deliberately much lighter than publishCluster: no embedding/clustering, no follow-up
* thread detection (each recap stands alone), hero image and sources are just carried
* over from the constituent articles rather than re-resolved from raw content items.
*/
export async function publishEventRecap(
provider: InferenceProvider,
settings: GlobalSettings,
event: TrackedEvent,
constituents: MergedArticle[]
): Promise<MergedArticle> {
const { body, tagLabels } = await synthesizeRecap(provider, settings.selectedModels.synthesis, event.name, constituents);
const resolvedTags = [];
for (const label of tagLabels) {
try {
const embedding = await provider.embed(label, { model: settings.selectedModels.embedding });
resolvedTags.push(tags.resolveOrCreateTag(label, embedding, settings.tagDedupThreshold));
} catch (err) {
logger.error('events', `Tag embedding failed for "${label}": ${(err as Error).message}`);
}
}
const category = [...new Set(constituents.flatMap((a) => a.category))];
const heroImage = constituents.find((a) => a.heroImage)?.heroImage ?? null;
const now = new Date().toISOString();
return articles.insertArticle({
title: `${event.name}: recap`,
body,
heroImage,
video: null,
tweet: null,
telegramMessage: null,
category,
geo: constituents.find((a) => a.geo)?.geo ?? null,
eventId: event.id,
sourceCount: constituents.flatMap((a) => a.sources).length,
sources: constituents.flatMap((a) => a.sources),
publishedAt: now,
updatedAt: now,
mergeConfidence: 1.0,
tags: resolvedTags.map((t) => t.id),
threadId: randomUUID(),
previousArticleId: null,
nextArticleId: null,
topStories: constituents.some((a) => a.topStories),
isRecap: true
});
}
+46 -9
View File
@@ -1,8 +1,16 @@
import type { InferenceProvider } from '../inference/provider.js';
import type { ContentItem } from '../storage/db/types.js';
import type { ContentItem, MergedArticle } from '../storage/db/types.js';
const TAG_DELIMITER = '---TAGS---';
const RECAP_SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given a chronological list of articles already published about an ongoing tracked event, write a single recap article that:
- Summarizes what has happened across the period covered, in chronological order
- Highlights the most significant developments rather than restating every article
- Stays neutral and factual, without editorializing
- Is 3-5 short paragraphs
After the recap, on a new line, write exactly "${TAG_DELIMITER}" followed by 2-4 short comma-separated topic/entity tags (e.g. proper nouns, named events) that this recap is about. If nothing salient qualifies, leave the tag line empty.`;
const SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write a single original article that:
- Attributes specific claims to the outlet that reported them (e.g. "Reuters reported...", "AP notes...")
- Does not copy phrasing verbatim from any source
@@ -24,14 +32,7 @@ function buildPrompt(items: ContentItem[]): string {
.join('\n\n');
}
export async function synthesizeArticle(
provider: InferenceProvider,
model: string,
items: ContentItem[]
): Promise<SynthesisResult> {
const prompt = buildPrompt(items);
const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT });
function parseResult(raw: string): SynthesisResult {
const [body, tagSection] = raw.split(TAG_DELIMITER);
const tagLabels = (tagSection ?? '')
.split(',')
@@ -40,3 +41,39 @@ export async function synthesizeArticle(
return { body: body.trim(), tagLabels };
}
export async function synthesizeArticle(
provider: InferenceProvider,
model: string,
items: ContentItem[]
): Promise<SynthesisResult> {
const prompt = buildPrompt(items);
const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT });
return parseResult(raw);
}
function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string {
const entries = articles
.map((article, i) => `Article ${i + 1} (published ${article.publishedAt}):\nTitle: ${article.title}\n${article.body}`)
.join('\n\n');
return `Tracked event: ${eventName}\n\n${entries}`;
}
/**
* Recaps a period's worth of already-published articles under one tracked event — a
* different job from synthesizeArticle's same-story dedup (which merges multiple
* outlets' coverage of ONE story into one article): this summarizes many already-
* distinct articles about an ONGOING situation into a rolling wrap-up, so it gets its
* own prompt and reads from already-synthesized article bodies rather than raw feed
* summaries.
*/
export async function synthesizeRecap(
provider: InferenceProvider,
model: string,
eventName: string,
articles: MergedArticle[]
): Promise<SynthesisResult> {
const prompt = buildRecapPrompt(eventName, articles);
const raw = await provider.generate(prompt, { model, system: RECAP_SYSTEM_PROMPT });
return parseResult(raw);
}
+18 -31
View File
@@ -1,19 +1,20 @@
import type { InferenceProvider } from '../inference/provider.js';
import * as eventsDb from '../storage/db/events.js';
import * as contentItemsDb from '../storage/db/contentItems.js';
import { embedPendingItems } from '../pipeline/embedding.js';
import { publishCluster } from '../pipeline/publish.js';
import { randomUUID } from 'node:crypto';
import * as articlesDb from '../storage/db/articles.js';
import { publishEventRecap } from '../pipeline/publish.js';
import type { GlobalSettings } from '../storage/db/types.js';
import { logger } from '../storage/db/logs.js';
function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boolean {
if (event.cadence === 'continuous') return true; // handled every cycle like normal clustering, just scoped to its sources
const now = new Date();
const last = event.lastRecapAt ? new Date(event.lastRecapAt) : null;
if (event.cadence === 'hourly') {
// "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;
}
@@ -29,6 +30,12 @@ function isDue(event: ReturnType<typeof eventsDb.listActiveEvents>[number]): boo
return false;
}
/**
* Periodically writes an AI recap summarizing everything published under a tracked
* event since its last recap — additive alongside those individual articles (which
* publish immediately via the normal pipeline, see priorityQueue.ts), not a replacement
* for them.
*/
export async function runEventRecaps(provider: InferenceProvider, settings: GlobalSettings): Promise<number> {
const events = eventsDb.listActiveEvents();
let published = 0;
@@ -37,34 +44,14 @@ export async function runEventRecaps(provider: InferenceProvider, settings: Glob
if (event.sourceIds.length === 0 || !isDue(event)) continue;
const since = event.lastRecapAt ?? new Date(Date.now() - 24 * 3600_000).toISOString();
const items = contentItemsDb
.unclusteredItemsForSources(event.sourceIds, since)
.filter((item) => eventsDb.itemMatchesEventKeywords(item, event.keywords));
if (items.length === 0) continue;
const embedded = await embedPendingItems(provider, settings.selectedModels.embedding, items);
const withEmbeddings = embedded.filter((i) => i.embedding);
if (withEmbeddings.length === 0) continue;
const constituents = articlesDb.articlesForEventSince(event.id, since);
if (constituents.length === 0) continue;
try {
const article = await publishCluster(
provider,
settings,
{
id: randomUUID(),
items: withEmbeddings,
centroid: withEmbeddings[0].embedding!
},
{ eventId: event.id }
);
contentItemsDb.assignCluster(
withEmbeddings.map((i) => i.id),
article.id
);
const article = await publishEventRecap(provider, settings, event, constituents);
eventsDb.markRecapped(event.id);
published++;
logger.info('events', `Published recap for "${event.name}" from ${withEmbeddings.length} item(s)`);
logger.info('events', `Published recap for "${event.name}" from ${constituents.length} article(s)`);
} catch (err) {
logger.error('events', `Recap failed for "${event.name}": ${(err as Error).message}`);
}
+27 -16
View File
@@ -17,15 +17,18 @@ function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
}
/**
* An item is "claimed" by a tracked event — and so left for eventsRecap.ts to handle
* instead of normal synthesis — only if it belongs to one of the event's sources AND
* matches its keyword filter. An item from an event-linked source that doesn't match
* A tracked event is a displayed category like any other — matching items publish
* normally (individually or merged with same-story coverage, exactly like regular
* news), just tagged with the event's id so they're browsable under it and so
* eventsRecap.ts can periodically write an AI wrap-up from them. An item only counts as
* "claimed" if it belongs to one of the event's sources AND matches its keyword filter
* (e.g. a general Middle-East feed assigned to an "Iran war" event, but this particular
* item doesn't mention Iran) falls through to normal synthesis rather than being
* silently dropped — it just isn't part of that event's recap.
* item doesn't mention Iran, just isn't part of that event — it still publishes, only
* without the tag).
*/
function isClaimedByEvent(item: ContentItem, events: TrackedEvent[]): boolean {
return events.some((e) => e.sourceIds.includes(item.sourceId) && eventsDb.itemMatchesEventKeywords(item, e.keywords));
function claimedEventId(item: ContentItem, events: TrackedEvent[]): string | null {
const match = events.find((e) => e.sourceIds.includes(item.sourceId) && eventsDb.itemMatchesEventKeywords(item, e.keywords));
return match?.id ?? null;
}
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>): number {
@@ -52,7 +55,7 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>)
*/
export async function runPassthroughCycle(settings: GlobalSettings): Promise<number> {
const activeEvents = eventsDb.listActiveEvents();
const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents));
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
if (items.length === 0) return 0;
const categories = categoriesDb.listCategories();
@@ -66,7 +69,8 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
for (const item of ranked) {
try {
const article = await publishDirect(item, settings);
const eventId = claimedEventId(item, activeEvents) ?? undefined;
const article = await publishDirect(item, settings, { eventId });
contentItemsDb.assignCluster([item.id], article.id);
published++;
logger.info('synthesis', `Published "${article.title}" directly (no AI available)`);
@@ -79,15 +83,17 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
}
/**
* One pass of the synthesis queue: cluster whatever's unclustered (excluding items
* claimed by a tracked event — belonging to one of its sources AND matching its
* keyword filter, if any — which are handled by eventsRecap.ts instead), ordered by
* One pass of the synthesis queue: cluster whatever's unclustered, ordered by
* admin-defined category priority, and publish clusters that have cleared the
* hold-before-publish window.
* hold-before-publish window. Items claimed by an active tracked event (belonging to
* one of its sources and matching its keyword filter, if any) publish exactly like
* everything else — individually or merged with same-story coverage — just tagged with
* the event's id so they're browsable under it and eligible for eventsRecap.ts's
* periodic AI wrap-up.
*/
export async function runSynthesisCycle(provider: InferenceProvider, settings: GlobalSettings): Promise<number> {
const activeEvents = eventsDb.listActiveEvents();
const items = contentItemsDb.unclusteredItemsExcludingSources([]).filter((item) => !isClaimedByEvent(item, activeEvents));
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
if (items.length === 0) return 0;
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
@@ -104,7 +110,8 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
let publishedDirect = 0;
for (const item of directItems) {
try {
const article = await publishDirect(item, settings);
const eventId = claimedEventId(item, activeEvents) ?? undefined;
const article = await publishDirect(item, settings, { eventId });
contentItemsDb.assignCluster([item.id], article.id);
publishedDirect++;
const source = sourcesDb.getSource(item.sourceId);
@@ -140,7 +147,11 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
}
try {
const article = await publishCluster(provider, settings, cluster);
// A cluster's event tag comes from whichever of its items (if any) is claimed —
// in practice a cluster's items are all near-duplicate coverage of the same
// story, so they'd all match the same event's filter anyway when they match at all.
const eventId = cluster.items.map((i) => claimedEventId(i, activeEvents)).find((id) => id !== null) ?? undefined;
const article = await publishCluster(provider, settings, cluster, { eventId });
contentItemsDb.assignCluster(
cluster.items.map((i) => i.id),
cluster.id
+14 -4
View File
@@ -24,7 +24,8 @@ function rowToArticle(row: any): MergedArticle {
nextArticleId: row.next_article_id,
topStories: !!row.top_stories,
tweet: row.tweet ? JSON.parse(row.tweet) : null,
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null,
isRecap: !!row.is_recap
};
}
@@ -32,8 +33,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
const id = `art-${randomUUID()}`;
db.prepare(
`INSERT INTO merged_articles
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message, is_recap)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
article.title,
@@ -54,7 +55,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
article.nextArticleId,
article.topStories ? 1 : 0,
article.tweet ? JSON.stringify(article.tweet) : null,
article.telegramMessage ? JSON.stringify(article.telegramMessage) : null
article.telegramMessage ? JSON.stringify(article.telegramMessage) : null,
article.isRecap ? 1 : 0
);
if (article.previousArticleId) {
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId);
@@ -133,6 +135,14 @@ export function queryFeed(
return rows.map(rowToArticle);
}
/** Individual (non-recap) articles published under a tracked event since a timestamp — the recap job's input, see eventsRecap.ts. */
export function articlesForEventSince(eventId: string, since: string): MergedArticle[] {
const rows = db
.prepare('SELECT * FROM merged_articles WHERE event_id = ? AND is_recap = 0 AND published_at > ? ORDER BY published_at')
.all(eventId, since);
return rows.map(rowToArticle);
}
export function latestArticleInThread(threadId: string): MergedArticle | null {
const row = db
.prepare('SELECT * FROM merged_articles WHERE thread_id = ? ORDER BY published_at DESC LIMIT 1')
+5 -1
View File
@@ -86,7 +86,8 @@ export function migrate() {
next_article_id TEXT,
top_stories INTEGER NOT NULL DEFAULT 0, -- true if any contributing source opted into "Push to Top Stories?"
tweet TEXT, -- JSON {authorName, authorHandle, avatarUrl, sourceItemId}, nitter-sourced articles only
telegram_message TEXT -- JSON {channelName, channelUsername, channelAvatarUrl, sourceItemId, media}, telegram-sourced articles only
telegram_message TEXT, -- JSON {channelName, channelUsername, channelAvatarUrl, sourceItemId, media}, telegram-sourced articles only
is_recap INTEGER NOT NULL DEFAULT 0 -- true only for the AI-written periodic summary of a tracked event, see eventsRecap.ts
);
CREATE INDEX IF NOT EXISTS idx_articles_published ON merged_articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id);
@@ -240,6 +241,9 @@ export function migrate() {
if (!hasColumn('tracked_events', 'keywords')) {
db.exec("ALTER TABLE tracked_events ADD COLUMN keywords TEXT NOT NULL DEFAULT '[]'");
}
if (!hasColumn('merged_articles', 'is_recap')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real
+2
View File
@@ -155,6 +155,8 @@ export interface MergedArticle {
nextArticleId: string | null;
/** True if any contributing source opted into "Push to Top Stories?" — gates the homepage feed, see articles.queryFeed. */
topStories: boolean;
/** True only for the AI-written periodic summary of a tracked event (see eventsRecap.ts) — distinguishes it from the individual articles published under the same eventId. */
isRecap: boolean;
}
export interface Tag {
@@ -28,6 +28,10 @@
<div class="content">
<div class="meta">
{#if article.isRecap}
<span>🧵 AI Recap</span>
<span>&middot;</span>
{/if}
<span>{article.category[0] ?? ''}</span>
<span>&middot;</span>
<span>{sourceLabel}</span>
+2
View File
@@ -64,6 +64,8 @@ export interface MergedArticle {
threadId: string;
previousArticleId: string | null;
nextArticleId: string | null;
/** True only for the AI-written periodic summary of a tracked event — see the /event/[id] page. */
isRecap: boolean;
}
export interface Tag {
+10 -4
View File
@@ -30,12 +30,18 @@
// isn't itself a filterable category — it always means "everything, chronological",
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
// page. See MergeTab's category priority list for where these are managed.
const navItems = $derived(
data.categories.map((cat) => ({
//
// A tracked event 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.
const navItems = $derived([
...data.categories.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}` }))
]);
function isActive(href: string): boolean {
if (href === '/') return $page.url.pathname === '/';
+9 -3
View File
@@ -1,8 +1,14 @@
import type { LayoutLoad } from './$types';
import { getCategories } from '$lib/api';
import { getCategories, getEvents } from '$lib/api';
import { getPrivateAccessStatus } from '$lib/privateAccess';
export const load: LayoutLoad = async ({ fetch, data }) => {
const [categories, privateAccess] = await Promise.all([getCategories(fetch), getPrivateAccessStatus(fetch)]);
return { ...data, categories, privateAccess };
const [categories, events, privateAccess] = await Promise.all([
getCategories(fetch),
getEvents(fetch),
getPrivateAccessStatus(fetch)
]);
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
return { ...data, categories, events: events.filter((e) => e.active), privateAccess };
};
@@ -17,6 +17,10 @@
<article>
<div class="meta">
{#if a.isRecap}
<span>🧵 AI Recap</span>
<span>&middot;</span>
{/if}
{#if a.sourceCount > 1}
<span>⇄ Merged from {a.sourceCount} sources</span>
<span>&middot;</span>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PageData } from './$types';
import InfiniteFeed from '$lib/components/InfiniteFeed.svelte';
let { data }: { data: PageData } = $props();
</script>
<div class="head">
<span class="title">{data.name}</span>
<span class="sub">Tracked event — periodically recapped by AI</span>
</div>
<InfiniteFeed initial={data.initial} filters={data.filters} pageSize={data.pageSize} />
<style>
.head {
margin: 24px 0 8px;
}
.title {
font-family: var(--font-voice);
font-size: 26px;
font-weight: 500;
}
.sub {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
import type { PageLoad } from './$types';
import { getFeed } from '$lib/api';
const PAGE_SIZE = 15;
// Mirrors /category/[name] — a tracked event is a displayed category too, just backed
// by a source+keyword filter instead of manual per-source category checkboxes (see
// EventsTab.svelte). The event's own name comes from the root layout's already-loaded
// active-events list (same pattern category pages use to resolve a slug back to a
// real category name) rather than a second fetch.
export const load: PageLoad = async ({ params, fetch, parent }) => {
const { events } = await parent();
const match = events.find((e) => e.id === params.id);
const filters = { eventId: params.id };
const initial = await getFeed({ ...filters, limit: PAGE_SIZE }, fetch);
return { initial, filters, name: match?.name ?? 'Tracked event', pageSize: PAGE_SIZE };
};