PoE2: drop 1h/7d change, remove icons, link panel to poe.ninja

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-25 03:11:37 +00:00
parent 0a57e687a5
commit 37ac84c414
12 changed files with 77 additions and 152 deletions
+4 -4
View File
@@ -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.
+10 -21
View File
@@ -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<LeagueInfo> {
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<RawCurrencyOverv
// entry (i.e. are currently traded), not every currency poe.ninja has ever known about.
export async function browseCurrencies(leagueId: string): Promise<CurrencyBrowseEntry[]> {
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));
}
+3 -8
View File
@@ -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<void> {
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();
+6 -10
View File
@@ -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,
+13 -30
View File
@@ -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);
}
+3 -7
View File
@@ -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;
+2 -2
View File
@@ -213,8 +213,8 @@ export const getPoe2Watchlist = (fetchFn?: typeof fetch) =>
request<AdminPoe2Entry[]>('/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<AdminPoe2Entry>(
-5
View File
@@ -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;
}
@@ -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 @@
</div>
<p class="hint" style="margin: -6px 0 12px;">
{#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}<img class="result-icon" src={entry.icon} alt="" />{/if}
{entry.name}
</button>
{/each}
@@ -125,13 +124,7 @@
{#each watchlist as entry (entry.id)}
<div class="row">
<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>
<div class="pair-name">{entry.baseName} <span class="arrow"></span> {entry.quoteName}</div>
<button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove"></button>
</div>
{#if entry.lastError}
@@ -139,17 +132,11 @@
{: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>
<span class="changes">24h {fmtChange(entry.lastChange24h)}</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>
<span class="changes">24h {fmtChange(invertChangePercent(entry.lastChange24h))}</span>
</div>
{:else}
<div class="sub">Waiting for first poll…</div>
@@ -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);
@@ -1,13 +1,22 @@
<script lang="ts">
import type { Poe2Data } from '$lib/types';
import { formatPoeValue } from '$lib/format';
import { formatPoeValue, poeLeagueSlug } from '$lib/format';
let { poe2 }: { poe2: Poe2Data } = $props();
const link = $derived(poe2.leagueName ? `https://poe.ninja/poe2/economy/${poeLeagueSlug(poe2.leagueName)}/currency` : null);
</script>
<div class="widget">
<svelte:element
this={link ? 'a' : 'div'}
class="widget"
href={link ?? undefined}
target={link ? '_blank' : undefined}
rel={link ? 'noopener noreferrer' : undefined}
>
<div class="head">
<span class="title">PoE2</span>
{#if poe2.entries.length > 0}<span class="interval">24h</span>{/if}
</div>
{#if poe2.leagueName}
<p class="caption">{poe2.leagueName}</p>
@@ -16,19 +25,13 @@
<div class="list">
{#each poe2.entries as entry (entry.id)}
<div class="row">
<span class="label">
{#if entry.baseIcon}<img class="icon" src={entry.baseIcon} alt="" />{/if}
{entry.baseName}
<span class="arrow"></span>
{entry.quoteName}
</span>
<span class="label">{entry.baseName} <span class="arrow"></span> {entry.quoteName}</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>
@@ -39,13 +42,16 @@
{:else}
<p class="empty">No currency pairs tracked</p>
{/if}
</div>
</svelte:element>
<style>
.widget {
display: block;
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
color: inherit;
text-decoration: none;
}
.head {
display: flex;
@@ -57,6 +63,10 @@
font-weight: 500;
color: var(--text-muted);
}
.interval {
font-size: 10px;
color: var(--text-muted);
}
.caption {
font-size: 11px;
color: var(--text-muted);
@@ -79,21 +89,12 @@
border-top: none;
}
.label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
white-space: nowrap;
}
.arrow {
color: var(--text-muted);
}
.icon {
width: 16px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
}
.price {
font-size: 12px;
font-variant-numeric: tabular-nums;
@@ -109,11 +110,6 @@
.change {
margin-left: 4px;
}
.interval {
margin-left: 4px;
font-size: 10px;
color: var(--text-muted);
}
.empty {
font-size: 12px;
color: var(--text-muted);
+9
View File
@@ -55,3 +55,12 @@ export function invertChangePercent(change: number | null): number | null {
if (change === null) return null;
return -change / (1 + change / 100);
}
/**
* poe.ninja's own league-page slugs have no separators at all (e.g. "Runes of Aldur" ->
* "runesofaldur"), unlike this app's own dash-separated slugify() above — used to link the
* PoE2 panel out to https://poe.ninja/poe2/economy/{slug}/currency for the current league.
*/
export function poeLeagueSlug(leagueName: string): string {
return leagueName.toLowerCase().replace(/[^a-z0-9]/g, '');
}
+1 -5
View File
@@ -159,15 +159,11 @@ export interface Bookmark {
export interface Poe2WatchlistEntry {
id: string;
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;
/** 24h % change, self-computed from our own poll history — null if not enough history yet. */
lastChange24h: number | null;
lastChange7d: number | null;
}
export interface Poe2Data {