From 6d5d74b9bb083c4598312bb2157abd3886d3ac54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:54:55 +0000 Subject: [PATCH] Add PoE2 sidebar module: currency exchange watchlist Tracks Path of Exile 2 currency values via poe.ninja's public economy API, mirroring the existing Weather/Stocks sidebar modules. Always follows the current challenge league (auto-detected, no admin picker). Admin browses and picks currencies from a live list rather than typing symbols, since currency ids are opaque. Change % is a 7-day window, labeled accordingly to avoid the same interval ambiguity Stocks had. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/api/admin.ts | 31 +++ backend/src/api/public.ts | 6 + backend/src/poe2/client.ts | 106 ++++++++ backend/src/poe2/poller.ts | 50 ++++ backend/src/queue/scheduler.ts | 9 +- backend/src/storage/db/index.ts | 36 ++- backend/src/storage/db/poe2Watchlist.ts | 49 ++++ backend/src/storage/db/settings.ts | 18 +- backend/src/storage/db/types.ts | 28 +++ frontend/src/lib/adminApi.ts | 21 +- frontend/src/lib/adminTypes.ts | 26 ++ frontend/src/lib/api.ts | 6 +- .../src/lib/components/admin/Poe2Tab.svelte | 237 ++++++++++++++++++ .../lib/components/sidebar/Poe2Widget.svelte | 117 +++++++++ .../src/lib/components/sidebar/Sidebar.svelte | 6 +- frontend/src/lib/format.ts | 9 + frontend/src/lib/types.ts | 17 ++ frontend/src/routes/+layout.svelte | 2 +- frontend/src/routes/+layout.ts | 10 +- .../src/routes/admin/settings/+page.svelte | 4 + frontend/src/routes/admin/settings/+page.ts | 10 +- 21 files changed, 776 insertions(+), 22 deletions(-) create mode 100644 backend/src/poe2/client.ts create mode 100644 backend/src/poe2/poller.ts create mode 100644 backend/src/storage/db/poe2Watchlist.ts create mode 100644 frontend/src/lib/components/admin/Poe2Tab.svelte create mode 100644 frontend/src/lib/components/sidebar/Poe2Widget.svelte diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 95587e7..39cf0e0 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,34 @@ 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 { currencyId, name, icon } = req.body as { currencyId?: string; name?: string; icon?: string | null }; + if (!currencyId || !name) return reply.code(400).send({ error: 'currencyId and name are required' }); + const created = poe2WatchlistDb.addWatchlistEntry(currencyId, name, icon ?? null); + // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, + // and refreshes every existing entry's value 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..c909393 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, primaryCurrencyName, updatedAt } = settingsDb.getSettings().poe2; + return { leagueName, primaryCurrencyName, updatedAt, entries: poe2WatchlistDb.listWatchlist() }; + }); } diff --git a/backend/src/poe2/client.ts b/backend/src/poe2/client.ts new file mode 100644 index 0000000..b7d6748 --- /dev/null +++ b/backend/src/poe2/client.ts @@ -0,0 +1,106 @@ +// 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 two points): currency name/icon 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`). The icon field is `image` (a path relative to this +// same host), not `icon`. Confirmed `sparkline.totalChange` always equals the last entry of +// `sparkline.data`, and every observed `data` array has exactly 7 entries — so this is a +// 7-day cumulative % change, not some other window. +const BASE_URL = 'https://poe.ninja'; + +export interface LeagueInfo { + id: string; + name: string; +} + +export interface CurrencyBrowseEntry { + id: string; + name: string; + icon: string | null; +} + +export interface CurrencyQuote { + value: number; + /** 7-day cumulative % change (see file header) — null if this line had no sparkline data. */ + changePercent: number | null; +} + +interface RawCurrencyItem { + id: string; + name: string; + image?: string; +} + +interface RawCurrencyLine { + id: string; + primaryValue: number; + sparkline?: { totalChange: number; data: number[] } | null; +} + +interface RawCurrencyOverview { + core: { primary: string }; + lines: RawCurrencyLine[]; + items: RawCurrencyItem[]; // top-level, not core.items — see file header +} + +function resolveIcon(image: string | undefined): string | null { + return image ? `${BASE_URL}${image}` : null; +} + +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 metaById = new Map(items.map((item) => [item.id, item])); + return lines + .map((line) => { + const meta = metaById.get(line.id); + return { id: line.id, name: meta?.name ?? line.id, icon: resolveIcon(meta?.image) }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +// One overview fetch covers every watchlisted currency 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). +export async function fetchWatchlistQuotes( + leagueId: string, + currencyIds: string[] +): Promise<{ quotes: Map; primaryCurrencyName: string | null }> { + const { lines, items, core } = await fetchCurrencyOverview(leagueId); + const lineById = new Map(lines.map((line) => [line.id, line])); + const nameById = new Map(items.map((item) => [item.id, item.name])); + + const quotes = new Map(); + for (const id of currencyIds) { + const line = lineById.get(id); + if (!line) { + quotes.set(id, new Error('No longer traded in this league')); + continue; + } + quotes.set(id, { value: line.primaryValue, changePercent: line.sparkline?.totalChange ?? null }); + } + + const primaryCurrencyName = nameById.get(core.primary) ?? core.primary; + return { quotes, primaryCurrencyName }; +} diff --git a/backend/src/poe2/poller.ts b/backend/src/poe2/poller.ts new file mode 100644 index 0000000..6d2254b --- /dev/null +++ b/backend/src/poe2/poller.ts @@ -0,0 +1,50 @@ +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, fetchWatchlistQuotes } from './client.js'; + +// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a +// currency (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 currency 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 { quotes, primaryCurrencyName } = await fetchWatchlistQuotes( + league.id, + entries.map((e) => e.currencyId) + ); + for (const entry of entries) { + const quote = quotes.get(entry.currencyId); + if (!quote) { + poe2WatchlistDb.markPolled(entry.id, null, null, 'No quote returned'); + } else if (quote instanceof Error) { + poe2WatchlistDb.markPolled(entry.id, null, null, quote.message); + } else { + poe2WatchlistDb.markPolled(entry.id, quote.value, quote.changePercent, null); + } + } + settingsDb.updateSettings({ + poe2: { leagueId: league.id, leagueName: league.name, primaryCurrencyName, updatedAt: new Date().toISOString() } + }); + } 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..cbf6a14 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 = 15 * 60_000; // matches Stocks' cadence export function startScheduler() { const provider = () => { @@ -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 15m'); } diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 3df0497..aceb375 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -189,16 +189,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, -- e.g. "Divine Orb" — the unit every poe2_watchlist value is quoted in + 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 +211,22 @@ export function migrate() { created_at TEXT NOT NULL ); + -- Sidebar "PoE2" widget — currency watchlist priced off poe.ninja's PoE2 economy API + -- (see poe2/poller.ts), always against the current challenge league (auto-detected, + -- no admin config). Same shape as stock_tickers — poll state lives on the row. + CREATE TABLE IF NOT EXISTS poe2_watchlist ( + id TEXT PRIMARY KEY, + currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "divine" + name TEXT NOT NULL, -- captured at add-time from the browse picker, not re-resolved + icon TEXT, + priority_rank INTEGER NOT NULL, + last_value 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 ( @@ -302,6 +322,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..e6a3ee0 --- /dev/null +++ b/backend/src/storage/db/poe2Watchlist.ts @@ -0,0 +1,49 @@ +import { randomUUID } from 'node:crypto'; +import { db } from './index.js'; +import type { Poe2WatchlistEntry } from './types.js'; + +function rowToEntry(row: any): Poe2WatchlistEntry { + return { + id: row.id, + currencyId: row.currency_id, + name: row.name, + icon: row.icon, + priorityRank: row.priority_rank, + lastValue: row.last_value, + lastChangePercent: row.last_change_percent, + 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() — entries 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(currencyId: string, name: string, icon: string | null): Poe2WatchlistEntry { + const id = `poe2-${currencyId.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 poe2_watchlist').get() as { m: number }; + const createdAt = new Date().toISOString(); + db.prepare( + 'INSERT INTO poe2_watchlist (id, currency_id, name, icon, priority_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(id, currencyId, name, icon, maxRank.m + 1, createdAt); + return { + id, currencyId, name, icon, priorityRank: maxRank.m + 1, + lastValue: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt + }; +} + +export function removeWatchlistEntry(id: string) { + db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id); +} + +export function markPolled(id: string, value: number | null, changePercent: number | null, error: string | null) { + db.prepare( + 'UPDATE poe2_watchlist SET last_value = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?' + ).run(value, changePercent, new Date().toISOString(), error, id); +} diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index ed73f10..de27bfb 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -36,6 +36,12 @@ 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, + primaryCurrencyName: row.poe2_primary_currency_name, + updatedAt: row.poe2_updated_at } }; } @@ -52,7 +58,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 +71,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_primary_currency_name=?, poe2_updated_at=? WHERE id = 1` ).run( merged.mergeStrictness, @@ -95,7 +103,11 @@ 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.primaryCurrencyName, + merged.poe2.updatedAt ); return getSettings(); } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 8331859..290d568 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -233,6 +233,21 @@ export interface StockTicker { createdAt: string; } +export interface Poe2WatchlistEntry { + id: string; + /** Opaque id from poe.ninja's exchange overview, e.g. "divine" — not a display name. */ + currencyId: string; + name: string; + icon: string | null; + priorityRank: number; + lastValue: number | null; + /** Cumulative % change over poe.ninja's own sparkline window — confirmed to be 7 days (see poe2/client.ts). */ + lastChangePercent: number | null; + lastPolledAt: string | null; + lastError: string | null; + createdAt: string; +} + export interface Bookmark { id: string; name: string; @@ -299,6 +314,19 @@ 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; + /** e.g. "Divine Orb" — the unit every poe2_watchlist value is quoted in. */ + primaryCurrencyName: string | null; + updatedAt: string | null; + }; } export interface WeatherAlert { diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index c37e440..1e1da79 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,20 @@ 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 = (currencyId: string, name: string, icon: string | null, fetchFn?: typeof fetch) => + request( + '/api/admin/poe2/watchlist', + { method: 'POST', body: JSON.stringify({ currencyId, name, icon }) }, + 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..c5c9cb3 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -94,6 +94,31 @@ export interface AdminBookmark { isPrivate: boolean; } +export interface Poe2BrowseEntry { + id: string; + name: string; + icon: string | null; +} + +export interface AdminPoe2Entry { + id: string; + currencyId: string; + name: string; + icon: string | null; + priorityRank: number; + lastValue: number | null; + lastChangePercent: number | null; + lastPolledAt: string | null; + lastError: string | null; +} + +export interface AdminPoe2Settings { + leagueId: string | null; + leagueName: string | null; + primaryCurrencyName: string | null; + updatedAt: string | null; +} + export interface AdminSettings { mergeStrictness: 1 | 2 | 3 | 4 | 5; defaultPollIntervalMinutes: number; @@ -111,6 +136,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..7947ee1 --- /dev/null +++ b/frontend/src/lib/components/admin/Poe2Tab.svelte @@ -0,0 +1,237 @@ + + +
+ {watchlist.length} currencies tracked + +
+

