Files
homefeed/backend/src/widgets/weather/plugin.ts
T
Claude 837aa77bfc 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
2026-07-27 01:16:47 +00:00

48 lines
1.9 KiB
TypeScript

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}` });
}
});
}
};