diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 95587e7..8792ef5 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -5,6 +5,7 @@ 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 * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { totalStorageBytes } from '../storage/media/index.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; @@ -14,6 +15,8 @@ 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'; +import { browseCurrencies, fetchCurrentLeague } from '../poe2/client.js'; +import { pollPoe2Now } from '../poe2/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 @@ -276,6 +279,45 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.code(204).send(); }); + // --- PoE2 (league is always auto-detected, never admin-set — see poe2/poller.ts) --- + app.get('/api/admin/poe2/browse', async (_req, reply) => { + try { + const league = await fetchCurrentLeague(); + return await browseCurrencies(league.id); + } catch (err) { + return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` }); + } + }); + + app.get('/api/admin/poe2/watchlist', async () => poe2WatchlistDb.listWatchlist()); + + app.post('/api/admin/poe2/watchlist', async (req, reply) => { + const { base, quote } = req.body as { + base?: { currencyId?: string; name?: string }; + quote?: { currencyId?: string; name?: string }; + }; + if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) { + return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' }); + } + if (base.currencyId === quote.currencyId) { + return reply.code(400).send({ error: 'Base and quote currencies must be different' }); + } + const created = poe2WatchlistDb.addWatchlistEntry( + { currencyId: base.currencyId, name: base.name }, + { currencyId: quote.currencyId, name: quote.name } + ); + // Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap, + // and refreshes every existing entry's rate too. + pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); + return reply.code(201).send(created); + }); + + app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + poe2WatchlistDb.removeWatchlistEntry(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 b675e50..9765fb4 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -6,6 +6,7 @@ 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 * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; import { hasPrivateAccess } from './privateAccess.js'; export async function registerPublicRoutes(app: FastifyInstance) { @@ -69,4 +70,9 @@ export async function registerPublicRoutes(app: FastifyInstance) { if (hasPrivateAccess(req)) return bookmarks; return bookmarks.filter((b) => !b.isPrivate); }); + + app.get('/api/poe2', async () => { + const { leagueName, updatedAt } = settingsDb.getSettings().poe2; + return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() }; + }); } diff --git a/backend/src/poe2/client.ts b/backend/src/poe2/client.ts new file mode 100644 index 0000000..d3755db --- /dev/null +++ b/backend/src/poe2/client.ts @@ -0,0 +1,79 @@ +// poe.ninja's public PoE2 economy API — 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/ and backend/src/weather/ keep between the raw client +// and their callers. +// +// Response shape confirmed against real requests (not just the published docs, which were +// imprecise on this point): currency name metadata lives in a top-level `items[]` array on +// the overview response, NOT `core.items` (that only holds the handful of currencies used +// for `core.rates`/`primary`/`secondary`). +// +// Deliberately not using poe.ninja's own `core` (reference currency) or `sparkline` (a fixed +// 7-day window) fields at all — the watchlist tracks arbitrary currency pairs with 24h +// change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is +// expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate +// is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues +// below and poe2/poller.ts, which self-computes change from its own polling history instead. +const BASE_URL = 'https://poe.ninja'; + +export interface LeagueInfo { + id: string; + name: string; +} + +export interface CurrencyBrowseEntry { + id: string; + name: string; +} + +interface RawCurrencyItem { + id: string; + name: string; +} + +interface RawCurrencyLine { + id: string; + primaryValue: number; +} + +interface RawCurrencyOverview { + lines: RawCurrencyLine[]; + items: RawCurrencyItem[]; // top-level, not core.items — see file header +} + +export async function fetchCurrentLeague(): Promise { + const res = await fetch(`${BASE_URL}/poe2/api/economy/leagues`); + if (!res.ok) throw new Error(`poe.ninja leagues returned ${res.status}`); + const leagues = (await res.json()) as LeagueInfo[]; + if (leagues.length === 0) throw new Error('No active leagues returned'); + return leagues[0]; +} + +async function fetchCurrencyOverview(leagueId: string): Promise { + const url = `${BASE_URL}/poe2/api/economy/exchange/current/overview?league=${encodeURIComponent(leagueId)}&type=Currency`; + const res = await fetch(url); + if (!res.ok) throw new Error(`poe.ninja currency overview returned ${res.status}`); + return res.json(); +} + +// Fetches the whole traded-currency list for the admin's search-and-pick UI (see +// api/admin.ts's GET /api/admin/poe2/browse) — only currencies that actually have a `lines` +// entry (i.e. are currently traded), not every currency poe.ninja has ever known about. +export async function browseCurrencies(leagueId: string): Promise { + const { lines, items } = await fetchCurrencyOverview(leagueId); + const nameById = new Map(items.map((item) => [item.id, item.name])); + return lines + .map((line) => ({ id: line.id, name: nameById.get(line.id) ?? line.id })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +// One overview fetch covers every watchlisted pair regardless of list size — unlike +// Stocks, which needs one request per ticker (Yahoo has no equivalent single "give me all of +// these" endpoint without a cookie/crumb handshake). Every line's primaryValue is expressed +// in the same reference currency, so any pair's rate is just baseValue / quoteValue — the +// reference currency itself cancels out, meaning this same one fetch works for arbitrary +// pairs without needing to know or care what poe.ninja's own reference currency is. +export async function fetchCurrencyValues(leagueId: string): Promise> { + const { lines } = await fetchCurrencyOverview(leagueId); + return new Map(lines.map((line) => [line.id, line.primaryValue])); +} diff --git a/backend/src/poe2/poller.ts b/backend/src/poe2/poller.ts new file mode 100644 index 0000000..e6cd503 --- /dev/null +++ b/backend/src/poe2/poller.ts @@ -0,0 +1,63 @@ +import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; +import * as settingsDb from '../storage/db/settings.js'; +import { logger } from '../storage/db/logs.js'; +import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; + +const DAY_MS = 24 * 60 * 60_000; + +function pctChange(current: number, past: number | null): number | null { + if (past === null || past === 0) return null; + return ((current - past) / past) * 100; +} + +// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a pair +// (see api/admin.ts) — always re-detects the current challenge league fresh (cheap, guarantees +// correctness across league rotations with no separate staleness logic), then one overview +// request covers the whole watchlist. A pair whose base or quote currency is no longer traded +// this league gets its own lastError, it never aborts the rest of the batch. +export async function pollPoe2Now(): Promise { + let league; + try { + league = await fetchCurrentLeague(); + } catch (err) { + logger.error('poe2', `League lookup failed: ${(err as Error).message}`); + return; + } + + const entries = poe2WatchlistDb.listWatchlist(); + if (entries.length === 0) { + const { poe2 } = settingsDb.getSettings(); + settingsDb.updateSettings({ + poe2: { ...poe2, leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() } + }); + return; + } + + try { + const valuesById = await fetchCurrencyValues(league.id); + const now = new Date(); + const nowIso = now.toISOString(); + const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); + + for (const entry of entries) { + const baseValue = valuesById.get(entry.baseCurrencyId); + const quoteValue = valuesById.get(entry.quoteCurrencyId); + if (baseValue === undefined || quoteValue === undefined) { + poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league'); + continue; + } + + const rate = baseValue / quoteValue; + const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h)); + poe2WatchlistDb.recordRate(entry.id, rate, nowIso); + poe2WatchlistDb.markPolled(entry.id, rate, change24h, null); + } + + poe2WatchlistDb.pruneOldHistory(); + settingsDb.updateSettings({ + poe2: { leagueId: league.id, leagueName: league.name, updatedAt: nowIso } + }); + } catch (err) { + logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`); + } +} diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts index 7636923..b9d958c 100644 --- a/backend/src/queue/scheduler.ts +++ b/backend/src/queue/scheduler.ts @@ -7,12 +7,14 @@ 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'; +import { pollPoe2Now } from '../poe2/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 +const POE2_TICK_MS = 60 * 60_000; // poe.ninja's own overview data doesn't refresh faster than hourly, so polling more often than this just re-fetches the same numbers export function startScheduler() { const provider = () => { @@ -65,9 +67,9 @@ export function startScheduler() { } }, RETENTION_TICK_MS); - // 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 + // 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}`)); setInterval(() => { @@ -79,5 +81,10 @@ export function startScheduler() { 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'); + pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`)); + setInterval(() => { + pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`)); + }, POE2_TICK_MS); + + logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m, poe2 every 1h'); } diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 3df0497..0697e94 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -25,6 +25,14 @@ export function migrate() { db.exec('DROP TABLE IF EXISTS admin_users;'); db.exec('DROP TABLE IF EXISTS sessions;'); + // PoE2 watchlist model changed from "value quoted in one primary currency" to arbitrary + // currency pairs (base/quote) — the old single-currency rows can't be mapped onto a pair, + // so the table is dropped and rebuilt fresh below rather than migrated column-by-column. + const poe2WatchlistCols = db.prepare(`PRAGMA table_info(poe2_watchlist)`).all() as { name: string }[]; + if (poe2WatchlistCols.some((c) => c.name === 'currency_id')) { + db.exec('DROP TABLE IF EXISTS poe2_watchlist;'); + } + db.exec(` CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, @@ -189,16 +197,20 @@ export function migrate() { 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 + 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 ); - -- 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. + -- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (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" + symbol TEXT NOT NULL, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD" priority_rank INTEGER NOT NULL, last_price REAL, last_change_percent REAL, @@ -207,6 +219,36 @@ export function migrate() { created_at TEXT NOT NULL ); + -- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs + -- (see poe2/poller.ts), always against the current challenge league (auto-detected, + -- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are + -- captured at add-time from the browse picker, not re-resolved. No icon columns — + -- the UI doesn't display them. + CREATE TABLE IF NOT EXISTS poe2_watchlist ( + id TEXT PRIMARY KEY, + base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted" + base_name TEXT NOT NULL, + quote_currency_id TEXT NOT NULL, + quote_name TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + last_rate REAL, + last_change_24h REAL, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL + ); + + -- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching + -- 24h change window, so it's computed ourselves from this history (see + -- poe2/poller.ts). Pruned to the last 2 days on every poll. + CREATE TABLE IF NOT EXISTS poe2_rate_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + watchlist_id TEXT NOT NULL, + rate REAL NOT NULL, + recorded_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_poe2_rate_history_watchlist ON poe2_rate_history(watchlist_id, recorded_at); + -- 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 ( @@ -302,6 +344,12 @@ export function migrate() { 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 '[]'"); } + 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'); + } // 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. diff --git a/backend/src/storage/db/poe2Watchlist.ts b/backend/src/storage/db/poe2Watchlist.ts new file mode 100644 index 0000000..beba793 --- /dev/null +++ b/backend/src/storage/db/poe2Watchlist.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { Poe2WatchlistEntry } from './types.js'; + +interface CurrencyRef { + currencyId: string; + name: string; +} + +function rowToEntry(row: any): Poe2WatchlistEntry { + return { + id: row.id, + baseCurrencyId: row.base_currency_id, + baseName: row.base_name, + quoteCurrencyId: row.quote_currency_id, + quoteName: row.quote_name, + priorityRank: row.priority_rank, + lastRate: row.last_rate, + lastChange24h: row.last_change_24h, + lastPolledAt: row.last_polled_at, + lastError: row.last_error, + createdAt: row.created_at + }; +} + +export function listWatchlist(): Poe2WatchlistEntry[] { + const rows = db.prepare('SELECT * FROM poe2_watchlist ORDER BY priority_rank').all(); + return rows.map(rowToEntry); +} + +// No update() — currencies are picked from a live browse list (see poe2/client.ts's +// browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the +// rare "picked the wrong one" case. +export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry { + const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}` + .replace(/-+/g, '-') + .replace(/(^-|-$)/g, ''); + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM poe2_watchlist').get() as { m: number }; + const createdAt = new Date().toISOString(); + db.prepare( + `INSERT INTO poe2_watchlist + (id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt); + return { + id, + baseCurrencyId: base.currencyId, + baseName: base.name, + quoteCurrencyId: quote.currencyId, + quoteName: quote.name, + priorityRank: maxRank.m + 1, + lastRate: null, + lastChange24h: null, + lastPolledAt: null, + lastError: null, + createdAt + }; +} + +export function removeWatchlistEntry(id: string) { + db.prepare('DELETE FROM poe2_rate_history WHERE watchlist_id = ?').run(id); + db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id); +} + +// One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed +// from, since poe.ninja itself doesn't expose per-pair rates or a matching change window. +export function recordRate(watchlistId: string, rate: number, recordedAt: string) { + db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run( + watchlistId, + rate, + recordedAt + ); +} + +// The closest snapshot at-or-before cutoffIso — null if there isn't one yet (e.g. a +// pair added less than 24h ago), which the poller treats as "no change data yet" rather +// than fabricating a 0% figure. +export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null { + const row = db + .prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1') + .get(watchlistId, cutoffIso) as { rate: number } | undefined; + return row ? row.rate : null; +} + +export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) { + db.prepare( + `UPDATE poe2_watchlist + SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ? + WHERE id = ?` + ).run(rate, change24h, new Date().toISOString(), error, id); +} + +// Keeps history bounded — a day or two of slack past the 24h window is plenty for a +// poll to be briefly late without losing the data point it needs. +export function pruneOldHistory() { + const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + db.prepare('DELETE FROM poe2_rate_history WHERE recorded_at < ?').run(cutoff); +} diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index ed73f10..35723a3 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -36,6 +36,11 @@ function rowToSettings(row: any): GlobalSettings { daily: JSON.parse(row.weather_daily), alerts: JSON.parse(row.weather_alerts), updatedAt: row.weather_updated_at + }, + poe2: { + leagueId: row.poe2_league_id, + leagueName: row.poe2_league_name, + updatedAt: row.poe2_updated_at } }; } @@ -52,7 +57,8 @@ export function updateSettings(patch: Partial): GlobalSettings { ...patch, retention: { ...current.retention, ...(patch.retention ?? {}) }, selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) }, - weather: { ...current.weather, ...(patch.weather ?? {}) } + weather: { ...current.weather, ...(patch.weather ?? {}) }, + poe2: { ...current.poe2, ...(patch.poe2 ?? {}) } }; db.prepare( `UPDATE global_settings SET @@ -64,7 +70,8 @@ export function updateSettings(patch: Partial): GlobalSettings { 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=? + weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?, + poe2_league_id=?, poe2_league_name=?, poe2_updated_at=? WHERE id = 1` ).run( merged.mergeStrictness, @@ -95,7 +102,10 @@ export function updateSettings(patch: Partial): GlobalSettings { JSON.stringify(merged.weather.hourly), JSON.stringify(merged.weather.daily), JSON.stringify(merged.weather.alerts), - merged.weather.updatedAt + merged.weather.updatedAt, + merged.poe2.leagueId, + merged.poe2.leagueName, + merged.poe2.updatedAt ); return getSettings(); } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 8331859..45b759b 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -233,6 +233,27 @@ export interface StockTicker { createdAt: string; } +export interface Poe2WatchlistEntry { + id: string; + /** Opaque id from poe.ninja's exchange overview, e.g. "exalted" — not a display name. */ + baseCurrencyId: string; + baseName: string; + quoteCurrencyId: string; + quoteName: string; + priorityRank: number; + /** 1 base = lastRate quote. */ + lastRate: number | null; + /** + * % change over the last 24h, self-computed from poe2_rate_history since poe.ninja + * doesn't expose a matching change window (see poe2/poller.ts) — null when there + * isn't yet 24h of history (e.g. a pair just added). + */ + lastChange24h: number | null; + lastPolledAt: string | null; + lastError: string | null; + createdAt: string; +} + export interface Bookmark { id: string; name: string; @@ -299,6 +320,17 @@ export interface GlobalSettings { alerts: WeatherAlert[]; updatedAt: string | null; }; + /** + * Sidebar "PoE2" widget cache — see poe2/poller.ts. No admin-set config (unlike weather): + * the league is always auto-detected as the current challenge league, so this is purely + * a cache of what the last poll learned. Watchlist entries themselves live in the + * poe2_watchlist table, not here — same split as stock_tickers vs. this settings row. + */ + poe2: { + leagueId: string | null; + leagueName: string | null; + updatedAt: string | null; + }; } export interface WeatherAlert { diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index c37e440..304d197 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -11,7 +11,9 @@ import type { LogEntry, GeocodeResult, AdminStockTicker, - AdminBookmark + AdminBookmark, + Poe2BrowseEntry, + AdminPoe2Entry } from './adminTypes'; async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { @@ -202,3 +204,24 @@ export const updateBookmark = (id: string, patch: { name?: string; url?: string; export const deleteBookmark = (id: string, fetchFn?: typeof fetch) => request(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn); + +// PoE2 — league is always auto-detected, never admin-set (see poe2/poller.ts). +export const browsePoe2Currencies = (fetchFn?: typeof fetch) => + request('/api/admin/poe2/browse', {}, fetchFn); + +export const getPoe2Watchlist = (fetchFn?: typeof fetch) => + request('/api/admin/poe2/watchlist', {}, fetchFn); + +export const addPoe2WatchlistEntry = ( + base: { currencyId: string; name: string }, + quote: { currencyId: string; name: string }, + fetchFn?: typeof fetch +) => + request( + '/api/admin/poe2/watchlist', + { method: 'POST', body: JSON.stringify({ base, quote }) }, + fetchFn + ); + +export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 5d36070..2cc08d6 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -94,6 +94,30 @@ export interface AdminBookmark { isPrivate: boolean; } +export interface Poe2BrowseEntry { + id: string; + name: string; +} + +export interface AdminPoe2Entry { + id: string; + baseCurrencyId: string; + baseName: string; + quoteCurrencyId: string; + quoteName: string; + priorityRank: number; + lastRate: number | null; + lastChange24h: number | null; + lastPolledAt: string | null; + lastError: string | null; +} + +export interface AdminPoe2Settings { + leagueId: string | null; + leagueName: string | null; + updatedAt: string | null; +} + export interface AdminSettings { mergeStrictness: 1 | 2 | 3 | 4 | 5; defaultPollIntervalMinutes: number; @@ -111,6 +135,7 @@ export interface AdminSettings { retention: RetentionSettings; categoryPriority: CategoryPriority[]; weather: AdminWeatherSettings; + poe2: AdminPoe2Settings; } export interface AdminSource { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1e017a2..e0afe7d 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, Weather, StockTicker, Bookmark } from './types'; +import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data } from './types'; async function get(path: string, fetchFn: typeof fetch = fetch): Promise { // credentials: 'include' so the private-access cookie (see lib/privateAccess.ts) @@ -53,3 +53,7 @@ export function getStocks(fetchFn?: typeof fetch): Promise { export function getBookmarks(fetchFn?: typeof fetch): Promise { return get('/api/bookmarks', fetchFn); } + +export function getPoe2(fetchFn?: typeof fetch): Promise { + return get('/api/poe2', fetchFn); +} diff --git a/frontend/src/lib/components/admin/Poe2Tab.svelte b/frontend/src/lib/components/admin/Poe2Tab.svelte new file mode 100644 index 0000000..7cfd71d --- /dev/null +++ b/frontend/src/lib/components/admin/Poe2Tab.svelte @@ -0,0 +1,267 @@ + + +
+ {watchlist.length} pairs tracked + +
+

