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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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() };
|
||||
});
|
||||
}
|
||||
|
||||
+17
-33
@@ -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<CurrencyBrowse
|
||||
.sort((a, b) => 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<string, CurrencyQuote | Error>; 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<string, CurrencyQuote | Error>();
|
||||
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<Map<string, number>> {
|
||||
const { lines } = await fetchCurrencyOverview(leagueId);
|
||||
return new Map(lines.map((line) => [line.id, line.primaryValue]));
|
||||
}
|
||||
|
||||
+36
-18
@@ -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<void> {
|
||||
let league;
|
||||
try {
|
||||
@@ -27,22 +35,32 @@ export async function pollPoe2Now(): Promise<void> {
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>): 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>): GlobalSettings {
|
||||
merged.weather.updatedAt,
|
||||
merged.poe2.leagueId,
|
||||
merged.poe2.leagueName,
|
||||
merged.poe2.primaryCurrencyName,
|
||||
merged.poe2.updatedAt
|
||||
);
|
||||
return getSettings();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,10 +212,14 @@ export const browsePoe2Currencies = (fetchFn?: typeof fetch) =>
|
||||
export const getPoe2Watchlist = (fetchFn?: typeof fetch) =>
|
||||
request<AdminPoe2Entry[]>('/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<AdminPoe2Entry>(
|
||||
'/api/admin/poe2/watchlist',
|
||||
{ method: 'POST', body: JSON.stringify({ currencyId, name, icon }) },
|
||||
{ method: 'POST', body: JSON.stringify({ base, quote }) },
|
||||
fetchFn
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { AdminSettings, AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
|
||||
import { browsePoe2Currencies, addPoe2WatchlistEntry, removePoe2WatchlistEntry } from '$lib/adminApi';
|
||||
import { formatPoeValue } from '$lib/format';
|
||||
import { formatPoeValue, invertChangePercent } from '$lib/format';
|
||||
|
||||
let { settings, watchlist: initial }: { settings: AdminSettings; watchlist: AdminPoe2Entry[] } = $props();
|
||||
let watchlist = $state([...initial]);
|
||||
|
||||
let showAdd = $state(false);
|
||||
// Two-step wizard: pick the base currency first, then the quote currency (excluding the
|
||||
// base) — a pair needs both, and currency ids are opaque so they have to be picked from a
|
||||
// live list rather than typed (same idiom as the old single-currency picker).
|
||||
let step = $state<'base' | 'quote'>('base');
|
||||
let selectedBase = $state<Poe2BrowseEntry | null>(null);
|
||||
let query = $state('');
|
||||
// null = not fetched yet — fetched once on first "+ Add currency" click, then filtered
|
||||
// null = not fetched yet — fetched once on first "+ Add pair" click, then filtered
|
||||
// client-side on every keystroke (the whole category is a bounded, small list).
|
||||
let browseList = $state<Poe2BrowseEntry[] | null>(null);
|
||||
let browsing = $state(false);
|
||||
let browseError = $state<string | null>(null);
|
||||
|
||||
const watchedIds = $derived(new Set(watchlist.map((w) => w.currencyId)));
|
||||
const filtered = $derived(
|
||||
(browseList ?? [])
|
||||
.filter((c) => !watchedIds.has(c.id))
|
||||
.filter((c) => c.id !== selectedBase?.id)
|
||||
.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.slice(0, 30)
|
||||
);
|
||||
|
||||
async function openAdd() {
|
||||
showAdd = true;
|
||||
step = 'base';
|
||||
selectedBase = null;
|
||||
query = '';
|
||||
if (browseList) return;
|
||||
browsing = true;
|
||||
browseError = null;
|
||||
@@ -36,28 +43,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function pick(entry: Poe2BrowseEntry) {
|
||||
const created = await addPoe2WatchlistEntry(entry.id, entry.name, entry.icon);
|
||||
function pickBase(entry: Poe2BrowseEntry) {
|
||||
selectedBase = entry;
|
||||
step = 'quote';
|
||||
query = '';
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
watchlist = [...watchlist, created];
|
||||
showAdd = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function closeAdd() {
|
||||
showAdd = false;
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await removePoe2WatchlistEntry(id);
|
||||
watchlist = watchlist.filter((w) => w.id !== id);
|
||||
}
|
||||
|
||||
function fmtChange(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="count">{watchlist.length} currencies tracked</span>
|
||||
<button class="add-btn" onclick={openAdd}>+ Add currency</button>
|
||||
<span class="count">{watchlist.length} pairs tracked</span>
|
||||
<button class="add-btn" onclick={openAdd}>+ Add pair</button>
|
||||
</div>
|
||||
<p class="hint" style="margin: -6px 0 12px;">
|
||||
{#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}
|
||||
<div class="add-panel">
|
||||
<div class="step-label">
|
||||
{#if step === 'base'}
|
||||
Step 1 — pick the base currency
|
||||
{:else}
|
||||
Step 2 — pick what to quote <strong>{selectedBase?.name}</strong> against
|
||||
{/if}
|
||||
</div>
|
||||
<input type="text" bind:value={query} placeholder="Search currencies…" />
|
||||
{#if browsing}
|
||||
<p class="hint">Loading currency list…</p>
|
||||
@@ -75,7 +105,10 @@
|
||||
{:else}
|
||||
<div class="results">
|
||||
{#each filtered as entry (entry.id)}
|
||||
<button class="result-row" onclick={() => pick(entry)}>
|
||||
<button
|
||||
class="result-row"
|
||||
onclick={() => (step === 'base' ? pickBase(entry) : pickQuote(entry))}
|
||||
>
|
||||
{#if entry.icon}<img class="result-icon" src={entry.icon} alt="" />{/if}
|
||||
{entry.name}
|
||||
</button>
|
||||
@@ -83,7 +116,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="add-actions">
|
||||
<button onclick={() => (showAdd = false)}>Close</button>
|
||||
<button onclick={closeAdd}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -91,24 +124,36 @@
|
||||
<div class="list">
|
||||
{#each watchlist as entry (entry.id)}
|
||||
<div class="row">
|
||||
<div class="row-main">
|
||||
{#if entry.icon}<img class="icon" src={entry.icon} alt="" />{/if}
|
||||
<div>
|
||||
<div class="name">{entry.name}</div>
|
||||
{#if entry.lastError}
|
||||
<div class="sub"><span class="error">{entry.lastError}</span></div>
|
||||
{/if}
|
||||
<div class="row-head">
|
||||
<div class="pair-name">
|
||||
{#if entry.baseIcon}<img class="icon" src={entry.baseIcon} alt="" />{/if}
|
||||
{entry.baseName}
|
||||
<span class="arrow">⇄</span>
|
||||
{#if entry.quoteIcon}<img class="icon" src={entry.quoteIcon} alt="" />{/if}
|
||||
{entry.quoteName}
|
||||
</div>
|
||||
<button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove">✕</button>
|
||||
</div>
|
||||
{#if entry.lastValue !== null}
|
||||
<span class="price" class:up={(entry.lastChangePercent ?? 0) >= 0} class:down={(entry.lastChangePercent ?? 0) < 0}>
|
||||
{formatPoeValue(entry.lastValue)}
|
||||
{#if entry.lastChangePercent !== null}
|
||||
({entry.lastChangePercent >= 0 ? '+' : ''}{entry.lastChangePercent.toFixed(2)}%)
|
||||
{/if}
|
||||
</span>
|
||||
{#if entry.lastError}
|
||||
<div class="sub"><span class="error">{entry.lastError}</span></div>
|
||||
{:else if entry.lastRate !== null}
|
||||
<div class="direction">
|
||||
<span class="rate">1 {entry.baseName} = {formatPoeValue(entry.lastRate)} {entry.quoteName}</span>
|
||||
<span class="changes">
|
||||
1h {fmtChange(entry.lastChange1h)} · 24h {fmtChange(entry.lastChange24h)} · 7d {fmtChange(entry.lastChange7d)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="direction">
|
||||
<span class="rate">1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName}</span>
|
||||
<span class="changes">
|
||||
1h {fmtChange(invertChangePercent(entry.lastChange1h))} ·
|
||||
24h {fmtChange(invertChangePercent(entry.lastChange24h))} ·
|
||||
7d {fmtChange(invertChangePercent(entry.lastChange7d))}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sub">Waiting for first poll…</div>
|
||||
{/if}
|
||||
<button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -8,27 +8,27 @@
|
||||
<div class="widget">
|
||||
<div class="head">
|
||||
<span class="title">PoE2</span>
|
||||
{#if poe2.entries.length > 0}<span class="interval">7d</span>{/if}
|
||||
</div>
|
||||
{#if poe2.leagueName}
|
||||
<p class="caption">
|
||||
{poe2.leagueName}{poe2.primaryCurrencyName ? ` · in ${poe2.primaryCurrencyName}` : ''}
|
||||
</p>
|
||||
<p class="caption">{poe2.leagueName}</p>
|
||||
{/if}
|
||||
{#if poe2.entries.length > 0}
|
||||
<div class="list">
|
||||
{#each poe2.entries as entry (entry.id)}
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
{#if entry.icon}<img class="icon" src={entry.icon} alt="" />{/if}
|
||||
{entry.name}
|
||||
{#if entry.baseIcon}<img class="icon" src={entry.baseIcon} alt="" />{/if}
|
||||
{entry.baseName}
|
||||
<span class="arrow">→</span>
|
||||
{entry.quoteName}
|
||||
</span>
|
||||
{#if entry.lastValue !== null}
|
||||
<span class="price" class:up={(entry.lastChangePercent ?? 0) >= 0} class:down={(entry.lastChangePercent ?? 0) < 0}>
|
||||
{formatPoeValue(entry.lastValue)}
|
||||
{#if entry.lastChangePercent !== null}
|
||||
<span class="change">{entry.lastChangePercent >= 0 ? '+' : ''}{entry.lastChangePercent.toFixed(2)}%</span>
|
||||
{#if entry.lastRate !== null}
|
||||
<span class="price" class:up={(entry.lastChange24h ?? 0) >= 0} class:down={(entry.lastChange24h ?? 0) < 0}>
|
||||
{formatPoeValue(entry.lastRate)}
|
||||
{#if entry.lastChange24h !== null}
|
||||
<span class="change">{entry.lastChange24h >= 0 ? '+' : ''}{entry.lastChange24h.toFixed(2)}%</span>
|
||||
{/if}
|
||||
<span class="interval">24h</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="price">—</span>
|
||||
@@ -37,7 +37,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">No currencies tracked</p>
|
||||
<p class="empty">No currency pairs tracked</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user