Merge pull request #20 from Salastil/development

Cleanup of dead code, UI consolidation and improvement
This commit is contained in:
Salastil
2026-07-26 15:42:30 -04:00
committed by GitHub
34 changed files with 965 additions and 437 deletions
+16
View File
@@ -38,12 +38,28 @@ export async function registerAdminRoutes(app: FastifyInstance) {
categoriesDb.setCategoryOrder(body.categoryPriority);
delete body.categoryPriority;
}
const before = settingsDb.getSettings();
const settings = withStorageUsed(settingsDb.updateSettings(body));
if (body.weather) {
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
if (body.widgets) {
// Re-enabling a widget (see the Widgets tab) should show fresh data right away
// instead of waiting out its normal cadence (up to 45m/15m/1h) — scheduler.ts
// skips polling entirely while a widget is disabled, so there's nothing recent
// to fall back on otherwise.
if (body.widgets.weather && !before.widgets.weather) {
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
if (body.widgets.stocks && !before.widgets.stocks) {
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
}
if (body.widgets.poe2 && !before.widgets.poe2) {
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
}
}
return { ...settings, categoryPriority: categoriesDb.listCategories() };
});
+12 -2
View File
@@ -46,8 +46,10 @@ 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 }));
// Public fields only — sourceIds, keywords etc. stay admin-only.
return eventsDb
.listEvents()
.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,
@@ -60,6 +62,14 @@ export async function registerPublicRoutes(app: FastifyInstance) {
return categories.filter((c) => !c.isPrivate);
});
// Per-widget enable flags + display order — see the admin panel's consolidated
// "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these
// flags (see scheduler.ts); Sidebar.svelte renders in exactly this order.
app.get('/api/widgets', async () => {
const { widgets, widgetOrder } = settingsDb.getSettings();
return { ...widgets, order: widgetOrder };
});
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
app.get('/api/weather', async () => settingsDb.getSettings().weather);
+3 -4
View File
@@ -15,16 +15,15 @@ const adapters: Record<Source['type'], SourceAdapter> = {
telegram: telegramAdapter,
api: apiAdapter,
youtube: youtubeAdapter,
nitter: nitterAdapter,
custom: apiAdapter
nitter: nitterAdapter
};
// Which source types point at a real webpage worth following for the full article,
// as opposed to Telegram where the message itself *is* the content.
const FOLLOWS_LINK_FOR_FULL_ARTICLE: Source['type'][] = ['rss', 'api'];
export async function pollDueSources(defaultIntervalMinutes: number): Promise<number> {
const due = sourcesDb.sourcesDueForPoll(defaultIntervalMinutes);
export async function pollDueSources(): Promise<number> {
const due = sourcesDb.sourcesDueForPoll();
let ingested = 0;
for (const source of due) {
ingested += await pollOne(source);
+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;
}
/**
+47 -37
View File
@@ -7,7 +7,7 @@ import { embedPendingItems } from '../pipeline/embedding.js';
import { clusterItems } from '../pipeline/clustering.js';
import { publishCluster, publishDirect } from '../pipeline/publish.js';
import { logger } from '../storage/db/logs.js';
import type { GlobalSettings, ContentItem, TrackedEvent } from '../storage/db/types.js';
import type { GlobalSettings, ContentItem, TrackedEvent, Source } from '../storage/db/types.js';
function partition<T>(items: T[], predicate: (item: T) => boolean): [T[], T[]] {
const matches: T[] = [];
@@ -31,8 +31,8 @@ function claimedEventId(item: ContentItem, events: TrackedEvent[]): string | nul
return match?.id ?? null;
}
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>): number {
const source = sourcesDb.getSource(item.sourceId);
function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>, sourcesById: Map<string, Source>): number {
const source = sourcesById.get(item.sourceId);
const cats = source?.category ?? [];
let best = Infinity;
for (const cat of cats) {
@@ -45,6 +45,33 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>)
return best;
}
/**
* Shared by both the passthrough (no-AI) and synthesis direct-publish paths — same
* publish-then-tag-then-log/error shape, differing only in how the success/failure
* message describes why the item skipped merging.
*/
async function publishItemsDirect(
items: ContentItem[],
settings: GlobalSettings,
activeEvents: TrackedEvent[],
describeSuccess: (item: ContentItem) => string,
failureLabel: string
): Promise<number> {
let published = 0;
for (const item of items) {
try {
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 (${describeSuccess(item)})`);
} catch (err) {
logger.error('synthesis', `${failureLabel} for "${item.title}": ${(err as Error).message}`);
}
}
return published;
}
/**
* Fallback for when the AI service isn't reachable yet — publishes every eligible item
* immediately rather than leaving pages empty until Ollama is set up. Unlike the AI
@@ -58,28 +85,15 @@ export async function runPassthroughCycle(settings: GlobalSettings): Promise<num
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
if (items.length === 0) return 0;
const sourcesById = new Map(sourcesDb.listSources().map((s) => [s.id, s]));
const categories = categoriesDb.listCategories();
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
const ranked = items
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) }))
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName, sourcesById) }))
.sort((a, b) => a.rank - b.rank)
.map((r) => r.item);
let published = 0;
for (const item of ranked) {
try {
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)`);
} catch (err) {
logger.error('synthesis', `Passthrough publish failed for "${item.title}": ${(err as Error).message}`);
}
}
return published;
return publishItemsDirect(ranked, settings, activeEvents, () => 'no AI available', 'Passthrough publish failed');
}
/**
@@ -96,36 +110,32 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
const items = contentItemsDb.unclusteredItemsExcludingSources([]);
if (items.length === 0) return 0;
// One fetch of the full source list per cycle, reused below for both the
// direct-publish partition and each item's category/type lookups — avoids a
// separate sourcesDb.getSource() round-trip per item.
const sourcesById = new Map(sourcesDb.listSources().map((s) => [s.id, s]));
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
// anything else — each is always its own article, same shape whether the AI service
// is up or not. Route them straight to publishDirect, same as the no-AI passthrough path.
const directPublishSourceIds = new Set(
sourcesDb
.listSources()
.filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram')
.map((s) => s.id)
[...sourcesById.values()].filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram').map((s) => s.id)
);
const [directItems, mergeableItems] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
let publishedDirect = 0;
for (const item of directItems) {
try {
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);
logger.info('synthesis', `Published "${article.title}" directly (${source?.type ?? 'unknown'})`);
} catch (err) {
logger.error('synthesis', `Direct publish failed for "${item.title}": ${(err as Error).message}`);
}
}
const publishedDirect = await publishItemsDirect(
directItems,
settings,
activeEvents,
(item) => sourcesById.get(item.sourceId)?.type ?? 'unknown',
'Direct publish failed'
);
const categories = categoriesDb.listCategories();
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
const ranked = mergeableItems
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName) }))
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName, sourcesById) }))
.sort((a, b) => a.rank - b.rank)
.map((r) => r.item);
+18 -6
View File
@@ -24,8 +24,7 @@ export function startScheduler() {
setInterval(async () => {
try {
const settings = settingsDb.getSettings();
const ingested = await pollDueSources(settings.defaultPollIntervalMinutes);
const ingested = await pollDueSources();
if (ingested > 0) logger.info('scheduler', `Poll tick: ingested ${ingested} new item(s)`);
} catch (err) {
logger.error('scheduler', `Poll tick failed: ${(err as Error).message}`);
@@ -70,19 +69,32 @@ export function startScheduler() {
// Immediate first call for all three — unlike RSS sources (whose "due" check makes a
// brand-new source eligible on the very next 1-minute tick), weather/stocks/poe2 have
// no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after
// every restart.
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
// every restart. Each is also gated on its Widgets-tab enabled flag (see
// admin/settings' consolidated Widgets tab) — disabling a widget stops these external
// calls entirely rather than just hiding the sidebar box, so there's no pointless
// polling for something nobody's looking at. Re-enabling it triggers an immediate
// poll instead (see admin.ts's PATCH /api/admin/settings), same as this initial call.
if (settingsDb.getSettings().widgets.weather) {
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
}
setInterval(() => {
if (!settingsDb.getSettings().widgets.weather) return;
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
}, WEATHER_TICK_MS);
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
if (settingsDb.getSettings().widgets.stocks) {
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
}
setInterval(() => {
if (!settingsDb.getSettings().widgets.stocks) return;
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
}, STOCKS_TICK_MS);
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
if (settingsDb.getSettings().widgets.poe2) {
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
}
setInterval(() => {
if (!settingsDb.getSettings().widgets.poe2) return;
pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`));
}, POE2_TICK_MS);
-7
View File
@@ -143,13 +143,6 @@ export function articlesForEventSince(eventId: string, since: string): MergedArt
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')
.get(threadId);
return row ? rowToArticle(row) : null;
}
export function articlesOlderThan(days: number): MergedArticle[] {
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
const rows = db.prepare('SELECT * FROM merged_articles WHERE published_at < ?').all(cutoff);
-18
View File
@@ -69,15 +69,6 @@ export function unclusteredItemsExcludingSources(excludeSourceIds: string[]): Co
return items.filter((i) => !excludeSourceIds.includes(i.sourceId));
}
export function unclusteredItemsForSources(sourceIds: string[], sinceISO: string): ContentItem[] {
if (sourceIds.length === 0) return [];
const placeholders = sourceIds.map(() => '?').join(',');
const rows = db
.prepare(`SELECT * FROM content_items WHERE cluster_id IS NULL AND source_id IN (${placeholders}) AND fetched_at > ?`)
.all(...sourceIds, sinceISO);
return rows.map(rowToItem);
}
export function setEmbedding(id: string, embedding: number[]) {
db.prepare('UPDATE content_items SET embedding = ? WHERE id = ?').run(JSON.stringify(embedding), id);
}
@@ -93,11 +84,6 @@ export function resetClusterForItems(ids: string[]) {
for (const id of ids) stmt.run(id);
}
export function itemsByCluster(clusterId: string): ContentItem[] {
const rows = db.prepare('SELECT * FROM content_items WHERE cluster_id = ?').all(clusterId);
return rows.map(rowToItem);
}
export function itemsOlderThan(days: number): ContentItem[] {
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
const rows = db.prepare('SELECT * FROM content_items WHERE fetched_at < ?').all(cutoff);
@@ -117,7 +103,3 @@ export function itemsForSource(sourceId: string): ContentItem[] {
export function deleteContentItemsForSource(sourceId: string) {
db.prepare('DELETE FROM content_items WHERE source_id = ?').run(sourceId);
}
export function deleteAllContentItems() {
db.prepare('DELETE FROM content_items').run();
}
+8 -8
View File
@@ -9,9 +9,9 @@ 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,
lastRecapAt: row.last_recap_at,
createdAt: row.created_at
@@ -52,7 +52,7 @@ 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)
`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,
@@ -60,9 +60,9 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
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,
now
);
@@ -74,15 +74,15 @@ 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=?, 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,
merged.lastRecapAt,
id
+25 -19
View File
@@ -37,7 +37,7 @@ export function migrate() {
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL, -- rss | api | telegram | custom
type TEXT NOT NULL, -- rss | api | telegram | youtube | nitter
category TEXT NOT NULL DEFAULT '[]', -- JSON array
url TEXT,
config TEXT NOT NULL DEFAULT '{}', -- JSON: apiKey, telegramChannelId, authHeaders
@@ -130,9 +130,9 @@ 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,
last_recap_at TEXT,
created_at TEXT NOT NULL
@@ -168,7 +168,6 @@ export function migrate() {
CREATE TABLE IF NOT EXISTS global_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
merge_strictness INTEGER NOT NULL DEFAULT 3,
default_poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
hold_before_publish_minutes INTEGER NOT NULL DEFAULT 30,
tag_dedup_threshold REAL NOT NULL DEFAULT 0.82,
tag_expiry_days INTEGER NOT NULL DEFAULT 21,
@@ -185,6 +184,11 @@ export function migrate() {
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
widget_weather_enabled INTEGER NOT NULL DEFAULT 1,
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
widget_poe2_enabled INTEGER NOT NULL DEFAULT 1,
widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]', -- JSON array, admin-sortable via the Widgets tab
weather_location_name TEXT,
weather_latitude REAL,
weather_longitude REAL,
@@ -200,7 +204,6 @@ export function migrate() {
weather_updated_at TEXT, -- ISO timestamp, NULL pre-first-poll
poe2_league_id TEXT,
poe2_league_name TEXT,
poe2_primary_currency_name TEXT, -- unused since the watchlist moved to arbitrary currency pairs (no single "quoted in" currency anymore) — column kept rather than dropped, SQLite ALTER TABLE can't drop columns without a full table rebuild
poe2_updated_at TEXT
);
@@ -326,6 +329,12 @@ 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('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');
}
@@ -347,9 +356,19 @@ export function migrate() {
if (!hasColumn('global_settings', 'poe2_league_id')) {
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_id TEXT');
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_name TEXT');
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_primary_currency_name TEXT');
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_updated_at TEXT');
}
if (!hasColumn('global_settings', 'widget_weather_enabled')) {
db.exec('ALTER TABLE global_settings ADD COLUMN widget_weather_enabled INTEGER NOT NULL DEFAULT 1');
db.exec('ALTER TABLE global_settings ADD COLUMN widget_stocks_enabled INTEGER NOT NULL DEFAULT 1');
db.exec('ALTER TABLE global_settings ADD COLUMN widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1');
db.exec('ALTER TABLE global_settings ADD COLUMN widget_poe2_enabled INTEGER NOT NULL DEFAULT 1');
}
if (!hasColumn('global_settings', 'widget_order')) {
db.exec(
`ALTER TABLE global_settings ADD COLUMN widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]'`
);
}
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
// fresh install — the admin can remove/replace any of them via the Stocks tab.
@@ -368,19 +387,6 @@ export function migrate() {
});
}
// Stocks switched data providers from Stooq (walled off behind a proof-of-work
// challenge) to Yahoo Finance, which uses different symbol syntax — rewrites only
// rows still holding exactly one of the three old Stooq-format default symbols we
// ourselves seeded, never touching a symbol the admin typed in themselves.
const stooqToYahooSymbols: [string, string][] = [
['^dji', '^DJI'],
['^spx', '^GSPC'],
['btcusd', 'BTC-USD']
];
for (const [oldSymbol, newSymbol] of stooqToYahooSymbols) {
db.prepare('UPDATE stock_tickers SET symbol = ? WHERE symbol = ?').run(newSymbol, oldSymbol);
}
// 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
// filterable tag: it's the homepage view, now scoped to only the articles whose
+66 -46
View File
@@ -4,7 +4,6 @@ import type { GlobalSettings } from './types.js';
function rowToSettings(row: any): GlobalSettings {
return {
mergeStrictness: row.merge_strictness,
defaultPollIntervalMinutes: row.default_poll_interval_minutes,
holdBeforePublishMinutes: row.hold_before_publish_minutes,
tagDedupThreshold: row.tag_dedup_threshold,
tagExpiryDays: row.tag_expiry_days,
@@ -16,6 +15,13 @@ function rowToSettings(row: any): GlobalSettings {
nitterMediaMode: row.nitter_media_mode,
fxtwitterBaseUrl: row.fxtwitter_base_url,
telegramMediaMode: row.telegram_media_mode,
widgets: {
weather: !!row.widget_weather_enabled,
stocks: !!row.widget_stocks_enabled,
bookmarks: !!row.widget_bookmarks_enabled,
poe2: !!row.widget_poe2_enabled
},
widgetOrder: JSON.parse(row.widget_order),
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
@@ -58,54 +64,68 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) },
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
};
// Named params (rather than positional `?`) so this list can be reordered or
// extended without the column list and the bound-values list silently drifting
// out of sync — node:sqlite binds each by its `$name` key, not position.
db.prepare(
`UPDATE global_settings SET
merge_strictness=?, default_poll_interval_minutes=?, hold_before_publish_minutes=?,
tag_dedup_threshold=?, tag_expiry_days=?, follow_up_min_hours_since_last=?, follow_up_min_new_sources=?,
ai_service_host=?, ai_service_port=?, selected_models=?,
nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?,
published_article_max_age_days=?, raw_item_max_age_days=?,
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
weather_wind_unit=?, weather_pressure_unit=?,
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?,
poe2_league_id=?, poe2_league_name=?, poe2_updated_at=?
merge_strictness=$merge_strictness,
hold_before_publish_minutes=$hold_before_publish_minutes,
tag_dedup_threshold=$tag_dedup_threshold, tag_expiry_days=$tag_expiry_days,
follow_up_min_hours_since_last=$follow_up_min_hours_since_last, follow_up_min_new_sources=$follow_up_min_new_sources,
ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models,
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, telegram_media_mode=$telegram_media_mode,
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
widget_order=$widget_order,
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit,
weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude,
weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit,
weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily,
weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at,
poe2_league_id=$poe2_league_id, poe2_league_name=$poe2_league_name, poe2_updated_at=$poe2_updated_at
WHERE id = 1`
).run(
merged.mergeStrictness,
merged.defaultPollIntervalMinutes,
merged.holdBeforePublishMinutes,
merged.tagDedupThreshold,
merged.tagExpiryDays,
merged.followUpMinHoursSinceLast,
merged.followUpMinNewSources,
merged.aiServiceHost,
merged.aiServicePort,
JSON.stringify(merged.selectedModels),
merged.nitterMediaMode,
merged.fxtwitterBaseUrl,
merged.telegramMediaMode,
merged.retention.publishedArticleMaxAgeDays,
merged.retention.rawItemMaxAgeDays,
merged.retention.storageCapEnabled ? 1 : 0,
merged.retention.storageCapValue,
merged.retention.storageCapUnit,
merged.weather.locationName,
merged.weather.latitude,
merged.weather.longitude,
merged.weather.unit,
merged.weather.windUnit,
merged.weather.pressureUnit,
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
JSON.stringify(merged.weather.hourly),
JSON.stringify(merged.weather.daily),
JSON.stringify(merged.weather.alerts),
merged.weather.updatedAt,
merged.poe2.leagueId,
merged.poe2.leagueName,
merged.poe2.updatedAt
);
).run({
$merge_strictness: merged.mergeStrictness,
$hold_before_publish_minutes: merged.holdBeforePublishMinutes,
$tag_dedup_threshold: merged.tagDedupThreshold,
$tag_expiry_days: merged.tagExpiryDays,
$follow_up_min_hours_since_last: merged.followUpMinHoursSinceLast,
$follow_up_min_new_sources: merged.followUpMinNewSources,
$ai_service_host: merged.aiServiceHost,
$ai_service_port: merged.aiServicePort,
$selected_models: JSON.stringify(merged.selectedModels),
$nitter_media_mode: merged.nitterMediaMode,
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
$telegram_media_mode: merged.telegramMediaMode,
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
$widget_order: JSON.stringify(merged.widgetOrder),
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
$storage_cap_value: merged.retention.storageCapValue,
$storage_cap_unit: merged.retention.storageCapUnit,
$weather_location_name: merged.weather.locationName,
$weather_latitude: merged.weather.latitude,
$weather_longitude: merged.weather.longitude,
$weather_unit: merged.weather.unit,
$weather_wind_unit: merged.weather.windUnit,
$weather_pressure_unit: merged.weather.pressureUnit,
$weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null,
$weather_hourly: JSON.stringify(merged.weather.hourly),
$weather_daily: JSON.stringify(merged.weather.daily),
$weather_alerts: JSON.stringify(merged.weather.alerts),
$weather_updated_at: merged.weather.updatedAt,
$poe2_league_id: merged.poe2.leagueId,
$poe2_league_name: merged.poe2.leagueName,
$poe2_updated_at: merged.poe2.updatedAt
});
return getSettings();
}
+3 -4
View File
@@ -89,12 +89,11 @@ export function markPolled(id: string, error: string | null) {
);
}
/** Sources due for polling right now, based on their own interval (or the global default). */
export function sourcesDueForPoll(defaultIntervalMinutes: number): Source[] {
/** Sources due for polling right now, based on their own interval. */
export function sourcesDueForPoll(): Source[] {
const now = Date.now();
return listEnabledSources().filter((s) => {
if (!s.lastPolledAt) return true;
const interval = (s.pollIntervalMinutes || defaultIntervalMinutes) * 60_000;
return now - new Date(s.lastPolledAt).getTime() >= interval;
return now - new Date(s.lastPolledAt).getTime() >= s.pollIntervalMinutes * 60_000;
});
}
-7
View File
@@ -28,13 +28,6 @@ export function listActiveTags(): Tag[] {
return rows.map(rowToTag);
}
export function getTagsByIds(ids: string[]): Tag[] {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(',');
const rows = db.prepare(`SELECT * FROM tags WHERE id IN (${placeholders})`).all(...ids);
return rows.map(rowToTag);
}
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || b.length === 0 || a.length !== b.length) return 0;
let dot = 0,
+14 -4
View File
@@ -1,7 +1,7 @@
export interface Source {
id: string;
name: string;
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter' | 'custom';
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter';
category: string[];
url: string | null;
config: Record<string, unknown>;
@@ -178,9 +178,11 @@ 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;
retentionOverrideDays: number | null;
lastRecapAt: string | null;
createdAt: string;
@@ -265,7 +267,6 @@ export interface Bookmark {
export interface GlobalSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
holdBeforePublishMinutes: number;
tagDedupThreshold: number;
tagExpiryDays: number;
@@ -280,6 +281,15 @@ export interface GlobalSettings {
fxtwitterBaseUrl: string;
/** How Telegram message media (attached photos/videos, channel avatars) is served — see pipeline/publish.ts's resolveTelegramMedia. No "direct" option: Telegram has no public hotlinkable media URL, bytes only come from the authenticated MTProto session. */
telegramMediaMode: 'self-host' | 'proxy';
/** Per-widget enable flags — see admin/settings' consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers (scheduler.ts) are gated on these too, not just sidebar visibility; Bookmarks has no poller so its flag only affects the sidebar. */
widgets: {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
};
/** Sidebar widget display order, admin-sortable via the Widgets tab's up/down arrows — mirrored exactly by Sidebar.svelte. */
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
+230
View File
@@ -0,0 +1,230 @@
# Homefeed program review — July 2026
Scope: dead code, inefficiency, and UI/system consistency across the whole app
(backend + frontend, public site + admin panel), as requested after a long run of
incremental feature work. Findings below are left as originally written (historical
record); the "Resolved since this report" section tracks what's since been acted on.
## Resolved since this report
- **1.1** `ArticleCard.svelte` — deleted.
- **1.2** All five zero-call-site exports (`getTagsByIds`, `unclusteredItemsForSources`,
`itemsByCluster`, `deleteAllContentItems`, `latestArticleInThread`) — removed.
- **1.3** `poe2_primary_currency_name` — dropped from the schema entirely (CREATE TABLE
literal + the ALTER TABLE migration line), not just left as an inert column.
- **Inefficiencies** — the unconditional `stooqToYahooSymbols` startup rewrite
(long-since a no-op) was deleted outright; `settings.ts:updateSettings` now binds
named `$column` parameters instead of 33 positional `?`s, removing the
reorder-and-silently-corrupt risk; `runPassthroughCycle`/`runSynthesisCycle`'s
direct-publish logic now share one `publishItemsDirect` helper; the per-item
`sourcesDb.getSource()` N+1 in the synthesis tick was replaced with a single
`sourcesDb.listSources()` map reused for both the direct-publish partition and each
item's category-rank lookup. (`settings.ts`'s remaining structural size and
`migrate()`'s 410-line mixed-idiom growth were left as-is — real fixes but a larger
refactor than this pass warranted.)
- **`'custom'` source type** — removed entirely: dropped from the `Source['type']`
union (backend + frontend), the poller's adapter map, and the admin add-source
dropdown. It had no adapter of its own (silently aliased to the API adapter) and no
admin-facing purpose distinct from `api`.
Everything above was verified via `tsc --noEmit`/`svelte-check` (0 errors on both
sides), a full build of both packages, and a live smoke test against a fresh DB
(migrate() + a settings roundtrip through the new named-param query, confirming the
column is gone and unrelated fields are untouched).
See the **staleness/refresh recommendation** delivered separately in conversation —
that item was discussed, not changed, per the request to advise rather than implement.
---
## 1. Dead code
### 1.1 Frontend
- `frontend/src/lib/components/ArticleCard.svelte` — zero references anywhere under
`frontend/src` (confirmed via `grep -rn "ArticleCard" frontend/src`, zero matches
outside its own file). Superseded by `ArticleListRow.svelte`. Safe to delete.
### 1.2 Backend
Exported functions with no call sites found anywhere in `backend/src`:
- `backend/src/storage/db/tags.ts:31``getTagsByIds`
- `backend/src/storage/db/contentItems.ts:72``unclusteredItemsForSources`
- `backend/src/storage/db/contentItems.ts:96``itemsByCluster`
- `backend/src/storage/db/contentItems.ts:121``deleteAllContentItems`
- `backend/src/storage/db/articles.ts:146``latestArticleInThread`
### 1.3 Dead settings field
- `global_settings.poe2_primary_currency_name` — left over from before PoE2 moved to
per-pair tracking (base/quote currencies with a directly-computed rate, no longer
"everything quoted in one reference currency"). The column is harmless — dropping it
would mean a SQLite table rebuild for one nullable text column, not worth doing on
its own. `GlobalSettings.poe2` on the TS side no longer exposes it, so it's already
fully inert; only the raw DB column remains as an artifact.
### 1.4 Over-exported (not dead, but worth a look)
These are only ever called from within their own module, so exporting them invites
cross-module coupling that hasn't happened yet but could:
- `backend/src/storage/db/sources.ts:27``listEnabledSources`
- `backend/src/storage/db/events.ts:46``getEvent`
- `backend/src/clustering.ts:9``strictnessToThreshold`
---
## 2. Inefficiencies
- **`backend/src/storage/db/index.ts:375-382`** — the `stooqToYahooSymbols` stock
symbol rewrite runs unconditionally on every server start, forever, with no
skip-guard once it's already been applied to a given database. Same category of
issue as the unconditional `DROP TABLE IF EXISTS admin_users` / `admin_sessions` at
lines 25-26 — both re-run one-time work on every boot instead of gating it behind a
"have I already done this" check.
- **`backend/src/storage/db/settings.ts:updateSettings` (lines 77-109)** — 33
positional `?` SQL parameters matched to 33 arguments passed to `.run()` in the same
order, with nothing enforcing that the two lists stay in sync if either is edited.
Not currently broken, but a single misordered edit here would silently write the
wrong value into the wrong column with no type error to catch it.
- Synthesis's 60-second tick calls `sourcesDb.listSources()` on every iteration, which
is redundant if the source list hasn't changed since the last tick — a small,
low-priority optimization.
- `runPassthroughCycle` and `runSynthesisCycle`'s "direct publish" halves are
near-duplicate copy-pasted logic rather than a shared helper.
- The `migrate()` function (410 lines) mixes four different guarded/unguarded
backfill idioms with no schema-version tracking, which makes it hard to tell at a
glance whether a given block still needs to run or is a no-op on any DB that's
already current.
---
## 3. UI / layout consistency audit (public site)
- **`/weather` is not reachable from the top nav** — it's only linkable via the
sidebar widget, unlike every other route (home, categories, events, `/more`), which
are all in top nav.
- **Sidebar widgets don't share a component.** Weather, Stocks, and PoE2 each
hand-roll their own `.widget` shell but do converge on the same `.head` +
interval-tag pattern (`today`, `24h`, etc.). `BookmarksWidget` is the outlier: it
lacks that `.head`/interval wrapper the other three use, so it looks structurally
different from its neighbors in the same sidebar.
- Confirmed: the PoE2 icon-removal and 24h-only simplification from earlier this
session are fully complete on the frontend (verified via grep — no remaining icon
references).
---
## 4. UI / layout consistency audit (admin panel)
- **Near-duplicate CRUD tabs.** `StocksTab.svelte` and `BookmarksTab.svelte` are
close to byte-identical in structure (add row, list, delete button). `Poe2Tab.svelte`
is a variant of the same pattern; `WeatherTab.svelte` uses a different
settings-form pattern entirely. None of the three share a common component, so a fix
to one (e.g. the null/`—` display bug fixed in Poe2Tab this session) doesn't
propagate to the others even where the same bug class could exist.
- **Admin tab list has an implicit, unlabeled grouping** — Sources & Content /
Sidebar widgets (Weather, Stocks, PoE2, Bookmarks) / Integrations (Telegram) /
System (Logs, Settings) — but the tab bar renders them as one flat list with no
visual separation or heading, so the grouping only exists in the code's mental
model, not on screen.
- **`/admin` itself is a dead redirect-only render** — it exists only to bounce to
`/admin/settings` or another tab, with no content of its own.
---
## 5. Cross-system / type consistency findings
### 5.1 Duplicated types
- `WeatherHourEntry`, `WeatherDayEntry`, `WeatherCurrentConditions`, and
`WeatherAlert` are byte-identical between `frontend/src/lib/types.ts` and
`adminTypes.ts` — genuine duplication, not just similar shapes.
- `Category` and `CategoryPriority` are same-shape-but-differently-named — worth
confirming whether that's intentional (different semantic roles) or just drift from
copy-paste.
- Backend-side: `Tweet`/Telegram-message inline shapes are duplicated between
`ContentItem` and `MergedArticle` in `backend/src/storage/db/types.ts` (lines
78-108 vs. 125-143) rather than being extracted into a shared named interface.
### 5.2 CSS token drift
- A `12px` "card radius" value is hardcoded across roughly 20 files instead of using
the existing `var(--radius)` token (which is 8px) — meaning cards and the
token-driven `--radius` elements don't actually share one visual language despite
looking like they should.
- `LogsTab.svelte:99-101,157` hardcodes `#a8710f`/`#fff` for the "warn" log-level
color rather than using a token, unlike "err" which correctly uses `--text-danger`.
There's no `--text-warning` token defined to match.
### 5.3 Ingestion pipeline (traced end-to-end)
Flow: adapter → poller → priority queue → clustering/synthesis (or direct-publish
bypass) → articles table → public feed. The telegram/nitter/youtube direct-publish
bypass (skipping clustering/synthesis entirely for those source types) is coherent
and already explicitly commented as intentional — not an oversight, despite looking
unusual at first glance.
One asymmetry does look like a genuine oversight rather than a documented design
choice: the `'custom'` source type is excluded from both
`FOLLOWS_LINK_FOR_FULL_ARTICLE` (`backend/src/poller.ts:24`, which only lists
`['rss', 'api']`) and from `directPublishSourceIds` in
`backend/src/queue/priorityQueue.ts` — with no comment anywhere explaining why
`custom` is treated differently from `rss`, which otherwise behaves the same way a
`custom` source presumably would.
### 5.4 Frontend staleness beyond the sidebar
This session added a 5-minute `invalidate('app:sidebar')` timer
(`frontend/src/routes/+layout.svelte` / `+layout.ts`) so the sidebar widgets stay
fresh in a tab left open. That is the **only** refresh mechanism anywhere in the
frontend. Every other route — home (`/`), `/category/[name]`, `/event/[id]`,
`/article/[id]`, `/more`, and notably **`/admin/settings`** — is a pure one-shot
`load()` with no refresh at all. This is fine for content pages (a stale article list
is a minor nuisance, fixed by navigating), but is a real staleness risk specifically
for `/admin/settings`: an admin who leaves that page open to watch Logs, AI-status,
or Telegram connection status will see indefinitely stale state with no signal that
it's stale.
### 5.5 Public API field leak (most concrete/actionable finding)
`backend/src/api/public.ts` — the categories, bookmarks, and events routes all
explicitly filter their response shape down to public-safe fields:
```ts
// lines ~48-51, 57-61, 68-72 — filtered
app.get('/api/events', async () => eventsDb.listEvents().filter(e => e.active).map(({ id, name, ... }) => ({ id, name, ... })));
```
But stocks and PoE2 do not:
```ts
// line 66
app.get('/api/stocks', async () => stocksDb.listStockTickers());
// lines 74-77
app.get('/api/poe2', async () => {
const { leagueName, updatedAt } = settingsDb.getSettings().poe2;
return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() };
});
```
`listStockTickers()` and `listWatchlist()` return the full DB row, including
`lastError` and `lastPolledAt` — internal poller-diagnostic fields with no reason to
be visible to an unauthenticated visitor. This is inconsistent with how every other
public route in the same file handles the same concern, and is a straightforward fix:
map each entry down to its public-facing fields the same way categories/bookmarks/
events already do.
---
## Appendix: surveyed and found fine
Recorded here so these don't get re-litigated in a future review:
- **Logging** — a single shared `logger`, consistent lowercase tags, and only two
`console.log` calls outside of it, both explicitly commented as intentional
exceptions (in `index.ts`).
- **API route organization** — `admin.ts` (329 lines / 38 routes) is cleanly
sectioned into 11 logical groups; `public.ts` (78 lines / 9 routes) has no
duplicated logic. 52 routes total, no redundant endpoints found.
- **Scheduler cadences** — weather (45m), stocks (15m), and PoE2 (1h) all poll
immediately on start by design; the ingestion poll/synthesis loop (60s) and
retention sweep (1h) deliberately don't, also by design. No redundant external
fetches were found across the whole scheduler table.
- **Module organization pattern** — the `client.ts` (raw external API) +
`poller.ts` (orchestration) split is followed consistently for weather, stocks, and
PoE2. Telegram is the one deliberate exception (a stateful GramJS client/credentials
singleton, ingested through the generic per-source `ingestion/poller.ts` rather than
`queue/scheduler.ts`) — this is explicitly commented in the code as an intentional
divergence, not an inconsistency to fix.
+13 -4
View File
@@ -118,9 +118,15 @@ export interface AdminPoe2Settings {
updatedAt: string | null;
}
export interface AdminWidgetsEnabled {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
}
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
holdBeforePublishMinutes: number;
tagDedupThreshold: number;
tagExpiryDays: number;
@@ -132,6 +138,8 @@ export interface AdminSettings {
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
fxtwitterBaseUrl: string;
telegramMediaMode: 'self-host' | 'proxy';
widgets: AdminWidgetsEnabled;
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
@@ -141,7 +149,7 @@ export interface AdminSettings {
export interface AdminSource {
id: string;
name: string;
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter' | 'custom';
type: 'rss' | 'api' | 'telegram' | 'youtube' | 'nitter';
category: string[];
url: string;
config?: Record<string, unknown>;
@@ -159,9 +167,10 @@ 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 -1
View File
@@ -1,5 +1,5 @@
import { getBackendUrl } from './config';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data } from './types';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from './types';
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
@@ -57,3 +57,7 @@ export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
return get<Poe2Data>('/api/poe2', fetchFn);
}
export function getWidgetsEnabled(fetchFn?: typeof fetch): Promise<WidgetsEnabled> {
return get<WidgetsEnabled>('/api/widgets', fetchFn);
}
@@ -1,76 +0,0 @@
<script lang="ts">
import type { MergedArticle } from '$lib/types';
import { timeAgo } from '$lib/format';
let { article }: { article: MergedArticle } = $props();
const sourceLabel = $derived(
article.sourceCount > 1
? `${article.sourceCount} sources`
: (article.sources[0]?.sourceName ?? 'Single source')
);
</script>
<a class="card" href={`/article/${article.id}`}>
{#if article.heroImage}
<img class="thumb" src={article.heroImage.url} alt="" loading="lazy" />
{:else}
<div class="thumb placeholder"></div>
{/if}
<div class="meta">
{#if article.video}
<i class="tag-icon"></i> Video
{:else if article.sourceCount > 1}
<i class="tag-icon"></i> {sourceLabel}
{:else}
{sourceLabel}
{/if}
</div>
<div class="title">{article.title}</div>
<div class="sub">{timeAgo(article.publishedAt)}</div>
</a>
<style>
.card {
display: block;
color: inherit;
}
.card:hover {
text-decoration: none;
}
.card:hover .title {
text-decoration: underline;
}
.thumb {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: var(--radius);
margin-bottom: 8px;
background: var(--surface-1);
}
.thumb.placeholder {
display: block;
}
.meta {
font-size: 11px;
color: var(--text-accent);
margin-bottom: 3px;
display: flex;
align-items: center;
gap: 4px;
}
.tag-icon {
font-style: normal;
}
.title {
font-size: 14px;
font-weight: 500;
line-height: 1.35;
margin-bottom: 4px;
}
.sub {
font-size: 11px;
color: var(--text-muted);
}
</style>
@@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Same collapsed-by-default shell as WidgetSection, minus the enable/disable
// checkbox — for sections that are always "on" (Category priority, Sources) and
// just need to stay out of the way until expanded.
let {
title,
defaultExpanded = false,
children
}: {
title: string;
defaultExpanded?: boolean;
children: Snippet;
} = $props();
let expanded = $state(defaultExpanded);
</script>
<div class="section">
<button class="section-head" onclick={() => (expanded = !expanded)} aria-expanded={expanded}>
<span class="chevron" class:open={expanded}>▸</span>
<span class="title">{title}</span>
</button>
{#if expanded}
<div class="section-body">
{@render children()}
</div>
{/if}
</div>
<style>
.section {
background: var(--surface-1);
border-radius: 12px;
margin-bottom: 14px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: transparent;
border: none;
padding: 12px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
text-align: left;
}
.chevron {
display: inline-block;
font-size: 10px;
color: var(--text-muted);
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.section-body {
padding: 14px;
padding-top: 0;
border-top: 0.5px solid var(--border);
}
.section-body > :global(*:first-child) {
margin-top: 14px;
}
</style>
@@ -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: '', cadence: 'daily' as AdminTrackedEvent['cadence'], cadenceTime: '18:00' });
// 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,17 +16,13 @@
description: '',
sourceIdSet: new Set<string>(),
keywordsText: '',
cadence: 'daily' as AdminTrackedEvent['cadence'],
cadenceTime: '18:00',
recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'],
isSpillover: false,
retentionOverrideDays: null as number | null
};
}
let editForm = $state(emptyEditForm());
function sourceNames(ids: string[]) {
return ids.map((id) => sources.find((s) => s.id === id)?.name).filter(Boolean).join(', ') || 'No sources assigned';
}
async function handleAdd() {
if (!newEvent.name) return;
const created = await addEvent({
@@ -32,12 +30,12 @@
description: '',
sourceIds: [],
keywords: [],
cadence: newEvent.cadence,
cadenceTime: newEvent.cadence === 'daily' ? newEvent.cadenceTime : null,
recapIntervalHours: newEvent.recapIntervalHours,
isSpillover: false,
retentionOverrideDays: null
});
events = [...events, created];
newEvent = { name: '', cadence: 'daily', cadenceTime: '18:00' };
newEvent = { name: '', recapIntervalHours: null };
showAdd = false;
}
@@ -58,8 +56,8 @@
description: event.description,
sourceIdSet: new Set(event.sourceIds),
keywordsText: event.keywords.join(', '),
cadence: event.cadence,
cadenceTime: event.cadenceTime ?? '18:00',
recapIntervalHours: event.recapIntervalHours,
isSpillover: event.isSpillover,
retentionOverrideDays: event.retentionOverrideDays
};
}
@@ -86,8 +84,8 @@
description: editForm.description,
sourceIds: [...editForm.sourceIdSet],
keywords,
cadence: editForm.cadence,
cadenceTime: editForm.cadence === 'daily' ? editForm.cadenceTime : null,
recapIntervalHours: editForm.recapIntervalHours,
isSpillover: editForm.isSpillover,
retentionOverrideDays: editForm.retentionOverrideDays
});
events = events.map((e) => (e.id === editingId ? updated : e));
@@ -96,22 +94,31 @@
</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}>
<option value="continuous">Continuous</option>
<option value="daily">Daily</option>
<option value="hourly">Hourly</option>
<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 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>
{#if newEvent.cadence === 'daily'}
<input type="text" placeholder="18:00" bind:value={newEvent.cadenceTime} />
{/if}
<p class="hint">
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">
<button onclick={() => (showAdd = false)}>Cancel</button>
@@ -126,15 +133,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 +155,33 @@
<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 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>
<p class="hint">
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>
@@ -170,12 +192,7 @@
<div>
<div class="name">{event.name}</div>
<div class="sub">
{sourceNames(event.sourceIds)}
{#if event.keywords.length > 0}
· matching {event.keywords.map((k) => `"${k}"`).join(', ')}
{/if}
·
{event.cadence === 'daily' ? `daily recap at ${event.cadenceTime}` : event.cadence}
{event.sourceIds.length} source{event.sourceIds.length === 1 ? '' : 's'} active
</div>
</div>
<span class="badge" class:active={event.active} onclick={() => toggleActive(event)} role="button" tabindex="0">
@@ -314,4 +331,27 @@
.edit-panel .hint {
margin: 6px 0 10px;
}
.cadence-block {
margin-top: 12px;
padding-top: 12px;
border-top: 0.5px solid var(--border);
}
.cadence-block .hint {
margin-top: 6px;
}
.edit-spillover {
display: flex;
margin-top: 12px;
}
.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>
@@ -1,9 +1,11 @@
<script lang="ts">
import type { AdminSettings } from '$lib/adminTypes';
import type { AdminSettings, AdminSource } from '$lib/adminTypes';
import { updateSettings, createCategory, deleteCategory } from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte';
import CollapsibleSection from './CollapsibleSection.svelte';
import SourcesTab from './SourcesTab.svelte';
let { settings }: { settings: AdminSettings } = $props();
let { settings, sources }: { settings: AdminSettings; sources: AdminSource[] } = $props();
let local = $state({ ...settings, categoryPriority: [...settings.categoryPriority] });
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
@@ -26,7 +28,6 @@
try {
await updateSettings({
mergeStrictness: local.mergeStrictness,
defaultPollIntervalMinutes: local.defaultPollIntervalMinutes,
holdBeforePublishMinutes: local.holdBeforePublishMinutes,
followUpMinHoursSinceLast: local.followUpMinHoursSinceLast,
followUpMinNewSources: local.followUpMinNewSources,
@@ -89,78 +90,7 @@
}
</script>
<div class="panel">
<div class="head">
<span class="panel-title">Merge strictness</span>
<SaveStatus {status} />
</div>
<p class="hint">How similar articles must be before they're combined into one story.</p>
<div class="slider-row">
<span class="end">Loose</span>
<input
type="range"
min="1"
max="5"
step="1"
bind:value={local.mergeStrictness}
oninput={scheduleSave}
/>
<span class="end">Strict</span>
<span class="value">{local.mergeStrictness}</span>
</div>
</div>
<div class="grid-2">
<div class="panel">
<span class="panel-title">Poll interval</span>
<p class="hint">How often each source is checked for new items.</p>
<select bind:value={local.defaultPollIntervalMinutes} onchange={scheduleSave}>
<option value={5}>Every 5 minutes</option>
<option value={15}>Every 15 minutes</option>
<option value={60}>Every hour</option>
</select>
</div>
<div class="panel">
<span class="panel-title">Hold before publish</span>
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
<select bind:value={local.holdBeforePublishMinutes} onchange={scheduleSave}>
<option value={0}>Publish immediately</option>
<option value={30}>Wait 30 minutes</option>
<option value={120}>Wait 2 hours</option>
</select>
</div>
</div>
<div class="panel">
<span class="panel-title">Follow-up articles</span>
<p class="hint">
Instead of editing a published article, a distinct follow-up is created once enough new
corroborating sources arrive after enough time has passed.
</p>
<div class="grid-2">
<div>
<label class="field-label" for="followup-hours">Minimum time since last article</label>
<select id="followup-hours" bind:value={local.followUpMinHoursSinceLast} onchange={scheduleSave}>
<option value={1}>1 hour</option>
<option value={6}>6 hours</option>
<option value={12}>12 hours</option>
<option value={24}>24 hours</option>
</select>
</div>
<div>
<label class="field-label" for="followup-sources">Minimum new sources</label>
<select id="followup-sources" bind:value={local.followUpMinNewSources} onchange={scheduleSave}>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</div>
</div>
</div>
<div class="panel">
<span class="panel-title">Category priority</span>
<CollapsibleSection title="Category priority">
<p class="hint">
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
categories just wait longer when the queue is busy. This list also drives the site's nav —
@@ -225,6 +155,69 @@
{addingCategory ? 'Adding…' : '+ Add'}
</button>
</div>
</CollapsibleSection>
<CollapsibleSection title="Sources">
<SourcesTab {sources} categories={local.categoryPriority} />
</CollapsibleSection>
<div class="panel">
<div class="head">
<span class="panel-title">Merge strictness</span>
<SaveStatus {status} />
</div>
<p class="hint">How similar articles must be before they're combined into one story.</p>
<div class="slider-row">
<span class="end">Loose</span>
<input
type="range"
min="1"
max="5"
step="1"
bind:value={local.mergeStrictness}
oninput={scheduleSave}
/>
<span class="end">Strict</span>
<span class="value">{local.mergeStrictness}</span>
</div>
</div>
<div class="panel">
<span class="panel-title">Hold before publish</span>
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
<select bind:value={local.holdBeforePublishMinutes} onchange={scheduleSave}>
<option value={0}>Publish immediately</option>
<option value={30}>Wait 30 minutes</option>
<option value={120}>Wait 2 hours</option>
</select>
</div>
<div class="panel">
<span class="panel-title">Follow-up articles</span>
<p class="hint">
Instead of editing a published article, a distinct follow-up is created once enough new
corroborating sources arrive after enough time has passed.
</p>
<div class="grid-2">
<div>
<label class="field-label" for="followup-hours">Minimum time since last article</label>
<select id="followup-hours" bind:value={local.followUpMinHoursSinceLast} onchange={scheduleSave}>
<option value={1}>1 hour</option>
<option value={6}>6 hours</option>
<option value={12}>12 hours</option>
<option value={24}>24 hours</option>
</select>
</div>
<div>
<label class="field-label" for="followup-sources">Minimum new sources</label>
<select id="followup-sources" bind:value={local.followUpMinNewSources} onchange={scheduleSave}>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</div>
</div>
</div>
<div class="panel">
@@ -196,7 +196,6 @@
<option value="telegram">Telegram</option>
<option value="youtube">YouTube</option>
<option value="nitter">Nitter</option>
<option value="custom">Custom</option>
</select>
{#if form.type === 'youtube'}
<input placeholder="Channel URL (@handle or /channel/UC…), or channel ID" bind:value={form.channelId} />
@@ -396,7 +395,7 @@
}
.row {
display: grid;
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 120px;
grid-template-columns: 20px 1.4fr 0.7fr 0.9fr 0.7fr 170px;
gap: 10px;
padding: 10px;
align-items: center;
@@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
enabled,
onToggle,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
children
}: {
title: string;
enabled: boolean;
onToggle: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
children: Snippet;
} = $props();
let expanded = $state(false);
</script>
<div class="section">
<div class="section-head">
<button class="head-btn" onclick={() => (expanded = !expanded)} aria-expanded={expanded}>
<span class="chevron" class:open={expanded}>▸</span>
<span class="title">{title}</span>
</button>
<button class="icon-btn" onclick={onMoveUp} disabled={!canMoveUp} aria-label="Move up"></button>
<button class="icon-btn" onclick={onMoveDown} disabled={!canMoveDown} aria-label="Move down"></button>
<span class="badge" class:active={enabled} onclick={onToggle} role="button" tabindex="0">
{enabled ? 'Active' : 'Disabled'}
</span>
</div>
{#if expanded}
<div class="section-body">
{@render children()}
</div>
{/if}
</div>
<style>
.section {
background: var(--surface-1);
border-radius: 12px;
margin-bottom: 12px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
}
.icon-btn {
font-size: 11px;
padding: 2px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.head-btn {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
padding: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
flex: 1;
min-width: 0;
text-align: left;
}
.chevron {
display: inline-block;
font-size: 10px;
color: var(--text-muted);
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.badge {
font-size: 11px;
padding: 2px 10px;
border-radius: var(--radius);
background: var(--surface-2);
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.badge.active {
background: var(--bg-accent);
color: var(--text-accent);
}
.section-body {
padding: 14px;
padding-top: 0;
border-top: 0.5px solid var(--border);
margin-top: 0;
}
.section-body > :global(*:first-child) {
margin-top: 14px;
}
</style>
@@ -0,0 +1,69 @@
<script lang="ts">
import type { AdminSettings, AdminStockTicker, AdminBookmark, AdminPoe2Entry } from '$lib/adminTypes';
import { updateSettings } from '$lib/adminApi';
import WidgetSection from './WidgetSection.svelte';
import WeatherTab from './WeatherTab.svelte';
import StocksTab from './StocksTab.svelte';
import BookmarksTab from './BookmarksTab.svelte';
import Poe2Tab from './Poe2Tab.svelte';
let {
settings,
stockTickers,
bookmarks,
poe2Watchlist
}: {
settings: AdminSettings;
stockTickers: AdminStockTicker[];
bookmarks: AdminBookmark[];
poe2Watchlist: AdminPoe2Entry[];
} = $props();
// Local copies so each toggle/reorder reflects immediately — same idiom as
// BookmarksTab's per-row "Private" toggle.
let widgets = $state({ ...settings.widgets });
let widgetOrder = $state([...settings.widgetOrder]);
const titles: Record<(typeof widgetOrder)[number], string> = {
weather: 'Weather',
stocks: 'Stocks',
bookmarks: 'Bookmarks',
poe2: 'PoE2'
};
async function toggle(key: keyof typeof widgets) {
widgets[key] = !widgets[key];
await updateSettings({ widgets });
}
async function move(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= widgetOrder.length) return;
const arr = [...widgetOrder];
[arr[index], arr[target]] = [arr[target], arr[index]];
widgetOrder = arr;
await updateSettings({ widgetOrder });
}
</script>
{#each widgetOrder as key, i (key)}
<WidgetSection
title={titles[key]}
enabled={widgets[key]}
onToggle={() => toggle(key)}
canMoveUp={i > 0}
canMoveDown={i < widgetOrder.length - 1}
onMoveUp={() => move(i, -1)}
onMoveDown={() => move(i, 1)}
>
{#if key === 'weather'}
<WeatherTab {settings} />
{:else if key === 'stocks'}
<StocksTab tickers={stockTickers} />
{:else if key === 'bookmarks'}
<BookmarksTab {bookmarks} />
{:else if key === 'poe2'}
<Poe2Tab {settings} watchlist={poe2Watchlist} />
{/if}
</WidgetSection>
{/each}
@@ -1,12 +1,24 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Weather, StockTicker, Bookmark, Poe2Data } from '$lib/types';
import type { Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from '$lib/types';
import WeatherWidget from './WeatherWidget.svelte';
import StocksWidget from './StocksWidget.svelte';
import BookmarksWidget from './BookmarksWidget.svelte';
import Poe2Widget from './Poe2Widget.svelte';
let { weather, stocks, bookmarks, poe2 }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[]; poe2: Poe2Data } = $props();
let {
weather,
stocks,
bookmarks,
poe2,
widgetsEnabled
}: {
weather: Weather;
stocks: StockTicker[];
bookmarks: Bookmark[];
poe2: Poe2Data;
widgetsEnabled: WidgetsEnabled;
} = $props();
// Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the viewport. Plain
// `position: sticky` alone can only pin a box at a constant offset — it can't reveal
@@ -55,11 +67,13 @@
$effect(() => {
// Widget data changing the sidebar's natural height needs a remeasure, not just a
// scroll-position update.
// scroll-position update. widgetsEnabled changes it too — a disabled widget is
// removed from the flow entirely, not just emptied.
void weather;
void stocks;
void bookmarks;
void poe2;
void widgetsEnabled;
remeasure();
});
@@ -76,10 +90,17 @@
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
<WeatherWidget {weather} />
<StocksWidget {stocks} />
<Poe2Widget {poe2} />
<BookmarksWidget {bookmarks} />
{#each widgetsEnabled.order as key (key)}
{#if key === 'weather' && widgetsEnabled.weather}
<WeatherWidget {weather} />
{:else if key === 'stocks' && widgetsEnabled.stocks}
<StocksWidget {stocks} />
{:else if key === 'poe2' && widgetsEnabled.poe2}
<Poe2Widget {poe2} />
{:else if key === 'bookmarks' && widgetsEnabled.bookmarks}
<BookmarksWidget {bookmarks} />
{/if}
{/each}
</div>
</aside>
</div>
+11 -1
View File
@@ -80,7 +80,8 @@ export interface TrackedEventPublic {
id: string;
name: string;
active: boolean;
cadence: string;
recapIntervalHours: number | null;
isSpillover: boolean;
}
export interface Category {
@@ -171,3 +172,12 @@ export interface Poe2Data {
updatedAt: string | null;
entries: Poe2WatchlistEntry[];
}
/** Per-widget sidebar visibility + display order, admin-set from the consolidated "Widgets" tab. */
export interface WidgetsEnabled {
weather: boolean;
stocks: boolean;
bookmarks: boolean;
poe2: boolean;
order: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
}
+8 -5
View File
@@ -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 {
@@ -129,7 +132,7 @@
{@render children()}
</div>
{#if showSidebar}
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} />
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} widgetsEnabled={data.widgetsEnabled} />
{/if}
</main>
+6 -4
View File
@@ -1,5 +1,5 @@
import type { LayoutLoad } from './$types';
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2 } from '$lib/api';
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2, getWidgetsEnabled } from '$lib/api';
import { getPrivateAccessStatus } from '$lib/privateAccess';
// Named so the layout can be re-fetched on its own (see +layout.svelte's periodic
@@ -7,14 +7,15 @@ import { getPrivateAccessStatus } from '$lib/privateAccess';
// own pagination state, which a blanket invalidateAll() would reset every refresh.
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('app:sidebar');
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2] = await Promise.all([
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2, widgetsEnabled] = await Promise.all([
getCategories(fetch),
getEvents(fetch),
getPrivateAccessStatus(fetch),
getWeather(fetch),
getStocks(fetch),
getBookmarks(fetch),
getPoe2(fetch)
getPoe2(fetch),
getWidgetsEnabled(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.
@@ -26,6 +27,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
weather,
stocks,
bookmarks,
poe2
poe2,
widgetsEnabled
};
};
@@ -1,29 +1,21 @@
<script lang="ts">
import type { PageData } from './$types';
import MergeTab from '$lib/components/admin/MergeTab.svelte';
import SourcesTab from '$lib/components/admin/SourcesTab.svelte';
import ModelsTab from '$lib/components/admin/ModelsTab.svelte';
import RetentionTab from '$lib/components/admin/RetentionTab.svelte';
import EventsTab from '$lib/components/admin/EventsTab.svelte';
import WeatherTab from '$lib/components/admin/WeatherTab.svelte';
import StocksTab from '$lib/components/admin/StocksTab.svelte';
import BookmarksTab from '$lib/components/admin/BookmarksTab.svelte';
import Poe2Tab from '$lib/components/admin/Poe2Tab.svelte';
import WidgetsTab from '$lib/components/admin/WidgetsTab.svelte';
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
import LogsTab from '$lib/components/admin/LogsTab.svelte';
let { data }: { data: PageData } = $props();
const tabs = [
{ id: 'merge', label: 'Merge' },
{ id: 'sources', label: 'Sources' },
{ id: 'merge', label: 'Sources & Merge' },
{ id: 'models', label: 'Models' },
{ id: 'retention', label: 'Retention' },
{ id: 'events', label: 'Tracked events' },
{ id: 'weather', label: 'Weather' },
{ id: 'stocks', label: 'Stocks' },
{ id: 'bookmarks', label: 'Bookmarks' },
{ id: 'poe2', label: 'PoE2' },
{ id: 'events', label: 'Tracked items' },
{ id: 'widgets', label: 'Widgets' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
];
@@ -44,23 +36,15 @@
<div class="content">
{#if active === 'merge'}
<MergeTab settings={data.settings} />
{:else if active === 'sources'}
<SourcesTab sources={data.sources} categories={data.settings.categoryPriority} />
<MergeTab settings={data.settings} sources={data.sources} />
{:else if active === 'models'}
<ModelsTab settings={data.settings} models={data.models} aiStatus={data.aiStatus} />
{:else if active === 'retention'}
<RetentionTab settings={data.settings} />
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'weather'}
<WeatherTab settings={data.settings} />
{:else if active === 'stocks'}
<StocksTab tickers={data.stockTickers} />
{:else if active === 'bookmarks'}
<BookmarksTab bookmarks={data.bookmarks} />
{:else if active === 'poe2'}
<Poe2Tab settings={data.settings} watchlist={data.poe2Watchlist} />
{:else if active === 'widgets'}
<WidgetsTab settings={data.settings} stockTickers={data.stockTickers} bookmarks={data.bookmarks} poe2Watchlist={data.poe2Watchlist} />
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}
+1 -1
View File
@@ -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} />
+13 -1
View File
@@ -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>
+16 -7
View File
@@ -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 };
};
+4 -5
View File
@@ -3,7 +3,6 @@
let settings = {
mergeStrictness: 3,
defaultPollIntervalMinutes: 15,
holdBeforePublishMinutes: 30,
tagDedupThreshold: 0.82,
tagExpiryDays: 21,
@@ -87,9 +86,9 @@ 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,9 +96,9 @@ 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 };