From 6d5d74b9bb083c4598312bb2157abd3886d3ac54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:54:55 +0000 Subject: [PATCH 1/8] 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'); From a51387902ca96a2d3cccb80deee4b2a48e464a15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 02:48:28 +0000 Subject: [PATCH 2/8] Rework PoE2 module: pairwise currency exchange rates, 1h/24h/7d change PoE2's economy is inherently pairwise (Exalted vs Chaos, Divine vs Exalted), not everything quoted in one reference currency, so the watchlist now tracks admin-picked currency pairs and shows both directions with 1h/24h/7d change. poe.ninja doesn't expose per-pair rates or multiple change windows, so both are self-computed: any pair's rate comes from dividing the two currencies' primaryValue (same reference currency cancels out), and change% is derived from our own poll-history snapshots rather than poe.ninja's fixed 7-day sparkline. The inverse direction's change is exact closed-form math from the forward change, not a sign-flip approximation. The old single-currency watchlist schema can't be mapped onto pairs, so migrate() drops and rebuilds poe2_watchlist when it detects the old shape. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/api/admin.ts | 19 ++- backend/src/api/public.ts | 4 +- backend/src/poe2/client.ts | 50 ++---- backend/src/poe2/poller.ts | 54 ++++-- backend/src/storage/db/index.ts | 42 ++++- backend/src/storage/db/poe2Watchlist.ts | 98 +++++++++-- backend/src/storage/db/settings.ts | 4 +- backend/src/storage/db/types.ts | 26 ++- frontend/src/lib/adminApi.ts | 8 +- frontend/src/lib/adminTypes.ts | 16 +- .../src/lib/components/admin/Poe2Tab.svelte | 160 ++++++++++++------ .../lib/components/sidebar/Poe2Widget.svelte | 39 +++-- frontend/src/lib/format.ts | 11 ++ frontend/src/lib/types.ts | 17 +- 14 files changed, 373 insertions(+), 175 deletions(-) diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 39cf0e0..a490b62 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -292,11 +292,22 @@ export async function registerAdminRoutes(app: FastifyInstance) { 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); + const { base, quote } = req.body as { + base?: { currencyId?: string; name?: string; icon?: string | null }; + quote?: { currencyId?: string; name?: string; icon?: string | null }; + }; + 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, icon: base.icon ?? null }, + { currencyId: quote.currencyId, name: quote.name, icon: quote.icon ?? null } + ); // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, - // and refreshes every existing entry's value too. + // 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); }); diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index c909393..9765fb4 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -72,7 +72,7 @@ export async function registerPublicRoutes(app: FastifyInstance) { }); app.get('/api/poe2', async () => { - const { leagueName, primaryCurrencyName, updatedAt } = settingsDb.getSettings().poe2; - return { leagueName, primaryCurrencyName, updatedAt, entries: poe2WatchlistDb.listWatchlist() }; + 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 index b7d6748..07922a6 100644 --- a/backend/src/poe2/client.ts +++ b/backend/src/poe2/client.ts @@ -7,9 +7,15 @@ // 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. +// same host), not `icon`. +// +// 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 1h/24h/7d +// change, neither of which poe.ninja's overview exposes 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 { @@ -23,12 +29,6 @@ export interface CurrencyBrowseEntry { 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; @@ -38,11 +38,9 @@ interface RawCurrencyItem { 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 } @@ -80,27 +78,13 @@ export async function browseCurrencies(leagueId: string): Promise a.name.localeCompare(b.name)); } -// One overview fetch covers every watchlisted currency regardless of list size — unlike +// 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). -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 }; +// 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 index 6d2254b..5e4a2cf 100644 --- a/backend/src/poe2/poller.ts +++ b/backend/src/poe2/poller.ts @@ -1,13 +1,21 @@ 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'; +import { fetchCurrentLeague, fetchCurrencyValues } 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. +const HOUR_MS = 60 * 60_000; +const DAY_MS = 24 * HOUR_MS; + +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 { @@ -27,22 +35,32 @@ export async function pollPoe2Now(): Promise { } try { - const { quotes, primaryCurrencyName } = await fetchWatchlistQuotes( - league.id, - entries.map((e) => e.currencyId) - ); + const valuesById = await fetchCurrencyValues(league.id); + const now = new Date(); + const nowIso = now.toISOString(); + const cutoff1h = new Date(now.getTime() - HOUR_MS).toISOString(); + const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); + const cutoff7d = new Date(now.getTime() - 7 * DAY_MS).toISOString(); + 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); + const baseValue = valuesById.get(entry.baseCurrencyId); + const quoteValue = valuesById.get(entry.quoteCurrencyId); + if (baseValue === undefined || quoteValue === undefined) { + poe2WatchlistDb.markPolled(entry.id, null, null, null, null, 'One or both currencies no longer traded in this league'); + continue; } + + const rate = baseValue / quoteValue; + const change1h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff1h)); + const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h)); + const change7d = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff7d)); + poe2WatchlistDb.recordRate(entry.id, rate, nowIso); + poe2WatchlistDb.markPolled(entry.id, rate, change1h, change24h, change7d, null); } + + poe2WatchlistDb.pruneOldHistory(); settingsDb.updateSettings({ - poe2: { leagueId: league.id, leagueName: league.name, primaryCurrencyName, updatedAt: new Date().toISOString() } + 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/storage/db/index.ts b/backend/src/storage/db/index.ts index aceb375..e203fc9 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, @@ -192,7 +200,7 @@ export function migrate() { weather_updated_at TEXT, -- ISO timestamp, NULL pre-first-poll poe2_league_id TEXT, poe2_league_name TEXT, - poe2_primary_currency_name TEXT, -- e.g. "Divine Orb" — the unit every poe2_watchlist value is quoted in + 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 ); @@ -211,22 +219,40 @@ export function migrate() { created_at TEXT NOT NULL ); - -- Sidebar "PoE2" widget — currency watchlist priced off poe.ninja's PoE2 economy API + -- 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). Same shape as stock_tickers — poll state lives on the row. + -- no admin config). Rate is "1 base = last_rate quote"; both currencies' names/icons + -- are captured at add-time from the browse picker, not re-resolved. 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, + base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted" + base_name TEXT NOT NULL, + base_icon TEXT, + quote_currency_id TEXT NOT NULL, + quote_name TEXT NOT NULL, + quote_icon TEXT, priority_rank INTEGER NOT NULL, - last_value REAL, - last_change_percent REAL, + last_rate REAL, + last_change_1h REAL, + last_change_24h REAL, + last_change_7d REAL, last_polled_at TEXT, last_error TEXT, created_at TEXT NOT NULL ); + -- Per-poll rate snapshots for the pairs above — poe.ninja only exposes one 7-day + -- change window, so 1h/24h/7d change is computed ourselves from this history + -- (see poe2/poller.ts), rather than trusting a field poe.ninja doesn't provide. + -- Pruned to the last 8 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 ( diff --git a/backend/src/storage/db/poe2Watchlist.ts b/backend/src/storage/db/poe2Watchlist.ts index e6a3ee0..8a41f44 100644 --- a/backend/src/storage/db/poe2Watchlist.ts +++ b/backend/src/storage/db/poe2Watchlist.ts @@ -2,15 +2,26 @@ import { randomUUID } from 'node:crypto'; import { db } from './index.js'; import type { Poe2WatchlistEntry } from './types.js'; +interface CurrencyRef { + currencyId: string; + name: string; + icon: string | null; +} + function rowToEntry(row: any): Poe2WatchlistEntry { return { id: row.id, - currencyId: row.currency_id, - name: row.name, - icon: row.icon, + baseCurrencyId: row.base_currency_id, + baseName: row.base_name, + baseIcon: row.base_icon, + quoteCurrencyId: row.quote_currency_id, + quoteName: row.quote_name, + quoteIcon: row.quote_icon, priorityRank: row.priority_rank, - lastValue: row.last_value, - lastChangePercent: row.last_change_percent, + lastRate: row.last_rate, + lastChange1h: row.last_change_1h, + lastChange24h: row.last_change_24h, + lastChange7d: row.last_change_7d, lastPolledAt: row.last_polled_at, lastError: row.last_error, createdAt: row.created_at @@ -22,28 +33,83 @@ export function listWatchlist(): Poe2WatchlistEntry[] { return rows.map(rowToEntry); } -// No update() — entries are picked from a live browse list (see poe2/client.ts's +// 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(currencyId: string, name: string, icon: string | null): Poe2WatchlistEntry { - const id = `poe2-${currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; +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, currency_id, name, icon, priority_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)' - ).run(id, currencyId, name, icon, maxRank.m + 1, createdAt); + `INSERT INTO poe2_watchlist + (id, base_currency_id, base_name, base_icon, quote_currency_id, quote_name, quote_icon, priority_rank, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(id, base.currencyId, base.name, base.icon, quote.currencyId, quote.name, quote.icon, maxRank.m + 1, createdAt); return { - id, currencyId, name, icon, priorityRank: maxRank.m + 1, - lastValue: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt + id, + baseCurrencyId: base.currencyId, + baseName: base.name, + baseIcon: base.icon, + quoteCurrencyId: quote.currencyId, + quoteName: quote.name, + quoteIcon: quote.icon, + priorityRank: maxRank.m + 1, + lastRate: null, + lastChange1h: null, + lastChange24h: null, + lastChange7d: 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); } -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); +// One snapshot per poll (see poe2/poller.ts) — the raw material 1h/24h/7d change is +// computed from, since poe.ninja itself doesn't expose per-pair rates or multiple +// change windows. +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 a window 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, + change1h: number | null, + change24h: number | null, + change7d: number | null, + error: string | null +) { + db.prepare( + `UPDATE poe2_watchlist + SET last_rate = ?, last_change_1h = ?, last_change_24h = ?, last_change_7d = ?, last_polled_at = ?, last_error = ? + WHERE id = ?` + ).run(rate, change1h, change24h, change7d, new Date().toISOString(), error, id); +} + +// Keeps history bounded — 8 days is enough slack past the 7d window for a poll to be +// briefly late without losing the data point it needs. +export function pruneOldHistory() { + const cutoff = new Date(Date.now() - 8 * 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 de27bfb..35723a3 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -40,7 +40,6 @@ function rowToSettings(row: any): GlobalSettings { poe2: { leagueId: row.poe2_league_id, leagueName: row.poe2_league_name, - primaryCurrencyName: row.poe2_primary_currency_name, updatedAt: row.poe2_updated_at } }; @@ -72,7 +71,7 @@ export function updateSettings(patch: Partial): GlobalSettings { weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?, weather_wind_unit=?, weather_pressure_unit=?, weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?, - poe2_league_id=?, poe2_league_name=?, poe2_primary_currency_name=?, poe2_updated_at=? + poe2_league_id=?, poe2_league_name=?, poe2_updated_at=? WHERE id = 1` ).run( merged.mergeStrictness, @@ -106,7 +105,6 @@ export function updateSettings(patch: Partial): GlobalSettings { 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 290d568..ce2fb73 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -235,14 +235,24 @@ export interface StockTicker { 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; + /** Opaque id from poe.ninja's exchange overview, e.g. "exalted" — not a display name. */ + baseCurrencyId: string; + baseName: string; + baseIcon: string | null; + quoteCurrencyId: string; + quoteName: string; + quoteIcon: 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; + /** 1 base = lastRate quote. */ + lastRate: number | null; + /** + * % change over the last 1h/24h/7d, self-computed from poe2_rate_history since + * poe.ninja only exposes one 7-day window (see poe2/poller.ts) — null when there + * isn't yet enough history for that window (e.g. a pair just added). + */ + lastChange1h: number | null; + lastChange24h: number | null; + lastChange7d: number | null; lastPolledAt: string | null; lastError: string | null; createdAt: string; @@ -323,8 +333,6 @@ export interface GlobalSettings { 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; }; } diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 1e1da79..c078413 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -212,10 +212,14 @@ export const browsePoe2Currencies = (fetchFn?: typeof fetch) => 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) => +export const addPoe2WatchlistEntry = ( + base: { currencyId: string; name: string; icon: string | null }, + quote: { currencyId: string; name: string; icon: string | null }, + fetchFn?: typeof fetch +) => request( '/api/admin/poe2/watchlist', - { method: 'POST', body: JSON.stringify({ currencyId, name, icon }) }, + { method: 'POST', body: JSON.stringify({ base, quote }) }, fetchFn ); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index c5c9cb3..367d0c2 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -102,12 +102,17 @@ export interface Poe2BrowseEntry { export interface AdminPoe2Entry { id: string; - currencyId: string; - name: string; - icon: string | null; + baseCurrencyId: string; + baseName: string; + baseIcon: string | null; + quoteCurrencyId: string; + quoteName: string; + quoteIcon: string | null; priorityRank: number; - lastValue: number | null; - lastChangePercent: number | null; + lastRate: number | null; + lastChange1h: number | null; + lastChange24h: number | null; + lastChange7d: number | null; lastPolledAt: string | null; lastError: string | null; } @@ -115,7 +120,6 @@ export interface AdminPoe2Entry { export interface AdminPoe2Settings { leagueId: string | null; leagueName: string | null; - primaryCurrencyName: string | null; updatedAt: string | null; } diff --git a/frontend/src/lib/components/admin/Poe2Tab.svelte b/frontend/src/lib/components/admin/Poe2Tab.svelte index 7947ee1..e5b6df4 100644 --- a/frontend/src/lib/components/admin/Poe2Tab.svelte +++ b/frontend/src/lib/components/admin/Poe2Tab.svelte @@ -1,29 +1,36 @@
- {watchlist.length} currencies tracked - + {watchlist.length} pairs tracked +