+ {#if settings.poe2.leagueName} + Tracking {settings.poe2.leagueName} · change is over the last 24h. + {:else} + League not detected yet — check back after the next poll (every hour). + {/if} +

+ +{#if showAdd} +
+
+ {#if step === 'base'} + Step 1 — pick the base currency + {:else} + Step 2 — pick what to quote {selectedBase?.name} against + {/if} +
+ + {#if browsing} +

Loading currency list…

+ {:else if browseError} +

{browseError}

+ {:else if filtered.length === 0} +

{query ? 'No matches' : 'No traded currencies found for this league'}

+ {:else} +
+ {#each filtered as entry (entry.id)} + + {/each} +
+ {/if} +
+ +
+
+{/if} + +
+ {#each watchlist as entry (entry.id)} +
+
+
{entry.baseName} {entry.quoteName}
+ +
+ {#if entry.lastError} +
{entry.lastError}
+ {:else if entry.lastRate !== null} +
+ 1 {entry.baseName} = {formatPoeValue(entry.lastRate)} {entry.quoteName} + 24h {fmtChange(entry.lastChange24h)} +
+
+ 1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName} + 24h {fmtChange(invertChangePercent(entry.lastChange24h))} +
+ {:else} +
Waiting for first poll…
+ {/if} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/sidebar/Poe2Widget.svelte b/frontend/src/lib/components/sidebar/Poe2Widget.svelte new file mode 100644 index 0000000..1cbdaf6 --- /dev/null +++ b/frontend/src/lib/components/sidebar/Poe2Widget.svelte @@ -0,0 +1,116 @@ + + + +
+ PoE2 + {#if poe2.entries.length > 0}24h{/if} +
+ {#if poe2.leagueName} +

{poe2.leagueName}

+ {/if} + {#if poe2.entries.length > 0} +
+ {#each poe2.entries as entry (entry.id)} +
+ {entry.baseName} {entry.quoteName} + {#if entry.lastRate !== null} + = 0} class:down={entry.lastChange24h !== null && entry.lastChange24h < 0}> + {formatPoeValue(entry.lastRate)} + {entry.lastChange24h !== null ? `${entry.lastChange24h >= 0 ? '+' : ''}${entry.lastChange24h.toFixed(2)}%` : '—'} + + {:else} + + {/if} +
+ {/each} +
+ {:else} +

No currency pairs tracked

+ {/if} +
+ + diff --git a/frontend/src/lib/components/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte index 952c138..250d620 100644 --- a/frontend/src/lib/components/sidebar/Sidebar.svelte +++ b/frontend/src/lib/components/sidebar/Sidebar.svelte @@ -1,24 +1,101 @@ - + diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 6f5c197..bccf078 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -21,6 +21,36 @@ export function exactTime(iso: string): string { return `${mm}/${dd}/${yyyy} - ${String(hours).padStart(2, '0')}:${minutes} ${ampm}`; } +/** + * Open-Meteo's daily forecast returns bare "YYYY-MM-DD" dates (no time component) meaning + * that calendar day in the forecast location's own timezone. `new Date("YYYY-MM-DD")` parses + * that as UTC midnight per the ECMAScript spec — displaying it in the browser's local + * timezone (anything behind UTC, i.e. all of the Americas) then rolls it back to the + * *previous* day, making "today" look like it already passed. Parsing via the (year, month, + * day) constructor instead builds the date in local time, avoiding the UTC round-trip. + */ +export function parseDateOnly(dateOnly: string): Date { + const [year, month, day] = dateOnly.split('-').map(Number); + return new Date(year, month - 1, day); +} + +function ordinal(n: number): string { + const lastDigit = n % 10; + const lastTwoDigits = n % 100; + if (lastDigit === 1 && lastTwoDigits !== 11) return `${n}st`; + if (lastDigit === 2 && lastTwoDigits !== 12) return `${n}nd`; + if (lastDigit === 3 && lastTwoDigits !== 13) return `${n}rd`; + return `${n}th`; +} + +/** "Friday July, 24th, 2026" — full weekday/month name with an ordinal day, for the 7-day forecast headings. */ +export function formatDayHeading(dateOnly: string): string { + const d = parseDateOnly(dateOnly); + const weekday = d.toLocaleDateString([], { weekday: 'long' }); + const month = d.toLocaleDateString([], { month: 'long' }); + return `${weekday} ${month}, ${ordinal(d.getDate())}, ${d.getFullYear()}`; +} + export function slugify(name: string): string { return name .toLowerCase() @@ -35,3 +65,32 @@ export function excerpt(body: string, sentenceCount = 2): string { const sentences = singleLine.match(/[^.!?]+[.!?]+/g) ?? [singleLine]; return sentences.slice(0, sentenceCount).join(' ').trim(); } + +/** PoE currency values span many orders of magnitude (e.g. 0.00002749 to 4856) — a fixed decimal count alone reads badly across that range. */ +export function formatPoeValue(value: number): string { + if (value === 0) return '0'; + if (value >= 100) return value.toFixed(0); + if (value >= 1) return value.toFixed(2); + if (value >= 0.01) return value.toFixed(3); + return value.toPrecision(2); +} + +/** + * A pair's inverse-direction % change isn't the negation of the forward change (that's only + * an approximation) — it's the reciprocal-return identity: if rate went from `old` to `new`, + * forward change = (new-old)/old, and inverse change = (1/new - 1/old)/(1/old) = (old-new)/new + * = -change/(1+change/100). Derivable from the forward change alone, no extra data needed. + */ +export function invertChangePercent(change: number | null): number | null { + if (change === null) return null; + return -change / (1 + change / 100); +} + +/** + * poe.ninja's own league-page slugs have no separators at all (e.g. "Runes of Aldur" -> + * "runesofaldur"), unlike this app's own dash-separated slugify() above — used to link the + * PoE2 panel out to https://poe.ninja/poe2/economy/{slug}/currency for the current league. + */ +export function poeLeagueSlug(leagueName: string): string { + return leagueName.toLowerCase().replace(/[^a-z0-9]/g, ''); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b2579ec..58d4f73 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -155,3 +155,19 @@ export interface Bookmark { url: string; isPrivate: boolean; } + +export interface Poe2WatchlistEntry { + id: string; + baseName: string; + quoteName: string; + /** 1 base = lastRate quote. */ + lastRate: number | null; + /** 24h % change, self-computed from our own poll history — null if not enough history yet. */ + lastChange24h: number | null; +} + +export interface Poe2Data { + leagueName: string | null; + updatedAt: string | null; + entries: Poe2WatchlistEntry[]; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index d809efe..e030e52 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -117,7 +117,7 @@ {@render children()} {#if showSidebar} - + {/if} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 9386df2..8e4935a 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1,15 +1,16 @@ import type { LayoutLoad } from './$types'; -import { getCategories, getEvents, getWeather, getStocks, getBookmarks } from '$lib/api'; +import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2 } from '$lib/api'; import { getPrivateAccessStatus } from '$lib/privateAccess'; export const load: LayoutLoad = async ({ fetch, data }) => { - const [categories, events, privateAccess, weather, stocks, bookmarks] = await Promise.all([ + const [categories, events, privateAccess, weather, stocks, bookmarks, poe2] = await Promise.all([ getCategories(fetch), getEvents(fetch), getPrivateAccessStatus(fetch), getWeather(fetch), getStocks(fetch), - getBookmarks(fetch) + getBookmarks(fetch), + getPoe2(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. @@ -20,6 +21,7 @@ export const load: LayoutLoad = async ({ fetch, data }) => { privateAccess, weather, stocks, - bookmarks + bookmarks, + poe2 }; }; diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte index 8282507..07e8aba 100644 --- a/frontend/src/routes/admin/settings/+page.svelte +++ b/frontend/src/routes/admin/settings/+page.svelte @@ -8,6 +8,7 @@ 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 ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte'; import LogsTab from '$lib/components/admin/LogsTab.svelte'; @@ -22,6 +23,7 @@ { id: 'weather', label: 'Weather' }, { id: 'stocks', label: 'Stocks' }, { id: 'bookmarks', label: 'Bookmarks' }, + { id: 'poe2', label: 'PoE2' }, { id: 'connections', label: 'Connections' }, { id: 'logs', label: 'Logs' } ]; @@ -57,6 +59,8 @@ {:else if active === 'bookmarks'} + {:else if active === 'poe2'} + {: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 a8576df..eb4c82d 100644 --- a/frontend/src/routes/admin/settings/+page.ts +++ b/frontend/src/routes/admin/settings/+page.ts @@ -9,7 +9,8 @@ import { getTelegramStatus, getLogs, getStockTickers, - getAdminBookmarks + getAdminBookmarks, + getPoe2Watchlist } from '$lib/adminApi'; import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes'; @@ -17,13 +18,14 @@ const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] }; export const load: PageLoad = async ({ fetch }) => { try { - const [settings, sources, events, logs, stockTickers, bookmarks] = await Promise.all([ + const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist] = await Promise.all([ getSettings(fetch), getSources(fetch), getEvents(fetch), getLogs({}, fetch), getStockTickers(fetch), - getAdminBookmarks(fetch) + getAdminBookmarks(fetch), + getPoe2Watchlist(fetch) ]); // The AI service (Ollama) may not be running yet — that shouldn't take down the @@ -40,7 +42,7 @@ export const load: PageLoad = async ({ fetch }) => { () => ({ credentialsConfigured: false, connected: false, phone: null }) ); - return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks }; + return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks, poe2Watchlist }; } catch (err) { if ((err as { status?: number }).status === 401) { throw redirect(302, '/admin/login?redirectTo=/admin/settings'); diff --git a/frontend/src/routes/weather/+page.svelte b/frontend/src/routes/weather/+page.svelte index 1cf32a7..0dfc063 100644 --- a/frontend/src/routes/weather/+page.svelte +++ b/frontend/src/routes/weather/+page.svelte @@ -1,6 +1,6 @@