Add PoE2 sidebar module: currency exchange watchlist
Tracks Path of Exile 2 currency values via poe.ninja's public economy API, mirroring the existing Weather/Stocks sidebar modules. Always follows the current challenge league (auto-detected, no admin picker). Admin browses and picks currencies from a live list rather than typing symbols, since currency ids are opaque. Change % is a 7-day window, labeled accordingly to avoid the same interval ambiguity Stocks had. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -5,6 +5,7 @@ import * as eventsDb from '../storage/db/events.js';
|
|||||||
import * as categoriesDb from '../storage/db/categories.js';
|
import * as categoriesDb from '../storage/db/categories.js';
|
||||||
import * as stocksDb from '../storage/db/stocks.js';
|
import * as stocksDb from '../storage/db/stocks.js';
|
||||||
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
||||||
|
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
|
||||||
import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
|
import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
|
||||||
import { totalStorageBytes } from '../storage/media/index.js';
|
import { totalStorageBytes } from '../storage/media/index.js';
|
||||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||||
@@ -14,6 +15,8 @@ import * as telegramClient from '../telegram/client.js';
|
|||||||
import { geocodeLocation } from '../weather/client.js';
|
import { geocodeLocation } from '../weather/client.js';
|
||||||
import { pollWeatherNow } from '../weather/poller.js';
|
import { pollWeatherNow } from '../weather/poller.js';
|
||||||
import { pollStocksNow } from '../stocks/poller.js';
|
import { pollStocksNow } from '../stocks/poller.js';
|
||||||
|
import { browseCurrencies, fetchCurrentLeague } from '../poe2/client.js';
|
||||||
|
import { pollPoe2Now } from '../poe2/poller.js';
|
||||||
|
|
||||||
// Not part of GlobalSettings itself (nothing to persist) — computed fresh on every
|
// Not part of GlobalSettings itself (nothing to persist) — computed fresh on every
|
||||||
// settings read/write so the Retention tab's "currently using" line and usage bar
|
// settings read/write so the Retention tab's "currently using" line and usage bar
|
||||||
@@ -276,6 +279,34 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- PoE2 (league is always auto-detected, never admin-set — see poe2/poller.ts) ---
|
||||||
|
app.get('/api/admin/poe2/browse', async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
const league = await fetchCurrentLeague();
|
||||||
|
return await browseCurrencies(league.id);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
|
||||||
|
// and refreshes every existing entry's value too.
|
||||||
|
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
|
||||||
|
return reply.code(201).send(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
poe2WatchlistDb.removeWatchlistEntry(id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
// --- Logs ---
|
// --- Logs ---
|
||||||
app.get('/api/admin/logs', async (req) => {
|
app.get('/api/admin/logs', async (req) => {
|
||||||
const { level, limit } = req.query as { level?: string; limit?: string };
|
const { level, limit } = req.query as { level?: string; limit?: string };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as categoriesDb from '../storage/db/categories.js';
|
|||||||
import * as settingsDb from '../storage/db/settings.js';
|
import * as settingsDb from '../storage/db/settings.js';
|
||||||
import * as stocksDb from '../storage/db/stocks.js';
|
import * as stocksDb from '../storage/db/stocks.js';
|
||||||
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
||||||
|
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
|
||||||
import { hasPrivateAccess } from './privateAccess.js';
|
import { hasPrivateAccess } from './privateAccess.js';
|
||||||
|
|
||||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||||
@@ -69,4 +70,9 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
|||||||
if (hasPrivateAccess(req)) return bookmarks;
|
if (hasPrivateAccess(req)) return bookmarks;
|
||||||
return bookmarks.filter((b) => !b.isPrivate);
|
return bookmarks.filter((b) => !b.isPrivate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/poe2', async () => {
|
||||||
|
const { leagueName, primaryCurrencyName, updatedAt } = settingsDb.getSettings().poe2;
|
||||||
|
return { leagueName, primaryCurrencyName, updatedAt, entries: poe2WatchlistDb.listWatchlist() };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// poe.ninja's public PoE2 economy API — free, no account or API key required. This is the
|
||||||
|
// only file that talks to it; poller.ts orchestrates when/how results get saved, same
|
||||||
|
// separation as backend/src/telegram/ and backend/src/weather/ keep between the raw client
|
||||||
|
// 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`. 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.
|
||||||
|
const BASE_URL = 'https://poe.ninja';
|
||||||
|
|
||||||
|
export interface LeagueInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrencyBrowseEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
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;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
const leagues = (await res.json()) as LeagueInfo[];
|
||||||
|
if (leagues.length === 0) throw new Error('No active leagues returned');
|
||||||
|
return leagues[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrencyOverview(leagueId: string): Promise<RawCurrencyOverview> {
|
||||||
|
const url = `${BASE_URL}/poe2/api/economy/exchange/current/overview?league=${encodeURIComponent(leagueId)}&type=Currency`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`poe.ninja currency overview returned ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches the whole traded-currency list for the admin's search-and-pick UI (see
|
||||||
|
// api/admin.ts's GET /api/admin/poe2/browse) — only currencies that actually have a `lines`
|
||||||
|
// 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]));
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One overview fetch covers every watchlisted currency 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export async function pollPoe2Now(): Promise<void> {
|
||||||
|
let league;
|
||||||
|
try {
|
||||||
|
league = await fetchCurrentLeague();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('poe2', `League lookup failed: ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = poe2WatchlistDb.listWatchlist();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const { poe2 } = settingsDb.getSettings();
|
||||||
|
settingsDb.updateSettings({
|
||||||
|
poe2: { ...poe2, leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { quotes, primaryCurrencyName } = await fetchWatchlistQuotes(
|
||||||
|
league.id,
|
||||||
|
entries.map((e) => e.currencyId)
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsDb.updateSettings({
|
||||||
|
poe2: { leagueId: league.id, leagueName: league.name, primaryCurrencyName, updatedAt: new Date().toISOString() }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ import * as settingsDb from '../storage/db/settings.js';
|
|||||||
import { logger } from '../storage/db/logs.js';
|
import { logger } from '../storage/db/logs.js';
|
||||||
import { pollWeatherNow } from '../weather/poller.js';
|
import { pollWeatherNow } from '../weather/poller.js';
|
||||||
import { pollStocksNow } from '../stocks/poller.js';
|
import { pollStocksNow } from '../stocks/poller.js';
|
||||||
|
import { pollPoe2Now } from '../poe2/poller.js';
|
||||||
|
|
||||||
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
|
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
|
||||||
const SYNTHESIS_TICK_MS = 60_000;
|
const SYNTHESIS_TICK_MS = 60_000;
|
||||||
const RETENTION_TICK_MS = 60 * 60_000; // hourly
|
const RETENTION_TICK_MS = 60 * 60_000; // hourly
|
||||||
const WEATHER_TICK_MS = 45 * 60_000;
|
const WEATHER_TICK_MS = 45 * 60_000;
|
||||||
const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather
|
const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather
|
||||||
|
const POE2_TICK_MS = 15 * 60_000; // matches Stocks' cadence
|
||||||
|
|
||||||
export function startScheduler() {
|
export function startScheduler() {
|
||||||
const provider = () => {
|
const provider = () => {
|
||||||
@@ -79,5 +81,10 @@ export function startScheduler() {
|
|||||||
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
|
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
|
||||||
}, STOCKS_TICK_MS);
|
}, STOCKS_TICK_MS);
|
||||||
|
|
||||||
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m');
|
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
|
||||||
|
setInterval(() => {
|
||||||
|
pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`));
|
||||||
|
}, POE2_TICK_MS);
|
||||||
|
|
||||||
|
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m, poe2 every 15m');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,16 +189,20 @@ export function migrate() {
|
|||||||
weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
|
weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||||
weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array
|
weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||||
weather_alerts TEXT NOT NULL DEFAULT '[]', -- JSON array — active NWS alerts for the configured location, US-only (see weather/client.ts)
|
weather_alerts TEXT NOT NULL DEFAULT '[]', -- JSON array — active NWS alerts for the configured location, US-only (see weather/client.ts)
|
||||||
weather_updated_at TEXT -- ISO timestamp, NULL pre-first-poll
|
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_updated_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Sidebar "Stocks" widget — polled every 15 minutes from Stooq (see stocks/poller.ts).
|
-- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see
|
||||||
-- Price/change/poll-state live directly on the row, same as sources.last_polled_at,
|
-- stocks/poller.ts). Price/change/poll-state live directly on the row, same as
|
||||||
-- rather than a separate quote-cache table.
|
-- sources.last_polled_at, rather than a separate quote-cache table.
|
||||||
CREATE TABLE IF NOT EXISTS stock_tickers (
|
CREATE TABLE IF NOT EXISTS stock_tickers (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
label TEXT NOT NULL,
|
label TEXT NOT NULL,
|
||||||
symbol TEXT NOT NULL, -- Stooq symbol syntax, e.g. "^dji", "aapl.us", "btcusd"
|
symbol TEXT NOT NULL, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD"
|
||||||
priority_rank INTEGER NOT NULL,
|
priority_rank INTEGER NOT NULL,
|
||||||
last_price REAL,
|
last_price REAL,
|
||||||
last_change_percent REAL,
|
last_change_percent REAL,
|
||||||
@@ -207,6 +211,22 @@ export function migrate() {
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Sidebar "PoE2" widget — currency watchlist priced off poe.ninja's PoE2 economy API
|
||||||
|
-- (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.
|
||||||
|
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,
|
||||||
|
priority_rank INTEGER NOT NULL,
|
||||||
|
last_value REAL,
|
||||||
|
last_change_percent REAL,
|
||||||
|
last_polled_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
|
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
|
||||||
-- via is_private (same private-access lock feature as categories.is_private).
|
-- via is_private (same private-access lock feature as categories.is_private).
|
||||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
@@ -302,6 +322,12 @@ export function migrate() {
|
|||||||
db.exec("ALTER TABLE global_settings ADD COLUMN weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg'");
|
db.exec("ALTER TABLE global_settings ADD COLUMN weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg'");
|
||||||
db.exec("ALTER TABLE global_settings ADD COLUMN weather_alerts TEXT NOT NULL DEFAULT '[]'");
|
db.exec("ALTER TABLE global_settings ADD COLUMN weather_alerts TEXT NOT NULL DEFAULT '[]'");
|
||||||
}
|
}
|
||||||
|
if (!hasColumn('global_settings', 'poe2_league_id')) {
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_id TEXT');
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_league_name TEXT');
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_primary_currency_name TEXT');
|
||||||
|
db.exec('ALTER TABLE global_settings ADD COLUMN poe2_updated_at TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
|
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
|
||||||
// fresh install — the admin can remove/replace any of them via the Stocks tab.
|
// fresh install — the admin can remove/replace any of them via the Stocks tab.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { db } from './index.js';
|
||||||
|
import type { Poe2WatchlistEntry } from './types.js';
|
||||||
|
|
||||||
|
function rowToEntry(row: any): Poe2WatchlistEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
currencyId: row.currency_id,
|
||||||
|
name: row.name,
|
||||||
|
icon: row.icon,
|
||||||
|
priorityRank: row.priority_rank,
|
||||||
|
lastValue: row.last_value,
|
||||||
|
lastChangePercent: row.last_change_percent,
|
||||||
|
lastPolledAt: row.last_polled_at,
|
||||||
|
lastError: row.last_error,
|
||||||
|
createdAt: row.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWatchlist(): Poe2WatchlistEntry[] {
|
||||||
|
const rows = db.prepare('SELECT * FROM poe2_watchlist ORDER BY priority_rank').all();
|
||||||
|
return rows.map(rowToEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No update() — entries 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)}`;
|
||||||
|
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);
|
||||||
|
return {
|
||||||
|
id, currencyId, name, icon, priorityRank: maxRank.m + 1,
|
||||||
|
lastValue: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWatchlistEntry(id: string) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -36,6 +36,12 @@ function rowToSettings(row: any): GlobalSettings {
|
|||||||
daily: JSON.parse(row.weather_daily),
|
daily: JSON.parse(row.weather_daily),
|
||||||
alerts: JSON.parse(row.weather_alerts),
|
alerts: JSON.parse(row.weather_alerts),
|
||||||
updatedAt: row.weather_updated_at
|
updatedAt: row.weather_updated_at
|
||||||
|
},
|
||||||
|
poe2: {
|
||||||
|
leagueId: row.poe2_league_id,
|
||||||
|
leagueName: row.poe2_league_name,
|
||||||
|
primaryCurrencyName: row.poe2_primary_currency_name,
|
||||||
|
updatedAt: row.poe2_updated_at
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -52,7 +58,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
|||||||
...patch,
|
...patch,
|
||||||
retention: { ...current.retention, ...(patch.retention ?? {}) },
|
retention: { ...current.retention, ...(patch.retention ?? {}) },
|
||||||
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
|
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
|
||||||
weather: { ...current.weather, ...(patch.weather ?? {}) }
|
weather: { ...current.weather, ...(patch.weather ?? {}) },
|
||||||
|
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }
|
||||||
};
|
};
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE global_settings SET
|
`UPDATE global_settings SET
|
||||||
@@ -64,7 +71,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
|||||||
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
|
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
|
||||||
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
|
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
|
||||||
weather_wind_unit=?, weather_pressure_unit=?,
|
weather_wind_unit=?, weather_pressure_unit=?,
|
||||||
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?
|
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?,
|
||||||
|
poe2_league_id=?, poe2_league_name=?, poe2_primary_currency_name=?, poe2_updated_at=?
|
||||||
WHERE id = 1`
|
WHERE id = 1`
|
||||||
).run(
|
).run(
|
||||||
merged.mergeStrictness,
|
merged.mergeStrictness,
|
||||||
@@ -95,7 +103,11 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
|||||||
JSON.stringify(merged.weather.hourly),
|
JSON.stringify(merged.weather.hourly),
|
||||||
JSON.stringify(merged.weather.daily),
|
JSON.stringify(merged.weather.daily),
|
||||||
JSON.stringify(merged.weather.alerts),
|
JSON.stringify(merged.weather.alerts),
|
||||||
merged.weather.updatedAt
|
merged.weather.updatedAt,
|
||||||
|
merged.poe2.leagueId,
|
||||||
|
merged.poe2.leagueName,
|
||||||
|
merged.poe2.primaryCurrencyName,
|
||||||
|
merged.poe2.updatedAt
|
||||||
);
|
);
|
||||||
return getSettings();
|
return getSettings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,21 @@ export interface StockTicker {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
lastPolledAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Bookmark {
|
export interface Bookmark {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -299,6 +314,19 @@ export interface GlobalSettings {
|
|||||||
alerts: WeatherAlert[];
|
alerts: WeatherAlert[];
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Sidebar "PoE2" widget cache — see poe2/poller.ts. No admin-set config (unlike weather):
|
||||||
|
* the league is always auto-detected as the current challenge league, so this is purely
|
||||||
|
* a cache of what the last poll learned. Watchlist entries themselves live in the
|
||||||
|
* poe2_watchlist table, not here — same split as stock_tickers vs. this settings row.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherAlert {
|
export interface WeatherAlert {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import type {
|
|||||||
LogEntry,
|
LogEntry,
|
||||||
GeocodeResult,
|
GeocodeResult,
|
||||||
AdminStockTicker,
|
AdminStockTicker,
|
||||||
AdminBookmark
|
AdminBookmark,
|
||||||
|
Poe2BrowseEntry,
|
||||||
|
AdminPoe2Entry
|
||||||
} from './adminTypes';
|
} from './adminTypes';
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
|
||||||
@@ -202,3 +204,20 @@ export const updateBookmark = (id: string, patch: { name?: string; url?: string;
|
|||||||
|
|
||||||
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
|
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
|
||||||
request<void>(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
|
request<void>(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
|
||||||
|
|
||||||
|
// PoE2 — league is always auto-detected, never admin-set (see poe2/poller.ts).
|
||||||
|
export const browsePoe2Currencies = (fetchFn?: typeof fetch) =>
|
||||||
|
request<Poe2BrowseEntry[]>('/api/admin/poe2/browse', {}, fetchFn);
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
request<AdminPoe2Entry>(
|
||||||
|
'/api/admin/poe2/watchlist',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ currencyId, name, icon }) },
|
||||||
|
fetchFn
|
||||||
|
);
|
||||||
|
|
||||||
|
export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) =>
|
||||||
|
request<void>(`/api/admin/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn);
|
||||||
|
|||||||
@@ -94,6 +94,31 @@ export interface AdminBookmark {
|
|||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Poe2BrowseEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPoe2Entry {
|
||||||
|
id: string;
|
||||||
|
currencyId: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
priorityRank: number;
|
||||||
|
lastValue: number | null;
|
||||||
|
lastChangePercent: number | null;
|
||||||
|
lastPolledAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPoe2Settings {
|
||||||
|
leagueId: string | null;
|
||||||
|
leagueName: string | null;
|
||||||
|
primaryCurrencyName: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminSettings {
|
export interface AdminSettings {
|
||||||
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
||||||
defaultPollIntervalMinutes: number;
|
defaultPollIntervalMinutes: number;
|
||||||
@@ -111,6 +136,7 @@ export interface AdminSettings {
|
|||||||
retention: RetentionSettings;
|
retention: RetentionSettings;
|
||||||
categoryPriority: CategoryPriority[];
|
categoryPriority: CategoryPriority[];
|
||||||
weather: AdminWeatherSettings;
|
weather: AdminWeatherSettings;
|
||||||
|
poe2: AdminPoe2Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSource {
|
export interface AdminSource {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getBackendUrl } from './config';
|
import { getBackendUrl } from './config';
|
||||||
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark } from './types';
|
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data } from './types';
|
||||||
|
|
||||||
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
||||||
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
|
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
|
||||||
@@ -53,3 +53,7 @@ export function getStocks(fetchFn?: typeof fetch): Promise<StockTicker[]> {
|
|||||||
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
|
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
|
||||||
return get<Bookmark[]>('/api/bookmarks', fetchFn);
|
return get<Bookmark[]>('/api/bookmarks', fetchFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
|
||||||
|
return get<Poe2Data>('/api/poe2', fetchFn);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AdminSettings, AdminPoe2Entry, Poe2BrowseEntry } from '$lib/adminTypes';
|
||||||
|
import { browsePoe2Currencies, addPoe2WatchlistEntry, removePoe2WatchlistEntry } from '$lib/adminApi';
|
||||||
|
import { formatPoeValue } from '$lib/format';
|
||||||
|
|
||||||
|
let { settings, watchlist: initial }: { settings: AdminSettings; watchlist: AdminPoe2Entry[] } = $props();
|
||||||
|
let watchlist = $state([...initial]);
|
||||||
|
|
||||||
|
let showAdd = $state(false);
|
||||||
|
let query = $state('');
|
||||||
|
// null = not fetched yet — fetched once on first "+ Add currency" 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.name.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
.slice(0, 30)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function openAdd() {
|
||||||
|
showAdd = true;
|
||||||
|
if (browseList) return;
|
||||||
|
browsing = true;
|
||||||
|
browseError = null;
|
||||||
|
try {
|
||||||
|
browseList = await browsePoe2Currencies();
|
||||||
|
} catch {
|
||||||
|
browseError = 'poe.ninja unreachable';
|
||||||
|
} finally {
|
||||||
|
browsing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pick(entry: Poe2BrowseEntry) {
|
||||||
|
const created = await addPoe2WatchlistEntry(entry.id, entry.name, entry.icon);
|
||||||
|
watchlist = [...watchlist, created];
|
||||||
|
showAdd = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await removePoe2WatchlistEntry(id);
|
||||||
|
watchlist = watchlist.filter((w) => w.id !== id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="count">{watchlist.length} currencies tracked</span>
|
||||||
|
<button class="add-btn" onclick={openAdd}>+ Add currency</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.
|
||||||
|
{:else}
|
||||||
|
League not detected yet — check back after the next poll (every 15 minutes).
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if showAdd}
|
||||||
|
<div class="add-panel">
|
||||||
|
<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={() => pick(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={() => (showAdd = false)}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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}
|
||||||
|
<button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove">✕</button>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.row-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--text-danger);
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.price.up {
|
||||||
|
color: var(--text-success);
|
||||||
|
}
|
||||||
|
.price.down {
|
||||||
|
color: var(--text-danger);
|
||||||
|
}
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Poe2Data } from '$lib/types';
|
||||||
|
import { formatPoeValue } from '$lib/format';
|
||||||
|
|
||||||
|
let { poe2 }: { poe2: Poe2Data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{/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}
|
||||||
|
</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}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="price">—</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">No currencies tracked</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.widget {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.interval {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.caption {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-top: 0.5px solid var(--border);
|
||||||
|
}
|
||||||
|
.row:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.price.up .change {
|
||||||
|
color: var(--text-success);
|
||||||
|
}
|
||||||
|
.price.down .change {
|
||||||
|
color: var(--text-danger);
|
||||||
|
}
|
||||||
|
.change {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Weather, StockTicker, Bookmark } from '$lib/types';
|
import type { Weather, StockTicker, Bookmark, Poe2Data } from '$lib/types';
|
||||||
import WeatherWidget from './WeatherWidget.svelte';
|
import WeatherWidget from './WeatherWidget.svelte';
|
||||||
import StocksWidget from './StocksWidget.svelte';
|
import StocksWidget from './StocksWidget.svelte';
|
||||||
import BookmarksWidget from './BookmarksWidget.svelte';
|
import BookmarksWidget from './BookmarksWidget.svelte';
|
||||||
|
import Poe2Widget from './Poe2Widget.svelte';
|
||||||
|
|
||||||
let { weather, stocks, bookmarks }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[] } = $props();
|
let { weather, stocks, bookmarks, poe2 }: { weather: Weather; stocks: StockTicker[]; bookmarks: Bookmark[]; poe2: Poe2Data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<WeatherWidget {weather} />
|
<WeatherWidget {weather} />
|
||||||
<StocksWidget {stocks} />
|
<StocksWidget {stocks} />
|
||||||
|
<Poe2Widget {poe2} />
|
||||||
<BookmarksWidget {bookmarks} />
|
<BookmarksWidget {bookmarks} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,12 @@ export function excerpt(body: string, sentenceCount = 2): string {
|
|||||||
const sentences = singleLine.match(/[^.!?]+[.!?]+/g) ?? [singleLine];
|
const sentences = singleLine.match(/[^.!?]+[.!?]+/g) ?? [singleLine];
|
||||||
return sentences.slice(0, sentenceCount).join(' ').trim();
|
return sentences.slice(0, sentenceCount).join(' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PoE currency values span many orders of magnitude (e.g. 0.00002749 to 4856) — a fixed decimal count alone reads badly across that range. */
|
||||||
|
export function formatPoeValue(value: number): string {
|
||||||
|
if (value === 0) return '0';
|
||||||
|
if (value >= 100) return value.toFixed(0);
|
||||||
|
if (value >= 1) return value.toFixed(2);
|
||||||
|
if (value >= 0.01) return value.toFixed(3);
|
||||||
|
return value.toPrecision(2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,3 +155,20 @@ export interface Bookmark {
|
|||||||
url: string;
|
url: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{#if showSidebar}
|
{#if showSidebar}
|
||||||
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} />
|
<Sidebar weather={data.weather} stocks={data.stocks} bookmarks={data.bookmarks} poe2={data.poe2} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { getCategories, getEvents, getWeather, getStocks, getBookmarks } from '$lib/api';
|
import { getCategories, getEvents, getWeather, getStocks, getBookmarks, getPoe2 } from '$lib/api';
|
||||||
import { getPrivateAccessStatus } from '$lib/privateAccess';
|
import { getPrivateAccessStatus } from '$lib/privateAccess';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, data }) => {
|
export const load: LayoutLoad = async ({ fetch, data }) => {
|
||||||
const [categories, events, privateAccess, weather, stocks, bookmarks] = await Promise.all([
|
const [categories, events, privateAccess, weather, stocks, bookmarks, poe2] = await Promise.all([
|
||||||
getCategories(fetch),
|
getCategories(fetch),
|
||||||
getEvents(fetch),
|
getEvents(fetch),
|
||||||
getPrivateAccessStatus(fetch),
|
getPrivateAccessStatus(fetch),
|
||||||
getWeather(fetch),
|
getWeather(fetch),
|
||||||
getStocks(fetch),
|
getStocks(fetch),
|
||||||
getBookmarks(fetch)
|
getBookmarks(fetch),
|
||||||
|
getPoe2(fetch)
|
||||||
]);
|
]);
|
||||||
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
|
// Tracked events are a displayed category like any other (see MergeTab/EventsTab) —
|
||||||
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
|
// only active ones show up as browsable, same as a paused/disabled category wouldn't.
|
||||||
@@ -20,6 +21,7 @@ export const load: LayoutLoad = async ({ fetch, data }) => {
|
|||||||
privateAccess,
|
privateAccess,
|
||||||
weather,
|
weather,
|
||||||
stocks,
|
stocks,
|
||||||
bookmarks
|
bookmarks,
|
||||||
|
poe2
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import WeatherTab from '$lib/components/admin/WeatherTab.svelte';
|
import WeatherTab from '$lib/components/admin/WeatherTab.svelte';
|
||||||
import StocksTab from '$lib/components/admin/StocksTab.svelte';
|
import StocksTab from '$lib/components/admin/StocksTab.svelte';
|
||||||
import BookmarksTab from '$lib/components/admin/BookmarksTab.svelte';
|
import BookmarksTab from '$lib/components/admin/BookmarksTab.svelte';
|
||||||
|
import Poe2Tab from '$lib/components/admin/Poe2Tab.svelte';
|
||||||
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
|
import ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
|
||||||
import LogsTab from '$lib/components/admin/LogsTab.svelte';
|
import LogsTab from '$lib/components/admin/LogsTab.svelte';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
{ id: 'weather', label: 'Weather' },
|
{ id: 'weather', label: 'Weather' },
|
||||||
{ id: 'stocks', label: 'Stocks' },
|
{ id: 'stocks', label: 'Stocks' },
|
||||||
{ id: 'bookmarks', label: 'Bookmarks' },
|
{ id: 'bookmarks', label: 'Bookmarks' },
|
||||||
|
{ id: 'poe2', label: 'PoE2' },
|
||||||
{ id: 'connections', label: 'Connections' },
|
{ id: 'connections', label: 'Connections' },
|
||||||
{ id: 'logs', label: 'Logs' }
|
{ id: 'logs', label: 'Logs' }
|
||||||
];
|
];
|
||||||
@@ -57,6 +59,8 @@
|
|||||||
<StocksTab tickers={data.stockTickers} />
|
<StocksTab tickers={data.stockTickers} />
|
||||||
{:else if active === 'bookmarks'}
|
{:else if active === 'bookmarks'}
|
||||||
<BookmarksTab bookmarks={data.bookmarks} />
|
<BookmarksTab bookmarks={data.bookmarks} />
|
||||||
|
{:else if active === 'poe2'}
|
||||||
|
<Poe2Tab settings={data.settings} watchlist={data.poe2Watchlist} />
|
||||||
{:else if active === 'connections'}
|
{:else if active === 'connections'}
|
||||||
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
|
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
|
||||||
{:else if active === 'logs'}
|
{:else if active === 'logs'}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
getTelegramStatus,
|
getTelegramStatus,
|
||||||
getLogs,
|
getLogs,
|
||||||
getStockTickers,
|
getStockTickers,
|
||||||
getAdminBookmarks
|
getAdminBookmarks,
|
||||||
|
getPoe2Watchlist
|
||||||
} from '$lib/adminApi';
|
} from '$lib/adminApi';
|
||||||
import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes';
|
import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes';
|
||||||
|
|
||||||
@@ -17,13 +18,14 @@ const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
|
|||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
try {
|
try {
|
||||||
const [settings, sources, events, logs, stockTickers, bookmarks] = await Promise.all([
|
const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist] = await Promise.all([
|
||||||
getSettings(fetch),
|
getSettings(fetch),
|
||||||
getSources(fetch),
|
getSources(fetch),
|
||||||
getEvents(fetch),
|
getEvents(fetch),
|
||||||
getLogs({}, fetch),
|
getLogs({}, fetch),
|
||||||
getStockTickers(fetch),
|
getStockTickers(fetch),
|
||||||
getAdminBookmarks(fetch)
|
getAdminBookmarks(fetch),
|
||||||
|
getPoe2Watchlist(fetch)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// The AI service (Ollama) may not be running yet — that shouldn't take down the
|
// The AI service (Ollama) may not be running yet — that shouldn't take down the
|
||||||
@@ -40,7 +42,7 @@ export const load: PageLoad = async ({ fetch }) => {
|
|||||||
() => ({ credentialsConfigured: false, connected: false, phone: null })
|
() => ({ credentialsConfigured: false, connected: false, phone: null })
|
||||||
);
|
);
|
||||||
|
|
||||||
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks };
|
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks, poe2Watchlist };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as { status?: number }).status === 401) {
|
if ((err as { status?: number }).status === 401) {
|
||||||
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
|
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
|
||||||
|
|||||||
Reference in New Issue
Block a user