{#if settings.poe2.leagueName} - Tracking {settings.poe2.leagueName}{settings.poe2.primaryCurrencyName - ? ` · values in ${settings.poe2.primaryCurrencyName}` - : ''} · change is over the last 7 days. + Tracking {settings.poe2.leagueName} · change is over the last 1h / 24h / 7d. {:else} League not detected yet — check back after the next poll (every 15 minutes). {/if} @@ -65,6 +88,13 @@ {#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…

@@ -75,7 +105,10 @@ {:else}
{#each filtered as entry (entry.id)} - @@ -83,7 +116,7 @@
{/if}
- +
{/if} @@ -91,24 +124,36 @@
{#each watchlist as entry (entry.id)}
-
- {#if entry.icon}{/if} -
-
{entry.name}
- {#if entry.lastError} -
{entry.lastError}
- {/if} +
+
+ {#if entry.baseIcon}{/if} + {entry.baseName} + + {#if entry.quoteIcon}{/if} + {entry.quoteName}
+
- {#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 entry.lastError} +
{entry.lastError}
+ {:else if entry.lastRate !== null} +
+ 1 {entry.baseName} = {formatPoeValue(entry.lastRate)} {entry.quoteName} + + 1h {fmtChange(entry.lastChange1h)} · 24h {fmtChange(entry.lastChange24h)} · 7d {fmtChange(entry.lastChange7d)} + +
+
+ 1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName} + + 1h {fmtChange(invertChangePercent(entry.lastChange1h))} · + 24h {fmtChange(invertChangePercent(entry.lastChange24h))} · + 7d {fmtChange(invertChangePercent(entry.lastChange7d))} + +
+ {:else} +
Waiting for first poll…
{/if} -
{/each}
@@ -139,6 +184,11 @@ padding: 14px; margin-bottom: 14px; } + .step-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; + } .add-panel input { width: 100%; margin-bottom: 8px; @@ -182,48 +232,58 @@ gap: 8px; } .row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; background: var(--surface-1); border-radius: var(--radius); padding: 10px 14px; } - .row-main { + .row-head { display: flex; align-items: center; - gap: 10px; - min-width: 0; + justify-content: space-between; + gap: 12px; } - .icon { - width: 24px; - height: 24px; - object-fit: contain; - flex-shrink: 0; - } - .name { + .pair-name { + display: flex; + align-items: center; + gap: 6px; font-size: 13px; font-weight: 500; + min-width: 0; + } + .arrow { + color: var(--text-muted); + font-weight: 400; + } + .icon { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; } .sub { font-size: 11px; color: var(--text-muted); + margin-top: 4px; } .error { color: var(--text-danger); } - .price { + .direction { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-top: 4px; font-size: 12px; + } + .rate { font-variant-numeric: tabular-nums; + } + .changes { + font-size: 11px; + color: var(--text-muted); white-space: nowrap; } - .price.up { - color: var(--text-success); - } - .price.down { - color: var(--text-danger); - } .icon-btn { font-size: 12px; padding: 3px 6px; diff --git a/frontend/src/lib/components/sidebar/Poe2Widget.svelte b/frontend/src/lib/components/sidebar/Poe2Widget.svelte index d1f9d33..2221fc1 100644 --- a/frontend/src/lib/components/sidebar/Poe2Widget.svelte +++ b/frontend/src/lib/components/sidebar/Poe2Widget.svelte @@ -8,27 +8,27 @@
PoE2 - {#if poe2.entries.length > 0}7d{/if}
{#if poe2.leagueName} -

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

+

{poe2.leagueName}

{/if} {#if poe2.entries.length > 0}
{#each poe2.entries as entry (entry.id)}
- {#if entry.icon}{/if} - {entry.name} + {#if entry.baseIcon}{/if} + {entry.baseName} + + {entry.quoteName} - {#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 entry.lastRate !== null} + = 0} class:down={(entry.lastChange24h ?? 0) < 0}> + {formatPoeValue(entry.lastRate)} + {#if entry.lastChange24h !== null} + {entry.lastChange24h >= 0 ? '+' : ''}{entry.lastChange24h.toFixed(2)}% {/if} + 24h {:else} @@ -37,7 +37,7 @@ {/each}
{:else} -

No currencies tracked

+

No currency pairs tracked

{/if}
@@ -57,10 +57,6 @@ font-weight: 500; color: var(--text-muted); } - .interval { - font-size: 10px; - color: var(--text-muted); - } .caption { font-size: 11px; color: var(--text-muted); @@ -85,8 +81,12 @@ .label { display: flex; align-items: center; - gap: 6px; + gap: 4px; font-size: 13px; + white-space: nowrap; + } + .arrow { + color: var(--text-muted); } .icon { width: 16px; @@ -109,6 +109,11 @@ .change { margin-left: 4px; } + .interval { + margin-left: 4px; + font-size: 10px; + color: var(--text-muted); + } .empty { font-size: 12px; color: var(--text-muted); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index b06df1f..2bb210e 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -44,3 +44,14 @@ export function formatPoeValue(value: number): string { 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); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index debaa97..29ccc7c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -158,17 +158,20 @@ export interface Bookmark { 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; + baseName: string; + baseIcon: string | null; + quoteName: string; + quoteIcon: string | null; + /** 1 base = lastRate quote. */ + lastRate: number | null; + /** % change self-computed from our own poll history — null if not enough history yet. */ + lastChange1h: number | null; + lastChange24h: number | null; + lastChange7d: 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[]; } From 0a57e687a5fda5b561187f5d86762e6a5b495d21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 02:54:23 +0000 Subject: [PATCH 3/8] =?UTF-8?q?Slow=20PoE2=20poller=20to=201=20hour=20?= =?UTF-8?q?=E2=80=94=20matches=20poe.ninja's=20own=20refresh=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poe.ninja's overview data doesn't update faster than hourly, so polling every 15 minutes was just re-fetching the same numbers. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/api/admin.ts | 2 +- backend/src/queue/scheduler.ts | 10 +++++----- frontend/src/lib/components/admin/Poe2Tab.svelte | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index a490b62..240f064 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -306,7 +306,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { { currencyId: base.currencyId, name: base.name, icon: base.icon ?? null }, { currencyId: quote.currencyId, name: quote.name, icon: quote.icon ?? null } ); - // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, + // 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); diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts index cbf6a14..b9d958c 100644 --- a/backend/src/queue/scheduler.ts +++ b/backend/src/queue/scheduler.ts @@ -14,7 +14,7 @@ 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 +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 = () => { @@ -67,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(() => { @@ -86,5 +86,5 @@ export function startScheduler() { 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'); + 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/frontend/src/lib/components/admin/Poe2Tab.svelte b/frontend/src/lib/components/admin/Poe2Tab.svelte index e5b6df4..39dc456 100644 --- a/frontend/src/lib/components/admin/Poe2Tab.svelte +++ b/frontend/src/lib/components/admin/Poe2Tab.svelte @@ -82,7 +82,7 @@ {#if settings.poe2.leagueName} Tracking {settings.poe2.leagueName} · change is over the last 1h / 24h / 7d. {:else} - League not detected yet — check back after the next poll (every 15 minutes). + League not detected yet — check back after the next poll (every hour). {/if}

From 37ac84c4144d894af9120d50abafb3309376294c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 03:11:37 +0000 Subject: [PATCH 4/8] PoE2: drop 1h/7d change, remove icons, link panel to poe.ninja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify to 24h-only change per pair (both directions) since that's all that's needed. Icons weren't adding anything to the display, so they're gone from the schema, API, and both components. The sidebar widget and admin tab's underlying data model both got smaller as a result — fewer columns, fewer fields, less to render. The sidebar panel now links out to poe.ninja's own currency page for the currently tracked league (https://poe.ninja/poe2/economy/{league slug}/currency), matching the existing pattern of Weather's widget linking to its own detail page. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/api/admin.ts | 8 ++-- backend/src/poe2/client.ts | 31 +++++-------- backend/src/poe2/poller.ts | 11 ++--- backend/src/storage/db/index.ts | 16 +++---- backend/src/storage/db/poe2Watchlist.ts | 43 ++++++------------ backend/src/storage/db/types.ts | 10 ++--- frontend/src/lib/adminApi.ts | 4 +- frontend/src/lib/adminTypes.ts | 5 --- .../src/lib/components/admin/Poe2Tab.svelte | 42 +++--------------- .../lib/components/sidebar/Poe2Widget.svelte | 44 +++++++++---------- frontend/src/lib/format.ts | 9 ++++ frontend/src/lib/types.ts | 6 +-- 12 files changed, 77 insertions(+), 152 deletions(-) diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 240f064..8792ef5 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -293,8 +293,8 @@ export async function registerAdminRoutes(app: FastifyInstance) { app.post('/api/admin/poe2/watchlist', async (req, reply) => { const { base, quote } = req.body as { - base?: { currencyId?: string; name?: string; icon?: string | null }; - quote?: { currencyId?: string; name?: string; icon?: string | null }; + 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' }); @@ -303,8 +303,8 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.code(400).send({ error: 'Base and quote currencies must be different' }); } const created = poe2WatchlistDb.addWatchlistEntry( - { currencyId: base.currencyId, name: base.name, icon: base.icon ?? null }, - { currencyId: quote.currencyId, name: quote.name, icon: quote.icon ?? null } + { 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. diff --git a/backend/src/poe2/client.ts b/backend/src/poe2/client.ts index 07922a6..d3755db 100644 --- a/backend/src/poe2/client.ts +++ b/backend/src/poe2/client.ts @@ -4,18 +4,16 @@ // 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`. +// 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 1h/24h/7d -// change, neither of which poe.ninja's overview exposes 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. +// 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 { @@ -26,13 +24,11 @@ export interface LeagueInfo { export interface CurrencyBrowseEntry { id: string; name: string; - icon: string | null; } interface RawCurrencyItem { id: string; name: string; - image?: string; } interface RawCurrencyLine { @@ -45,10 +41,6 @@ interface RawCurrencyOverview { 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}`); @@ -69,12 +61,9 @@ async function fetchCurrencyOverview(leagueId: string): Promise { const { lines, items } = await fetchCurrencyOverview(leagueId); - const metaById = new Map(items.map((item) => [item.id, item])); + const nameById = new Map(items.map((item) => [item.id, item.name])); return lines - .map((line) => { - const meta = metaById.get(line.id); - return { id: line.id, name: meta?.name ?? line.id, icon: resolveIcon(meta?.image) }; - }) + .map((line) => ({ id: line.id, name: nameById.get(line.id) ?? line.id })) .sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/backend/src/poe2/poller.ts b/backend/src/poe2/poller.ts index 5e4a2cf..e6cd503 100644 --- a/backend/src/poe2/poller.ts +++ b/backend/src/poe2/poller.ts @@ -3,8 +3,7 @@ import * as settingsDb from '../storage/db/settings.js'; import { logger } from '../storage/db/logs.js'; import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; -const HOUR_MS = 60 * 60_000; -const DAY_MS = 24 * HOUR_MS; +const DAY_MS = 24 * 60 * 60_000; function pctChange(current: number, past: number | null): number | null { if (past === null || past === 0) return null; @@ -38,24 +37,20 @@ export async function pollPoe2Now(): Promise { const valuesById = await fetchCurrencyValues(league.id); const now = new Date(); const nowIso = now.toISOString(); - const cutoff1h = new Date(now.getTime() - HOUR_MS).toISOString(); const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); - const cutoff7d = new Date(now.getTime() - 7 * 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, null, null, 'One or both currencies no longer traded in this league'); + poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league'); continue; } const rate = baseValue / quoteValue; - const change1h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff1h)); const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h)); - const change7d = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff7d)); poe2WatchlistDb.recordRate(entry.id, rate, nowIso); - poe2WatchlistDb.markPolled(entry.id, rate, change1h, change24h, change7d, null); + poe2WatchlistDb.markPolled(entry.id, rate, change24h, null); } poe2WatchlistDb.pruneOldHistory(); diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index e203fc9..0697e94 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -221,30 +221,26 @@ export function migrate() { -- 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/icons - -- are captured at add-time from the browse picker, not re-resolved. + -- 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, - base_icon TEXT, quote_currency_id TEXT NOT NULL, quote_name TEXT NOT NULL, - quote_icon TEXT, priority_rank INTEGER NOT NULL, last_rate REAL, - last_change_1h REAL, last_change_24h REAL, - last_change_7d REAL, last_polled_at TEXT, last_error TEXT, created_at TEXT NOT NULL ); - -- Per-poll rate snapshots for the pairs above — poe.ninja only exposes one 7-day - -- change window, so 1h/24h/7d change is computed ourselves from this history - -- (see poe2/poller.ts), rather than trusting a field poe.ninja doesn't provide. - -- Pruned to the last 8 days on every poll. + -- 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, diff --git a/backend/src/storage/db/poe2Watchlist.ts b/backend/src/storage/db/poe2Watchlist.ts index 8a41f44..beba793 100644 --- a/backend/src/storage/db/poe2Watchlist.ts +++ b/backend/src/storage/db/poe2Watchlist.ts @@ -5,7 +5,6 @@ import type { Poe2WatchlistEntry } from './types.js'; interface CurrencyRef { currencyId: string; name: string; - icon: string | null; } function rowToEntry(row: any): Poe2WatchlistEntry { @@ -13,15 +12,11 @@ function rowToEntry(row: any): Poe2WatchlistEntry { id: row.id, baseCurrencyId: row.base_currency_id, baseName: row.base_name, - baseIcon: row.base_icon, quoteCurrencyId: row.quote_currency_id, quoteName: row.quote_name, - quoteIcon: row.quote_icon, priorityRank: row.priority_rank, lastRate: row.last_rate, - lastChange1h: row.last_change_1h, lastChange24h: row.last_change_24h, - lastChange7d: row.last_change_7d, lastPolledAt: row.last_polled_at, lastError: row.last_error, createdAt: row.created_at @@ -44,22 +39,18 @@ export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2Wa const createdAt = new Date().toISOString(); db.prepare( `INSERT INTO poe2_watchlist - (id, base_currency_id, base_name, base_icon, quote_currency_id, quote_name, quote_icon, priority_rank, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run(id, base.currencyId, base.name, base.icon, quote.currencyId, quote.name, quote.icon, maxRank.m + 1, createdAt); + (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, - baseIcon: base.icon, quoteCurrencyId: quote.currencyId, quoteName: quote.name, - quoteIcon: quote.icon, priorityRank: maxRank.m + 1, lastRate: null, - lastChange1h: null, lastChange24h: null, - lastChange7d: null, lastPolledAt: null, lastError: null, createdAt @@ -71,9 +62,8 @@ export function removeWatchlistEntry(id: string) { db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id); } -// One snapshot per poll (see poe2/poller.ts) — the raw material 1h/24h/7d change is -// computed from, since poe.ninja itself doesn't expose per-pair rates or multiple -// change windows. +// 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, @@ -83,8 +73,8 @@ export function recordRate(watchlistId: string, rate: number, recordedAt: string } // The closest snapshot at-or-before cutoffIso — null if there isn't one yet (e.g. a -// pair added less than a window ago), which the poller treats as "no change data yet" -// rather than fabricating a 0% figure. +// 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') @@ -92,24 +82,17 @@ export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | return row ? row.rate : null; } -export function markPolled( - id: string, - rate: number | null, - change1h: number | null, - change24h: number | null, - change7d: number | null, - error: string | 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_1h = ?, last_change_24h = ?, last_change_7d = ?, last_polled_at = ?, last_error = ? + SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ? WHERE id = ?` - ).run(rate, change1h, change24h, change7d, new Date().toISOString(), error, id); + ).run(rate, change24h, new Date().toISOString(), error, id); } -// Keeps history bounded — 8 days is enough slack past the 7d window for a poll to be -// briefly late without losing the data point it needs. +// 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() - 8 * 24 * 60 * 60 * 1000).toISOString(); + 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/types.ts b/backend/src/storage/db/types.ts index ce2fb73..45b759b 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -238,21 +238,17 @@ export interface Poe2WatchlistEntry { /** Opaque id from poe.ninja's exchange overview, e.g. "exalted" — not a display name. */ baseCurrencyId: string; baseName: string; - baseIcon: string | null; quoteCurrencyId: string; quoteName: string; - quoteIcon: string | null; priorityRank: number; /** 1 base = lastRate quote. */ lastRate: number | null; /** - * % change over the last 1h/24h/7d, self-computed from poe2_rate_history since - * poe.ninja only exposes one 7-day window (see poe2/poller.ts) — null when there - * isn't yet enough history for that window (e.g. a pair just added). + * % 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). */ - lastChange1h: number | null; lastChange24h: number | null; - lastChange7d: number | null; lastPolledAt: string | null; lastError: string | null; createdAt: string; diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index c078413..304d197 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -213,8 +213,8 @@ export const getPoe2Watchlist = (fetchFn?: typeof fetch) => request('/api/admin/poe2/watchlist', {}, fetchFn); export const addPoe2WatchlistEntry = ( - base: { currencyId: string; name: string; icon: string | null }, - quote: { currencyId: string; name: string; icon: string | null }, + base: { currencyId: string; name: string }, + quote: { currencyId: string; name: string }, fetchFn?: typeof fetch ) => request( diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 367d0c2..2cc08d6 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -97,22 +97,17 @@ export interface AdminBookmark { export interface Poe2BrowseEntry { id: string; name: string; - icon: string | null; } export interface AdminPoe2Entry { id: string; baseCurrencyId: string; baseName: string; - baseIcon: string | null; quoteCurrencyId: string; quoteName: string; - quoteIcon: string | null; priorityRank: number; lastRate: number | null; - lastChange1h: number | null; lastChange24h: number | null; - lastChange7d: number | null; lastPolledAt: string | null; lastError: string | null; } diff --git a/frontend/src/lib/components/admin/Poe2Tab.svelte b/frontend/src/lib/components/admin/Poe2Tab.svelte index 39dc456..7cfd71d 100644 --- a/frontend/src/lib/components/admin/Poe2Tab.svelte +++ b/frontend/src/lib/components/admin/Poe2Tab.svelte @@ -52,8 +52,8 @@ async function pickQuote(entry: Poe2BrowseEntry) { if (!selectedBase) return; const created = await addPoe2WatchlistEntry( - { currencyId: selectedBase.id, name: selectedBase.name, icon: selectedBase.icon }, - { currencyId: entry.id, name: entry.name, icon: entry.icon } + { currencyId: selectedBase.id, name: selectedBase.name }, + { currencyId: entry.id, name: entry.name } ); watchlist = [...watchlist, created]; showAdd = false; @@ -80,7 +80,7 @@

{#if settings.poe2.leagueName} - Tracking {settings.poe2.leagueName} · change is over the last 1h / 24h / 7d. + Tracking {settings.poe2.leagueName} · change is over the last 24h. {:else} League not detected yet — check back after the next poll (every hour). {/if} @@ -109,7 +109,6 @@ class="result-row" onclick={() => (step === 'base' ? pickBase(entry) : pickQuote(entry))} > - {#if entry.icon}{/if} {entry.name} {/each} @@ -125,13 +124,7 @@ {#each watchlist as entry (entry.id)}

-
- {#if entry.baseIcon}{/if} - {entry.baseName} - - {#if entry.quoteIcon}{/if} - {entry.quoteName} -
+
{entry.baseName} {entry.quoteName}
{#if entry.lastError} @@ -139,17 +132,11 @@ {:else if entry.lastRate !== null}
1 {entry.baseName} = {formatPoeValue(entry.lastRate)} {entry.quoteName} - - 1h {fmtChange(entry.lastChange1h)} · 24h {fmtChange(entry.lastChange24h)} · 7d {fmtChange(entry.lastChange7d)} - + 24h {fmtChange(entry.lastChange24h)}
1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName} - - 1h {fmtChange(invertChangePercent(entry.lastChange1h))} · - 24h {fmtChange(invertChangePercent(entry.lastChange24h))} · - 7d {fmtChange(invertChangePercent(entry.lastChange7d))} - + 24h {fmtChange(invertChangePercent(entry.lastChange24h))}
{:else}
Waiting for first poll…
@@ -203,9 +190,6 @@ border: 0.5px solid var(--border); } .result-row { - display: flex; - align-items: center; - gap: 8px; text-align: left; font-size: 12px; padding: 8px 10px; @@ -216,11 +200,6 @@ .result-row:hover { background: var(--bg-accent); } - .result-icon { - width: 20px; - height: 20px; - object-fit: contain; - } .add-actions { display: flex; justify-content: flex-end; @@ -243,9 +222,6 @@ gap: 12px; } .pair-name { - display: flex; - align-items: center; - gap: 6px; font-size: 13px; font-weight: 500; min-width: 0; @@ -254,12 +230,6 @@ color: var(--text-muted); font-weight: 400; } - .icon { - width: 18px; - height: 18px; - object-fit: contain; - flex-shrink: 0; - } .sub { font-size: 11px; color: var(--text-muted); diff --git a/frontend/src/lib/components/sidebar/Poe2Widget.svelte b/frontend/src/lib/components/sidebar/Poe2Widget.svelte index 2221fc1..d2f5fc3 100644 --- a/frontend/src/lib/components/sidebar/Poe2Widget.svelte +++ b/frontend/src/lib/components/sidebar/Poe2Widget.svelte @@ -1,13 +1,22 @@ -
+
PoE2 + {#if poe2.entries.length > 0}24h{/if}
{#if poe2.leagueName}

{poe2.leagueName}

@@ -16,19 +25,13 @@
{#each poe2.entries as entry (entry.id)}
- - {#if entry.baseIcon}{/if} - {entry.baseName} - - {entry.quoteName} - + {entry.baseName} {entry.quoteName} {#if entry.lastRate !== null} = 0} class:down={(entry.lastChange24h ?? 0) < 0}> {formatPoeValue(entry.lastRate)} {#if entry.lastChange24h !== null} {entry.lastChange24h >= 0 ? '+' : ''}{entry.lastChange24h.toFixed(2)}% {/if} - 24h {:else} @@ -39,13 +42,16 @@ {:else}

No currency pairs tracked

{/if} -
+ From 4066c48412e7d0186fbaced17305e227523d2d1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 03:50:18 +0000 Subject: [PATCH 8/8] Sidebar: sync scroll instead of a separate inner scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (max-height + overflow-y:auto) made every widget reachable but required scrolling the sidebar itself independently of the article column — two different scroll regions felt janky. Replaced it with a three-layer structure: a plain spacer sized to the sidebar's full natural height (reserving the right amount of page scroll room), a sticky+clipped viewport box, and a content wrapper translated upward via a scroll listener. The translation amount is driven by how far the page has scrolled past the point where the sidebar started sticking, clamped to the overflow amount — so scrolling the article feed down reveals more of the sidebar in lockstep, and scrolling back up reverses it, all through the single page scrollbar. Short sidebars that already fit the viewport are unaffected (reveal range is zero, so it behaves exactly like plain sticky-to-top as before). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- .../src/lib/components/sidebar/Sidebar.svelte | 100 +++++++++++++++--- 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte index 478e7fe..250d620 100644 --- a/frontend/src/lib/components/sidebar/Sidebar.svelte +++ b/frontend/src/lib/components/sidebar/Sidebar.svelte @@ -1,4 +1,5 @@ - +