bf0cb11070
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
75 lines
2.9 KiB
TypeScript
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}`);
|
|
}
|
|
}
|