Files
homefeed/frontend/src/lib/components/admin/Poe2Tab.svelte
T
Claude 0a57e687a5 Slow PoE2 poller to 1 hour — matches poe.ninja's own refresh rate
poe.ninja's overview data doesn't update faster than hourly, so
polling every 15 minutes was just re-fetching the same numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 02:54:23 +00:00

298 lines
7.4 KiB
Svelte

<script lang="ts">
import type { AdminSettings, AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
import { browsePoe2Currencies, addPoe2WatchlistEntry, removePoe2WatchlistEntry } from '$lib/adminApi';
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 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 filtered = $derived(
(browseList ?? [])
.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;
try {
browseList = await browsePoe2Currencies();
} catch {
browseError = 'poe.ninja unreachable';
} finally {
browsing = false;
}
}
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;
}
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} 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} · change is over the last 1h / 24h / 7d.
{:else}
League not detected yet — check back after the next poll (every hour).
{/if}
</p>
{#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>
{:else if browseError}
<p class="hint" style="color: var(--text-danger);">{browseError}</p>
{:else if filtered.length === 0}
<p class="hint">{query ? 'No matches' : 'No traded currencies found for this league'}</p>
{:else}
<div class="results">
{#each filtered as entry (entry.id)}
<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>
{/each}
</div>
{/if}
<div class="add-actions">
<button onclick={closeAdd}>Close</button>
</div>
</div>
{/if}
<div class="list">
{#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>
<button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove"></button>
</div>
{#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}
</div>
{/each}
</div>
<style>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.count {
font-size: 12px;
color: var(--text-muted);
}
.add-btn {
font-size: 12px;
padding: 6px 12px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin: 0;
}
.add-panel {
background: var(--surface-1);
border-radius: 12px;
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;
}
.results {
display: flex;
flex-direction: column;
max-height: 280px;
overflow-y: auto;
border-radius: var(--radius);
overflow-x: hidden;
border: 0.5px solid var(--border);
}
.result-row {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
font-size: 12px;
padding: 8px 10px;
background: var(--surface-2);
border: none;
border-radius: 0;
}
.result-row:hover {
background: var(--bg-accent);
}
.result-icon {
width: 20px;
height: 20px;
object-fit: contain;
}
.add-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
background: var(--surface-1);
border-radius: var(--radius);
padding: 10px 14px;
}
.row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.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);
}
.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;
}
.icon-btn {
font-size: 12px;
padding: 3px 6px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.icon-btn.danger:hover {
color: var(--text-danger);
}
</style>