Add sidebar: Weather, Stocks, and Bookmarks widgets
A persistent right-hand sidebar (hidden only on /admin/**) with three independent widgets: current-conditions weather (Open-Meteo, no API key) linking to a new /weather forecast page; Dow/S&P/crypto/stock tickers (Stooq, polled every 15 minutes); and admin-curated bookmark links reusing the existing private-access lock per entry. Weather and stocks are each self-contained modules (client + poller) under backend/src/weather and backend/src/stocks, mirroring how telegram/ is separated from the rest of the ingestion pipeline, so either can be modified or removed independently. Bookmarks has no external service, so it follows the plainer categories/events DB-module + CRUD-route pattern instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -3,12 +3,17 @@ import * as settingsDb from '../storage/db/settings.js';
|
||||
import * as sourcesDb from '../storage/db/sources.js';
|
||||
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 { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
|
||||
import { totalStorageBytes } from '../storage/media/index.js';
|
||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||
import { pollSourceNow } from '../ingestion/poller.js';
|
||||
import { logger, listLogs } from '../storage/db/logs.js';
|
||||
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';
|
||||
|
||||
// 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
|
||||
@@ -31,6 +36,11 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
delete body.categoryPriority;
|
||||
}
|
||||
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.
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
|
||||
}
|
||||
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
||||
});
|
||||
|
||||
@@ -202,6 +212,70 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
return reply.code(200).send(telegramClient.getStatus());
|
||||
});
|
||||
|
||||
// --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) ---
|
||||
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}` });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Stocks ---
|
||||
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();
|
||||
});
|
||||
|
||||
// --- Bookmarks ---
|
||||
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();
|
||||
});
|
||||
|
||||
// --- Logs ---
|
||||
app.get('/api/admin/logs', async (req) => {
|
||||
const { level, limit } = req.query as { level?: string; limit?: string };
|
||||
|
||||
@@ -3,6 +3,9 @@ import * as articlesDb from '../storage/db/articles.js';
|
||||
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 stocksDb from '../storage/db/stocks.js';
|
||||
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
||||
import { hasPrivateAccess } from './privateAccess.js';
|
||||
|
||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
@@ -55,4 +58,15 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
if (hasPrivateAccess(req)) return categories;
|
||||
return categories.filter((c) => !c.isPrivate);
|
||||
});
|
||||
|
||||
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
|
||||
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
||||
|
||||
app.get('/api/stocks', async () => stocksDb.listStockTickers());
|
||||
|
||||
app.get('/api/bookmarks', async (req) => {
|
||||
const bookmarks = bookmarksDb.listBookmarks();
|
||||
if (hasPrivateAccess(req)) return bookmarks;
|
||||
return bookmarks.filter((b) => !b.isPrivate);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,10 +5,14 @@ import { runRetentionSweep } from './retention.js';
|
||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||
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';
|
||||
|
||||
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
|
||||
|
||||
export function startScheduler() {
|
||||
const provider = () => {
|
||||
@@ -61,5 +65,19 @@ export function startScheduler() {
|
||||
}
|
||||
}, RETENTION_TICK_MS);
|
||||
|
||||
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h');
|
||||
// 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
|
||||
// every restart.
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
|
||||
setInterval(() => {
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
|
||||
}, WEATHER_TICK_MS);
|
||||
|
||||
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
|
||||
setInterval(() => {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Stooq (stooq.com) — free CSV quote endpoint, no account or API key required, and it
|
||||
// accepts multiple symbols batched into one request. This is the only file that talks to
|
||||
// it; poller.ts orchestrates when/how results get saved, same separation as
|
||||
// backend/src/telegram/ keeps between the raw client and its callers.
|
||||
//
|
||||
// Stooq's quote line has no prior-close field, so "change %" here is computed as
|
||||
// (close - open) / open * 100 — an intraday-vs-open approximation, not a true
|
||||
// prior-day change. Accepted simplification for a basic ticker widget.
|
||||
|
||||
export interface StockQuote {
|
||||
price: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
export async function fetchQuotes(symbols: string[]): Promise<Map<string, StockQuote | Error>> {
|
||||
const results = new Map<string, StockQuote | Error>();
|
||||
if (symbols.length === 0) return results;
|
||||
|
||||
const url = `https://stooq.com/q/l/?s=${symbols.map(encodeURIComponent).join(',')}&f=sd2t2ohlcv&h&e=csv`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Stooq returned ${res.status}`);
|
||||
const text = await res.text();
|
||||
|
||||
// Header: Symbol,Date,Time,Open,High,Low,Close,Volume — no quoted/embedded-comma
|
||||
// fields in this format, so a plain split is sufficient (no CSV library needed).
|
||||
const lines = text.trim().split('\n').slice(1);
|
||||
const bySymbol = new Map<string, string[]>();
|
||||
for (const line of lines) {
|
||||
const cols = line.split(',');
|
||||
if (cols.length < 7) continue;
|
||||
bySymbol.set(cols[0].toLowerCase(), cols);
|
||||
}
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const cols = bySymbol.get(symbol.toLowerCase());
|
||||
if (!cols) {
|
||||
results.set(symbol, new Error('Symbol not found in Stooq response'));
|
||||
continue;
|
||||
}
|
||||
const open = Number(cols[3]);
|
||||
const close = Number(cols[6]);
|
||||
if (cols[3] === 'N/D' || cols[6] === 'N/D' || !Number.isFinite(open) || !Number.isFinite(close) || open === 0) {
|
||||
results.set(symbol, new Error('Stooq has no data for this symbol'));
|
||||
continue;
|
||||
}
|
||||
results.set(symbol, { price: close, changePercent: ((close - open) / open) * 100 });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as stocksDb from '../storage/db/stocks.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 batched Stooq request for every configured ticker. A
|
||||
// symbol Stooq can't resolve gets its own lastError, it never aborts the whole batch.
|
||||
export async function pollStocksNow(): Promise<void> {
|
||||
const tickers = stocksDb.listStockTickers();
|
||||
if (tickers.length === 0) return;
|
||||
|
||||
let quotes: Map<string, { price: number; changePercent: number } | Error>;
|
||||
try {
|
||||
quotes = await fetchQuotes(tickers.map((t) => t.symbol));
|
||||
} catch (err) {
|
||||
logger.error('stocks', `Poll failed: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ticker of tickers) {
|
||||
const quote = quotes.get(ticker.symbol);
|
||||
if (!quote) {
|
||||
stocksDb.markStockPolled(ticker.id, null, null, 'No quote returned');
|
||||
} else if (quote instanceof Error) {
|
||||
stocksDb.markStockPolled(ticker.id, null, null, quote.message);
|
||||
} else {
|
||||
stocksDb.markStockPolled(ticker.id, quote.price, quote.changePercent, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from './index.js';
|
||||
import type { Bookmark } from './types.js';
|
||||
|
||||
function rowToBookmark(row: any): Bookmark {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
priorityRank: row.priority_rank,
|
||||
isPrivate: !!row.is_private,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
|
||||
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
|
||||
export function listBookmarks(): Bookmark[] {
|
||||
const rows = db.prepare('SELECT * FROM bookmarks 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 createdAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO bookmarks (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);
|
||||
if (!existing) return null;
|
||||
const current = rowToBookmark(existing);
|
||||
const merged = { ...current, ...patch };
|
||||
db.prepare('UPDATE bookmarks 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);
|
||||
}
|
||||
@@ -176,7 +176,41 @@ export function migrate() {
|
||||
storage_cap_unit TEXT NOT NULL DEFAULT 'GB',
|
||||
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
|
||||
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
|
||||
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host' -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
|
||||
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
|
||||
weather_location_name TEXT,
|
||||
weather_latitude REAL,
|
||||
weather_longitude REAL,
|
||||
weather_unit TEXT NOT NULL DEFAULT 'fahrenheit', -- celsius | fahrenheit
|
||||
weather_current TEXT, -- JSON {temp, conditionText, icon}, NULL pre-first-poll
|
||||
weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||
weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||
weather_updated_at TEXT -- ISO timestamp, NULL pre-first-poll
|
||||
);
|
||||
|
||||
-- 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.
|
||||
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"
|
||||
priority_rank INTEGER NOT NULL,
|
||||
last_price REAL,
|
||||
last_change_percent REAL,
|
||||
last_polled_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
|
||||
-- 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
|
||||
@@ -248,6 +282,33 @@ export function migrate() {
|
||||
if (!hasColumn('merged_articles', 'is_recap')) {
|
||||
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!hasColumn('global_settings', 'weather_unit')) {
|
||||
db.exec('ALTER TABLE global_settings ADD COLUMN weather_location_name TEXT');
|
||||
db.exec('ALTER TABLE global_settings ADD COLUMN weather_latitude REAL');
|
||||
db.exec('ALTER TABLE global_settings ADD COLUMN weather_longitude REAL');
|
||||
db.exec("ALTER TABLE global_settings ADD COLUMN weather_unit TEXT NOT NULL DEFAULT 'fahrenheit'");
|
||||
db.exec('ALTER TABLE global_settings ADD COLUMN weather_current TEXT');
|
||||
db.exec("ALTER TABLE global_settings ADD COLUMN weather_hourly TEXT NOT NULL DEFAULT '[]'");
|
||||
db.exec("ALTER TABLE global_settings ADD COLUMN weather_daily TEXT NOT NULL DEFAULT '[]'");
|
||||
db.exec('ALTER TABLE global_settings ADD COLUMN weather_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.
|
||||
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', '^spx'],
|
||||
['Bitcoin', 'btcusd']
|
||||
];
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
|
||||
// general news sources belong here, not on "Top stories" itself, which isn't a real
|
||||
|
||||
@@ -22,6 +22,17 @@ 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,
|
||||
// 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),
|
||||
updatedAt: row.weather_updated_at
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -37,7 +48,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
...current,
|
||||
...patch,
|
||||
retention: { ...current.retention, ...(patch.retention ?? {}) },
|
||||
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) }
|
||||
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
|
||||
weather: { ...current.weather, ...(patch.weather ?? {}) }
|
||||
};
|
||||
db.prepare(
|
||||
`UPDATE global_settings SET
|
||||
@@ -46,7 +58,9 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
ai_service_host=?, ai_service_port=?, selected_models=?,
|
||||
nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?,
|
||||
published_article_max_age_days=?, raw_item_max_age_days=?,
|
||||
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?
|
||||
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
|
||||
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
|
||||
weather_current=?, weather_hourly=?, weather_daily=?, weather_updated_at=?
|
||||
WHERE id = 1`
|
||||
).run(
|
||||
merged.mergeStrictness,
|
||||
@@ -66,7 +80,15 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
merged.retention.rawItemMaxAgeDays,
|
||||
merged.retention.storageCapEnabled ? 1 : 0,
|
||||
merged.retention.storageCapValue,
|
||||
merged.retention.storageCapUnit
|
||||
merged.retention.storageCapUnit,
|
||||
merged.weather.locationName,
|
||||
merged.weather.latitude,
|
||||
merged.weather.longitude,
|
||||
merged.weather.unit,
|
||||
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
|
||||
JSON.stringify(merged.weather.hourly),
|
||||
JSON.stringify(merged.weather.daily),
|
||||
merged.weather.updatedAt
|
||||
);
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from './index.js';
|
||||
import type { StockTicker } from './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 stock_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 stock_tickers').get() as { m: number };
|
||||
const createdAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO stock_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 stock_tickers WHERE id = ?').get(id);
|
||||
if (!existing) return null;
|
||||
const current = rowToTicker(existing);
|
||||
const merged = { ...current, ...patch };
|
||||
db.prepare('UPDATE stock_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
|
||||
return { ...merged };
|
||||
}
|
||||
|
||||
export function deleteStockTicker(id: string) {
|
||||
db.prepare('DELETE FROM stock_tickers WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
|
||||
db.prepare(
|
||||
'UPDATE stock_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
|
||||
).run(price, changePercent, new Date().toISOString(), error, id);
|
||||
}
|
||||
@@ -206,6 +206,42 @@ 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;
|
||||
symbol: string;
|
||||
priorityRank: number;
|
||||
lastPrice: number | null;
|
||||
lastChangePercent: number | null;
|
||||
lastPolledAt: string | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
priorityRank: number;
|
||||
isPrivate: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
mergeStrictness: 1 | 2 | 3 | 4 | 5;
|
||||
defaultPollIntervalMinutes: number;
|
||||
@@ -230,4 +266,15 @@ 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';
|
||||
current: { temp: number; conditionText: string; icon: string } | null;
|
||||
hourly: WeatherHourEntry[];
|
||||
daily: WeatherDayEntry[];
|
||||
updatedAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Open-Meteo (api.open-meteo.com / geocoding-api.open-meteo.com) — free, no account or API
|
||||
// key required, which is why it was picked over any commercial weather provider. This is
|
||||
// the only file that talks to it; poller.ts orchestrates when/how the result gets saved,
|
||||
// same separation as backend/src/telegram/ keeps between the raw client and its callers.
|
||||
|
||||
export interface GeocodeResult {
|
||||
name: string;
|
||||
admin1: string | null;
|
||||
country: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface WeatherCondition {
|
||||
text: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// WMO weather interpretation codes, as returned by Open-Meteo's weather_code field —
|
||||
// https://open-meteo.com/en/docs lists the full table this summarizes.
|
||||
const WMO_CONDITIONS: Record<number, WeatherCondition> = {
|
||||
0: { text: 'Clear sky', icon: '☀️' },
|
||||
1: { text: 'Mainly clear', icon: '🌤️' },
|
||||
2: { text: 'Partly cloudy', icon: '⛅' },
|
||||
3: { text: 'Overcast', icon: '☁️' },
|
||||
45: { text: 'Fog', icon: '🌫️' },
|
||||
48: { text: 'Depositing rime fog', icon: '🌫️' },
|
||||
51: { text: 'Light drizzle', icon: '🌦️' },
|
||||
53: { text: 'Moderate drizzle', icon: '🌦️' },
|
||||
55: { text: 'Dense drizzle', icon: '🌦️' },
|
||||
56: { text: 'Light freezing drizzle', icon: '🌧️' },
|
||||
57: { text: 'Dense freezing drizzle', icon: '🌧️' },
|
||||
61: { text: 'Slight rain', icon: '🌧️' },
|
||||
63: { text: 'Moderate rain', icon: '🌧️' },
|
||||
65: { text: 'Heavy rain', icon: '🌧️' },
|
||||
66: { text: 'Light freezing rain', icon: '🌧️' },
|
||||
67: { text: 'Heavy freezing rain', icon: '🌧️' },
|
||||
71: { text: 'Slight snow', icon: '🌨️' },
|
||||
73: { text: 'Moderate snow', icon: '🌨️' },
|
||||
75: { text: 'Heavy snow', icon: '❄️' },
|
||||
77: { text: 'Snow grains', icon: '❄️' },
|
||||
80: { text: 'Slight rain showers', icon: '🌦️' },
|
||||
81: { text: 'Moderate rain showers', icon: '🌦️' },
|
||||
82: { text: 'Violent rain showers', icon: '⛈️' },
|
||||
85: { text: 'Slight snow showers', icon: '🌨️' },
|
||||
86: { text: 'Heavy snow showers', icon: '🌨️' },
|
||||
95: { text: 'Thunderstorm', icon: '⛈️' },
|
||||
96: { text: 'Thunderstorm, slight hail', icon: '⛈️' },
|
||||
99: { text: 'Thunderstorm, heavy hail', icon: '⛈️' }
|
||||
};
|
||||
|
||||
export function wmoToCondition(code: number): WeatherCondition {
|
||||
return WMO_CONDITIONS[code] ?? { text: 'Unknown', icon: '❔' };
|
||||
}
|
||||
|
||||
export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Geocoding API returned ${res.status}`);
|
||||
const data = (await res.json()) as {
|
||||
results?: { name: string; admin1?: string; country?: string; latitude: number; longitude: number }[];
|
||||
};
|
||||
return (data.results ?? []).map((r) => ({
|
||||
name: r.name,
|
||||
admin1: r.admin1 ?? null,
|
||||
country: r.country ?? null,
|
||||
latitude: r.latitude,
|
||||
longitude: r.longitude
|
||||
}));
|
||||
}
|
||||
|
||||
export interface ForecastResult {
|
||||
current: { temp: number; conditionText: string; icon: string };
|
||||
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
|
||||
daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[];
|
||||
}
|
||||
|
||||
export async function fetchForecast(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
unit: 'celsius' | 'fahrenheit'
|
||||
): Promise<ForecastResult> {
|
||||
const url =
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
|
||||
`¤t=temperature_2m,weather_code&hourly=temperature_2m,weather_code` +
|
||||
`&daily=temperature_2m_max,temperature_2m_min,weather_code` +
|
||||
`&temperature_unit=${unit}&timezone=auto&forecast_days=7`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
|
||||
const data = (await res.json()) as {
|
||||
current: { temperature_2m: number; weather_code: number };
|
||||
hourly: { time: string[]; temperature_2m: number[]; weather_code: number[] };
|
||||
daily: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weather_code: number[] };
|
||||
};
|
||||
|
||||
const currentCondition = wmoToCondition(data.current.weather_code);
|
||||
const current = { temp: data.current.temperature_2m, conditionText: currentCondition.text, icon: currentCondition.icon };
|
||||
|
||||
// hourly.time starts at today's midnight, not the current hour — find the first entry
|
||||
// at or after now so the strip shown to the user starts from "now", not from midnight.
|
||||
const now = Date.now();
|
||||
const startIdx = Math.max(
|
||||
0,
|
||||
data.hourly.time.findIndex((t) => new Date(t).getTime() >= now)
|
||||
);
|
||||
const hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => {
|
||||
const idx = startIdx + i;
|
||||
const condition = wmoToCondition(data.hourly.weather_code[idx]);
|
||||
return { time, temp: data.hourly.temperature_2m[idx], conditionText: condition.text, icon: condition.icon };
|
||||
});
|
||||
|
||||
const daily = data.daily.time.map((date, i) => {
|
||||
const condition = wmoToCondition(data.daily.weather_code[i]);
|
||||
return {
|
||||
date,
|
||||
tempMax: data.daily.temperature_2m_max[i],
|
||||
tempMin: data.daily.temperature_2m_min[i],
|
||||
conditionText: condition.text,
|
||||
icon: condition.icon
|
||||
};
|
||||
});
|
||||
|
||||
return { current, hourly, daily };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import { fetchForecast } from './client.js';
|
||||
|
||||
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
|
||||
// the weather location/unit (see api/admin.ts) — writes straight into global_settings'
|
||||
// weather_* columns via settingsDb, same singleton-row approach as retention.
|
||||
export async function pollWeatherNow(): Promise<void> {
|
||||
const { weather } = settingsDb.getSettings();
|
||||
if (weather.latitude === null || weather.longitude === null) {
|
||||
// No location configured yet — not an error, just nothing to do.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { current, hourly, daily } = await fetchForecast(weather.latitude, weather.longitude, weather.unit);
|
||||
settingsDb.updateSettings({
|
||||
weather: { ...weather, current, hourly, daily, updatedAt: new Date().toISOString() }
|
||||
});
|
||||
} catch (err) {
|
||||
// Leave the existing cache untouched — a stale forecast beats a blank widget.
|
||||
logger.error('weather', `Poll failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user