Merge pull request #17 from Salastil/development

Development
This commit is contained in:
Salastil
2026-07-24 23:52:42 -04:00
committed by GitHub
22 changed files with 1020 additions and 38 deletions
+42
View File
@@ -5,6 +5,7 @@ import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as stocksDb from '../storage/db/stocks.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 { totalStorageBytes } from '../storage/media/index.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 { pollWeatherNow } from '../weather/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
// settings read/write so the Retention tab's "currently using" line and usage bar
@@ -276,6 +279,45 @@ export async function registerAdminRoutes(app: FastifyInstance) {
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 { base, quote } = req.body as {
base?: { currencyId?: string; name?: string };
quote?: { currencyId?: string; name?: string };
};
if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) {
return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' });
}
if (base.currencyId === quote.currencyId) {
return reply.code(400).send({ error: 'Base and quote currencies must be different' });
}
const created = poe2WatchlistDb.addWatchlistEntry(
{ currencyId: base.currencyId, name: base.name },
{ currencyId: quote.currencyId, name: quote.name }
);
// Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap,
// and refreshes every existing entry's rate too.
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 ---
app.get('/api/admin/logs', async (req) => {
const { level, limit } = req.query as { level?: string; limit?: string };
+6
View File
@@ -6,6 +6,7 @@ import * as categoriesDb from '../storage/db/categories.js';
import * as settingsDb from '../storage/db/settings.js';
import * as stocksDb from '../storage/db/stocks.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
import { hasPrivateAccess } from './privateAccess.js';
export async function registerPublicRoutes(app: FastifyInstance) {
@@ -69,4 +70,9 @@ export async function registerPublicRoutes(app: FastifyInstance) {
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
});
app.get('/api/poe2', async () => {
const { leagueName, updatedAt } = settingsDb.getSettings().poe2;
return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() };
});
}
+79
View File
@@ -0,0 +1,79 @@
// 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 this point): currency name metadata lives in a top-level `items[]` array on
// the overview response, NOT `core.items` (that only holds the handful of currencies used
// for `core.rates`/`primary`/`secondary`).
//
// Deliberately not using poe.ninja's own `core` (reference currency) or `sparkline` (a fixed
// 7-day window) fields at all — the watchlist tracks arbitrary currency pairs with 24h
// change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is
// expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate
// is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues
// below and poe2/poller.ts, which self-computes change from its own polling history instead.
const BASE_URL = 'https://poe.ninja';
export interface LeagueInfo {
id: string;
name: string;
}
export interface CurrencyBrowseEntry {
id: string;
name: string;
}
interface RawCurrencyItem {
id: string;
name: string;
}
interface RawCurrencyLine {
id: string;
primaryValue: number;
}
interface RawCurrencyOverview {
lines: RawCurrencyLine[];
items: RawCurrencyItem[]; // top-level, not core.items — see file header
}
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 nameById = new Map(items.map((item) => [item.id, item.name]));
return lines
.map((line) => ({ id: line.id, name: nameById.get(line.id) ?? line.id }))
.sort((a, b) => a.name.localeCompare(b.name));
}
// One overview fetch covers every watchlisted pair regardless of list size — unlike
// Stocks, which needs one request per ticker (Yahoo has no equivalent single "give me all of
// these" endpoint without a cookie/crumb handshake). Every line's primaryValue is expressed
// in the same reference currency, so any pair's rate is just baseValue / quoteValue — the
// reference currency itself cancels out, meaning this same one fetch works for arbitrary
// pairs without needing to know or care what poe.ninja's own reference currency is.
export async function fetchCurrencyValues(leagueId: string): Promise<Map<string, number>> {
const { lines } = await fetchCurrencyOverview(leagueId);
return new Map(lines.map((line) => [line.id, line.primaryValue]));
}
+63
View File
@@ -0,0 +1,63 @@
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, fetchCurrencyValues } from './client.js';
const DAY_MS = 24 * 60 * 60_000;
function pctChange(current: number, past: number | null): number | null {
if (past === null || past === 0) return null;
return ((current - past) / past) * 100;
}
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a pair
// (see api/admin.ts) — always re-detects the current challenge league fresh (cheap, guarantees
// correctness across league rotations with no separate staleness logic), then one overview
// request covers the whole watchlist. A pair whose base or quote currency is no longer traded
// this league gets its own lastError, it never aborts the rest of the batch.
export async function pollPoe2Now(): Promise<void> {
let league;
try {
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 valuesById = await fetchCurrencyValues(league.id);
const now = new Date();
const nowIso = now.toISOString();
const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString();
for (const entry of entries) {
const baseValue = valuesById.get(entry.baseCurrencyId);
const quoteValue = valuesById.get(entry.quoteCurrencyId);
if (baseValue === undefined || quoteValue === undefined) {
poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league');
continue;
}
const rate = baseValue / quoteValue;
const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h));
poe2WatchlistDb.recordRate(entry.id, rate, nowIso);
poe2WatchlistDb.markPolled(entry.id, rate, change24h, null);
}
poe2WatchlistDb.pruneOldHistory();
settingsDb.updateSettings({
poe2: { leagueId: league.id, leagueName: league.name, updatedAt: nowIso }
});
} catch (err) {
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
}
}
+11 -4
View File
@@ -7,12 +7,14 @@ import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { pollWeatherNow } from '../weather/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 SYNTHESIS_TICK_MS = 60_000;
const RETENTION_TICK_MS = 60 * 60_000; // hourly
const WEATHER_TICK_MS = 45 * 60_000;
const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather
const POE2_TICK_MS = 60 * 60_000; // poe.ninja's own overview data doesn't refresh faster than hourly, so polling more often than this just re-fetches the same numbers
export function startScheduler() {
const provider = () => {
@@ -65,9 +67,9 @@ export function startScheduler() {
}
}, RETENTION_TICK_MS);
// Immediate first call for both — unlike RSS sources (whose "due" check makes a
// brand-new source eligible on the very next 1-minute tick), weather/stocks have no
// such shortcut; without this the sidebar is empty for up to 45/15 minutes after
// Immediate first call for all three — unlike RSS sources (whose "due" check makes a
// brand-new source eligible on the very next 1-minute tick), weather/stocks/poe2 have
// no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after
// every restart.
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
setInterval(() => {
@@ -79,5 +81,10 @@ export function startScheduler() {
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
}, 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 1h');
}
+53 -5
View File
@@ -25,6 +25,14 @@ export function migrate() {
db.exec('DROP TABLE IF EXISTS admin_users;');
db.exec('DROP TABLE IF EXISTS sessions;');
// PoE2 watchlist model changed from "value quoted in one primary currency" to arbitrary
// currency pairs (base/quote) — the old single-currency rows can't be mapped onto a pair,
// so the table is dropped and rebuilt fresh below rather than migrated column-by-column.
const poe2WatchlistCols = db.prepare(`PRAGMA table_info(poe2_watchlist)`).all() as { name: string }[];
if (poe2WatchlistCols.some((c) => c.name === 'currency_id')) {
db.exec('DROP TABLE IF EXISTS poe2_watchlist;');
}
db.exec(`
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
@@ -189,16 +197,20 @@ export function migrate() {
weather_hourly 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_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, -- unused since the watchlist moved to arbitrary currency pairs (no single "quoted in" currency anymore) — column kept rather than dropped, SQLite ALTER TABLE can't drop columns without a full table rebuild
poe2_updated_at TEXT
);
-- Sidebar "Stocks" widget — polled every 15 minutes from Stooq (see stocks/poller.ts).
-- Price/change/poll-state live directly on the row, same as sources.last_polled_at,
-- rather than a separate quote-cache table.
-- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see
-- stocks/poller.ts). Price/change/poll-state live directly on the row, same as
-- sources.last_polled_at, rather than a separate quote-cache table.
CREATE TABLE IF NOT EXISTS stock_tickers (
id TEXT PRIMARY KEY,
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,
last_price REAL,
last_change_percent REAL,
@@ -207,6 +219,36 @@ export function migrate() {
created_at TEXT NOT NULL
);
-- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs
-- (see poe2/poller.ts), always against the current challenge league (auto-detected,
-- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are
-- captured at add-time from the browse picker, not re-resolved. No icon columns —
-- the UI doesn't display them.
CREATE TABLE IF NOT EXISTS poe2_watchlist (
id TEXT PRIMARY KEY,
base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted"
base_name TEXT NOT NULL,
quote_currency_id TEXT NOT NULL,
quote_name TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
last_rate REAL,
last_change_24h REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
-- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching
-- 24h change window, so it's computed ourselves from this history (see
-- poe2/poller.ts). Pruned to the last 2 days on every poll.
CREATE TABLE IF NOT EXISTS poe2_rate_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watchlist_id TEXT NOT NULL,
rate REAL NOT NULL,
recorded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_poe2_rate_history_watchlist ON poe2_rate_history(watchlist_id, recorded_at);
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
-- via is_private (same private-access lock feature as categories.is_private).
CREATE TABLE IF NOT EXISTS bookmarks (
@@ -302,6 +344,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_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
// fresh install — the admin can remove/replace any of them via the Stocks tab.
+98
View File
@@ -0,0 +1,98 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Poe2WatchlistEntry } from './types.js';
interface CurrencyRef {
currencyId: string;
name: string;
}
function rowToEntry(row: any): Poe2WatchlistEntry {
return {
id: row.id,
baseCurrencyId: row.base_currency_id,
baseName: row.base_name,
quoteCurrencyId: row.quote_currency_id,
quoteName: row.quote_name,
priorityRank: row.priority_rank,
lastRate: row.last_rate,
lastChange24h: row.last_change_24h,
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() — currencies are picked from a live browse list (see poe2/client.ts's
// browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the
// rare "picked the wrong one" case.
export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry {
const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}`
.replace(/-+/g, '-')
.replace(/(^-|-$)/g, '');
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM poe2_watchlist').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO poe2_watchlist
(id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt);
return {
id,
baseCurrencyId: base.currencyId,
baseName: base.name,
quoteCurrencyId: quote.currencyId,
quoteName: quote.name,
priorityRank: maxRank.m + 1,
lastRate: null,
lastChange24h: null,
lastPolledAt: null,
lastError: null,
createdAt
};
}
export function removeWatchlistEntry(id: string) {
db.prepare('DELETE FROM poe2_rate_history WHERE watchlist_id = ?').run(id);
db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id);
}
// One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed
// from, since poe.ninja itself doesn't expose per-pair rates or a matching change window.
export function recordRate(watchlistId: string, rate: number, recordedAt: string) {
db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
watchlistId,
rate,
recordedAt
);
}
// The closest snapshot at-or-before cutoffIso — null if there isn't one yet (e.g. a
// pair added less than 24h ago), which the poller treats as "no change data yet" rather
// than fabricating a 0% figure.
export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null {
const row = db
.prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
.get(watchlistId, cutoffIso) as { rate: number } | undefined;
return row ? row.rate : null;
}
export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) {
db.prepare(
`UPDATE poe2_watchlist
SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ?
WHERE id = ?`
).run(rate, change24h, new Date().toISOString(), error, id);
}
// Keeps history bounded — a day or two of slack past the 24h window is plenty for a
// poll to be briefly late without losing the data point it needs.
export function pruneOldHistory() {
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
db.prepare('DELETE FROM poe2_rate_history WHERE recorded_at < ?').run(cutoff);
}
+13 -3
View File
@@ -36,6 +36,11 @@ function rowToSettings(row: any): GlobalSettings {
daily: JSON.parse(row.weather_daily),
alerts: JSON.parse(row.weather_alerts),
updatedAt: row.weather_updated_at
},
poe2: {
leagueId: row.poe2_league_id,
leagueName: row.poe2_league_name,
updatedAt: row.poe2_updated_at
}
};
}
@@ -52,7 +57,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
...patch,
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) }
weather: { ...current.weather, ...(patch.weather ?? {}) },
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }
};
db.prepare(
`UPDATE global_settings SET
@@ -64,7 +70,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
weather_wind_unit=?, weather_pressure_unit=?,
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?
weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?,
poe2_league_id=?, poe2_league_name=?, poe2_updated_at=?
WHERE id = 1`
).run(
merged.mergeStrictness,
@@ -95,7 +102,10 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
JSON.stringify(merged.weather.hourly),
JSON.stringify(merged.weather.daily),
JSON.stringify(merged.weather.alerts),
merged.weather.updatedAt
merged.weather.updatedAt,
merged.poe2.leagueId,
merged.poe2.leagueName,
merged.poe2.updatedAt
);
return getSettings();
}
+32
View File
@@ -233,6 +233,27 @@ export interface StockTicker {
createdAt: string;
}
export interface Poe2WatchlistEntry {
id: string;
/** Opaque id from poe.ninja's exchange overview, e.g. "exalted" — not a display name. */
baseCurrencyId: string;
baseName: string;
quoteCurrencyId: string;
quoteName: string;
priorityRank: number;
/** 1 base = lastRate quote. */
lastRate: number | null;
/**
* % change over the last 24h, self-computed from poe2_rate_history since poe.ninja
* doesn't expose a matching change window (see poe2/poller.ts) — null when there
* isn't yet 24h of history (e.g. a pair just added).
*/
lastChange24h: number | null;
lastPolledAt: string | null;
lastError: string | null;
createdAt: string;
}
export interface Bookmark {
id: string;
name: string;
@@ -299,6 +320,17 @@ export interface GlobalSettings {
alerts: WeatherAlert[];
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;
updatedAt: string | null;
};
}
export interface WeatherAlert {
+24 -1
View File
@@ -11,7 +11,9 @@ import type {
LogEntry,
GeocodeResult,
AdminStockTicker,
AdminBookmark
AdminBookmark,
Poe2BrowseEntry,
AdminPoe2Entry
} from './adminTypes';
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
@@ -202,3 +204,24 @@ export const updateBookmark = (id: string, patch: { name?: string; url?: string;
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
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 = (
base: { currencyId: string; name: string },
quote: { currencyId: string; name: string },
fetchFn?: typeof fetch
) =>
request<AdminPoe2Entry>(
'/api/admin/poe2/watchlist',
{ method: 'POST', body: JSON.stringify({ base, quote }) },
fetchFn
);
export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn);
+25
View File
@@ -94,6 +94,30 @@ export interface AdminBookmark {
isPrivate: boolean;
}
export interface Poe2BrowseEntry {
id: string;
name: string;
}
export interface AdminPoe2Entry {
id: string;
baseCurrencyId: string;
baseName: string;
quoteCurrencyId: string;
quoteName: string;
priorityRank: number;
lastRate: number | null;
lastChange24h: number | null;
lastPolledAt: string | null;
lastError: string | null;
}
export interface AdminPoe2Settings {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
@@ -111,6 +135,7 @@ export interface AdminSettings {
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
poe2: AdminPoe2Settings;
}
export interface AdminSource {
+5 -1
View File
@@ -1,5 +1,5 @@
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> {
// 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[]> {
return get<Bookmark[]>('/api/bookmarks', fetchFn);
}
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
return get<Poe2Data>('/api/poe2', fetchFn);
}
@@ -0,0 +1,267 @@
<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 },
{ currencyId: entry.id, name: entry.name }
);
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 24h.
{: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))}
>
{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">{entry.baseName} <span class="arrow"></span> {entry.quoteName}</div>
<button class="icon-btn danger" onclick={() => handleDelete(entry.id)} title="Remove"></button>
</div>
{#if entry.lastError}
<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">24h {fmtChange(entry.lastChange24h)}</span>
</div>
<div class="direction">
<span class="rate">1 {entry.quoteName} = {formatPoeValue(1 / entry.lastRate)} {entry.baseName}</span>
<span class="changes">24h {fmtChange(invertChangePercent(entry.lastChange24h))}</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 {
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);
}
.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 {
font-size: 13px;
font-weight: 500;
min-width: 0;
}
.arrow {
color: var(--text-muted);
font-weight: 400;
}
.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>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { Poe2Data } from '$lib/types';
import { formatPoeValue, poeLeagueSlug } from '$lib/format';
let { poe2 }: { poe2: Poe2Data } = $props();
const link = $derived(poe2.leagueName ? `https://poe.ninja/poe2/economy/${poeLeagueSlug(poe2.leagueName)}/currency` : null);
</script>
<svelte:element
this={link ? 'a' : 'div'}
class="widget"
href={link ?? undefined}
target={link ? '_blank' : undefined}
rel={link ? 'noopener noreferrer' : undefined}
>
<div class="head">
<span class="title">PoE2</span>
{#if poe2.entries.length > 0}<span class="interval">24h</span>{/if}
</div>
{#if poe2.leagueName}
<p class="caption">{poe2.leagueName}</p>
{/if}
{#if poe2.entries.length > 0}
<div class="list">
{#each poe2.entries as entry (entry.id)}
<div class="row">
<span class="label">{entry.baseName} <span class="arrow"></span> {entry.quoteName}</span>
{#if entry.lastRate !== null}
<span class="price" class:up={entry.lastChange24h !== null && entry.lastChange24h >= 0} class:down={entry.lastChange24h !== null && entry.lastChange24h < 0}>
{formatPoeValue(entry.lastRate)}
<span class="change">{entry.lastChange24h !== null ? `${entry.lastChange24h >= 0 ? '+' : ''}${entry.lastChange24h.toFixed(2)}%` : '—'}</span>
</span>
{:else}
<span class="price"></span>
{/if}
</div>
{/each}
</div>
{:else}
<p class="empty">No currency pairs tracked</p>
{/if}
</svelte:element>
<style>
.widget {
display: block;
background: var(--surface-1);
border-radius: 12px;
padding: 14px;
color: inherit;
text-decoration: none;
}
.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 {
font-size: 13px;
white-space: nowrap;
}
.arrow {
color: var(--text-muted);
}
.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,24 +1,101 @@
<script lang="ts">
import type { Weather, StockTicker, Bookmark } from '$lib/types';
import { tick } from 'svelte';
import type { Weather, StockTicker, Bookmark, Poe2Data } from '$lib/types';
import WeatherWidget from './WeatherWidget.svelte';
import StocksWidget from './StocksWidget.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();
// Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the viewport. Plain
// `position: sticky` alone can only pin a box at a constant offset — it can't reveal
// content taller than that box without either an internal scrollbar (a second,
// separate scroll region — janky) or shifting the content within the box as the page
// scrolls. This does the latter: `.sidebar-track` is a plain (non-positioned) spacer
// sized to the widgets' full natural height, so the page reserves exactly enough
// scroll room; `.sidebar-viewport` is the sticky, viewport-capped, clipped box; and
// `.sidebar-content` is translated upward inside it in lockstep with how far the page
// has scrolled past the point where the sidebar started sticking — one continuous
// scroll gesture over the whole page drives it, not a separate widget-local scrollbar.
const TOP_MARGIN = 20;
const BOTTOM_MARGIN = 20;
let trackEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state();
let trackHeight = $state(0);
let viewportHeight = $state(0);
let progress = $state(0);
function update() {
if (!trackEl || !contentEl) return;
const naturalHeight = contentEl.offsetHeight;
const cappedHeight = Math.min(naturalHeight, window.innerHeight - TOP_MARGIN - BOTTOM_MARGIN);
const revealRange = naturalHeight - cappedHeight;
trackHeight = naturalHeight;
viewportHeight = cappedHeight;
if (revealRange <= 0) {
progress = 0;
return;
}
// trackEl is never itself positioned/sticky, so its rect always reflects genuine
// scroll progress — how far its top has moved past TOP_MARGIN is exactly how much
// extra scrolling has happened since the sidebar started sticking.
const extraScroll = TOP_MARGIN - trackEl.getBoundingClientRect().top;
progress = Math.max(0, Math.min(revealRange, extraScroll));
}
async function remeasure() {
await tick();
update();
}
$effect(() => {
// Widget data changing the sidebar's natural height needs a remeasure, not just a
// scroll-position update.
void weather;
void stocks;
void bookmarks;
void poe2;
remeasure();
});
$effect(() => {
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', remeasure);
return () => {
window.removeEventListener('scroll', update);
window.removeEventListener('resize', remeasure);
};
});
</script>
<aside class="sidebar">
<WeatherWidget {weather} />
<StocksWidget {stocks} />
<BookmarksWidget {bookmarks} />
</aside>
<div class="sidebar-track" bind:this={trackEl} style:height="{trackHeight}px">
<aside class="sidebar-viewport" style:height="{viewportHeight}px">
<div class="sidebar-content" bind:this={contentEl} style:transform="translateY(-{progress}px)">
<WeatherWidget {weather} />
<StocksWidget {stocks} />
<Poe2Widget {poe2} />
<BookmarksWidget {bookmarks} />
</div>
</aside>
</div>
<style>
.sidebar {
.sidebar-track {
position: relative;
}
.sidebar-viewport {
position: sticky;
top: 20px;
overflow: hidden;
}
.sidebar-content {
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 20px;
}
</style>
+59
View File
@@ -21,6 +21,36 @@ export function exactTime(iso: string): string {
return `${mm}/${dd}/${yyyy} - ${String(hours).padStart(2, '0')}:${minutes} ${ampm}`;
}
/**
* Open-Meteo's daily forecast returns bare "YYYY-MM-DD" dates (no time component) meaning
* that calendar day in the forecast location's own timezone. `new Date("YYYY-MM-DD")` parses
* that as UTC midnight per the ECMAScript spec — displaying it in the browser's local
* timezone (anything behind UTC, i.e. all of the Americas) then rolls it back to the
* *previous* day, making "today" look like it already passed. Parsing via the (year, month,
* day) constructor instead builds the date in local time, avoiding the UTC round-trip.
*/
export function parseDateOnly(dateOnly: string): Date {
const [year, month, day] = dateOnly.split('-').map(Number);
return new Date(year, month - 1, day);
}
function ordinal(n: number): string {
const lastDigit = n % 10;
const lastTwoDigits = n % 100;
if (lastDigit === 1 && lastTwoDigits !== 11) return `${n}st`;
if (lastDigit === 2 && lastTwoDigits !== 12) return `${n}nd`;
if (lastDigit === 3 && lastTwoDigits !== 13) return `${n}rd`;
return `${n}th`;
}
/** "Friday July, 24th, 2026" — full weekday/month name with an ordinal day, for the 7-day forecast headings. */
export function formatDayHeading(dateOnly: string): string {
const d = parseDateOnly(dateOnly);
const weekday = d.toLocaleDateString([], { weekday: 'long' });
const month = d.toLocaleDateString([], { month: 'long' });
return `${weekday} ${month}, ${ordinal(d.getDate())}, ${d.getFullYear()}`;
}
export function slugify(name: string): string {
return name
.toLowerCase()
@@ -35,3 +65,32 @@ export function excerpt(body: string, sentenceCount = 2): string {
const sentences = singleLine.match(/[^.!?]+[.!?]+/g) ?? [singleLine];
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);
}
/**
* A pair's inverse-direction % change isn't the negation of the forward change (that's only
* an approximation) — it's the reciprocal-return identity: if rate went from `old` to `new`,
* forward change = (new-old)/old, and inverse change = (1/new - 1/old)/(1/old) = (old-new)/new
* = -change/(1+change/100). Derivable from the forward change alone, no extra data needed.
*/
export function invertChangePercent(change: number | null): number | null {
if (change === null) return null;
return -change / (1 + change / 100);
}
/**
* 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, '');
}
+16
View File
@@ -155,3 +155,19 @@ export interface Bookmark {
url: string;
isPrivate: boolean;
}
export interface Poe2WatchlistEntry {
id: string;
baseName: string;
quoteName: string;
/** 1 base = lastRate quote. */
lastRate: number | null;
/** 24h % change, self-computed from our own poll history — null if not enough history yet. */
lastChange24h: number | null;
}
export interface Poe2Data {
leagueName: string | null;
updatedAt: string | null;
entries: Poe2WatchlistEntry[];
}
+1 -1
View File
@@ -117,7 +117,7 @@
{@render children()}
</div>
{#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}
</main>
+6 -4
View File
@@ -1,15 +1,16 @@
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';
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),
getEvents(fetch),
getPrivateAccessStatus(fetch),
getWeather(fetch),
getStocks(fetch),
getBookmarks(fetch)
getBookmarks(fetch),
getPoe2(fetch)
]);
// 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.
@@ -20,6 +21,7 @@ export const load: LayoutLoad = async ({ fetch, data }) => {
privateAccess,
weather,
stocks,
bookmarks
bookmarks,
poe2
};
};
@@ -8,6 +8,7 @@
import WeatherTab from '$lib/components/admin/WeatherTab.svelte';
import StocksTab from '$lib/components/admin/StocksTab.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 LogsTab from '$lib/components/admin/LogsTab.svelte';
@@ -22,6 +23,7 @@
{ id: 'weather', label: 'Weather' },
{ id: 'stocks', label: 'Stocks' },
{ id: 'bookmarks', label: 'Bookmarks' },
{ id: 'poe2', label: 'PoE2' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
];
@@ -57,6 +59,8 @@
<StocksTab tickers={data.stockTickers} />
{:else if active === 'bookmarks'}
<BookmarksTab bookmarks={data.bookmarks} />
{:else if active === 'poe2'}
<Poe2Tab settings={data.settings} watchlist={data.poe2Watchlist} />
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}
+6 -4
View File
@@ -9,7 +9,8 @@ import {
getTelegramStatus,
getLogs,
getStockTickers,
getAdminBookmarks
getAdminBookmarks,
getPoe2Watchlist
} from '$lib/adminApi';
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 }) => {
try {
const [settings, sources, events, logs, stockTickers, bookmarks] = await Promise.all([
const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch),
getStockTickers(fetch),
getAdminBookmarks(fetch)
getAdminBookmarks(fetch),
getPoe2Watchlist(fetch)
]);
// 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 })
);
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks };
return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks, poe2Watchlist };
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
+7 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import { timeAgo } from '$lib/format';
import { timeAgo, formatDayHeading } from '$lib/format';
let { data }: { data: PageData } = $props();
const weather = $derived(data.weather);
@@ -91,7 +91,7 @@
<div class="daily-list">
{#each weather.daily as day (day.date)}
<div class="day-row">
<span class="day-name">{new Date(day.date).toLocaleDateString([], { weekday: 'short' })}</span>
<span class="day-name">{formatDayHeading(day.date)}</span>
<span class="day-icon">{day.icon}</span>
<span class="day-condition">{day.conditionText}</span>
<span class="day-range">{Math.round(day.tempMax)}° / {Math.round(day.tempMin)}°</span>
@@ -257,12 +257,13 @@
.daily-list {
display: flex;
flex-direction: column;
max-width: 480px;
max-width: 640px;
}
.day-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
gap: 6px 14px;
padding: 10px 0;
border-top: 0.5px solid var(--border);
}
@@ -272,7 +273,8 @@
.day-name {
font-size: 13px;
font-weight: 500;
width: 40px;
white-space: nowrap;
flex: 1 0 auto;
}
.day-icon {
font-size: 20px;