From fbb879effe1c20f7ddd45ec968ba2a8aa29c9d1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 23:25:36 +0000 Subject: [PATCH] Rearchitect tracked events: a real displayed category, recap is additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/pipeline/publish.ts | 73 +++++++++++++++++-- backend/src/pipeline/synthesis.ts | 55 +++++++++++--- backend/src/queue/eventsRecap.ts | 49 +++++-------- backend/src/queue/priorityQueue.ts | 43 +++++++---- backend/src/storage/db/articles.ts | 18 ++++- backend/src/storage/db/index.ts | 6 +- backend/src/storage/db/types.ts | 2 + .../src/lib/components/ArticleListRow.svelte | 4 + frontend/src/lib/types.ts | 2 + frontend/src/routes/+layout.svelte | 14 +++- frontend/src/routes/+layout.ts | 12 ++- frontend/src/routes/article/[id]/+page.svelte | 4 + frontend/src/routes/event/[id]/+page.svelte | 30 ++++++++ frontend/src/routes/event/[id]/+page.ts | 18 +++++ 14 files changed, 256 insertions(+), 74 deletions(-) create mode 100644 frontend/src/routes/event/[id]/+page.svelte create mode 100644 frontend/src/routes/event/[id]/+page.ts diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index a158ac7..ab19350 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -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 { +export async function publishDirect( + item: ContentItem, + settings: GlobalSettings, + opts: { eventId?: string } = {} +): Promise { 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 { + 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 + }); +} diff --git a/backend/src/pipeline/synthesis.ts b/backend/src/pipeline/synthesis.ts index 0480fb0..d52a539 100644 --- a/backend/src/pipeline/synthesis.ts +++ b/backend/src/pipeline/synthesis.ts @@ -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 { - 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 { + 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 { + const prompt = buildRecapPrompt(eventName, articles); + const raw = await provider.generate(prompt, { model, system: RECAP_SYSTEM_PROMPT }); + return parseResult(raw); +} diff --git a/backend/src/queue/eventsRecap.ts b/backend/src/queue/eventsRecap.ts index 329e581..3541e37 100644 --- a/backend/src/queue/eventsRecap.ts +++ b/backend/src/queue/eventsRecap.ts @@ -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[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[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 { 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}`); } diff --git a/backend/src/queue/priorityQueue.ts b/backend/src/queue/priorityQueue.ts index 68b2355..c6234fa 100644 --- a/backend/src/queue/priorityQueue.ts +++ b/backend/src/queue/priorityQueue.ts @@ -17,15 +17,18 @@ function partition(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): number { @@ -52,7 +55,7 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map) */ export async function runPassthroughCycle(settings: GlobalSettings): Promise { 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 { 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 diff --git a/backend/src/storage/db/articles.ts b/backend/src/storage/db/articles.ts index 62bbffe..85ea331 100644 --- a/backend/src/storage/db/articles.ts +++ b/backend/src/storage/db/articles.ts @@ -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 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 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') diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index e05746f..1dd2108 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -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 diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 152c3f2..d8e73d1 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -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 { diff --git a/frontend/src/lib/components/ArticleListRow.svelte b/frontend/src/lib/components/ArticleListRow.svelte index 07b3d69..6c44483 100644 --- a/frontend/src/lib/components/ArticleListRow.svelte +++ b/frontend/src/lib/components/ArticleListRow.svelte @@ -28,6 +28,10 @@
+ {#if article.isRecap} + ๐Ÿงต AI Recap + · + {/if} {article.category[0] ?? ''} · {sourceLabel} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 10d3128..0a3e773 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index a87439b..36dadec 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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 === '/'; diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index cc08a1f..3f91bbc 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -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 }; }; diff --git a/frontend/src/routes/article/[id]/+page.svelte b/frontend/src/routes/article/[id]/+page.svelte index c9308a3..5bf579a 100644 --- a/frontend/src/routes/article/[id]/+page.svelte +++ b/frontend/src/routes/article/[id]/+page.svelte @@ -17,6 +17,10 @@
+ {#if a.isRecap} + ๐Ÿงต AI Recap + · + {/if} {#if a.sourceCount > 1} โ‡„ Merged from {a.sourceCount} sources · diff --git a/frontend/src/routes/event/[id]/+page.svelte b/frontend/src/routes/event/[id]/+page.svelte new file mode 100644 index 0000000..ce674b9 --- /dev/null +++ b/frontend/src/routes/event/[id]/+page.svelte @@ -0,0 +1,30 @@ + + +
+ {data.name} + Tracked event โ€” periodically recapped by AI +
+ + + + diff --git a/frontend/src/routes/event/[id]/+page.ts b/frontend/src/routes/event/[id]/+page.ts new file mode 100644 index 0000000..29bac4b --- /dev/null +++ b/frontend/src/routes/event/[id]/+page.ts @@ -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 }; +};