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) => { app.post('/api/admin/poe2/watchlist', async (req, reply) => {
const { base, quote } = req.body as { const { base, quote } = req.body as {
base?: { currencyId?: string; name?: string; icon?: string | null }; base?: { currencyId?: string; name?: string };
quote?: { currencyId?: string; name?: string; icon?: string | null }; quote?: { currencyId?: string; name?: string };
}; };
if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) { if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) {
return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' }); 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' }); return reply.code(400).send({ error: 'Base and quote currencies must be different' });
} }
const created = poe2WatchlistDb.addWatchlistEntry( const created = poe2WatchlistDb.addWatchlistEntry(
{ currencyId: base.currencyId, name: base.name, icon: base.icon ?? null }, { currencyId: base.currencyId, name: base.name },
{ currencyId: quote.currencyId, name: quote.name, icon: quote.icon ?? null } { currencyId: quote.currencyId, name: quote.name }
); );
// Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap, // Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap,
// and refreshes every existing entry's rate too. // and refreshes every existing entry's rate too.
+10 -21
View File
@@ -4,18 +4,16 @@
// and their callers. // and their callers.
// //
// Response shape confirmed against real requests (not just the published docs, which were // 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 // imprecise on this point): currency name metadata lives in a top-level `items[]` array on
// on the overview response, NOT `core.items` (that only holds the handful of currencies used // 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 // for `core.rates`/`primary`/`secondary`).
// same host), not `icon`.
// //
// Deliberately not using poe.ninja's own `core` (reference currency) or `sparkline` (a fixed // 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 // 7-day window) fields at all — the watchlist tracks arbitrary currency pairs with 24h
// change, neither of which poe.ninja's overview exposes directly. Every line's `primaryValue` // change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is
// is expressed in the same (unspecified, and irrelevant) reference currency, so any pair's // expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate
// rate is just baseValue / quoteValue with the reference cancelling out — see // is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues
// fetchCurrencyValues below and poe2/poller.ts, which self-computes change from its own // below and poe2/poller.ts, which self-computes change from its own polling history instead.
// polling history instead.
const BASE_URL = 'https://poe.ninja'; const BASE_URL = 'https://poe.ninja';
export interface LeagueInfo { export interface LeagueInfo {
@@ -26,13 +24,11 @@ export interface LeagueInfo {
export interface CurrencyBrowseEntry { export interface CurrencyBrowseEntry {
id: string; id: string;
name: string; name: string;
icon: string | null;
} }
interface RawCurrencyItem { interface RawCurrencyItem {
id: string; id: string;
name: string; name: string;
image?: string;
} }
interface RawCurrencyLine { interface RawCurrencyLine {
@@ -45,10 +41,6 @@ interface RawCurrencyOverview {
items: RawCurrencyItem[]; // top-level, not core.items — see file header 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> { export async function fetchCurrentLeague(): Promise<LeagueInfo> {
const res = await fetch(`${BASE_URL}/poe2/api/economy/leagues`); const res = await fetch(`${BASE_URL}/poe2/api/economy/leagues`);
if (!res.ok) throw new Error(`poe.ninja leagues returned ${res.status}`); 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. // entry (i.e. are currently traded), not every currency poe.ninja has ever known about.
export async function browseCurrencies(leagueId: string): Promise<CurrencyBrowseEntry[]> { export async function browseCurrencies(leagueId: string): Promise<CurrencyBrowseEntry[]> {
const { lines, items } = await fetchCurrencyOverview(leagueId); 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 return lines
.map((line) => { .map((line) => ({ id: line.id, name: nameById.get(line.id) ?? line.id }))
const meta = metaById.get(line.id);
return { id: line.id, name: meta?.name ?? line.id, icon: resolveIcon(meta?.image) };
})
.sort((a, b) => a.name.localeCompare(b.name)); .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 { logger } from '../storage/db/logs.js';
import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; import { fetchCurrentLeague, fetchCurrencyValues } from './client.js';
const HOUR_MS = 60 * 60_000; const DAY_MS = 24 * 60 * 60_000;
const DAY_MS = 24 * HOUR_MS;
function pctChange(current: number, past: number | null): number | null { function pctChange(current: number, past: number | null): number | null {
if (past === null || past === 0) return 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 valuesById = await fetchCurrencyValues(league.id);
const now = new Date(); const now = new Date();
const nowIso = now.toISOString(); const nowIso = now.toISOString();
const cutoff1h = new Date(now.getTime() - HOUR_MS).toISOString();
const cutoff24h = new Date(now.getTime() - DAY_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) { for (const entry of entries) {
const baseValue = valuesById.get(entry.baseCurrencyId); const baseValue = valuesById.get(entry.baseCurrencyId);
const quoteValue = valuesById.get(entry.quoteCurrencyId); const quoteValue = valuesById.get(entry.quoteCurrencyId);
if (baseValue === undefined || quoteValue === undefined) { 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; continue;
} }
const rate = baseValue / quoteValue; const rate = baseValue / quoteValue;
const change1h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff1h));
const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h)); 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.recordRate(entry.id, rate, nowIso);
poe2WatchlistDb.markPolled(entry.id, rate, change1h, change24h, change7d, null); poe2WatchlistDb.markPolled(entry.id, rate, change24h, null);
} }
poe2WatchlistDb.pruneOldHistory(); poe2WatchlistDb.pruneOldHistory();
+6 -10
View File
@@ -221,30 +221,26 @@ export function migrate() {
-- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs -- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs
-- (see poe2/poller.ts), always against the current challenge league (auto-detected, -- (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 -- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are
-- are captured at add-time from the browse picker, not re-resolved. -- 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 ( CREATE TABLE IF NOT EXISTS poe2_watchlist (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted" base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted"
base_name TEXT NOT NULL, base_name TEXT NOT NULL,
base_icon TEXT,
quote_currency_id TEXT NOT NULL, quote_currency_id TEXT NOT NULL,
quote_name TEXT NOT NULL, quote_name TEXT NOT NULL,
quote_icon TEXT,
priority_rank INTEGER NOT NULL, priority_rank INTEGER NOT NULL,
last_rate REAL, last_rate REAL,
last_change_1h REAL,
last_change_24h REAL, last_change_24h REAL,
last_change_7d REAL,
last_polled_at TEXT, last_polled_at TEXT,
last_error TEXT, last_error TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
-- Per-poll rate snapshots for the pairs above — poe.ninja only exposes one 7-day -- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching
-- change window, so 1h/24h/7d change is computed ourselves from this history -- 24h change window, so it's computed ourselves from this history (see
-- (see poe2/poller.ts), rather than trusting a field poe.ninja doesn't provide. -- poe2/poller.ts). Pruned to the last 2 days on every poll.
-- Pruned to the last 8 days on every poll.
CREATE TABLE IF NOT EXISTS poe2_rate_history ( CREATE TABLE IF NOT EXISTS poe2_rate_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
watchlist_id TEXT NOT NULL, watchlist_id TEXT NOT NULL,
+13 -30
View File
@@ -5,7 +5,6 @@ import type { Poe2WatchlistEntry } from './types.js';
interface CurrencyRef { interface CurrencyRef {
currencyId: string; currencyId: string;
name: string; name: string;
icon: string | null;
} }
function rowToEntry(row: any): Poe2WatchlistEntry { function rowToEntry(row: any): Poe2WatchlistEntry {
@@ -13,15 +12,11 @@ function rowToEntry(row: any): Poe2WatchlistEntry {
id: row.id, id: row.id,
baseCurrencyId: row.base_currency_id, baseCurrencyId: row.base_currency_id,
baseName: row.base_name, baseName: row.base_name,
baseIcon: row.base_icon,
quoteCurrencyId: row.quote_currency_id, quoteCurrencyId: row.quote_currency_id,
quoteName: row.quote_name, quoteName: row.quote_name,
quoteIcon: row.quote_icon,
priorityRank: row.priority_rank, priorityRank: row.priority_rank,
lastRate: row.last_rate, lastRate: row.last_rate,
lastChange1h: row.last_change_1h,
lastChange24h: row.last_change_24h, lastChange24h: row.last_change_24h,
lastChange7d: row.last_change_7d,
lastPolledAt: row.last_polled_at, lastPolledAt: row.last_polled_at,
lastError: row.last_error, lastError: row.last_error,
createdAt: row.created_at createdAt: row.created_at
@@ -44,22 +39,18 @@ export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2Wa
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
db.prepare( db.prepare(
`INSERT INTO poe2_watchlist `INSERT INTO poe2_watchlist
(id, base_currency_id, base_name, base_icon, quote_currency_id, quote_name, quote_icon, priority_rank, created_at) (id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(id, base.currencyId, base.name, base.icon, quote.currencyId, quote.name, quote.icon, maxRank.m + 1, createdAt); ).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt);
return { return {
id, id,
baseCurrencyId: base.currencyId, baseCurrencyId: base.currencyId,
baseName: base.name, baseName: base.name,
baseIcon: base.icon,
quoteCurrencyId: quote.currencyId, quoteCurrencyId: quote.currencyId,
quoteName: quote.name, quoteName: quote.name,
quoteIcon: quote.icon,
priorityRank: maxRank.m + 1, priorityRank: maxRank.m + 1,
lastRate: null, lastRate: null,
lastChange1h: null,
lastChange24h: null, lastChange24h: null,
lastChange7d: null,
lastPolledAt: null, lastPolledAt: null,
lastError: null, lastError: null,
createdAt createdAt
@@ -71,9 +62,8 @@ export function removeWatchlistEntry(id: string) {
db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id); 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 // One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed
// computed from, since poe.ninja itself doesn't expose per-pair rates or multiple // from, since poe.ninja itself doesn't expose per-pair rates or a matching change window.
// change windows.
export function recordRate(watchlistId: string, rate: number, recordedAt: string) { export function recordRate(watchlistId: string, rate: number, recordedAt: string) {
db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run( db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
watchlistId, 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 // 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" // pair added less than 24h ago), which the poller treats as "no change data yet" rather
// rather than fabricating a 0% figure. // than fabricating a 0% figure.
export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null { export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null {
const row = db const row = db
.prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1') .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; return row ? row.rate : null;
} }
export function markPolled( export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) {
id: string,
rate: number | null,
change1h: number | null,
change24h: number | null,
change7d: number | null,
error: string | null
) {
db.prepare( db.prepare(
`UPDATE poe2_watchlist `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 = ?` 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 // Keeps history bounded — a day or two of slack past the 24h window is plenty for a
// briefly late without losing the data point it needs. // poll to be briefly late without losing the data point it needs.
export function pruneOldHistory() { 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); 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. */ /** Opaque id from poe.ninja's exchange overview, e.g. "exalted" — not a display name. */
baseCurrencyId: string; baseCurrencyId: string;
baseName: string; baseName: string;
baseIcon: string | null;
quoteCurrencyId: string; quoteCurrencyId: string;
quoteName: string; quoteName: string;
quoteIcon: string | null;
priorityRank: number; priorityRank: number;
/** 1 base = lastRate quote. */ /** 1 base = lastRate quote. */
lastRate: number | null; lastRate: number | null;
/** /**
* % change over the last 1h/24h/7d, self-computed from poe2_rate_history since * % change over the last 24h, self-computed from poe2_rate_history since poe.ninja
* poe.ninja only exposes one 7-day window (see poe2/poller.ts) — null when there * doesn't expose a matching change window (see poe2/poller.ts) — null when there
* isn't yet enough history for that window (e.g. a pair just added). * isn't yet 24h of history (e.g. a pair just added).
*/ */
lastChange1h: number | null;
lastChange24h: number | null; lastChange24h: number | null;
lastChange7d: number | null;
lastPolledAt: string | null; lastPolledAt: string | null;
lastError: string | null; lastError: string | null;
createdAt: string; createdAt: string;
+2 -2
View File
@@ -213,8 +213,8 @@ export const getPoe2Watchlist = (fetchFn?: typeof fetch) =>
request<AdminPoe2Entry[]>('/api/admin/poe2/watchlist', {}, fetchFn); request<AdminPoe2Entry[]>('/api/admin/poe2/watchlist', {}, fetchFn);
export const addPoe2WatchlistEntry = ( export const addPoe2WatchlistEntry = (
base: { currencyId: string; name: string; icon: string | null }, base: { currencyId: string; name: string },
quote: { currencyId: string; name: string; icon: string | null }, quote: { currencyId: string; name: string },
fetchFn?: typeof fetch fetchFn?: typeof fetch
) => ) =>
request<AdminPoe2Entry>( request<AdminPoe2Entry>(
-5
View File
@@ -97,22 +97,17 @@ export interface AdminBookmark {
export interface Poe2BrowseEntry { export interface Poe2BrowseEntry {
id: string; id: string;
name: string; name: string;
icon: string | null;
} }
export interface AdminPoe2Entry { export interface AdminPoe2Entry {
id: string; id: string;
baseCurrencyId: string; baseCurrencyId: string;
baseName: string; baseName: string;
baseIcon: string | null;
quoteCurrencyId: string; quoteCurrencyId: string;
quoteName: string; quoteName: string;
quoteIcon: string | null;
priorityRank: number; priorityRank: number;
lastRate: number | null; lastRate: number | null;
lastChange1h: number | null;
lastChange24h: number | null; lastChange24h: number | null;
lastChange7d: number | null;
lastPolledAt: string | null; lastPolledAt: string | null;
lastError: string | null; lastError: string | null;
} }
@@ -52,8 +52,8 @@
async function pickQuote(entry: Poe2BrowseEntry) { async function pickQuote(entry: Poe2BrowseEntry) {
if (!selectedBase) return; if (!selectedBase) return;
const created = await addPoe2WatchlistEntry( const created = await addPoe2WatchlistEntry(
{ currencyId: selectedBase.id, name: selectedBase.name, icon: selectedBase.icon }, { currencyId: selectedBase.id, name: selectedBase.name },
{ currencyId: entry.id, name: entry.name, icon: entry.icon } { currencyId: entry.id, name: entry.name }
); );
watchlist = [...watchlist, created]; watchlist = [...watchlist, created];
showAdd = false; showAdd = false;
@@ -80,7 +80,7 @@
</div> </div>
<p class="hint" style="margin: -6px 0 12px;"> <p class="hint" style="margin: -6px 0 12px;">
{#if settings.poe2.leagueName} {#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} {:else}
League not detected yet — check back after the next poll (every hour). League not detected yet — check back after the next poll (every hour).
{/if} {/if}
@@ -109,7 +109,6 @@
class="result-row" class="result-row"
onclick={() => (step === 'base' ? pickBase(entry) : pickQuote(entry))} onclick={() => (step === 'base' ? pickBase(entry) : pickQuote(entry))}
> >
{#if entry.icon}<img class="result-icon" src={entry.icon} alt="" />{/if}
{entry.name} {entry.name}
</button> </button>
{/each} {/each}
@@ -125,13 +124,7 @@
{#each watchlist as entry (entry.id)} {#each watchlist as entry (entry.id)}
<div class="row"> <div class="row">
<div class="row-head"> <div class="row-head">
<div class="pair-name"> <div class="pair-name">{entry.baseName} <span class="arrow"></span> {entry.quoteName}</div>
{#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> <button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove"></button>
</div> </div>
{#if entry.lastError} {#if entry.lastError}
@@ -139,17 +132,11 @@
{:else if entry.lastRate !== null} {:else if entry.lastRate !== null}
<div class="direction"> <div class="direction">
<span class="rate">1 {entry.baseName} = {formatPoeValue(entry.lastRate)} {entry.quoteName}</span> <span class="rate">1 {entry.baseName} = {formatPoeValue(entry.lastRate)} {entry.quoteName}</span>
<span class="changes"> <span class="changes">24h {fmtChange(entry.lastChange24h)}</span>
1h {fmtChange(entry.lastChange1h)} · 24h {fmtChange(entry.lastChange24h)} · 7d {fmtChange(entry.lastChange7d)}
</span>
</div> </div>
<div class="direction"> <div class="direction">
<span class="rate">1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName}</span> <span class="rate">1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName}</span>
<span class="changes"> <span class="changes">24h {fmtChange(invertChangePercent(entry.lastChange24h))}</span>
1h {fmtChange(invertChangePercent(entry.lastChange1h))} ·
24h {fmtChange(invertChangePercent(entry.lastChange24h))} ·
7d {fmtChange(invertChangePercent(entry.lastChange7d))}
</span>
</div> </div>
{:else} {:else}
<div class="sub">Waiting for first poll…</div> <div class="sub">Waiting for first poll…</div>
@@ -203,9 +190,6 @@
border: 0.5px solid var(--border); border: 0.5px solid var(--border);
} }
.result-row { .result-row {
display: flex;
align-items: center;
gap: 8px;
text-align: left; text-align: left;
font-size: 12px; font-size: 12px;
padding: 8px 10px; padding: 8px 10px;
@@ -216,11 +200,6 @@
.result-row:hover { .result-row:hover {
background: var(--bg-accent); background: var(--bg-accent);
} }
.result-icon {
width: 20px;
height: 20px;
object-fit: contain;
}
.add-actions { .add-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -243,9 +222,6 @@
gap: 12px; gap: 12px;
} }
.pair-name { .pair-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
min-width: 0; min-width: 0;
@@ -254,12 +230,6 @@
color: var(--text-muted); color: var(--text-muted);
font-weight: 400; font-weight: 400;
} }
.icon {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.sub { .sub {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
@@ -1,13 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { Poe2Data } from '$lib/types'; import type { Poe2Data } from '$lib/types';
import { formatPoeValue } from '$lib/format'; import { formatPoeValue, poeLeagueSlug } from '$lib/format';
let { poe2 }: { poe2: Poe2Data } = $props(); let { poe2 }: { poe2: Poe2Data } = $props();
const link = $derived(poe2.leagueName ? `https://poe.ninja/poe2/economy/${poeLeagueSlug(poe2.leagueName)}/currency` : null);
</script> </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"> <div class="head">
<span class="title">PoE2</span> <span class="title">PoE2</span>
{#if poe2.entries.length > 0}<span class="interval">24h</span>{/if}
</div> </div>
{#if poe2.leagueName} {#if poe2.leagueName}
<p class="caption">{poe2.leagueName}</p> <p class="caption">{poe2.leagueName}</p>
@@ -16,19 +25,13 @@
<div class="list"> <div class="list">
{#each poe2.entries as entry (entry.id)} {#each poe2.entries as entry (entry.id)}
<div class="row"> <div class="row">
<span class="label"> <span class="label">{entry.baseName} <span class="arrow"></span> {entry.quoteName}</span>
{#if entry.baseIcon}<img class="icon" src={entry.baseIcon} alt="" />{/if}
{entry.baseName}
<span class="arrow"></span>
{entry.quoteName}
</span>
{#if entry.lastRate !== null} {#if entry.lastRate !== null}
<span class="price" class:up={(entry.lastChange24h ?? 0) >= 0} class:down={(entry.lastChange24h ?? 0) < 0}> <span class="price" class:up={(entry.lastChange24h ?? 0) >= 0} class:down={(entry.lastChange24h ?? 0) < 0}>
{formatPoeValue(entry.lastRate)} {formatPoeValue(entry.lastRate)}
{#if entry.lastChange24h !== null} {#if entry.lastChange24h !== null}
<span class="change">{entry.lastChange24h >= 0 ? '+' : ''}{entry.lastChange24h.toFixed(2)}%</span> <span class="change">{entry.lastChange24h >= 0 ? '+' : ''}{entry.lastChange24h.toFixed(2)}%</span>
{/if} {/if}
<span class="interval">24h</span>
</span> </span>
{:else} {:else}
<span class="price"></span> <span class="price"></span>
@@ -39,13 +42,16 @@
{:else} {:else}
<p class="empty">No currency pairs tracked</p> <p class="empty">No currency pairs tracked</p>
{/if} {/if}
</div> </svelte:element>
<style> <style>
.widget { .widget {
display: block;
background: var(--surface-1); background: var(--surface-1);
border-radius: 12px; border-radius: 12px;
padding: 14px; padding: 14px;
color: inherit;
text-decoration: none;
} }
.head { .head {
display: flex; display: flex;
@@ -57,6 +63,10 @@
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
} }
.interval {
font-size: 10px;
color: var(--text-muted);
}
.caption { .caption {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
@@ -79,21 +89,12 @@
border-top: none; border-top: none;
} }
.label { .label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
} }
.arrow { .arrow {
color: var(--text-muted); color: var(--text-muted);
} }
.icon {
width: 16px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
}
.price { .price {
font-size: 12px; font-size: 12px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -109,11 +110,6 @@
.change { .change {
margin-left: 4px; margin-left: 4px;
} }
.interval {
margin-left: 4px;
font-size: 10px;
color: var(--text-muted);
}
.empty { .empty {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
+9
View File
@@ -55,3 +55,12 @@ export function invertChangePercent(change: number | null): number | null {
if (change === null) return null; if (change === null) return null;
return -change / (1 + change / 100); 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 { export interface Poe2WatchlistEntry {
id: string; id: string;
baseName: string; baseName: string;
baseIcon: string | null;
quoteName: string; quoteName: string;
quoteIcon: string | null;
/** 1 base = lastRate quote. */ /** 1 base = lastRate quote. */
lastRate: number | null; lastRate: number | null;
/** % change self-computed from our own poll history — null if not enough history yet. */ /** 24h % change, self-computed from our own poll history — null if not enough history yet. */
lastChange1h: number | null;
lastChange24h: number | null; lastChange24h: number | null;
lastChange7d: number | null;
} }
export interface Poe2Data { export interface Poe2Data {