Files
homefeed/backend/src/widgets/poe2/poll.ts
T
Claude bf0cb11070 Add pluggable widget system; migrate PoE2 as the pilot implementation
Widgets are no longer hand-wired per-feature across scheduler.ts, settings.ts,
and the API routes. A new WidgetPlugin interface (backend/src/widgets/types.ts)
lets a widget declare its own schema migration, poll interval, routes, and
uninstall hook; an installed_widgets registry table replaces the closed
widgets/widgetOrder unions on global_settings, and the scheduler/route
registration now iterate loaded widgets generically instead of one hardcoded
block per widget.

PoE2 moves into backend/src/widgets/poe2/ as the first real plugin (its tables
renamed to the widget_poe2_ convention, league cache moved into a new generic
widget_kv store). Weather/Stocks/Bookmarks get thin wrapper plugins so they
share the same dispatch loop without migrating their schema.

New admin routes let a widget be uploaded live (POST /api/admin/widgets, JSON
body with inline file contents — no archive dependency needed) and removed
with full data pruning (DELETE /api/admin/widgets/:id): a host-side safety-net
sweep drops any widget_<id>_* table and widget_kv rows regardless of whether
the widget's own uninstall() hook runs, so deleted widgets don't leave dead
data behind. Built-in widgets can't be deleted through this route.

Verified against a copy of the real dev DB: old poe2_watchlist data survives
the rename, the migration is idempotent on a second boot, and a live-uploaded
test widget was polled, queried, and fully deleted (table/kv/on-disk directory
all gone) without a restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-27 00:27:27 +00:00

75 lines
2.9 KiB
TypeScript

import * as poe2Db from './db.js';
import { getKv, setKv } from '../../storage/db/widgetKv.js';
import { logger } from '../../storage/db/logs.js';
import { fetchCurrentLeague, fetchCurrencyValues } from './client.js';
const DAY_MS = 24 * 60 * 60_000;
const WIDGET_ID = 'poe2';
interface LeagueCache {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
// Cache of what the last poll learned about the current league — no admin-set config
// (unlike weather): the league is always auto-detected, so this is purely a cache. Lives
// in the generic widget_kv store (see storage/db/widgetKv.ts) so it's prunable via the
// same deleteAllKv('poe2') path as everything else this widget owns.
export function getLeagueCache(): LeagueCache {
return getKv<LeagueCache>(WIDGET_ID, 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
}
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, via plugin.poll) and immediately after the
// admin adds a pair (see plugin.ts's admin routes) — 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<void> {
let league;
try {
league = await fetchCurrentLeague();
} catch (err) {
logger.error('poe2', `League lookup failed: ${(err as Error).message}`);
return;
}
const entries = poe2Db.listWatchlist();
if (entries.length === 0) {
setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() });
return;
}
try {
const valuesById = await fetchCurrencyValues(league.id);
const now = new Date();
const nowIso = now.toISOString();
const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString();
for (const entry of entries) {
const baseValue = valuesById.get(entry.baseCurrencyId);
const quoteValue = valuesById.get(entry.quoteCurrencyId);
if (baseValue === undefined || quoteValue === undefined) {
poe2Db.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league');
continue;
}
const rate = baseValue / quoteValue;
const change24h = pctChange(rate, poe2Db.rateAtOrBefore(entry.id, cutoff24h));
poe2Db.recordRate(entry.id, rate, nowIso);
poe2Db.markPolled(entry.id, rate, change24h, null);
}
poe2Db.pruneOldHistory();
setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: nowIso });
} catch (err) {
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
}
}