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
99 lines
3.8 KiB
TypeScript
99 lines
3.8 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { db } from '../../storage/db/index.js';
|
|
import type { Poe2WatchlistEntry } from '../../storage/db/types.js';
|
|
|
|
interface CurrencyRef {
|
|
currencyId: string;
|
|
name: string;
|
|
}
|
|
|
|
function rowToEntry(row: any): Poe2WatchlistEntry {
|
|
return {
|
|
id: row.id,
|
|
baseCurrencyId: row.base_currency_id,
|
|
baseName: row.base_name,
|
|
quoteCurrencyId: row.quote_currency_id,
|
|
quoteName: row.quote_name,
|
|
priorityRank: row.priority_rank,
|
|
lastRate: row.last_rate,
|
|
lastChange24h: row.last_change_24h,
|
|
lastPolledAt: row.last_polled_at,
|
|
lastError: row.last_error,
|
|
createdAt: row.created_at
|
|
};
|
|
}
|
|
|
|
export function listWatchlist(): Poe2WatchlistEntry[] {
|
|
const rows = db.prepare('SELECT * FROM widget_poe2_watchlist ORDER BY priority_rank').all();
|
|
return rows.map(rowToEntry);
|
|
}
|
|
|
|
// No update() — currencies are picked from a live browse list (see widgets/poe2/client.ts's
|
|
// browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the
|
|
// rare "picked the wrong one" case.
|
|
export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry {
|
|
const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}`
|
|
.replace(/-+/g, '-')
|
|
.replace(/(^-|-$)/g, '');
|
|
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_poe2_watchlist').get() as { m: number };
|
|
const createdAt = new Date().toISOString();
|
|
db.prepare(
|
|
`INSERT INTO widget_poe2_watchlist
|
|
(id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt);
|
|
return {
|
|
id,
|
|
baseCurrencyId: base.currencyId,
|
|
baseName: base.name,
|
|
quoteCurrencyId: quote.currencyId,
|
|
quoteName: quote.name,
|
|
priorityRank: maxRank.m + 1,
|
|
lastRate: null,
|
|
lastChange24h: null,
|
|
lastPolledAt: null,
|
|
lastError: null,
|
|
createdAt
|
|
};
|
|
}
|
|
|
|
export function removeWatchlistEntry(id: string) {
|
|
db.prepare('DELETE FROM widget_poe2_rate_history WHERE watchlist_id = ?').run(id);
|
|
db.prepare('DELETE FROM widget_poe2_watchlist WHERE id = ?').run(id);
|
|
}
|
|
|
|
// One snapshot per poll (see widgets/poe2/poll.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 widget_poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
|
|
watchlistId,
|
|
rate,
|
|
recordedAt
|
|
);
|
|
}
|
|
|
|
// The closest snapshot at-or-before cutoffIso — null if there isn't one yet (e.g. a
|
|
// pair added less than 24h ago), which the poller treats as "no change data yet" rather
|
|
// than fabricating a 0% figure.
|
|
export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null {
|
|
const row = db
|
|
.prepare('SELECT rate FROM widget_poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
|
|
.get(watchlistId, cutoffIso) as { rate: number } | undefined;
|
|
return row ? row.rate : null;
|
|
}
|
|
|
|
export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) {
|
|
db.prepare(
|
|
`UPDATE widget_poe2_watchlist
|
|
SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ?
|
|
WHERE id = ?`
|
|
).run(rate, change24h, new Date().toISOString(), error, id);
|
|
}
|
|
|
|
// Keeps history bounded — a day or two of slack past the 24h window is plenty for a
|
|
// poll to be briefly late without losing the data point it needs.
|
|
export function pruneOldHistory() {
|
|
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
|
db.prepare('DELETE FROM widget_poe2_rate_history WHERE recorded_at < ?').run(cutoff);
|
|
}
|