Full widget parity, /api/widget/<id> route namespace, live sidebar rendering

Weather, Stocks, and Bookmarks move into backend/src/widgets/<id>/ alongside
PoE2, each a full WidgetPlugin rather than a thin wrapper: their tables are
renamed to the widget_<id>_ convention (stock_tickers, bookmarks) or moved off
global_settings entirely into the generic widget_kv store (weather's
location/unit config and forecast cache). Every widget's routes move under a
consistent /api/widget/<id> (public) and /api/admin/widget/<id> (admin)
namespace, replacing the previous flat /api/weather, /api/admin/poe2/browse,
etc. The registry-management routes (list/upload/enable/delete any widget)
stay at /api/admin/widgets since they address the collection, not one
widget's own data.

This also closes the gap where an uploaded widget had backend data plumbing
but no visible presence anywhere: a widget's poll() can now publish to a
generic GET /api/widget/:id/report feed (registered once, works for any id
with zero per-widget route registration, so it's live immediately after
upload with no restart); the sidebar renders it via a new GenericWidgetCard,
or via a new DynamicWidgetSlot that dynamic-imports an optional pre-built
vanilla-JS frontend bundle the widget can ship (served from a new
/widget-assets/:id/* route) — plain browser import(), not blocked by
SvelteKit's ahead-of-time Svelte compilation the way raw .svelte source would
be. A widget's own custom API routes still need a restart to register
(a hard Fastify limitation), but its data/poll/report and any custom frontend
UI now work fully live. The admin Widgets tab gained an upload form and a
list of installed pluggable widgets with enable/delete.

Verified against a copy of the real dev DB (all three renames + the weather
config/cache migration fire once and are idempotent on a second boot) and
through a real browser: uploaded a widget with both a report-driven poll and
a custom frontend bundle, confirmed it renders live in the sidebar with no
backend restart, then deleted it and confirmed it disappears along with all
of its data (table/kv rows/on-disk files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-27 01:16:47 +00:00
parent bf0cb11070
commit 837aa77bfc
37 changed files with 1119 additions and 410 deletions
+20 -8
View File
@@ -37,14 +37,6 @@ export async function registerAdminRoutes(app: FastifyInstance) {
}
const before = settingsDb.getSettings();
const settings = withStorageUsed(settingsDb.updateSettings(body));
if (body.weather) {
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
loadedWidgets
.get('weather')
?.poll?.run()
.catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
}
if (body.widgets) {
// Re-enabling a widget (see the Widgets tab) should show fresh data right away
// instead of waiting out its normal cadence — scheduler.ts skips polling
@@ -246,6 +238,26 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return reply.code(201).send({ id: result.id });
});
app.patch('/api/admin/widgets/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { enabled } = req.body as { enabled?: boolean };
const widget = installedWidgetsDb.getInstalled(id);
if (!widget) return reply.code(404).send({ error: 'not found' });
if (typeof enabled === 'boolean') {
installedWidgetsDb.setEnabled(id, enabled);
// Re-enabling should show fresh data right away rather than waiting out the
// widget's own poll interval — same "immediate poll on enable" behavior the
// 4 built-ins get via PATCH /api/admin/settings above.
if (enabled && !widget.enabled) {
loadedWidgets
.get(id)
?.poll?.run()
.catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`));
}
}
return installedWidgetsDb.getInstalled(id);
});
app.delete('/api/admin/widgets/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const widget = installedWidgetsDb.getInstalled(id);
+28 -6
View File
@@ -4,6 +4,9 @@ import * as tagsDb from '../storage/db/tags.js';
import * as eventsDb from '../storage/db/events.js';
import * as categoriesDb from '../storage/db/categories.js';
import * as settingsDb from '../storage/db/settings.js';
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { getKv } from '../storage/db/widgetKv.js';
import type { WidgetReport } from '../widgets/report.js';
import { hasPrivateAccess } from './privateAccess.js';
export async function registerPublicRoutes(app: FastifyInstance) {
@@ -59,15 +62,34 @@ export async function registerPublicRoutes(app: FastifyInstance) {
return categories.filter((c) => !c.isPrivate);
});
// Per-widget enable flags + display order — see the admin panel's consolidated
// "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these
// flags (see scheduler.ts); Sidebar.svelte renders in exactly this order.
// Per-widget enable flags + display order for the 4 built-ins — see the admin panel's
// consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on
// these flags (see scheduler.ts); Sidebar.svelte renders in exactly this order. The
// `pluggable` array lists enabled *uploaded* widgets separately (their ids aren't part
// of the closed weather|stocks|bookmarks|poe2 union `order` uses) — see
// widgets/registry.ts, Sidebar.svelte's GenericWidgetCard/DynamicWidgetSlot.
app.get('/api/widgets', async () => {
const { widgets, widgetOrder } = settingsDb.getSettings();
return { ...widgets, order: widgetOrder };
const pluggable = installedWidgetsDb
.listInstalled()
.filter((w) => w.source === 'uploaded' && w.enabled)
.map((w) => ({ id: w.id, displayName: w.displayName, frontendEntry: w.frontendEntry }));
return { ...widgets, order: widgetOrder, pluggable };
});
// Per-widget public routes (GET /api/weather, /api/stocks, /api/bookmarks, /api/poe2)
// are registered by each widget's own plugin — see widgets/registry.ts's generic
// Generic live-data feed any widget (built-in or uploaded) can publish to via
// setKv(id, 'report', ...) in its own poll.run() — see widgets/report.ts. Registered
// once here rather than per-widget, so it works for a widget uploaded after this
// process started, with zero new route registration (Fastify refuses routes added
// after app.listen(), so this is what makes "poll live -> sidebar shows it live" work
// for an uploaded widget without a restart).
app.get('/api/widget/:id/report', async (req, reply) => {
const { id } = req.params as { id: string };
if (!installedWidgetsDb.getInstalled(id)?.enabled) return reply.code(404).send();
return { data: getKv<WidgetReport>(id, 'report') };
});
// Per-widget public routes (GET /api/widget/weather, /stocks, /bookmarks, /poe2) are
// registered by each widget's own plugin — see widgets/registry.ts's generic
// registerPublicRoutes loop in index.ts.
}
-47
View File
@@ -1,47 +0,0 @@
import type { WidgetPlugin } from '../widgets/types.js';
import * as bookmarksDb from '../storage/db/bookmarks.js';
import { hasPrivateAccess } from '../api/privateAccess.js';
// Built-in sidebar "Bookmarks" widget — thin wrapper so it funnels into the same
// route-registration loop as pluggable widgets (see widgets/registry.ts); no poll (purely
// admin-curated links) and no migrate()/uninstall() since its schema isn't moving. Not
// going through the upload/delete lifecycle — see widgets/types.ts.
export const bookmarksPlugin: WidgetPlugin = {
id: 'bookmarks',
displayName: 'Bookmarks',
version: '1.0.0',
registerPublicRoutes(app) {
app.get('/api/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
});
},
registerAdminRoutes(app) {
app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks());
app.post('/api/admin/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.patch('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
}
};
+21
View File
@@ -19,6 +19,7 @@ import { loadAllWidgets, loadedWidgets } from './widgets/registry.js';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
function printApiKeyBanner() {
const line = '='.repeat(64);
@@ -103,6 +104,26 @@ async function main() {
return reply.send(fs.createReadStream(filePath));
});
// A widget's optional pre-built frontend bundle (see widgets/manifest.ts's
// frontendEntry) — one generic wildcard route rather than one per widget, so it works
// for a widget uploaded after this process started, with no restart (unlike a
// widget's own custom API routes, which do need one — see widgets/install.ts).
// Explicit Content-Type is required here (unlike /media/:filename above) — browsers
// reject a dynamically-imported module whose response isn't served as a JS MIME
// type. The wildcard also lets a bundle's own relative imports (e.g. `import
// './helper.mjs'`) resolve automatically, since the browser requests those against
// this same route.
app.get('/widget-assets/:id/*', async (req, reply) => {
const { id } = req.params as { id: string };
const rel = (req.params as { '*': string })['*'];
if (rel.includes('..')) return reply.code(400).send();
const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel);
if (!fs.existsSync(filePath)) return reply.code(404).send();
if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript');
else if (rel.endsWith('.css')) reply.type('text/css');
return reply.send(fs.createReadStream(filePath));
});
// Static "/media/proxy" and "/media/telegram-proxy" take priority over the
// "/media/:filename" param route above regardless of registration order
// (find-my-way, Fastify's router, always prefers a static segment over a parametric
-54
View File
@@ -1,54 +0,0 @@
import type { WidgetPlugin } from '../widgets/types.js';
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import { pollStocksNow } from './poller.js';
// Built-in sidebar "Stocks" widget — thin wrapper so it funnels into the same
// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts); its
// tickers stay in the dedicated stock_tickers table exactly as before. Not going through
// the upload/delete lifecycle — see widgets/types.ts; no migrate()/uninstall() since its
// schema isn't moving.
export const stocksPlugin: WidgetPlugin = {
id: 'stocks',
displayName: 'Stocks',
version: '1.0.0',
poll: {
// Per admin spec — stock prices move faster than weather.
intervalMs: 15 * 60_000,
run: pollStocksNow
},
registerPublicRoutes(app) {
app.get('/api/stocks', async () => stocksDb.listStockTickers());
},
registerAdminRoutes(app) {
app.get('/api/admin/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
}
};
+54 -43
View File
@@ -45,6 +45,20 @@ export function migrate() {
db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;');
}
// Stocks and Bookmarks moved into the pluggable-widget system too (backend/src/widgets/
// stocks/, widgets/bookmarks/) — same plain-rename treatment as PoE2 above, now owned by
// each widget's own plugin.migrate() instead of this file's CREATE TABLE block.
const hasOldStockTickersTable = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='stock_tickers'`).get();
if (hasOldStockTickersTable) {
db.exec('ALTER TABLE stock_tickers RENAME TO widget_stocks_tickers;');
}
// "bookmarks" is generic enough to collide with something else entirely — check for the
// old bookmarks shape specifically (its distinctive is_private column) before renaming.
const oldBookmarksCols = db.prepare(`PRAGMA table_info(bookmarks)`).all() as { name: string }[];
if (oldBookmarksCols.some((c) => c.name === 'is_private')) {
db.exec('ALTER TABLE bookmarks RENAME TO widget_bookmarks_items;');
}
db.exec(`
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
@@ -220,21 +234,6 @@ export function migrate() {
poe2_updated_at TEXT
);
-- 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, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD"
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
-- Generic config/cache store for pluggable widgets (see widgets/types.ts,
-- storage/db/widgetKv.ts) — lets a widget stay fully prunable by widget_id alone on
-- uninstall without a bespoke table for simple key/value state.
@@ -259,20 +258,10 @@ export function migrate() {
priority_rank INTEGER NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
owned_tables TEXT NOT NULL DEFAULT '[]', -- JSON array, self-reported by the plugin — used by the uninstall safety-net sweep
frontend_entry TEXT, -- relative path to an optional pre-built browser JS bundle, served from /widget-assets/<id>/*
installed_at TEXT NOT NULL
);
-- 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 (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
-- the resulting login session. Deliberately its own table, not part of
-- global_settings, so these encrypted blobs never ride along in the generic
@@ -382,22 +371,8 @@ export function migrate() {
`ALTER TABLE global_settings ADD COLUMN widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]'`
);
}
// 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.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
if (!hasColumn('installed_widgets', 'frontend_entry')) {
db.exec('ALTER TABLE installed_widgets ADD COLUMN frontend_entry TEXT');
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
@@ -439,7 +414,12 @@ export function migrate() {
? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]')
: ['weather', 'stocks', 'poe2', 'bookmarks'];
const displayNames: Record<string, string> = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' };
const codePaths: Record<string, string> = { weather: 'weather', stocks: 'stocks', bookmarks: 'bookmarks', poe2: 'widgets/poe2' };
const codePaths: Record<string, string> = {
weather: 'widgets/weather',
stocks: 'widgets/stocks',
bookmarks: 'widgets/bookmarks',
poe2: 'widgets/poe2'
};
const ownedTables: Record<string, string[]> = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] };
const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true);
const insertWidget = db.prepare(
@@ -480,4 +460,35 @@ export function migrate() {
new Date().toISOString()
);
}
// Weather's config (admin-set location/units) and cache (last-polled forecast) move off
// their bare global_settings columns into two widget_kv entries — same "every widget
// owns its own data" consistency as PoE2's league cache above. Old columns left in
// place, unread.
const weatherRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any;
const hasWeatherConfigKv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'weather' AND key = 'config'").get();
if (weatherRow?.weather_location_name && !hasWeatherConfigKv) {
const nowIso = new Date().toISOString();
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'config', ?, ?)`).run(
JSON.stringify({
locationName: weatherRow.weather_location_name,
latitude: weatherRow.weather_latitude,
longitude: weatherRow.weather_longitude,
unit: weatherRow.weather_unit,
windUnit: weatherRow.weather_wind_unit,
pressureUnit: weatherRow.weather_pressure_unit
}),
nowIso
);
db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'cache', ?, ?)`).run(
JSON.stringify({
current: weatherRow.weather_current ? JSON.parse(weatherRow.weather_current) : null,
hourly: JSON.parse(weatherRow.weather_hourly ?? '[]'),
daily: JSON.parse(weatherRow.weather_daily ?? '[]'),
alerts: JSON.parse(weatherRow.weather_alerts ?? '[]'),
updatedAt: weatherRow.weather_updated_at
}),
nowIso
);
}
}
+5 -2
View File
@@ -11,6 +11,7 @@ function rowToWidget(row: any): InstalledWidget {
priorityRank: row.priority_rank,
version: row.version,
ownedTables: JSON.parse(row.owned_tables),
frontendEntry: row.frontend_entry ?? null,
installedAt: row.installed_at
};
}
@@ -33,13 +34,14 @@ export function insertWidget(widget: {
priorityRank?: number;
version?: string;
ownedTables?: string[];
frontendEntry?: string | null;
}): InstalledWidget {
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM installed_widgets').get() as { m: number };
const priorityRank = widget.priorityRank ?? maxRank.m + 1;
const installedAt = new Date().toISOString();
db.prepare(
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, installed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, frontend_entry, installed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
widget.id,
widget.displayName,
@@ -49,6 +51,7 @@ export function insertWidget(widget: {
priorityRank,
widget.version ?? '1.0.0',
JSON.stringify(widget.ownedTables ?? []),
widget.frontendEntry ?? null,
installedAt
);
return getInstalled(widget.id)!;
+6 -46
View File
@@ -1,16 +1,9 @@
import { db } from './index.js';
import type { GlobalSettings } from './types.js';
import * as installedWidgetsDb from './installedWidgets.js';
import { getKv, setKv } from './widgetKv.js';
const BUILTIN_WIDGET_IDS = ['weather', 'stocks', 'bookmarks', 'poe2'] as const;
interface Poe2LeagueCache {
leagueId: string | null;
leagueName: string | null;
updatedAt: string | null;
}
// widgets/widgetOrder are computed from the installed_widgets registry (see
// storage/db/installedWidgets.ts, widgets/registry.ts) rather than stored as their own
// global_settings columns — the registry is the single source of truth for enable state
@@ -32,8 +25,10 @@ function widgetsAndOrder(): Pick<GlobalSettings, 'widgets' | 'widgetOrder'> {
return { widgets, widgetOrder };
}
// Every widget's own config/data now lives behind its own /api/widget/<id> routes (see
// widgets/weather/db.ts, widgets/poe2/poll.ts's league cache, etc.) — this settings blob
// is just the scalar pipeline knobs plus the 4 builtins' enable/order flags.
function rowToSettings(row: any): GlobalSettings {
const poe2Cache = getKv<Poe2LeagueCache>('poe2', 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
return {
mergeStrictness: row.merge_strictness,
holdBeforePublishMinutes: row.hold_before_publish_minutes,
@@ -55,22 +50,7 @@ function rowToSettings(row: any): GlobalSettings {
storageCapEnabled: !!row.storage_cap_enabled,
storageCapValue: row.storage_cap_value,
storageCapUnit: row.storage_cap_unit
},
weather: {
locationName: row.weather_location_name,
latitude: row.weather_latitude,
longitude: row.weather_longitude,
unit: row.weather_unit,
windUnit: row.weather_wind_unit,
pressureUnit: row.weather_pressure_unit,
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
current: row.weather_current ? JSON.parse(row.weather_current) : null,
hourly: JSON.parse(row.weather_hourly),
daily: JSON.parse(row.weather_daily),
alerts: JSON.parse(row.weather_alerts),
updatedAt: row.weather_updated_at
},
poe2: poe2Cache
}
};
}
@@ -86,8 +66,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
...patch,
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) },
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
};
@@ -99,9 +77,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
if (patch.widgetOrder) {
installedWidgetsDb.reorder(patch.widgetOrder);
}
if (patch.poe2) {
setKv('poe2', 'leagueCache', merged.poe2);
}
// Named params (rather than positional `?`) so this list can be reordered or
// extended without the column list and the bound-values list silently drifting
@@ -116,11 +91,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url,
telegram_media_mode=$telegram_media_mode,
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit,
weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude,
weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit,
weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily,
weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit
WHERE id = 1`
).run({
$merge_strictness: merged.mergeStrictness,
@@ -140,18 +111,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
$storage_cap_value: merged.retention.storageCapValue,
$storage_cap_unit: merged.retention.storageCapUnit,
$weather_location_name: merged.weather.locationName,
$weather_latitude: merged.weather.latitude,
$weather_longitude: merged.weather.longitude,
$weather_unit: merged.weather.unit,
$weather_wind_unit: merged.weather.windUnit,
$weather_pressure_unit: merged.weather.pressureUnit,
$weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null,
$weather_hourly: JSON.stringify(merged.weather.hourly),
$weather_daily: JSON.stringify(merged.weather.daily),
$weather_alerts: JSON.stringify(merged.weather.alerts),
$weather_updated_at: merged.weather.updatedAt
$storage_cap_unit: merged.retention.storageCapUnit
});
return getSettings();
}
+2 -67
View File
@@ -208,21 +208,6 @@ export interface Category {
isSpillover: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface StockTicker {
id: string;
label: string;
@@ -268,6 +253,8 @@ export interface InstalledWidget {
version: string;
/** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */
ownedTables: string[];
/** Relative path (within the widget's install dir) to an optional pre-built browser JS bundle, served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Null for a widget with no custom frontend (falls back to the generic report card). */
frontendEntry: string | null;
installedAt: string;
}
@@ -314,56 +301,4 @@ export interface GlobalSettings {
storageCapValue: number;
storageCapUnit: 'MB' | 'GB';
};
/** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */
weather: {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */
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 {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
-36
View File
@@ -1,36 +0,0 @@
import type { WidgetPlugin } from '../widgets/types.js';
import * as settingsDb from '../storage/db/settings.js';
import { geocodeLocation } from './client.js';
import { pollWeatherNow } from './poller.js';
// Built-in sidebar "Weather" widget — thin wrapper so it funnels into the same
// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts), while
// its config/cache stay on global_settings' weather_* columns exactly as before. Not going
// through the upload/delete lifecycle — see widgets/types.ts and the plan's "built-in vs
// pluggable" split; no migrate()/uninstall() since its schema isn't moving.
export const weatherPlugin: WidgetPlugin = {
id: 'weather',
displayName: 'Weather',
version: '1.0.0',
poll: {
intervalMs: 45 * 60_000,
run: pollWeatherNow
},
registerPublicRoutes(app) {
app.get('/api/weather', async () => settingsDb.getSettings().weather);
},
registerAdminRoutes(app) {
app.get('/api/admin/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
});
}
};
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Bookmark } from './types.js';
import { db } from '../../storage/db/index.js';
import type { Bookmark } from '../../storage/db/types.js';
function rowToBookmark(row: any): Bookmark {
return {
@@ -14,33 +14,33 @@ function rowToBookmark(row: any): Bookmark {
}
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
// happens at the route layer (GET /api/widget/bookmarks), same as categoriesDb.listCategories().
export function listBookmarks(): Bookmark[] {
const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all();
const rows = db.prepare('SELECT * FROM widget_bookmarks_items ORDER BY priority_rank').all();
return rows.map(rowToBookmark);
}
export function createBookmark(name: string, url: string, isPrivate = false): Bookmark {
const id = `bm-${name.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 bookmarks').get() as { m: number };
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_bookmarks_items').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO widget_bookmarks_items (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt);
return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt };
}
export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null {
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id);
const existing = db.prepare('SELECT * FROM widget_bookmarks_items WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToBookmark(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
db.prepare('UPDATE widget_bookmarks_items SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
merged.name, merged.url, merged.isPrivate ? 1 : 0, id
);
return merged;
}
export function deleteBookmark(id: string) {
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id);
}
+68
View File
@@ -0,0 +1,68 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import * as bookmarksDb from './db.js';
import { hasPrivateAccess } from '../../api/privateAccess.js';
const OWNED_TABLES = ['widget_bookmarks_items'];
// Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public via
// is_private (same private-access lock feature as categories.is_private). No poll (purely
// admin-curated, no external fetch). Built-in and non-deletable, but otherwise a full
// WidgetPlugin like an uploaded one.
export const bookmarksPlugin: WidgetPlugin = {
id: 'bookmarks',
displayName: 'Bookmarks',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_bookmarks_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
`);
},
registerPublicRoutes(app) {
app.get('/api/widget/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
});
},
registerAdminRoutes(app) {
app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks());
app.post('/api/admin/widget/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) {
return reply.code(400).send({ error: 'name and url are required' });
}
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
return reply.code(201).send(created);
});
app.patch('/api/admin/widget/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = bookmarksDb.updateBookmark(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/widget/bookmarks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
bookmarksDb.deleteBookmark(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_bookmarks_items;');
}
};
+2 -1
View File
@@ -51,7 +51,8 @@ export async function installUploadedWidget(manifest: unknown, files: unknown):
source: 'uploaded',
codePath: dir,
version: m.version,
ownedTables: plugin.ownedTables ?? []
ownedTables: plugin.ownedTables ?? [],
frontendEntry: m.frontendEntry ?? null
});
startWidgetPolling(plugin);
+8
View File
@@ -8,6 +8,8 @@ export interface WidgetManifest {
version: string;
/** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */
entry: string;
/** Optional relative path within `files` to a pre-built browser JS bundle (plain vanilla JS, not raw Svelte source — see widgets/registry.ts's frontend-loading notes) exporting a default `{ mount(container, ctx) }`. Served from /widget-assets/<id>/* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Omit for a widget with no custom UI — it falls back to the generic report card. */
frontendEntry?: string;
}
// Upload body shape: { manifest, files: { "index.mjs": "<source text>", ... } } — plain
@@ -21,11 +23,17 @@ export function validateManifest(manifest: unknown, files: unknown): string | nu
if (typeof m.displayName !== 'string' || !m.displayName.trim()) return 'manifest.displayName is required';
if (typeof m.version !== 'string' || !m.version.trim()) return 'manifest.version is required';
if (typeof m.entry !== 'string' || !m.entry.trim()) return 'manifest.entry is required';
if (m.frontendEntry !== undefined && (typeof m.frontendEntry !== 'string' || !m.frontendEntry.trim())) {
return 'manifest.frontendEntry must be a non-empty string when present';
}
if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object';
const entries = Object.entries(files as Record<string, unknown>);
if (entries.length === 0) return 'files must be a non-empty object';
if (!(m.entry in (files as Record<string, unknown>))) return `entry "${m.entry as string}" not found in files`;
if (m.frontendEntry !== undefined && !(m.frontendEntry in (files as Record<string, unknown>))) {
return `frontendEntry "${m.frontendEntry as string}" not found in files`;
}
let totalBytes = 0;
for (const [relPath, content] of entries) {
+5 -5
View File
@@ -52,7 +52,7 @@ export const poe2Plugin: WidgetPlugin = {
},
registerPublicRoutes(app) {
app.get('/api/poe2', async () => {
app.get('/api/widget/poe2', async () => {
const { leagueName, updatedAt } = getLeagueCache();
return { leagueName, updatedAt, entries: poe2Db.listWatchlist() };
});
@@ -60,7 +60,7 @@ export const poe2Plugin: WidgetPlugin = {
registerAdminRoutes(app) {
// League is always auto-detected, never admin-set.
app.get('/api/admin/poe2/browse', async (_req, reply) => {
app.get('/api/admin/widget/poe2/browse', async (_req, reply) => {
try {
const league = await fetchCurrentLeague();
return await browseCurrencies(league.id);
@@ -69,9 +69,9 @@ export const poe2Plugin: WidgetPlugin = {
}
});
app.get('/api/admin/poe2/watchlist', async () => poe2Db.listWatchlist());
app.get('/api/admin/widget/poe2/watchlist', async () => poe2Db.listWatchlist());
app.post('/api/admin/poe2/watchlist', async (req, reply) => {
app.post('/api/admin/widget/poe2/watchlist', async (req, reply) => {
const { base, quote } = req.body as {
base?: { currencyId?: string; name?: string };
quote?: { currencyId?: string; name?: string };
@@ -92,7 +92,7 @@ export const poe2Plugin: WidgetPlugin = {
return reply.code(201).send(created);
});
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
app.delete('/api/admin/widget/poe2/watchlist/:id', async (req, reply) => {
const { id } = req.params as { id: string };
poe2Db.removeWatchlistEntry(id);
return reply.code(204).send();
+3 -3
View File
@@ -6,9 +6,9 @@ import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
import { logger } from '../storage/db/logs.js';
import type { WidgetPlugin } from './types.js';
import { sweepOrphanedWidgetData } from './sweep.js';
import { weatherPlugin } from '../weather/plugin.js';
import { stocksPlugin } from '../stocks/plugin.js';
import { bookmarksPlugin } from '../bookmarks/plugin.js';
import { weatherPlugin } from './weather/plugin.js';
import { stocksPlugin } from './stocks/plugin.js';
import { bookmarksPlugin } from './bookmarks/plugin.js';
import { poe2Plugin } from './poe2/plugin.js';
// Both built-in and uploaded widgets funnel into this one map — scheduler dispatch and
+13
View File
@@ -0,0 +1,13 @@
/**
* Generic live-data shape a widget's poll.run() can publish via
* setKv(id, 'report', report) (see storage/db/widgetKv.ts) for the sidebar's generic
* report card to render — see api/public.ts's GET /api/widget/:id/report, registered once
* at startup so it works for any widget id including ones uploaded after boot, with zero
* per-widget route registration (sidesteps Fastify's "no routes after listen()" limit).
*/
export interface WidgetReport {
title: string;
headline?: { value: string; delta?: string } | null;
rows?: { label: string; value: string }[];
updatedAt: string | null;
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { db } from '../../storage/db/index.js';
import type { StockTicker } from '../../storage/db/types.js';
function rowToTicker(row: any): StockTicker {
return {
id: row.id,
label: row.label,
symbol: row.symbol,
priorityRank: row.priority_rank,
lastPrice: row.last_price,
lastChangePercent: row.last_change_percent,
lastPolledAt: row.last_polled_at,
lastError: row.last_error,
createdAt: row.created_at
};
}
export function listStockTickers(): StockTicker[] {
const rows = db.prepare('SELECT * FROM widget_stocks_tickers ORDER BY priority_rank').all();
return rows.map(rowToTicker);
}
export function createStockTicker(label: string, symbol: string): StockTicker {
const id = `stk-${symbol.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 widget_stocks_tickers').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
).run(id, label, symbol, maxRank.m + 1, createdAt);
return {
id, label, symbol, priorityRank: maxRank.m + 1,
lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
};
}
export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null {
const existing = db.prepare('SELECT * FROM widget_stocks_tickers WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToTicker(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE widget_stocks_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
return { ...merged };
}
export function deleteStockTicker(id: string) {
db.prepare('DELETE FROM widget_stocks_tickers WHERE id = ?').run(id);
}
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
db.prepare(
'UPDATE widget_stocks_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
).run(price, changePercent, new Date().toISOString(), error, id);
}
+94
View File
@@ -0,0 +1,94 @@
import type { DatabaseSync } from 'node:sqlite';
import type { WidgetPlugin } from '../types.js';
import { logger } from '../../storage/db/logs.js';
import * as stocksDb from './db.js';
import { pollStocksNow } from './poll.js';
const OWNED_TABLES = ['widget_stocks_tickers'];
// Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see poll.ts).
// Price/change/poll-state live directly on its own table, same as sources.last_polled_at,
// rather than a separate quote-cache table. Built-in and non-deletable, but otherwise a
// full WidgetPlugin like an uploaded one.
export const stocksPlugin: WidgetPlugin = {
id: 'stocks',
displayName: 'Stocks',
version: '1.0.0',
ownedTables: OWNED_TABLES,
migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS widget_stocks_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
`);
// Seed a handful of sensible default tickers so the widget isn't empty on a fresh
// install — the admin can remove/replace any of them.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM widget_stocks_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^DJI'],
['S&P 500', '^GSPC'],
['Bitcoin', 'BTC-USD']
];
const stmt = db.prepare(
'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
}
},
poll: {
// Per admin spec — stock prices move faster than weather.
intervalMs: 15 * 60_000,
run: pollStocksNow
},
registerPublicRoutes(app) {
app.get('/api/widget/stocks', async () => stocksDb.listStockTickers());
},
registerAdminRoutes(app) {
app.get('/api/admin/widget/stocks', async () => stocksDb.listStockTickers());
app.post('/api/admin/widget/stocks', async (req, reply) => {
const { label, symbol } = req.body as { label?: string; symbol?: string };
if (!label || !label.trim() || !symbol || !symbol.trim()) {
return reply.code(400).send({ error: 'label and symbol are required' });
}
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
// and refreshes every existing ticker's price too.
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
return reply.code(201).send(created);
});
app.patch('/api/admin/widget/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const updated = stocksDb.updateStockTicker(id, req.body as any);
if (!updated) return reply.code(404).send({ error: 'not found' });
return updated;
});
app.delete('/api/admin/widget/stocks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
stocksDb.deleteStockTicker(id);
return reply.code(204).send();
});
},
uninstall(db: DatabaseSync) {
db.exec('DROP TABLE IF EXISTS widget_stocks_tickers;');
}
};
@@ -1,11 +1,11 @@
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import * as stocksDb from './db.js';
import { logger } from '../../storage/db/logs.js';
import { fetchQuotes } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a
// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why
// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError,
// it never aborts the rest of the batch.
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin adds a ticker (see plugin.ts's admin routes) — one request per configured ticker
// (see client.ts for why there's no batch endpoint here). A symbol Yahoo can't resolve
// gets its own lastError, it never aborts the rest of the batch.
export async function pollStocksNow(): Promise<void> {
const tickers = stocksDb.listStockTickers();
if (tickers.length === 0) return;
+103
View File
@@ -0,0 +1,103 @@
import { getKv, setKv } from '../../storage/db/widgetKv.js';
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface WeatherAlert {
id: string;
event: string;
headline: string;
severity: string;
expires: string;
}
export interface WeatherConfig {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
windUnit: 'mph' | 'kph';
pressureUnit: 'inHg' | 'hPa';
}
export interface WeatherCache {
current: {
temp: number;
/** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */
feelsLike: number;
conditionText: string;
icon: string;
/** Percent, 0-100. */
humidity: number;
/** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */
precipitationChance: number;
/** Already in the admin's configured windUnit. */
windSpeed: number;
/** 8-point compass abbreviation, e.g. "NW". */
windDirection: string;
/** Already in the admin's configured pressureUnit. */
pressure: number;
sunrise: string;
sunset: string;
} | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
/** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See client.ts's fetchActiveAlerts. */
alerts: WeatherAlert[];
updatedAt: string | null;
}
const WIDGET_ID = 'weather';
const DEFAULT_CONFIG: WeatherConfig = {
locationName: null,
latitude: null,
longitude: null,
unit: 'fahrenheit',
windUnit: 'mph',
pressureUnit: 'inHg'
};
const DEFAULT_CACHE: WeatherCache = {
current: null,
hourly: [],
daily: [],
alerts: [],
updatedAt: null
};
// Config (admin-settable: location/units) and cache (poll-computed forecast) are stored as
// two separate widget_kv keys — mirrors the singleton-row split stock_tickers/bookmarks
// tables already had from global_settings, just via the generic kv store instead of a
// bespoke table (this widget has no data shaped like rows, so no dedicated table is needed).
export function getConfig(): WeatherConfig {
return getKv<WeatherConfig>(WIDGET_ID, 'config') ?? DEFAULT_CONFIG;
}
export function setConfig(patch: Partial<WeatherConfig>): WeatherConfig {
const merged = { ...getConfig(), ...patch };
setKv(WIDGET_ID, 'config', merged);
return merged;
}
export function getCache(): WeatherCache {
return getKv<WeatherCache>(WIDGET_ID, 'cache') ?? DEFAULT_CACHE;
}
export function setCache(patch: Partial<WeatherCache>): WeatherCache {
const merged = { ...getCache(), ...patch };
setKv(WIDGET_ID, 'cache', merged);
return merged;
}
+47
View File
@@ -0,0 +1,47 @@
import type { WidgetPlugin } from '../types.js';
import { logger } from '../../storage/db/logs.js';
import { geocodeLocation } from './client.js';
import * as weatherDb from './db.js';
import { pollWeatherNow } from './poll.js';
// Sidebar "Weather" widget — config (location/units) and cache (forecast) live entirely in
// widget_kv (see db.ts); no bespoke table needed. Built-in and non-deletable (source:
// 'builtin' in installed_widgets), but otherwise a full WidgetPlugin like an uploaded one.
export const weatherPlugin: WidgetPlugin = {
id: 'weather',
displayName: 'Weather',
version: '1.0.0',
poll: {
intervalMs: 45 * 60_000,
run: pollWeatherNow
},
registerPublicRoutes(app) {
app.get('/api/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
},
registerAdminRoutes(app) {
// Returns config + cache together (same shape as the public route) so the admin
// tab can show "currently showing X" status alongside the location/unit form.
app.get('/api/admin/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() }));
app.patch('/api/admin/widget/weather', async (req) => {
weatherDb.setConfig(req.body as any);
// Poll immediately rather than waiting for the next scheduler tick (up to 45
// minutes) — the admin just changed the location/unit and expects to see it reflected.
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
return { ...weatherDb.getConfig(), ...weatherDb.getCache() };
});
app.get('/api/admin/widget/weather/geocode', async (req, reply) => {
const { query } = req.query as { query?: string };
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
try {
return await geocodeLocation(query.trim());
} catch (err) {
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
}
});
}
};
@@ -1,26 +1,29 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { logger } from '../../storage/db/logs.js';
import { fetchForecast, fetchActiveAlerts } from './client.js';
import * as weatherDb from './db.js';
import type { WeatherCache } from './db.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
// the weather location/units (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
// admin changes the weather location/units (see plugin.ts's admin routes) — writes into
// the widget's own widget_kv cache entry via weatherDb, same singleton-cache approach as
// before, just no longer riding on global_settings.
export async function pollWeatherNow(): Promise<void> {
const { weather } = settingsDb.getSettings();
if (weather.latitude === null || weather.longitude === null) {
const config = weatherDb.getConfig();
if (config.latitude === null || config.longitude === null) {
// No location configured yet — not an error, just nothing to do.
return;
}
let forecastUpdate: Partial<typeof weather> = {};
const cache = weatherDb.getCache();
let forecastUpdate: Partial<WeatherCache> = {};
let forecastSucceeded = false;
try {
const { current, hourly, daily } = await fetchForecast(
weather.latitude,
weather.longitude,
weather.unit,
weather.windUnit,
weather.pressureUnit
config.latitude,
config.longitude,
config.unit,
config.windUnit,
config.pressureUnit
);
forecastUpdate = { current, hourly, daily };
forecastSucceeded = true;
@@ -33,20 +36,17 @@ export async function pollWeatherNow(): Promise<void> {
// reliably (and expectedly) for every non-US location. A failure here shouldn't
// touch the forecast update above, and unlike a stale forecast, a stale alert that's
// since expired is worse to keep showing than none at all — clear to empty on failure.
let alerts = weather.alerts;
let alerts = cache.alerts;
try {
alerts = await fetchActiveAlerts(weather.latitude, weather.longitude);
alerts = await fetchActiveAlerts(config.latitude, config.longitude);
} catch (err) {
alerts = [];
logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`);
}
settingsDb.updateSettings({
weather: {
...weather,
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt
}
weatherDb.setCache({
...forecastUpdate,
alerts,
updatedAt: forecastSucceeded ? new Date().toISOString() : cache.updatedAt
});
}