+ {#if settings.poe2.leagueName} + Tracking {settings.poe2.leagueName}{settings.poe2.primaryCurrencyName + ? ` · values in ${settings.poe2.primaryCurrencyName}` + : ''} · change is over the last 7 days. + {:else} + League not detected yet — check back after the next poll (every 15 minutes). + {/if} +

+ +{#if showAdd} +
+ + {#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)} +
+
+ {#if entry.icon}{/if} +
+
{entry.name}
+ {#if entry.lastError} +
{entry.lastError}
+ {/if} +
+
+ {#if entry.lastValue !== null} + = 0} class:down={(entry.lastChangePercent ?? 0) < 0}> + {formatPoeValue(entry.lastValue)} + {#if entry.lastChangePercent !== null} + ({entry.lastChangePercent >= 0 ? '+' : ''}{entry.lastChangePercent.toFixed(2)}%) + {/if} + + {/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..d1f9d33 --- /dev/null +++ b/frontend/src/lib/components/sidebar/Poe2Widget.svelte @@ -0,0 +1,117 @@ + + +
+
+ PoE2 + {#if poe2.entries.length > 0}7d{/if} +
+ {#if poe2.leagueName} +

+ {poe2.leagueName}{poe2.primaryCurrencyName ? ` · in ${poe2.primaryCurrencyName}` : ''} +

+ {/if} + {#if poe2.entries.length > 0} +
+ {#each poe2.entries as entry (entry.id)} +
+ + {#if entry.icon}{/if} + {entry.name} + + {#if entry.lastValue !== null} + = 0} class:down={(entry.lastChangePercent ?? 0) < 0}> + {formatPoeValue(entry.lastValue)} + {#if entry.lastChangePercent !== null} + {entry.lastChangePercent >= 0 ? '+' : ''}{entry.lastChangePercent.toFixed(2)}% + {/if} + + {:else} + + {/if} +
+ {/each} +
+ {:else} +

No currencies tracked

+ {/if} +
+ + diff --git a/frontend/src/lib/components/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte index 952c138..acffee1 100644 --- a/frontend/src/lib/components/sidebar/Sidebar.svelte +++ b/frontend/src/lib/components/sidebar/Sidebar.svelte @@ -1,15 +1,17 @@ diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 6f5c197..b06df1f 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -35,3 +35,12 @@ 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); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b2579ec..debaa97 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -155,3 +155,20 @@ export interface Bookmark { url: string; isPrivate: boolean; } + +export interface Poe2WatchlistEntry { + id: string; + name: string; + icon: string | null; + lastValue: number | null; + /** 7-day cumulative % change — see backend/src/poe2/client.ts. */ + lastChangePercent: number | null; +} + +export interface Poe2Data { + leagueName: string | null; + /** e.g. "Divine Orb" — the unit every entry's lastValue is quoted in. */ + primaryCurrencyName: 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');