diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index c52bf4c..95587e7 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -3,12 +3,17 @@ import * as settingsDb from '../storage/db/settings.js'; import * as sourcesDb from '../storage/db/sources.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; +import * as stocksDb from '../storage/db/stocks.js'; +import * as bookmarksDb from '../storage/db/bookmarks.js'; import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { totalStorageBytes } from '../storage/media/index.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import { pollSourceNow } from '../ingestion/poller.js'; import { logger, listLogs } from '../storage/db/logs.js'; import * as telegramClient from '../telegram/client.js'; +import { geocodeLocation } from '../weather/client.js'; +import { pollWeatherNow } from '../weather/poller.js'; +import { pollStocksNow } from '../stocks/poller.js'; // Not part of GlobalSettings itself (nothing to persist) — computed fresh on every // settings read/write so the Retention tab's "currently using" line and usage bar @@ -31,14 +36,19 @@ export async function registerAdminRoutes(app: FastifyInstance) { delete body.categoryPriority; } 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}`)); + } return { ...settings, categoryPriority: categoriesDb.listCategories() }; }); // --- Categories (add/remove — reordering/privacy is via PATCH /settings above) --- app.post('/api/admin/categories', async (req, reply) => { - const { name, isPrivate } = req.body as { name?: string; isPrivate?: boolean }; + const { name, isPrivate, isSpillover } = req.body as { name?: string; isPrivate?: boolean; isSpillover?: boolean }; if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' }); - const created = categoriesDb.createCategory(name.trim(), !!isPrivate); + const created = categoriesDb.createCategory(name.trim(), !!isPrivate, !!isSpillover); return reply.code(201).send(created); }); @@ -202,6 +212,70 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.code(200).send(telegramClient.getStatus()); }); + // --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) --- + app.get('/api/admin/weather/geocode', async (req, reply) => { + const { query } = req.query as { query?: string }; + if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' }); + try { + return await geocodeLocation(query.trim()); + } catch (err) { + return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` }); + } + }); + + // --- Stocks --- + app.get('/api/admin/stocks', async () => stocksDb.listStockTickers()); + + app.post('/api/admin/stocks', async (req, reply) => { + const { label, symbol } = req.body as { label?: string; symbol?: string }; + if (!label || !label.trim() || !symbol || !symbol.trim()) { + return reply.code(400).send({ error: 'label and symbol are required' }); + } + const created = stocksDb.createStockTicker(label.trim(), symbol.trim()); + // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, + // and refreshes every existing ticker's price too. + pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/stocks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = stocksDb.updateStockTicker(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/stocks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + stocksDb.deleteStockTicker(id); + return reply.code(204).send(); + }); + + // --- Bookmarks --- + app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks()); + + app.post('/api/admin/bookmarks', async (req, reply) => { + const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean }; + if (!name || !name.trim() || !url || !url.trim()) { + return reply.code(400).send({ error: 'name and url are required' }); + } + const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/bookmarks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = bookmarksDb.updateBookmark(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/bookmarks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + bookmarksDb.deleteBookmark(id); + return reply.code(204).send(); + }); + // --- Logs --- app.get('/api/admin/logs', async (req) => { const { level, limit } = req.query as { level?: string; limit?: string }; diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index 252120c..b675e50 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -3,6 +3,9 @@ import * as articlesDb from '../storage/db/articles.js'; import * as tagsDb from '../storage/db/tags.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; +import * as settingsDb from '../storage/db/settings.js'; +import * as stocksDb from '../storage/db/stocks.js'; +import * as bookmarksDb from '../storage/db/bookmarks.js'; import { hasPrivateAccess } from './privateAccess.js'; export async function registerPublicRoutes(app: FastifyInstance) { @@ -55,4 +58,15 @@ export async function registerPublicRoutes(app: FastifyInstance) { if (hasPrivateAccess(req)) return categories; return categories.filter((c) => !c.isPrivate); }); + + // Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel. + app.get('/api/weather', async () => settingsDb.getSettings().weather); + + app.get('/api/stocks', async () => stocksDb.listStockTickers()); + + app.get('/api/bookmarks', async (req) => { + const bookmarks = bookmarksDb.listBookmarks(); + if (hasPrivateAccess(req)) return bookmarks; + return bookmarks.filter((b) => !b.isPrivate); + }); } diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts index 433a209..7636923 100644 --- a/backend/src/queue/scheduler.ts +++ b/backend/src/queue/scheduler.ts @@ -5,10 +5,14 @@ import { runRetentionSweep } from './retention.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import * as settingsDb from '../storage/db/settings.js'; import { logger } from '../storage/db/logs.js'; +import { pollWeatherNow } from '../weather/poller.js'; +import { pollStocksNow } from '../stocks/poller.js'; const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency const SYNTHESIS_TICK_MS = 60_000; const RETENTION_TICK_MS = 60 * 60_000; // hourly +const WEATHER_TICK_MS = 45 * 60_000; +const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather export function startScheduler() { const provider = () => { @@ -61,5 +65,19 @@ export function startScheduler() { } }, RETENTION_TICK_MS); - logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h'); + // Immediate first call for both — unlike RSS sources (whose "due" check makes a + // brand-new source eligible on the very next 1-minute tick), weather/stocks have no + // such shortcut; without this the sidebar is empty for up to 45/15 minutes after + // every restart. + pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`)); + setInterval(() => { + 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}`)); + setInterval(() => { + pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`)); + }, STOCKS_TICK_MS); + + logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m'); } diff --git a/backend/src/stocks/client.ts b/backend/src/stocks/client.ts new file mode 100644 index 0000000..637c475 --- /dev/null +++ b/backend/src/stocks/client.ts @@ -0,0 +1,61 @@ +// Yahoo Finance's unofficial chart endpoint — free, no account or API key required. +// This is the only file that talks to it; poller.ts orchestrates when/how results get +// saved, same separation as backend/src/telegram/ keeps between the raw client and its +// callers. +// +// Previously used Stooq's CSV quote endpoint, which started gating every request behind +// a client-side proof-of-work challenge (compute a SHA-256 hashcash puzzle in JS, POST it +// to /__verify) — not something a plain server-side fetch can pass, and not worth running +// a headless browser to poll ticker prices. Confirmed via manual curl testing that Yahoo's +// /v8/finance/chart/ endpoint still works with a plain fetch, but ONLY with a +// browser-like User-Agent header — bare `curl`/`fetch` UAs get a 429 on the very first +// request, before any real volume. This is an undocumented, unofficial API Yahoo could +// change or wall off without notice, same caveat as Stooq — if it goes the same way, +// there's no realistic simple-fetch alternative left; the fallback would be a provider +// requiring a free API key. +const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; + +export interface StockQuote { + price: number; + changePercent: number; +} + +export async function fetchQuotes(symbols: string[]): Promise> { + const results = new Map(); + if (symbols.length === 0) return results; + + // No batch endpoint used here — Yahoo's multi-symbol /v7/finance/quote requires a + // cookie+crumb handshake first, while /v8/finance/chart/ (single symbol, no + // crumb needed) is the one confirmed to work with just a User-Agent. One request per + // ticker per poll is trivial at the scale of a sidebar widget (a handful of tickers, + // polled every 15 minutes). + await Promise.all( + symbols.map(async (symbol) => { + try { + const res = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`, { + headers: { 'User-Agent': USER_AGENT } + }); + if (!res.ok) throw new Error(`Yahoo returned ${res.status}`); + const data = (await res.json()) as { + chart: { + result: { meta: { regularMarketPrice: number; previousClose?: number; chartPreviousClose?: number } }[] | null; + error: { description: string } | null; + }; + }; + if (data.chart.error) throw new Error(data.chart.error.description); + const meta = data.chart.result?.[0]?.meta; + if (!meta) throw new Error('No data returned for this symbol'); + const previousClose = meta.previousClose ?? meta.chartPreviousClose; + if (previousClose === undefined) throw new Error('No previous close available for this symbol'); + results.set(symbol, { + price: meta.regularMarketPrice, + changePercent: ((meta.regularMarketPrice - previousClose) / previousClose) * 100 + }); + } catch (err) { + results.set(symbol, err instanceof Error ? err : new Error(String(err))); + } + }) + ); + + return results; +} diff --git a/backend/src/stocks/poller.ts b/backend/src/stocks/poller.ts new file mode 100644 index 0000000..84ccb26 --- /dev/null +++ b/backend/src/stocks/poller.ts @@ -0,0 +1,31 @@ +import * as stocksDb from '../storage/db/stocks.js'; +import { logger } from '../storage/db/logs.js'; +import { fetchQuotes } from './client.js'; + +// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a +// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why +// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError, +// it never aborts the rest of the batch. +export async function pollStocksNow(): Promise { + const tickers = stocksDb.listStockTickers(); + if (tickers.length === 0) return; + + let quotes: Map; + try { + quotes = await fetchQuotes(tickers.map((t) => t.symbol)); + } catch (err) { + logger.error('stocks', `Poll failed: ${(err as Error).message}`); + return; + } + + for (const ticker of tickers) { + const quote = quotes.get(ticker.symbol); + if (!quote) { + stocksDb.markStockPolled(ticker.id, null, null, 'No quote returned'); + } else if (quote instanceof Error) { + stocksDb.markStockPolled(ticker.id, null, null, quote.message); + } else { + stocksDb.markStockPolled(ticker.id, quote.price, quote.changePercent, null); + } + } +} diff --git a/backend/src/storage/db/bookmarks.ts b/backend/src/storage/db/bookmarks.ts new file mode 100644 index 0000000..0882ca9 --- /dev/null +++ b/backend/src/storage/db/bookmarks.ts @@ -0,0 +1,46 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { Bookmark } from './types.js'; + +function rowToBookmark(row: any): Bookmark { + return { + id: row.id, + name: row.name, + url: row.url, + priorityRank: row.priority_rank, + isPrivate: !!row.is_private, + createdAt: row.created_at + }; +} + +// Always returns every bookmark, private or not — filtering for unauthenticated visitors +// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories(). +export function listBookmarks(): Bookmark[] { + const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all(); + return rows.map(rowToBookmark); +} + +export function createBookmark(name: string, url: string, isPrivate = false): Bookmark { + const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number }; + const createdAt = new Date().toISOString(); + db.prepare( + 'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt); + return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt }; +} + +export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null { + const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id); + if (!existing) return null; + const current = rowToBookmark(existing); + const merged = { ...current, ...patch }; + db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run( + merged.name, merged.url, merged.isPrivate ? 1 : 0, id + ); + return merged; +} + +export function deleteBookmark(id: string) { + db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id); +} diff --git a/backend/src/storage/db/categories.ts b/backend/src/storage/db/categories.ts index da670d8..2397610 100644 --- a/backend/src/storage/db/categories.ts +++ b/backend/src/storage/db/categories.ts @@ -8,7 +8,8 @@ function rowToCategory(row: any): Category { name: row.name, priorityRank: row.priority_rank, isDefault: !!row.is_default, - isPrivate: !!row.is_private + isPrivate: !!row.is_private, + isSpillover: !!row.is_spillover }; } @@ -23,21 +24,18 @@ export function listPrivateCategoryNames(): string[] { return rows.map((r) => r.name); } -export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean }[]) { - const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ? WHERE id = ?'); - for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.id); +export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean; isSpillover: boolean }[]) { + const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ?, is_spillover = ? WHERE id = ?'); + for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.isSpillover ? 1 : 0, c.id); } -export function createCategory(name: string, isPrivate = false): Category { +export function createCategory(name: string, isPrivate = false, isSpillover = false): Category { const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number }; - db.prepare('INSERT INTO categories (id, name, priority_rank, is_default, is_private) VALUES (?, ?, ?, 0, ?)').run( - id, - name, - maxRank.m + 1, - isPrivate ? 1 : 0 - ); - return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate }; + db.prepare( + 'INSERT INTO categories (id, name, priority_rank, is_default, is_private, is_spillover) VALUES (?, ?, ?, 0, ?, ?)' + ).run(id, name, maxRank.m + 1, isPrivate ? 1 : 0, isSpillover ? 1 : 0); + return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate, isSpillover }; } export function deleteCategory(id: string) { diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 1dd2108..3df0497 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -144,7 +144,8 @@ export function migrate() { name TEXT NOT NULL, priority_rank INTEGER NOT NULL, is_default INTEGER NOT NULL DEFAULT 0, - is_private INTEGER NOT NULL DEFAULT 0 + is_private INTEGER NOT NULL DEFAULT 0, + is_spillover INTEGER NOT NULL DEFAULT 0 -- collapsed into the nav's "More »" overflow page instead of its own tab ); CREATE TABLE IF NOT EXISTS logs ( @@ -175,7 +176,46 @@ export function migrate() { storage_cap_unit TEXT NOT NULL DEFAULT 'GB', 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) + telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL) + weather_location_name TEXT, + weather_latitude REAL, + weather_longitude REAL, + weather_unit TEXT NOT NULL DEFAULT 'fahrenheit', -- celsius | fahrenheit + weather_wind_unit TEXT NOT NULL DEFAULT 'mph', -- mph | kph + weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg', -- inHg | hPa + -- JSON {temp, feelsLike, conditionText, icon, humidity, precipitationChance, + -- windSpeed, windDirection, pressure, sunrise, sunset}, NULL pre-first-poll + weather_current TEXT, + weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array + weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array + weather_alerts TEXT NOT NULL DEFAULT '[]', -- JSON array — active NWS alerts for the configured location, US-only (see weather/client.ts) + weather_updated_at TEXT -- ISO timestamp, NULL pre-first-poll + ); + + -- Sidebar "Stocks" widget — polled every 15 minutes from Stooq (see stocks/poller.ts). + -- Price/change/poll-state live directly on the row, same as sources.last_polled_at, + -- rather than a separate quote-cache table. + CREATE TABLE IF NOT EXISTS stock_tickers ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + symbol TEXT NOT NULL, -- Stooq symbol syntax, e.g. "^dji", "aapl.us", "btcusd" + priority_rank INTEGER NOT NULL, + last_price REAL, + last_change_percent REAL, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL + ); + + -- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public + -- via is_private (same private-access lock feature as categories.is_private). + CREATE TABLE IF NOT EXISTS bookmarks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + is_private INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL ); -- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and @@ -229,6 +269,9 @@ export function migrate() { if (!hasColumn('categories', 'is_private')) { db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0'); } + if (!hasColumn('categories', 'is_spillover')) { + db.exec('ALTER TABLE categories ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0'); + } if (!hasColumn('content_items', 'telegram_message')) { db.exec('ALTER TABLE content_items ADD COLUMN telegram_message TEXT'); } @@ -244,6 +287,51 @@ export function migrate() { if (!hasColumn('merged_articles', 'is_recap')) { db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0'); } + if (!hasColumn('global_settings', 'weather_unit')) { + db.exec('ALTER TABLE global_settings ADD COLUMN weather_location_name TEXT'); + db.exec('ALTER TABLE global_settings ADD COLUMN weather_latitude REAL'); + db.exec('ALTER TABLE global_settings ADD COLUMN weather_longitude REAL'); + db.exec("ALTER TABLE global_settings ADD COLUMN weather_unit TEXT NOT NULL DEFAULT 'fahrenheit'"); + db.exec('ALTER TABLE global_settings ADD COLUMN weather_current TEXT'); + db.exec("ALTER TABLE global_settings ADD COLUMN weather_hourly TEXT NOT NULL DEFAULT '[]'"); + db.exec("ALTER TABLE global_settings ADD COLUMN weather_daily TEXT NOT NULL DEFAULT '[]'"); + db.exec('ALTER TABLE global_settings ADD COLUMN weather_updated_at TEXT'); + } + if (!hasColumn('global_settings', 'weather_wind_unit')) { + db.exec("ALTER TABLE global_settings ADD COLUMN weather_wind_unit TEXT NOT NULL DEFAULT 'mph'"); + db.exec("ALTER TABLE global_settings ADD COLUMN weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg'"); + db.exec("ALTER TABLE global_settings ADD COLUMN weather_alerts TEXT NOT NULL DEFAULT '[]'"); + } + + // 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. + const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number }; + if (tickerCount.c === 0) { + const defaults: [string, string][] = [ + ['Dow Jones', '^DJI'], + ['S&P 500', '^GSPC'], + ['Bitcoin', 'BTC-USD'] + ]; + const stmt = db.prepare( + 'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)' + ); + defaults.forEach(([label, symbol], i) => { + stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString()); + }); + } + + // 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 diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index 13cb383..ed73f10 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -22,6 +22,20 @@ function rowToSettings(row: any): GlobalSettings { storageCapEnabled: !!row.storage_cap_enabled, storageCapValue: row.storage_cap_value, storageCapUnit: row.storage_cap_unit + }, + weather: { + locationName: row.weather_location_name, + latitude: row.weather_latitude, + longitude: row.weather_longitude, + unit: row.weather_unit, + windUnit: row.weather_wind_unit, + pressureUnit: row.weather_pressure_unit, + // Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse. + current: row.weather_current ? JSON.parse(row.weather_current) : null, + hourly: JSON.parse(row.weather_hourly), + daily: JSON.parse(row.weather_daily), + alerts: JSON.parse(row.weather_alerts), + updatedAt: row.weather_updated_at } }; } @@ -37,7 +51,8 @@ export function updateSettings(patch: Partial): GlobalSettings { ...current, ...patch, retention: { ...current.retention, ...(patch.retention ?? {}) }, - selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) } + selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) }, + weather: { ...current.weather, ...(patch.weather ?? {}) } }; db.prepare( `UPDATE global_settings SET @@ -46,7 +61,10 @@ export function updateSettings(patch: Partial): GlobalSettings { 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=? + 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=? WHERE id = 1` ).run( merged.mergeStrictness, @@ -66,7 +84,18 @@ export function updateSettings(patch: Partial): GlobalSettings { merged.retention.rawItemMaxAgeDays, merged.retention.storageCapEnabled ? 1 : 0, merged.retention.storageCapValue, - merged.retention.storageCapUnit + 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 ); return getSettings(); } diff --git a/backend/src/storage/db/stocks.ts b/backend/src/storage/db/stocks.ts new file mode 100644 index 0000000..9f28874 --- /dev/null +++ b/backend/src/storage/db/stocks.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { StockTicker } from './types.js'; + +function rowToTicker(row: any): StockTicker { + return { + id: row.id, + label: row.label, + symbol: row.symbol, + priorityRank: row.priority_rank, + lastPrice: row.last_price, + lastChangePercent: row.last_change_percent, + lastPolledAt: row.last_polled_at, + lastError: row.last_error, + createdAt: row.created_at + }; +} + +export function listStockTickers(): StockTicker[] { + const rows = db.prepare('SELECT * FROM stock_tickers ORDER BY priority_rank').all(); + return rows.map(rowToTicker); +} + +export function createStockTicker(label: string, symbol: string): StockTicker { + const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM stock_tickers').get() as { m: number }; + const createdAt = new Date().toISOString(); + db.prepare( + 'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(id, label, symbol, maxRank.m + 1, createdAt); + return { + id, label, symbol, priorityRank: maxRank.m + 1, + lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt + }; +} + +export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null { + const existing = db.prepare('SELECT * FROM stock_tickers WHERE id = ?').get(id); + if (!existing) return null; + const current = rowToTicker(existing); + const merged = { ...current, ...patch }; + db.prepare('UPDATE stock_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id); + return { ...merged }; +} + +export function deleteStockTicker(id: string) { + db.prepare('DELETE FROM stock_tickers WHERE id = ?').run(id); +} + +export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) { + db.prepare( + 'UPDATE stock_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?' + ).run(price, changePercent, new Date().toISOString(), error, id); +} diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index d8e73d1..8331859 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -202,6 +202,44 @@ export interface Category { isDefault: boolean; /** Hidden from /api/categories, /api/feed, and article detail for anyone without a valid private-access cookie. */ isPrivate: boolean; + /** Grouped into the nav's "More »" overflow page instead of getting its own top-level tab — see +layout.svelte and /more. */ + isSpillover: boolean; +} + +export interface WeatherHourEntry { + time: string; + temp: number; + conditionText: string; + icon: string; +} + +export interface WeatherDayEntry { + date: string; + tempMax: number; + tempMin: number; + conditionText: string; + icon: string; +} + +export interface StockTicker { + id: string; + label: string; + symbol: string; + priorityRank: number; + lastPrice: number | null; + lastChangePercent: number | null; + lastPolledAt: string | null; + lastError: string | null; + createdAt: string; +} + +export interface Bookmark { + id: string; + name: string; + url: string; + priorityRank: number; + isPrivate: boolean; + createdAt: string; } export interface GlobalSettings { @@ -228,4 +266,45 @@ export interface GlobalSettings { storageCapValue: number; storageCapUnit: 'MB' | 'GB'; }; + /** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */ + weather: { + locationName: string | null; + latitude: number | null; + longitude: number | null; + unit: 'celsius' | 'fahrenheit'; + windUnit: 'mph' | 'kph'; + pressureUnit: 'inHg' | 'hPa'; + current: { + temp: number; + /** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */ + feelsLike: number; + conditionText: string; + icon: string; + /** Percent, 0-100. */ + humidity: number; + /** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */ + precipitationChance: number; + /** Already in the admin's configured windUnit. */ + windSpeed: number; + /** 8-point compass abbreviation, e.g. "NW". */ + windDirection: string; + /** Already in the admin's configured pressureUnit. */ + pressure: number; + sunrise: string; + sunset: string; + } | null; + hourly: WeatherHourEntry[]; + daily: WeatherDayEntry[]; + /** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */ + alerts: WeatherAlert[]; + updatedAt: string | null; + }; +} + +export interface WeatherAlert { + id: string; + event: string; + headline: string; + severity: string; + expires: string; } diff --git a/backend/src/weather/client.ts b/backend/src/weather/client.ts new file mode 100644 index 0000000..626f20f --- /dev/null +++ b/backend/src/weather/client.ts @@ -0,0 +1,215 @@ +// Open-Meteo (api.open-meteo.com / geocoding-api.open-meteo.com) — free, no account or API +// key required, which is why it was picked over any commercial weather provider. This is +// the only file that talks to it; poller.ts orchestrates when/how the result gets saved, +// same separation as backend/src/telegram/ keeps between the raw client and its callers. + +export interface GeocodeResult { + name: string; + admin1: string | null; + country: string | null; + latitude: number; + longitude: number; +} + +export interface WeatherCondition { + text: string; + icon: string; +} + +// WMO weather interpretation codes, as returned by Open-Meteo's weather_code field — +// https://open-meteo.com/en/docs lists the full table this summarizes. +const WMO_CONDITIONS: Record = { + 0: { text: 'Clear sky', icon: '☀️' }, + 1: { text: 'Mainly clear', icon: '🌤️' }, + 2: { text: 'Partly cloudy', icon: '⛅' }, + 3: { text: 'Overcast', icon: '☁️' }, + 45: { text: 'Fog', icon: '🌫️' }, + 48: { text: 'Depositing rime fog', icon: '🌫️' }, + 51: { text: 'Light drizzle', icon: '🌦️' }, + 53: { text: 'Moderate drizzle', icon: '🌦️' }, + 55: { text: 'Dense drizzle', icon: '🌦️' }, + 56: { text: 'Light freezing drizzle', icon: '🌧️' }, + 57: { text: 'Dense freezing drizzle', icon: '🌧️' }, + 61: { text: 'Slight rain', icon: '🌧️' }, + 63: { text: 'Moderate rain', icon: '🌧️' }, + 65: { text: 'Heavy rain', icon: '🌧️' }, + 66: { text: 'Light freezing rain', icon: '🌧️' }, + 67: { text: 'Heavy freezing rain', icon: '🌧️' }, + 71: { text: 'Slight snow', icon: '🌨️' }, + 73: { text: 'Moderate snow', icon: '🌨️' }, + 75: { text: 'Heavy snow', icon: '❄️' }, + 77: { text: 'Snow grains', icon: '❄️' }, + 80: { text: 'Slight rain showers', icon: '🌦️' }, + 81: { text: 'Moderate rain showers', icon: '🌦️' }, + 82: { text: 'Violent rain showers', icon: '⛈️' }, + 85: { text: 'Slight snow showers', icon: '🌨️' }, + 86: { text: 'Heavy snow showers', icon: '🌨️' }, + 95: { text: 'Thunderstorm', icon: '⛈️' }, + 96: { text: 'Thunderstorm, slight hail', icon: '⛈️' }, + 99: { text: 'Thunderstorm, heavy hail', icon: '⛈️' } +}; + +export function wmoToCondition(code: number): WeatherCondition { + return WMO_CONDITIONS[code] ?? { text: 'Unknown', icon: '❔' }; +} + +const COMPASS_POINTS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + +function degreesToCompass(degrees: number): string { + return COMPASS_POINTS[Math.round(degrees / 45) % 8]; +} + +function hPaToInHg(hpa: number): number { + return hpa * 0.0295299830714; +} + +export async function geocodeLocation(query: string): Promise { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Geocoding API returned ${res.status}`); + const data = (await res.json()) as { + results?: { name: string; admin1?: string; country?: string; latitude: number; longitude: number }[]; + }; + return (data.results ?? []).map((r) => ({ + name: r.name, + admin1: r.admin1 ?? null, + country: r.country ?? null, + latitude: r.latitude, + longitude: r.longitude + })); +} + +export interface CurrentConditions { + temp: number; + feelsLike: number; + conditionText: string; + icon: string; + humidity: number; + precipitationChance: number; + windSpeed: number; + windDirection: string; + pressure: number; + sunrise: string; + sunset: string; +} + +export interface ForecastResult { + current: CurrentConditions; + hourly: { time: string; temp: number; conditionText: string; icon: string }[]; + daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[]; +} + +export async function fetchForecast( + latitude: number, + longitude: number, + unit: 'celsius' | 'fahrenheit', + windUnit: 'mph' | 'kph', + pressureUnit: 'inHg' | 'hPa' +): Promise { + const url = + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` + + `¤t=temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` + + `&hourly=temperature_2m,weather_code,precipitation_probability` + + `&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset` + + `&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&timezone=auto&forecast_days=7`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Forecast API returned ${res.status}`); + const data = (await res.json()) as { + current: { + temperature_2m: number; + apparent_temperature: number; + weather_code: number; + relative_humidity_2m: number; + wind_speed_10m: number; + wind_direction_10m: number; + pressure_msl: number; + }; + hourly: { time: string[]; temperature_2m: number[]; weather_code: number[]; precipitation_probability: number[] }; + daily: { + time: string[]; + temperature_2m_max: number[]; + temperature_2m_min: number[]; + weather_code: number[]; + sunrise: string[]; + sunset: string[]; + }; + }; + + // hourly.time starts at today's midnight, not the current hour — find the first entry + // at or after now so the strip shown to the user starts from "now", not from midnight, + // and so the current hour's precipitation_probability can stand in for "right now" + // (there's no true instantaneous "chance of rain" measurement, current forecasts don't have one). + const now = Date.now(); + const startIdx = Math.max( + 0, + data.hourly.time.findIndex((t) => new Date(t).getTime() >= now) + ); + + const currentCondition = wmoToCondition(data.current.weather_code); + const pressure = pressureUnit === 'inHg' ? hPaToInHg(data.current.pressure_msl) : data.current.pressure_msl; + const current: CurrentConditions = { + temp: data.current.temperature_2m, + feelsLike: data.current.apparent_temperature, + conditionText: currentCondition.text, + icon: currentCondition.icon, + humidity: data.current.relative_humidity_2m, + precipitationChance: data.hourly.precipitation_probability[startIdx] ?? 0, + windSpeed: data.current.wind_speed_10m, + windDirection: degreesToCompass(data.current.wind_direction_10m), + pressure: pressureUnit === 'inHg' ? Math.round(pressure * 100) / 100 : Math.round(pressure), + sunrise: data.daily.sunrise[0], + sunset: data.daily.sunset[0] + }; + + const hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => { + const idx = startIdx + i; + const condition = wmoToCondition(data.hourly.weather_code[idx]); + return { time, temp: data.hourly.temperature_2m[idx], conditionText: condition.text, icon: condition.icon }; + }); + + const daily = data.daily.time.map((date, i) => { + const condition = wmoToCondition(data.daily.weather_code[i]); + return { + date, + tempMax: data.daily.temperature_2m_max[i], + tempMin: data.daily.temperature_2m_min[i], + conditionText: condition.text, + icon: condition.icon + }; + }); + + return { current, hourly, daily }; +} + +export interface WeatherAlertResult { + id: string; + event: string; + headline: string; + severity: string; + expires: string; +} + +// US National Weather Service — free, no key, no account, covers the US and territories +// only. A non-US location will reliably fail this call; that's expected, not an error +// (see poller.ts, which treats a failure here as "no alerts" rather than propagating it). +export async function fetchActiveAlerts(latitude: number, longitude: number): Promise { + const url = `https://api.weather.gov/alerts/active?point=${latitude},${longitude}`; + const res = await fetch(url, { + headers: { + // NWS's API usage policy requires an identifying User-Agent on every request. + 'User-Agent': 'Homefeed/1.0 (self-hosted news aggregator)', + Accept: 'application/geo+json' + } + }); + if (!res.ok) throw new Error(`NWS alerts API returned ${res.status}`); + const data = (await res.json()) as { + features: { id: string; properties: { event: string; headline: string; severity: string; expires: string } }[]; + }; + return data.features.map((f) => ({ + id: f.id, + event: f.properties.event, + headline: f.properties.headline, + severity: f.properties.severity, + expires: f.properties.expires + })); +} diff --git a/backend/src/weather/poller.ts b/backend/src/weather/poller.ts new file mode 100644 index 0000000..18ac676 --- /dev/null +++ b/backend/src/weather/poller.ts @@ -0,0 +1,52 @@ +import * as settingsDb from '../storage/db/settings.js'; +import { logger } from '../storage/db/logs.js'; +import { fetchForecast, fetchActiveAlerts } from './client.js'; + +// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes +// the weather location/units (see api/admin.ts) — writes straight into global_settings' +// weather_* columns via settingsDb, same singleton-row approach as retention. +export async function pollWeatherNow(): Promise { + const { weather } = settingsDb.getSettings(); + if (weather.latitude === null || weather.longitude === null) { + // No location configured yet — not an error, just nothing to do. + return; + } + + let forecastUpdate: Partial = {}; + let forecastSucceeded = false; + try { + const { current, hourly, daily } = await fetchForecast( + weather.latitude, + weather.longitude, + weather.unit, + weather.windUnit, + weather.pressureUnit + ); + forecastUpdate = { current, hourly, daily }; + forecastSucceeded = true; + } catch (err) { + // Leave the existing cache untouched — a stale forecast beats a blank widget. + logger.error('weather', `Forecast poll failed: ${(err as Error).message}`); + } + + // Fetched independently of the forecast — the NWS only covers the US, so this fails + // reliably (and expectedly) for every non-US location. A failure here shouldn't + // touch the forecast update above, and unlike a stale forecast, a stale alert that's + // since expired is worse to keep showing than none at all — clear to empty on failure. + let alerts = weather.alerts; + try { + alerts = await fetchActiveAlerts(weather.latitude, weather.longitude); + } catch (err) { + alerts = []; + logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`); + } + + settingsDb.updateSettings({ + weather: { + ...weather, + ...forecastUpdate, + alerts, + updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt + } + }); +} diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 5a2fd2a..c37e440 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -4,10 +4,14 @@ import type { AdminSettings, AdminSource, AdminTrackedEvent, + CategoryPriority, ModelCatalog, AiStatus, TelegramStatus, - LogEntry + LogEntry, + GeocodeResult, + AdminStockTicker, + AdminBookmark } from './adminTypes'; async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { @@ -60,10 +64,10 @@ export const updateSettings = (patch: Partial, fetchFn?: typeof f request('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); // Categories -export const createCategory = (name: string, isPrivate = false, fetchFn?: typeof fetch) => - request<{ id: string; name: string; priorityRank: number; isDefault: boolean; isPrivate: boolean }>( +export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) => + request( '/api/admin/categories', - { method: 'POST', body: JSON.stringify({ name, isPrivate }) }, + { method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) }, fetchFn ); @@ -163,3 +167,38 @@ export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: nu const qs = new URLSearchParams(filters as Record).toString(); return request(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn); }; + +// Weather — config/cache lives on AdminSettings.weather (see updateSettings above); this +// is just the geocoding lookup used to resolve a typed city name to lat/lon. +export const geocodeLocation = (query: string, fetchFn?: typeof fetch) => + request(`/api/admin/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn); + +// Stocks +export const getStockTickers = (fetchFn?: typeof fetch) => + request('/api/admin/stocks', {}, fetchFn); + +export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) => + request('/api/admin/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn); + +export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) => + request(`/api/admin/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + +export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/stocks/${id}`, { method: 'DELETE' }, fetchFn); + +// Bookmarks +export const getAdminBookmarks = (fetchFn?: typeof fetch) => + request('/api/admin/bookmarks', {}, fetchFn); + +export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) => + request( + '/api/admin/bookmarks', + { method: 'POST', body: JSON.stringify({ name, url, isPrivate }) }, + fetchFn + ); + +export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) => + request(`/api/admin/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + +export const deleteBookmark = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 0ee3e7d..5d36070 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -13,6 +13,85 @@ export interface CategoryPriority { priorityRank: number; isDefault: boolean; isPrivate: boolean; + isSpillover: boolean; +} + +export interface WeatherHourEntry { + time: string; + temp: number; + conditionText: string; + icon: string; +} + +export interface WeatherDayEntry { + date: string; + tempMax: number; + tempMin: number; + conditionText: string; + icon: string; +} + +export interface WeatherCurrentConditions { + temp: number; + feelsLike: number; + conditionText: string; + icon: string; + humidity: number; + precipitationChance: number; + windSpeed: number; + windDirection: string; + pressure: number; + sunrise: string; + sunset: string; +} + +export interface WeatherAlert { + id: string; + event: string; + headline: string; + severity: string; + expires: string; +} + +export interface AdminWeatherSettings { + locationName: string | null; + latitude: number | null; + longitude: number | null; + unit: 'celsius' | 'fahrenheit'; + windUnit: 'mph' | 'kph'; + pressureUnit: 'inHg' | 'hPa'; + current: WeatherCurrentConditions | null; + hourly: WeatherHourEntry[]; + daily: WeatherDayEntry[]; + alerts: WeatherAlert[]; + updatedAt: string | null; +} + +export interface GeocodeResult { + name: string; + admin1: string | null; + country: string | null; + latitude: number; + longitude: number; +} + +export interface AdminStockTicker { + id: string; + label: string; + symbol: string; + priorityRank: number; + lastPrice: number | null; + lastChangePercent: number | null; + lastPolledAt: string | null; + lastError: string | null; +} + +export interface AdminBookmark { + id: string; + name: string; + url: string; + priorityRank: number; + isPrivate: boolean; } export interface AdminSettings { @@ -31,6 +110,7 @@ export interface AdminSettings { telegramMediaMode: 'self-host' | 'proxy'; retention: RetentionSettings; categoryPriority: CategoryPriority[]; + weather: AdminWeatherSettings; } export interface AdminSource { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f4583c4..1e017a2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import { getBackendUrl } from './config'; -import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types'; +import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark } from './types'; async function get(path: string, fetchFn: typeof fetch = fetch): Promise { // credentials: 'include' so the private-access cookie (see lib/privateAccess.ts) @@ -41,3 +41,15 @@ export function getEvents(fetchFn?: typeof fetch): Promise export function getCategories(fetchFn?: typeof fetch): Promise { return get('/api/categories', fetchFn); } + +export function getWeather(fetchFn?: typeof fetch): Promise { + return get('/api/weather', fetchFn); +} + +export function getStocks(fetchFn?: typeof fetch): Promise { + return get('/api/stocks', fetchFn); +} + +export function getBookmarks(fetchFn?: typeof fetch): Promise { + return get('/api/bookmarks', fetchFn); +} diff --git a/frontend/src/lib/components/admin/BookmarksTab.svelte b/frontend/src/lib/components/admin/BookmarksTab.svelte new file mode 100644 index 0000000..9cac122 --- /dev/null +++ b/frontend/src/lib/components/admin/BookmarksTab.svelte @@ -0,0 +1,194 @@ + + +
+ {bookmarks.length} bookmarks + +
+ +{#if showAdd} +
+
+ + +
+ +
+ + +
+
+{/if} + +
+ {#each bookmarks as bookmark (bookmark.id)} + {#if editingId === bookmark.id} +
+
+ + +
+
+ + +
+
+ {:else} +
+
+
{bookmark.name}
+
{bookmark.url}
+
+ + + +
+ {/if} + {/each} +
+ + diff --git a/frontend/src/lib/components/admin/MergeTab.svelte b/frontend/src/lib/components/admin/MergeTab.svelte index 5298704..be0c028 100644 --- a/frontend/src/lib/components/admin/MergeTab.svelte +++ b/frontend/src/lib/components/admin/MergeTab.svelte @@ -10,8 +10,15 @@ let saveTimer: ReturnType; let newCategoryName = $state(''); let newCategoryPrivate = $state(false); + let newCategorySpillover = $state(false); let addingCategory = $state(false); + // Advisory only — the nav starts getting too wide / wrapping past ~10 tabs, so this + // nudges the admin toward marking some categories as spillover once they cross that + // rough guideline. Never enforced — actual wrapping depends on name lengths and + // viewport width, which this simple count can't know. + const primaryCategoryCount = $derived(local.categoryPriority.filter((c) => !c.isSpillover).length); + function scheduleSave() { status = 'saving'; clearTimeout(saveTimer); @@ -40,10 +47,11 @@ if (!name) return; addingCategory = true; try { - const created = await createCategory(name, newCategoryPrivate); + const created = await createCategory(name, newCategoryPrivate, newCategorySpillover); local.categoryPriority = [...local.categoryPriority, created]; newCategoryName = ''; newCategoryPrivate = false; + newCategorySpillover = false; } finally { addingCategory = false; } @@ -54,6 +62,11 @@ scheduleSave(); } + function toggleSpillover(id: string) { + local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, isSpillover: !c.isSpillover } : c)); + scheduleSave(); + } + async function removeCategory(id: string, isDefault: boolean, name: string) { if (isDefault) { // Sensible-default categories can still be removed — e.g. a fresh install's @@ -153,8 +166,16 @@ categories just wait longer when the queue is busy. This list also drives the site's nav — remove anything you're not interested in (Business, Culture, etc.) or add your own. A private category (and everything in it) is hidden from the public site until a visitor - logs in with the lock icon in the masthead. + logs in with the lock icon in the masthead. A "More" category is collapsed into a single + "More »" nav tab instead of getting its own, and shows up on that overflow page with its + latest few articles.

+ {#if primaryCategoryCount > 10} +

+ {primaryCategoryCount} categories showing directly in the nav — consider marking some as + "More" below before it gets too wide (a rough guideline, not a hard limit). +

+ {/if}
{#each local.categoryPriority as cat, i (cat.id)}
@@ -165,6 +186,10 @@ togglePrivate(cat.id)} /> Private + {/if} @@ -249,6 +278,9 @@ color: var(--text-secondary); margin: 4px 0 12px; } + .hint.warn { + color: var(--text-accent); + } .slider-row { display: flex; align-items: center; diff --git a/frontend/src/lib/components/admin/StocksTab.svelte b/frontend/src/lib/components/admin/StocksTab.svelte new file mode 100644 index 0000000..b6a7cda --- /dev/null +++ b/frontend/src/lib/components/admin/StocksTab.svelte @@ -0,0 +1,203 @@ + + +
+ {tickers.length} tickers + +
+

Price and % change are today's — since the previous trading day's close.

+ +{#if showAdd} +
+
+ + +
+
+ + +
+

+ Yahoo Finance has no symbol search, so type the exact syntax: stocks are plain tickers + (AAPL), indices use a caret (^DJI, ^GSPC), crypto pairs use a dash (BTC-USD). Polled + every 15 minutes; a new ticker is polled immediately. +

+
+{/if} + +
+ {#each tickers as ticker (ticker.id)} + {#if editingId === ticker.id} +
+
+ + +
+
+ + +
+
+ {:else} +
+
+
{ticker.label}
+
+ {ticker.symbol} + {#if ticker.lastError} + · {ticker.lastError} + {/if} +
+
+ {#if ticker.lastPrice !== null} + = 0} class:down={(ticker.lastChangePercent ?? 0) < 0}> + {ticker.lastPrice.toFixed(2)} + {#if ticker.lastChangePercent !== null} + ({ticker.lastChangePercent >= 0 ? '+' : ''}{ticker.lastChangePercent.toFixed(2)}%) + {/if} + + {/if} + + +
+ {/if} + {/each} +
+ + diff --git a/frontend/src/lib/components/admin/WeatherTab.svelte b/frontend/src/lib/components/admin/WeatherTab.svelte new file mode 100644 index 0000000..21a195c --- /dev/null +++ b/frontend/src/lib/components/admin/WeatherTab.svelte @@ -0,0 +1,245 @@ + + +
+
+ Location + +
+

Powers the sidebar weather widget and the /weather page — searched via Open-Meteo's free geocoding lookup.

+
+ e.key === 'Enter' && handleSearch()} + placeholder="City name, e.g. Chicago" + /> + +
+ {#if searchError} +

{searchError}

+ {/if} + {#if results.length > 0} +
+ {#each results as r} + + {/each} +
+ {/if} + +
+ + {#if weather.locationName} + Configured: {weather.locationName} + {:else} + No location configured yet + {/if} + +
+ +
Temperature
+
+ {#each units as unit} + + {/each} +
+ +
Wind speed
+
+ {#each windUnits as unit} + + {/each} +
+ +
Pressure
+
+ {#each pressureUnits as unit} + + {/each} +
+ +

+ {#if weather.current} + Currently showing: {Math.round(weather.current.temp)}° (feels like {Math.round(weather.current.feelsLike)}°) · + {weather.current.conditionText} (updated {timeAgo(weather.updatedAt ?? '')}) + {:else} + Not showing any data yet — configure a location above, it polls immediately. + {/if} +

+
+ + diff --git a/frontend/src/lib/components/sidebar/BookmarksWidget.svelte b/frontend/src/lib/components/sidebar/BookmarksWidget.svelte new file mode 100644 index 0000000..86b3fd3 --- /dev/null +++ b/frontend/src/lib/components/sidebar/BookmarksWidget.svelte @@ -0,0 +1,53 @@ + + +
+ Bookmarks + {#if bookmarks.length > 0} +
+ {#each bookmarks as bookmark (bookmark.id)} + {bookmark.name} + {/each} +
+ {:else} +

No bookmarks yet

+ {/if} +
+ + diff --git a/frontend/src/lib/components/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte new file mode 100644 index 0000000..952c138 --- /dev/null +++ b/frontend/src/lib/components/sidebar/Sidebar.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/frontend/src/lib/components/sidebar/StocksWidget.svelte b/frontend/src/lib/components/sidebar/StocksWidget.svelte new file mode 100644 index 0000000..9dc0f83 --- /dev/null +++ b/frontend/src/lib/components/sidebar/StocksWidget.svelte @@ -0,0 +1,94 @@ + + +
+
+ Stocks + {#if stocks.length > 0}today{/if} +
+ {#if stocks.length > 0} +
+ {#each stocks as stock (stock.id)} +
+ {stock.label} + {#if stock.lastPrice !== null} + = 0} class:down={(stock.lastChangePercent ?? 0) < 0}> + {stock.lastPrice.toFixed(2)} + {#if stock.lastChangePercent !== null} + {stock.lastChangePercent >= 0 ? '+' : ''}{stock.lastChangePercent.toFixed(2)}% + {/if} + + {:else} + + {/if} +
+ {/each} +
+ {:else} +

No tickers configured

+ {/if} +
+ + diff --git a/frontend/src/lib/components/sidebar/WeatherWidget.svelte b/frontend/src/lib/components/sidebar/WeatherWidget.svelte new file mode 100644 index 0000000..e1cea08 --- /dev/null +++ b/frontend/src/lib/components/sidebar/WeatherWidget.svelte @@ -0,0 +1,75 @@ + + + + Weather{weather.locationName ? ` - ${weather.locationName}` : ''} + {#if weather.current} +
+ {weather.current.icon} +
+ {Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'} + {weather.current.conditionText} + Feels like {Math.round(weather.current.feelsLike)}° +
+
+ {:else} +

Not configured yet

+ {/if} +
+ + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0a3e773..b2579ec 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -89,4 +89,69 @@ export interface Category { priorityRank: number; isDefault: boolean; isPrivate: boolean; + isSpillover: boolean; +} + +export interface WeatherHourEntry { + time: string; + temp: number; + conditionText: string; + icon: string; +} + +export interface WeatherDayEntry { + date: string; + tempMax: number; + tempMin: number; + conditionText: string; + icon: string; +} + +export interface WeatherCurrentConditions { + temp: number; + feelsLike: number; + conditionText: string; + icon: string; + humidity: number; + precipitationChance: number; + windSpeed: number; + windDirection: string; + pressure: number; + sunrise: string; + sunset: string; +} + +export interface WeatherAlert { + id: string; + event: string; + headline: string; + severity: string; + expires: string; +} + +export interface Weather { + locationName: string | null; + unit: 'celsius' | 'fahrenheit'; + windUnit: 'mph' | 'kph'; + pressureUnit: 'inHg' | 'hPa'; + current: WeatherCurrentConditions | null; + hourly: WeatherHourEntry[]; + daily: WeatherDayEntry[]; + alerts: WeatherAlert[]; + updatedAt: string | null; +} + +export interface StockTicker { + id: string; + label: string; + symbol: string; + lastPrice: number | null; + lastChangePercent: number | null; +} + +export interface Bookmark { + id: string; + name: string; + url: string; + isPrivate: boolean; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 36dadec..d809efe 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { invalidateAll } from '$app/navigation'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import PrivateAccessModal from '$lib/components/PrivateAccessModal.svelte'; + import Sidebar from '$lib/components/sidebar/Sidebar.svelte'; import { logoutPrivateAccess } from '$lib/privateAccess'; import { slugify } from '$lib/format'; import type { LayoutData } from './$types'; @@ -12,6 +13,11 @@ let showLoginModal = $state(false); + // Admin pages already use full page width for their own tab UI — the sidebar's utility + // widgets don't belong there, unlike every reader-facing route (home, category, + // article, event, more, weather). + const showSidebar = $derived(!$page.url.pathname.startsWith('/admin')); + async function handleLockClick() { if (data.privateAccess.authenticated) { await logoutPrivateAccess(); @@ -29,18 +35,24 @@ // "Top stories" is a real Category row (it drives synthesis queue priority) but // 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. + // page, unless it's flagged "spillover" (see MergeTab.svelte's category priority + // 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 // 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 primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover)); + const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover)); + const navItems = $derived([ - ...data.categories.map((cat) => ({ + ...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}` })) + ...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })), + ...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : []) ]); function isActive(href: string): boolean { @@ -100,8 +112,13 @@ (showLoginModal = false)} onSuccess={handleLoginSuccess} /> {/if} -
- {@render children()} +
+
+ {@render children()} +
+ {#if showSidebar} + + {/if}
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 3f91bbc..9386df2 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,14 +1,25 @@ import type { LayoutLoad } from './$types'; -import { getCategories, getEvents } from '$lib/api'; +import { getCategories, getEvents, getWeather, getStocks, getBookmarks } from '$lib/api'; import { getPrivateAccessStatus } from '$lib/privateAccess'; export const load: LayoutLoad = async ({ fetch, data }) => { - const [categories, events, privateAccess] = await Promise.all([ + const [categories, events, privateAccess, weather, stocks, bookmarks] = await Promise.all([ getCategories(fetch), getEvents(fetch), - getPrivateAccessStatus(fetch) + getPrivateAccessStatus(fetch), + getWeather(fetch), + getStocks(fetch), + getBookmarks(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 }; + return { + ...data, + categories, + events: events.filter((e) => e.active), + privateAccess, + weather, + stocks, + bookmarks + }; }; diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte index c6506c7..8282507 100644 --- a/frontend/src/routes/admin/settings/+page.svelte +++ b/frontend/src/routes/admin/settings/+page.svelte @@ -5,6 +5,9 @@ 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 ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte'; import LogsTab from '$lib/components/admin/LogsTab.svelte'; @@ -16,6 +19,9 @@ { 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: 'connections', label: 'Connections' }, { id: 'logs', label: 'Logs' } ]; @@ -45,6 +51,12 @@ {:else if active === 'events'} + {:else if active === 'weather'} + + {:else if active === 'stocks'} + + {:else if active === 'bookmarks'} + {:else if active === 'connections'} {:else if active === 'logs'} diff --git a/frontend/src/routes/admin/settings/+page.ts b/frontend/src/routes/admin/settings/+page.ts index 4fb73f5..a8576df 100644 --- a/frontend/src/routes/admin/settings/+page.ts +++ b/frontend/src/routes/admin/settings/+page.ts @@ -1,17 +1,29 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -import { getSettings, getSources, getEvents, getModels, getAiStatus, getTelegramStatus, getLogs } from '$lib/adminApi'; +import { + getSettings, + getSources, + getEvents, + getModels, + getAiStatus, + getTelegramStatus, + getLogs, + getStockTickers, + getAdminBookmarks +} from '$lib/adminApi'; import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes'; const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] }; export const load: PageLoad = async ({ fetch }) => { try { - const [settings, sources, events, logs] = await Promise.all([ + const [settings, sources, events, logs, stockTickers, bookmarks] = await Promise.all([ getSettings(fetch), getSources(fetch), getEvents(fetch), - getLogs({}, fetch) + getLogs({}, fetch), + getStockTickers(fetch), + getAdminBookmarks(fetch) ]); // The AI service (Ollama) may not be running yet — that shouldn't take down the @@ -28,7 +40,7 @@ export const load: PageLoad = async ({ fetch }) => { () => ({ credentialsConfigured: false, connected: false, phone: null }) ); - return { settings, sources, events, models, aiStatus, telegramStatus, logs }; + return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks }; } catch (err) { if ((err as { status?: number }).status === 401) { throw redirect(302, '/admin/login?redirectTo=/admin/settings'); diff --git a/frontend/src/routes/more/+page.svelte b/frontend/src/routes/more/+page.svelte new file mode 100644 index 0000000..dbefa81 --- /dev/null +++ b/frontend/src/routes/more/+page.svelte @@ -0,0 +1,60 @@ + + +
+ More +
+ +
+ {#each data.sections as section (section.category.id)} + {#if section.articles.length > 0} +
+ {section.category.name} +
+ {#each section.articles as article (article.id)} + + {/each} +
+
+ {/if} + {/each} +
+ + diff --git a/frontend/src/routes/more/+page.ts b/frontend/src/routes/more/+page.ts new file mode 100644 index 0000000..2bc786d --- /dev/null +++ b/frontend/src/routes/more/+page.ts @@ -0,0 +1,22 @@ +import type { PageLoad } from './$types'; +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/[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 sections = await Promise.all( + spillover.map(async (category) => ({ + category, + articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch) + })) + ); + + return { sections }; +}; diff --git a/frontend/src/routes/weather/+page.svelte b/frontend/src/routes/weather/+page.svelte new file mode 100644 index 0000000..1cf32a7 --- /dev/null +++ b/frontend/src/routes/weather/+page.svelte @@ -0,0 +1,291 @@ + + +
+ Weather + {#if weather.locationName} + {weather.locationName} + {/if} +
+ +{#if !weather.current} +

Not configured yet — set a location in the admin panel's Weather tab.

+{:else} +
+ {weather.current.icon} +
+
+ {Math.round(weather.current.temp)}°{unitLabel} + Feels like {Math.round(weather.current.feelsLike)}° +
+ {weather.current.conditionText} + Updated {timeAgo(weather.updatedAt ?? '')} +
+
+ +
+
+ Humidity + {weather.current.humidity}% +
+
+ Precip. chance + {weather.current.precipitationChance}% +
+
+ Wind + {weather.current.windDirection} {Math.round(weather.current.windSpeed)} {weather.windUnit} +
+
+ Pressure + {weather.current.pressure} {weather.pressureUnit} +
+
+ Sunrise + {new Date(weather.current.sunrise).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} +
+
+ Sunset + {new Date(weather.current.sunset).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} +
+
+ + {#if weather.alerts.length > 0} +
+ Weather alerts +
+ {#each weather.alerts as alert (alert.id)} +
+
+ {alert.event} + Until {new Date(alert.expires).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} +
+

{alert.headline}

+
+ {/each} +
+
+ {/if} + +
+ Hourly +
+ {#each weather.hourly as hour (hour.time)} +
+ {new Date(hour.time).toLocaleTimeString([], { hour: 'numeric' })} + {hour.icon} + {Math.round(hour.temp)}° +
+ {/each} +
+
+ +
+ 7-day forecast +
+ {#each weather.daily as day (day.date)} +
+ {new Date(day.date).toLocaleDateString([], { weekday: 'short' })} + {day.icon} + {day.conditionText} + {Math.round(day.tempMax)}° / {Math.round(day.tempMin)}° +
+ {/each} +
+
+{/if} + + diff --git a/frontend/src/routes/weather/+page.ts b/frontend/src/routes/weather/+page.ts new file mode 100644 index 0000000..f60d14a --- /dev/null +++ b/frontend/src/routes/weather/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from './$types'; + +// The sidebar's WeatherWidget (see +layout.svelte/Sidebar.svelte) already fetches this +// same data via the root layout load — no need for a second fetch here. +export const load: PageLoad = async ({ parent }) => { + const { weather } = await parent(); + return { weather }; +};