From 8e1ba82eb4010891e719e8c25ffe76c22a030e2e Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 17:08:55 +0000
Subject: [PATCH 1/6] =?UTF-8?q?Add=20category=20nav=20spillover:=20"More?=
=?UTF-8?q?=20=C2=BB"=20tab=20+=20digest=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Categories can now be flagged isSpillover in the admin priority list,
collapsing them out of the main nav into a single "More »" tab that
links to a new /more page listing each spillover category's newest
articles, with the category name linking through to its full page.
Keeps the nav from growing unbounded or word-wrapping as categories
are added.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
backend/src/api/admin.ts | 4 +-
backend/src/storage/db/categories.ts | 22 ++---
backend/src/storage/db/index.ts | 6 +-
backend/src/storage/db/types.ts | 2 +
frontend/src/lib/adminApi.ts | 7 +-
frontend/src/lib/adminTypes.ts | 1 +
.../src/lib/components/admin/MergeTab.svelte | 36 +++++++-
frontend/src/lib/types.ts | 1 +
frontend/src/routes/+layout.svelte | 12 ++-
frontend/src/routes/more/+page.svelte | 91 +++++++++++++++++++
frontend/src/routes/more/+page.ts | 22 +++++
11 files changed, 181 insertions(+), 23 deletions(-)
create mode 100644 frontend/src/routes/more/+page.svelte
create mode 100644 frontend/src/routes/more/+page.ts
diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts
index c52bf4c..c239b2e 100644
--- a/backend/src/api/admin.ts
+++ b/backend/src/api/admin.ts
@@ -36,9 +36,9 @@ export async function registerAdminRoutes(app: FastifyInstance) {
// --- Categories (add/remove — reordering/privacy is via PATCH /settings above) ---
app.post('/api/admin/categories', async (req, reply) => {
- const { name, isPrivate } = req.body as { name?: string; isPrivate?: boolean };
+ const { name, isPrivate, isSpillover } = req.body as { name?: string; isPrivate?: boolean; isSpillover?: boolean };
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
- const created = categoriesDb.createCategory(name.trim(), !!isPrivate);
+ const created = categoriesDb.createCategory(name.trim(), !!isPrivate, !!isSpillover);
return reply.code(201).send(created);
});
diff --git a/backend/src/storage/db/categories.ts b/backend/src/storage/db/categories.ts
index da670d8..2397610 100644
--- a/backend/src/storage/db/categories.ts
+++ b/backend/src/storage/db/categories.ts
@@ -8,7 +8,8 @@ function rowToCategory(row: any): Category {
name: row.name,
priorityRank: row.priority_rank,
isDefault: !!row.is_default,
- isPrivate: !!row.is_private
+ isPrivate: !!row.is_private,
+ isSpillover: !!row.is_spillover
};
}
@@ -23,21 +24,18 @@ export function listPrivateCategoryNames(): string[] {
return rows.map((r) => r.name);
}
-export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean }[]) {
- const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ? WHERE id = ?');
- for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.id);
+export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean; isSpillover: boolean }[]) {
+ const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ?, is_spillover = ? WHERE id = ?');
+ for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.isSpillover ? 1 : 0, c.id);
}
-export function createCategory(name: string, isPrivate = false): Category {
+export function createCategory(name: string, isPrivate = false, isSpillover = false): Category {
const id = `cat-${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 categories').get() as { m: number };
- db.prepare('INSERT INTO categories (id, name, priority_rank, is_default, is_private) VALUES (?, ?, ?, 0, ?)').run(
- id,
- name,
- maxRank.m + 1,
- isPrivate ? 1 : 0
- );
- return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate };
+ db.prepare(
+ 'INSERT INTO categories (id, name, priority_rank, is_default, is_private, is_spillover) VALUES (?, ?, ?, 0, ?, ?)'
+ ).run(id, name, maxRank.m + 1, isPrivate ? 1 : 0, isSpillover ? 1 : 0);
+ return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate, isSpillover };
}
export function deleteCategory(id: string) {
diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts
index 1dd2108..dea8b34 100644
--- a/backend/src/storage/db/index.ts
+++ b/backend/src/storage/db/index.ts
@@ -144,7 +144,8 @@ export function migrate() {
name TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
- is_private INTEGER NOT NULL DEFAULT 0
+ is_private INTEGER NOT NULL DEFAULT 0,
+ is_spillover INTEGER NOT NULL DEFAULT 0 -- collapsed into the nav's "More »" overflow page instead of its own tab
);
CREATE TABLE IF NOT EXISTS logs (
@@ -229,6 +230,9 @@ export function migrate() {
if (!hasColumn('categories', 'is_private')) {
db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0');
}
+ if (!hasColumn('categories', 'is_spillover')) {
+ db.exec('ALTER TABLE categories ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0');
+ }
if (!hasColumn('content_items', 'telegram_message')) {
db.exec('ALTER TABLE content_items ADD COLUMN telegram_message TEXT');
}
diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts
index d8e73d1..2778201 100644
--- a/backend/src/storage/db/types.ts
+++ b/backend/src/storage/db/types.ts
@@ -202,6 +202,8 @@ export interface Category {
isDefault: boolean;
/** Hidden from /api/categories, /api/feed, and article detail for anyone without a valid private-access cookie. */
isPrivate: boolean;
+ /** Grouped into the nav's "More »" overflow page instead of getting its own top-level tab — see +layout.svelte and /more. */
+ isSpillover: boolean;
}
export interface GlobalSettings {
diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts
index 5a2fd2a..82dbeea 100644
--- a/frontend/src/lib/adminApi.ts
+++ b/frontend/src/lib/adminApi.ts
@@ -4,6 +4,7 @@ import type {
AdminSettings,
AdminSource,
AdminTrackedEvent,
+ CategoryPriority,
ModelCatalog,
AiStatus,
TelegramStatus,
@@ -60,10 +61,10 @@ export const updateSettings = (patch: Partial, fetchFn?: typeof f
request('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
// Categories
-export const createCategory = (name: string, isPrivate = false, fetchFn?: typeof fetch) =>
- request<{ id: string; name: string; priorityRank: number; isDefault: boolean; isPrivate: boolean }>(
+export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) =>
+ request(
'/api/admin/categories',
- { method: 'POST', body: JSON.stringify({ name, isPrivate }) },
+ { method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) },
fetchFn
);
diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts
index 0ee3e7d..3c7ec6b 100644
--- a/frontend/src/lib/adminTypes.ts
+++ b/frontend/src/lib/adminTypes.ts
@@ -13,6 +13,7 @@ export interface CategoryPriority {
priorityRank: number;
isDefault: boolean;
isPrivate: boolean;
+ isSpillover: boolean;
}
export interface AdminSettings {
diff --git a/frontend/src/lib/components/admin/MergeTab.svelte b/frontend/src/lib/components/admin/MergeTab.svelte
index 5298704..be0c028 100644
--- a/frontend/src/lib/components/admin/MergeTab.svelte
+++ b/frontend/src/lib/components/admin/MergeTab.svelte
@@ -10,8 +10,15 @@
let saveTimer: ReturnType;
let newCategoryName = $state('');
let newCategoryPrivate = $state(false);
+ let newCategorySpillover = $state(false);
let addingCategory = $state(false);
+ // Advisory only — the nav starts getting too wide / wrapping past ~10 tabs, so this
+ // nudges the admin toward marking some categories as spillover once they cross that
+ // rough guideline. Never enforced — actual wrapping depends on name lengths and
+ // viewport width, which this simple count can't know.
+ const primaryCategoryCount = $derived(local.categoryPriority.filter((c) => !c.isSpillover).length);
+
function scheduleSave() {
status = 'saving';
clearTimeout(saveTimer);
@@ -40,10 +47,11 @@
if (!name) return;
addingCategory = true;
try {
- const created = await createCategory(name, newCategoryPrivate);
+ const created = await createCategory(name, newCategoryPrivate, newCategorySpillover);
local.categoryPriority = [...local.categoryPriority, created];
newCategoryName = '';
newCategoryPrivate = false;
+ newCategorySpillover = false;
} finally {
addingCategory = false;
}
@@ -54,6 +62,11 @@
scheduleSave();
}
+ function toggleSpillover(id: string) {
+ local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, isSpillover: !c.isSpillover } : c));
+ scheduleSave();
+ }
+
async function removeCategory(id: string, isDefault: boolean, name: string) {
if (isDefault) {
// Sensible-default categories can still be removed — e.g. a fresh install's
@@ -153,8 +166,16 @@
categories just wait longer when the queue is busy. This list also drives the site's nav —
remove anything you're not interested in (Business, Culture, etc.) or add your own. A
private category (and everything in it) is hidden from the public site until a visitor
- logs in with the lock icon in the masthead.
+ logs in with the lock icon in the masthead. A "More" category is collapsed into a single
+ "More »" nav tab instead of getting its own, and shows up on that overflow page with its
+ latest few articles.
+ {#if primaryCategoryCount > 10}
+
+ {primaryCategoryCount} categories showing directly in the nav — consider marking some as
+ "More" below before it gets too wide (a rough guideline, not a hard limit).
+
+ {/if}
{#each local.categoryPriority as cat, i (cat.id)}
@@ -165,6 +186,10 @@
togglePrivate(cat.id)} />
Private
+
+ toggleSpillover(cat.id)} />
+ More
+
{/if}
move(i, -1)} disabled={i === 0} aria-label="Move up">▲
Private
+
+
+ More
+
{addingCategory ? 'Adding…' : '+ Add'}
@@ -249,6 +278,9 @@
color: var(--text-secondary);
margin: 4px 0 12px;
}
+ .hint.warn {
+ color: var(--text-accent);
+ }
.slider-row {
display: flex;
align-items: center;
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 0a3e773..09e5bcf 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -89,4 +89,5 @@ export interface Category {
priorityRank: number;
isDefault: boolean;
isPrivate: boolean;
+ isSpillover: boolean;
}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 36dadec..b40fe5f 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -29,18 +29,24 @@
// "Top stories" is a real Category row (it drives synthesis queue priority) but
// isn't itself a filterable category — it always means "everything, chronological",
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
- // page. See MergeTab's category priority list for where these are managed.
+ // page, unless it's flagged "spillover" (see MergeTab.svelte's category priority
+ // list) — those collapse into a single trailing "More »" tab instead, so the nav
+ // doesn't get too wide or wrap once there are more than a handful of categories.
//
// A tracked event is a displayed category too, just backed by a source+keyword
// filter instead of manual per-source category checkboxes, and periodically
// AI-recapped — see EventsTab.svelte. Active ones get their own /event/:id tab,
// appended after the regular categories.
+ const primaryCategories = $derived(data.categories.filter((c) => !c.isSpillover));
+ const spilloverCategories = $derived(data.categories.filter((c) => c.isSpillover));
+
const navItems = $derived([
- ...data.categories.map((cat) => ({
+ ...primaryCategories.map((cat) => ({
label: cat.name,
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
})),
- ...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` }))
+ ...data.events.map((event) => ({ label: event.name, href: `/event/${event.id}` })),
+ ...(spilloverCategories.length > 0 ? [{ label: 'More »', href: '/more' }] : [])
]);
function isActive(href: string): boolean {
diff --git a/frontend/src/routes/more/+page.svelte b/frontend/src/routes/more/+page.svelte
new file mode 100644
index 0000000..a902c1c
--- /dev/null
+++ b/frontend/src/routes/more/+page.svelte
@@ -0,0 +1,91 @@
+
+
+
+ More
+
+
+
+ {#each data.sections as section (section.category.id)}
+ {#if section.articles.length > 0}
+
+ {/if}
+ {/each}
+
+
+
diff --git a/frontend/src/routes/more/+page.ts b/frontend/src/routes/more/+page.ts
new file mode 100644
index 0000000..bb41710
--- /dev/null
+++ b/frontend/src/routes/more/+page.ts
@@ -0,0 +1,22 @@
+import type { PageLoad } from './$types';
+import { getFeed } from '$lib/api';
+
+const PREVIEW_COUNT = 4;
+
+// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
+// category (see MergeTab.svelte's "More" toggle) with its few newest articles, the
+// category name itself linking through to the full /category/:slug page. Mirrors
+// category/[name]/+page.ts's parent()-based category access rather than a second fetch.
+export const load: PageLoad = async ({ fetch, parent }) => {
+ const { categories } = await parent();
+ const spillover = categories.filter((c) => c.isSpillover);
+
+ const sections = await Promise.all(
+ spillover.map(async (category) => ({
+ category,
+ articles: await getFeed({ category: category.name, limit: PREVIEW_COUNT }, fetch)
+ }))
+ );
+
+ return { sections };
+};
From dbc922f3bd3db869d9ca280853c5a644abe775a6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 17:40:36 +0000
Subject: [PATCH 2/6] More page: render articles with ArticleListRow, bump
preview cap to 5
Reuses the same tweet/telegram/article rendering each category page
uses instead of a bespoke title-only row, so the digest matches what
users see after clicking through.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
frontend/src/routes/more/+page.svelte | 43 ++++-----------------------
frontend/src/routes/more/+page.ts | 2 +-
2 files changed, 7 insertions(+), 38 deletions(-)
diff --git a/frontend/src/routes/more/+page.svelte b/frontend/src/routes/more/+page.svelte
index a902c1c..dbefa81 100644
--- a/frontend/src/routes/more/+page.svelte
+++ b/frontend/src/routes/more/+page.svelte
@@ -1,6 +1,7 @@
@@ -14,12 +15,9 @@
{#if section.articles.length > 0}
{section.category.name}
-
+
@@ -56,36 +54,7 @@
.cat-name:hover {
color: var(--text-accent);
}
- .preview-list {
- display: flex;
- flex-direction: column;
- }
- .preview-row {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
- padding: 8px 0;
- border-top: 0.5px solid var(--border);
- color: inherit;
- }
- .preview-row:first-child {
- border-top: none;
- }
- .preview-row:hover {
- text-decoration: none;
- }
- .preview-row:hover .preview-title {
- text-decoration: underline;
- }
- .preview-title {
- font-size: 14px;
- line-height: 1.4;
- }
- .preview-time {
- font-size: 11px;
- color: var(--text-muted);
- white-space: nowrap;
- flex-shrink: 0;
+ .list {
+ max-width: 720px;
}
diff --git a/frontend/src/routes/more/+page.ts b/frontend/src/routes/more/+page.ts
index bb41710..2bc786d 100644
--- a/frontend/src/routes/more/+page.ts
+++ b/frontend/src/routes/more/+page.ts
@@ -1,7 +1,7 @@
import type { PageLoad } from './$types';
import { getFeed } from '$lib/api';
-const PREVIEW_COUNT = 4;
+const PREVIEW_COUNT = 5;
// The "More »" nav tab (see +layout.svelte) leads here — one section per spillover
// category (see MergeTab.svelte's "More" toggle) with its few newest articles, the
From a45a813a41d0b8cddb0dab0939c5cb5e93c279db Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 22:14:50 +0000
Subject: [PATCH 3/6] 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
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
backend/src/api/admin.ts | 74 +++++++
backend/src/api/public.ts | 14 ++
backend/src/queue/scheduler.ts | 20 +-
backend/src/stocks/client.ts | 50 +++++
backend/src/stocks/poller.ts | 30 +++
backend/src/storage/db/bookmarks.ts | 46 ++++
backend/src/storage/db/index.ts | 63 +++++-
backend/src/storage/db/settings.ts | 28 ++-
backend/src/storage/db/stocks.ts | 54 +++++
backend/src/storage/db/types.ts | 47 ++++
backend/src/weather/client.ts | 124 +++++++++++
backend/src/weather/poller.ts | 23 ++
frontend/src/lib/adminApi.ts | 40 +++-
frontend/src/lib/adminTypes.ts | 54 +++++
frontend/src/lib/api.ts | 14 +-
.../lib/components/admin/BookmarksTab.svelte | 194 +++++++++++++++++
.../src/lib/components/admin/StocksTab.svelte | 202 ++++++++++++++++++
.../lib/components/admin/WeatherTab.svelte | 197 +++++++++++++++++
.../components/sidebar/BookmarksWidget.svelte | 53 +++++
.../src/lib/components/sidebar/Sidebar.svelte | 24 +++
.../components/sidebar/StocksWidget.svelte | 82 +++++++
.../components/sidebar/WeatherWidget.svelte | 66 ++++++
frontend/src/lib/types.ts | 39 ++++
frontend/src/routes/+layout.svelte | 29 ++-
frontend/src/routes/+layout.ts | 19 +-
.../src/routes/admin/settings/+page.svelte | 12 ++
frontend/src/routes/admin/settings/+page.ts | 20 +-
frontend/src/routes/weather/+page.svelte | 170 +++++++++++++++
frontend/src/routes/weather/+page.ts | 8 +
29 files changed, 1779 insertions(+), 17 deletions(-)
create mode 100644 backend/src/stocks/client.ts
create mode 100644 backend/src/stocks/poller.ts
create mode 100644 backend/src/storage/db/bookmarks.ts
create mode 100644 backend/src/storage/db/stocks.ts
create mode 100644 backend/src/weather/client.ts
create mode 100644 backend/src/weather/poller.ts
create mode 100644 frontend/src/lib/components/admin/BookmarksTab.svelte
create mode 100644 frontend/src/lib/components/admin/StocksTab.svelte
create mode 100644 frontend/src/lib/components/admin/WeatherTab.svelte
create mode 100644 frontend/src/lib/components/sidebar/BookmarksWidget.svelte
create mode 100644 frontend/src/lib/components/sidebar/Sidebar.svelte
create mode 100644 frontend/src/lib/components/sidebar/StocksWidget.svelte
create mode 100644 frontend/src/lib/components/sidebar/WeatherWidget.svelte
create mode 100644 frontend/src/routes/weather/+page.svelte
create mode 100644 frontend/src/routes/weather/+page.ts
diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts
index c239b2e..95587e7 100644
--- a/backend/src/api/admin.ts
+++ b/backend/src/api/admin.ts
@@ -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 };
diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts
index 252120c..b675e50 100644
--- a/backend/src/api/public.ts
+++ b/backend/src/api/public.ts
@@ -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);
+ });
}
diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts
index 433a209..7636923 100644
--- a/backend/src/queue/scheduler.ts
+++ b/backend/src/queue/scheduler.ts
@@ -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');
}
diff --git a/backend/src/stocks/client.ts b/backend/src/stocks/client.ts
new file mode 100644
index 0000000..3e0eb98
--- /dev/null
+++ b/backend/src/stocks/client.ts
@@ -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> {
+ const results = new Map();
+ 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();
+ 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;
+}
diff --git a/backend/src/stocks/poller.ts b/backend/src/stocks/poller.ts
new file mode 100644
index 0000000..e81dbc4
--- /dev/null
+++ b/backend/src/stocks/poller.ts
@@ -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 {
+ const tickers = stocksDb.listStockTickers();
+ if (tickers.length === 0) return;
+
+ let quotes: Map;
+ 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);
+ }
+ }
+}
diff --git a/backend/src/storage/db/bookmarks.ts b/backend/src/storage/db/bookmarks.ts
new file mode 100644
index 0000000..0882ca9
--- /dev/null
+++ b/backend/src/storage/db/bookmarks.ts
@@ -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);
+}
diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts
index dea8b34..cd7fdc9 100644
--- a/backend/src/storage/db/index.ts
+++ b/backend/src/storage/db/index.ts
@@ -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
diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts
index 13cb383..187fb2b 100644
--- a/backend/src/storage/db/settings.ts
+++ b/backend/src/storage/db/settings.ts
@@ -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 {
...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 {
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 {
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();
}
diff --git a/backend/src/storage/db/stocks.ts b/backend/src/storage/db/stocks.ts
new file mode 100644
index 0000000..9f28874
--- /dev/null
+++ b/backend/src/storage/db/stocks.ts
@@ -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);
+}
diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts
index 2778201..749eea2 100644
--- a/backend/src/storage/db/types.ts
+++ b/backend/src/storage/db/types.ts
@@ -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;
+ };
}
diff --git a/backend/src/weather/client.ts b/backend/src/weather/client.ts
new file mode 100644
index 0000000..f226ef8
--- /dev/null
+++ b/backend/src/weather/client.ts
@@ -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 = {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/backend/src/weather/poller.ts b/backend/src/weather/poller.ts
new file mode 100644
index 0000000..9cb7a1e
--- /dev/null
+++ b/backend/src/weather/poller.ts
@@ -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 {
+ 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}`);
+ }
+}
diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts
index 82dbeea..c37e440 100644
--- a/frontend/src/lib/adminApi.ts
+++ b/frontend/src/lib/adminApi.ts
@@ -8,7 +8,10 @@ import type {
ModelCatalog,
AiStatus,
TelegramStatus,
- LogEntry
+ LogEntry,
+ GeocodeResult,
+ AdminStockTicker,
+ AdminBookmark
} from './adminTypes';
async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise {
@@ -164,3 +167,38 @@ export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: nu
const qs = new URLSearchParams(filters as Record).toString();
return request(`/api/admin/logs${qs ? `?${qs}` : ''}`, {}, fetchFn);
};
+
+// Weather — config/cache lives on AdminSettings.weather (see updateSettings above); this
+// is just the geocoding lookup used to resolve a typed city name to lat/lon.
+export const geocodeLocation = (query: string, fetchFn?: typeof fetch) =>
+ request(`/api/admin/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn);
+
+// Stocks
+export const getStockTickers = (fetchFn?: typeof fetch) =>
+ request('/api/admin/stocks', {}, fetchFn);
+
+export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) =>
+ request('/api/admin/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn);
+
+export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) =>
+ request(`/api/admin/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
+
+export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) =>
+ request(`/api/admin/stocks/${id}`, { method: 'DELETE' }, fetchFn);
+
+// Bookmarks
+export const getAdminBookmarks = (fetchFn?: typeof fetch) =>
+ request('/api/admin/bookmarks', {}, fetchFn);
+
+export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) =>
+ request(
+ '/api/admin/bookmarks',
+ { method: 'POST', body: JSON.stringify({ name, url, isPrivate }) },
+ fetchFn
+ );
+
+export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) =>
+ request(`/api/admin/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
+
+export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
+ request(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts
index 3c7ec6b..b42b866 100644
--- a/frontend/src/lib/adminTypes.ts
+++ b/frontend/src/lib/adminTypes.ts
@@ -16,6 +16,59 @@ export interface CategoryPriority {
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 AdminWeatherSettings {
+ 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;
+}
+
+export interface GeocodeResult {
+ name: string;
+ admin1: string | null;
+ country: string | null;
+ latitude: number;
+ longitude: number;
+}
+
+export interface AdminStockTicker {
+ id: string;
+ label: string;
+ symbol: string;
+ priorityRank: number;
+ lastPrice: number | null;
+ lastChangePercent: number | null;
+ lastPolledAt: string | null;
+ lastError: string | null;
+}
+
+export interface AdminBookmark {
+ id: string;
+ name: string;
+ url: string;
+ priorityRank: number;
+ isPrivate: boolean;
+}
+
export interface AdminSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
@@ -32,6 +85,7 @@ export interface AdminSettings {
telegramMediaMode: 'self-host' | 'proxy';
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
+ weather: AdminWeatherSettings;
}
export interface AdminSource {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index f4583c4..1e017a2 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,5 +1,5 @@
import { getBackendUrl } from './config';
-import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types';
+import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark } from './types';
async function get(path: string, fetchFn: typeof fetch = fetch): Promise {
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
@@ -41,3 +41,15 @@ export function getEvents(fetchFn?: typeof fetch): Promise
export function getCategories(fetchFn?: typeof fetch): Promise {
return get('/api/categories', fetchFn);
}
+
+export function getWeather(fetchFn?: typeof fetch): Promise {
+ return get('/api/weather', fetchFn);
+}
+
+export function getStocks(fetchFn?: typeof fetch): Promise {
+ return get('/api/stocks', fetchFn);
+}
+
+export function getBookmarks(fetchFn?: typeof fetch): Promise {
+ return get('/api/bookmarks', fetchFn);
+}
diff --git a/frontend/src/lib/components/admin/BookmarksTab.svelte b/frontend/src/lib/components/admin/BookmarksTab.svelte
new file mode 100644
index 0000000..9cac122
--- /dev/null
+++ b/frontend/src/lib/components/admin/BookmarksTab.svelte
@@ -0,0 +1,194 @@
+
+
+
+ {bookmarks.length} bookmarks
+ (showAdd = !showAdd)}>+ New bookmark
+
+
+{#if showAdd}
+
+{/if}
+
+
+ {#each bookmarks as bookmark (bookmark.id)}
+ {#if editingId === bookmark.id}
+
+ {:else}
+
+
+
{bookmark.name}
+
{bookmark.url}
+
+
+ togglePrivate(bookmark)} />
+ Private
+
+
startEdit(bookmark)} title="Edit">Edit
+
handleDelete(bookmark.id)} title="Delete">✕
+
+ {/if}
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/admin/StocksTab.svelte b/frontend/src/lib/components/admin/StocksTab.svelte
new file mode 100644
index 0000000..6b3dff0
--- /dev/null
+++ b/frontend/src/lib/components/admin/StocksTab.svelte
@@ -0,0 +1,202 @@
+
+
+
+ {tickers.length} tickers
+ (showAdd = !showAdd)}>+ New ticker
+
+
+{#if showAdd}
+
+
+
+
+
+
+ (showAdd = false)}>Cancel
+ Create
+
+
+ Stooq has no symbol search, so type the exact syntax: indices use a caret (^dji, ^spx),
+ stocks use a country suffix (aapl.us), crypto pairs have none (btcusd). Polled every 15
+ minutes; a new ticker is polled immediately.
+
+
+{/if}
+
+
+ {#each tickers as ticker (ticker.id)}
+ {#if editingId === ticker.id}
+
+ {:else}
+
+
+
{ticker.label}
+
+ {ticker.symbol}
+ {#if ticker.lastError}
+ · {ticker.lastError}
+ {/if}
+
+
+ {#if ticker.lastPrice !== null}
+
= 0} class:down={(ticker.lastChangePercent ?? 0) < 0}>
+ {ticker.lastPrice.toFixed(2)}
+ {#if ticker.lastChangePercent !== null}
+ ({ticker.lastChangePercent >= 0 ? '+' : ''}{ticker.lastChangePercent.toFixed(2)}%)
+ {/if}
+
+ {/if}
+
startEdit(ticker)} title="Edit">Edit
+
handleDelete(ticker.id)} title="Delete">✕
+
+ {/if}
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/admin/WeatherTab.svelte b/frontend/src/lib/components/admin/WeatherTab.svelte
new file mode 100644
index 0000000..f4ef51c
--- /dev/null
+++ b/frontend/src/lib/components/admin/WeatherTab.svelte
@@ -0,0 +1,197 @@
+
+
+
+
+ Location
+
+
+
Powers the sidebar weather widget and the /weather page — searched via Open-Meteo's free geocoding lookup.
+
+ e.key === 'Enter' && handleSearch()}
+ placeholder="City name, e.g. Chicago"
+ />
+ {searching ? 'Searching…' : 'Search'}
+
+ {#if searchError}
+
{searchError}
+ {/if}
+ {#if results.length > 0}
+
+ {#each results as r}
+ selectResult(r)}>
+ {r.name}{r.admin1 ? `, ${r.admin1}` : ''}{r.country ? ` — ${r.country}` : ''}
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if weather.locationName}
+ Configured: {weather.locationName}
+ {:else}
+ No location configured yet
+ {/if}
+
+
+
+
+ {#each units as unit}
+ {
+ weather.unit = unit.value;
+ scheduleSave();
+ }}
+ >
+ {unit.label}
+
+ {/each}
+
+
+
+ {#if weather.current}
+ Currently showing: {Math.round(weather.current.temp)}° · {weather.current.conditionText}
+ (updated {timeAgo(weather.updatedAt ?? '')})
+ {:else}
+ Not showing any data yet — configure a location above, it polls immediately.
+ {/if}
+
+
+
+
diff --git a/frontend/src/lib/components/sidebar/BookmarksWidget.svelte b/frontend/src/lib/components/sidebar/BookmarksWidget.svelte
new file mode 100644
index 0000000..86b3fd3
--- /dev/null
+++ b/frontend/src/lib/components/sidebar/BookmarksWidget.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte
new file mode 100644
index 0000000..952c138
--- /dev/null
+++ b/frontend/src/lib/components/sidebar/Sidebar.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/sidebar/StocksWidget.svelte b/frontend/src/lib/components/sidebar/StocksWidget.svelte
new file mode 100644
index 0000000..2fa56fa
--- /dev/null
+++ b/frontend/src/lib/components/sidebar/StocksWidget.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/sidebar/WeatherWidget.svelte b/frontend/src/lib/components/sidebar/WeatherWidget.svelte
new file mode 100644
index 0000000..b1263bf
--- /dev/null
+++ b/frontend/src/lib/components/sidebar/WeatherWidget.svelte
@@ -0,0 +1,66 @@
+
+
+
+ Weather
+ {#if weather.current}
+
+
{weather.current.icon}
+
+ {Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'}
+ {weather.current.conditionText}
+
+
+ {:else}
+ Not configured yet
+ {/if}
+
+
+
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 09e5bcf..6a5993e 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -91,3 +91,42 @@ export interface Category {
isPrivate: boolean;
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 Weather {
+ locationName: string | null;
+ unit: 'celsius' | 'fahrenheit';
+ current: { temp: number; conditionText: string; icon: string } | null;
+ hourly: WeatherHourEntry[];
+ daily: WeatherDayEntry[];
+ updatedAt: string | null;
+}
+
+export interface StockTicker {
+ id: string;
+ label: string;
+ symbol: string;
+ lastPrice: number | null;
+ lastChangePercent: number | null;
+}
+
+export interface Bookmark {
+ id: string;
+ name: string;
+ url: string;
+ isPrivate: boolean;
+}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index b40fe5f..d809efe 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -4,6 +4,7 @@
import { invalidateAll } from '$app/navigation';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import PrivateAccessModal from '$lib/components/PrivateAccessModal.svelte';
+ import Sidebar from '$lib/components/sidebar/Sidebar.svelte';
import { logoutPrivateAccess } from '$lib/privateAccess';
import { slugify } from '$lib/format';
import type { LayoutData } from './$types';
@@ -12,6 +13,11 @@
let showLoginModal = $state(false);
+ // Admin pages already use full page width for their own tab UI — the sidebar's utility
+ // widgets don't belong there, unlike every reader-facing route (home, category,
+ // article, event, more, weather).
+ const showSidebar = $derived(!$page.url.pathname.startsWith('/admin'));
+
async function handleLockClick() {
if (data.privateAccess.authenticated) {
await logoutPrivateAccess();
@@ -106,8 +112,13 @@
(showLoginModal = false)} onSuccess={handleLoginSuccess} />
{/if}
-
- {@render children()}
+
+
+ {@render children()}
+
+ {#if showSidebar}
+
+ {/if}
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts
index 3f91bbc..9386df2 100644
--- a/frontend/src/routes/+layout.ts
+++ b/frontend/src/routes/+layout.ts
@@ -1,14 +1,25 @@
import type { LayoutLoad } from './$types';
-import { getCategories, getEvents } from '$lib/api';
+import { getCategories, getEvents, getWeather, getStocks, getBookmarks } from '$lib/api';
import { getPrivateAccessStatus } from '$lib/privateAccess';
export const load: LayoutLoad = async ({ fetch, data }) => {
- const [categories, events, privateAccess] = await Promise.all([
+ const [categories, events, privateAccess, weather, stocks, bookmarks] = await Promise.all([
getCategories(fetch),
getEvents(fetch),
- getPrivateAccessStatus(fetch)
+ getPrivateAccessStatus(fetch),
+ getWeather(fetch),
+ getStocks(fetch),
+ getBookmarks(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.
- return { ...data, categories, events: events.filter((e) => e.active), privateAccess };
+ return {
+ ...data,
+ categories,
+ events: events.filter((e) => e.active),
+ privateAccess,
+ weather,
+ stocks,
+ bookmarks
+ };
};
diff --git a/frontend/src/routes/admin/settings/+page.svelte b/frontend/src/routes/admin/settings/+page.svelte
index c6506c7..8282507 100644
--- a/frontend/src/routes/admin/settings/+page.svelte
+++ b/frontend/src/routes/admin/settings/+page.svelte
@@ -5,6 +5,9 @@
import ModelsTab from '$lib/components/admin/ModelsTab.svelte';
import RetentionTab from '$lib/components/admin/RetentionTab.svelte';
import EventsTab from '$lib/components/admin/EventsTab.svelte';
+ 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 ConnectionsTab from '$lib/components/admin/ConnectionsTab.svelte';
import LogsTab from '$lib/components/admin/LogsTab.svelte';
@@ -16,6 +19,9 @@
{ id: 'models', label: 'Models' },
{ id: 'retention', label: 'Retention' },
{ id: 'events', label: 'Tracked events' },
+ { id: 'weather', label: 'Weather' },
+ { id: 'stocks', label: 'Stocks' },
+ { id: 'bookmarks', label: 'Bookmarks' },
{ id: 'connections', label: 'Connections' },
{ id: 'logs', label: 'Logs' }
];
@@ -45,6 +51,12 @@
{:else if active === 'events'}
+ {:else if active === 'weather'}
+
+ {:else if active === 'stocks'}
+
+ {:else if active === 'bookmarks'}
+
{:else if active === 'connections'}
{:else if active === 'logs'}
diff --git a/frontend/src/routes/admin/settings/+page.ts b/frontend/src/routes/admin/settings/+page.ts
index 4fb73f5..a8576df 100644
--- a/frontend/src/routes/admin/settings/+page.ts
+++ b/frontend/src/routes/admin/settings/+page.ts
@@ -1,17 +1,29 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
-import { getSettings, getSources, getEvents, getModels, getAiStatus, getTelegramStatus, getLogs } from '$lib/adminApi';
+import {
+ getSettings,
+ getSources,
+ getEvents,
+ getModels,
+ getAiStatus,
+ getTelegramStatus,
+ getLogs,
+ getStockTickers,
+ getAdminBookmarks
+} from '$lib/adminApi';
import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes';
const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
export const load: PageLoad = async ({ fetch }) => {
try {
- const [settings, sources, events, logs] = await Promise.all([
+ const [settings, sources, events, logs, stockTickers, bookmarks] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
- getLogs({}, fetch)
+ getLogs({}, fetch),
+ getStockTickers(fetch),
+ getAdminBookmarks(fetch)
]);
// The AI service (Ollama) may not be running yet — that shouldn't take down the
@@ -28,7 +40,7 @@ export const load: PageLoad = async ({ fetch }) => {
() => ({ credentialsConfigured: false, connected: false, phone: null })
);
- return { settings, sources, events, models, aiStatus, telegramStatus, logs };
+ return { settings, sources, events, models, aiStatus, telegramStatus, logs, stockTickers, bookmarks };
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
diff --git a/frontend/src/routes/weather/+page.svelte b/frontend/src/routes/weather/+page.svelte
new file mode 100644
index 0000000..e0ace89
--- /dev/null
+++ b/frontend/src/routes/weather/+page.svelte
@@ -0,0 +1,170 @@
+
+
+
+ Weather
+ {#if weather.locationName}
+ {weather.locationName}
+ {/if}
+
+
+{#if !weather.current}
+ Not configured yet — set a location in the admin panel's Weather tab.
+{:else}
+
+
{weather.current.icon}
+
+ {Math.round(weather.current.temp)}°{unitLabel}
+ {weather.current.conditionText}
+ Updated {timeAgo(weather.updatedAt ?? '')}
+
+
+
+
+
Hourly
+
+ {#each weather.hourly as hour (hour.time)}
+
+ {new Date(hour.time).toLocaleTimeString([], { hour: 'numeric' })}
+ {hour.icon}
+ {Math.round(hour.temp)}°
+
+ {/each}
+
+
+
+
+
7-day forecast
+
+ {#each weather.daily as day (day.date)}
+
+ {new Date(day.date).toLocaleDateString([], { weekday: 'short' })}
+ {day.icon}
+ {day.conditionText}
+ {Math.round(day.tempMax)}° / {Math.round(day.tempMin)}°
+
+ {/each}
+
+
+{/if}
+
+
diff --git a/frontend/src/routes/weather/+page.ts b/frontend/src/routes/weather/+page.ts
new file mode 100644
index 0000000..f60d14a
--- /dev/null
+++ b/frontend/src/routes/weather/+page.ts
@@ -0,0 +1,8 @@
+import type { PageLoad } from './$types';
+
+// The sidebar's WeatherWidget (see +layout.svelte/Sidebar.svelte) already fetches this
+// same data via the root layout load — no need for a second fetch here.
+export const load: PageLoad = async ({ parent }) => {
+ const { weather } = await parent();
+ return { weather };
+};
From b83640e980cfbaf86e97493a1aba86706b51ed93 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 22:52:53 +0000
Subject: [PATCH 4/6] Expand weather: feels-like, alerts, full current
conditions, 2-row hourly
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sidebar widget now reads "Weather - " and shows a "Feels
like" line (Open-Meteo's own apparent_temperature, which already
blends heat index and wind chill as appropriate rather than needing
season-specific logic here).
The /weather page gains a current-conditions grid (humidity,
precipitation chance, wind direction/speed, pressure, sunrise,
sunset — wind and pressure units independently configurable in the
admin Weather tab) and an alerts section sourced from the US National
Weather Service (free, no key, US-only — fails safe to no alerts
elsewhere) shown between current conditions and the hourly strip.
Also fixes the hourly strip, which is now a fixed 12-column grid (two
rows of 12) instead of one overflowing horizontal-scroll row that
extended into the sidebar's column.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
backend/src/storage/db/index.ts | 12 +-
backend/src/storage/db/settings.ts | 9 +-
backend/src/storage/db/types.ts | 32 ++++-
backend/src/weather/client.ts | 115 +++++++++++++--
backend/src/weather/poller.ts | 43 +++++-
frontend/src/lib/adminTypes.ts | 27 +++-
.../lib/components/admin/WeatherTab.svelte | 54 ++++++-
.../components/sidebar/WeatherWidget.svelte | 11 +-
frontend/src/lib/types.ts | 27 +++-
frontend/src/routes/weather/+page.svelte | 133 +++++++++++++++++-
10 files changed, 429 insertions(+), 34 deletions(-)
diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts
index cd7fdc9..fecb500 100644
--- a/backend/src/storage/db/index.ts
+++ b/backend/src/storage/db/index.ts
@@ -181,9 +181,14 @@ export function migrate() {
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_wind_unit TEXT NOT NULL DEFAULT 'mph', -- mph | kph
+ weather_pressure_unit TEXT NOT NULL DEFAULT 'inHg', -- inHg | hPa
+ -- JSON {temp, feelsLike, conditionText, icon, humidity, precipitationChance,
+ -- windSpeed, windDirection, pressure, sunrise, sunset}, NULL pre-first-poll
+ weather_current TEXT,
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
);
@@ -292,6 +297,11 @@ export function migrate() {
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');
}
+ if (!hasColumn('global_settings', 'weather_wind_unit')) {
+ db.exec("ALTER TABLE global_settings ADD COLUMN weather_wind_unit TEXT NOT NULL DEFAULT 'mph'");
+ 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 '[]'");
+ }
// 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.
diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts
index 187fb2b..ed73f10 100644
--- a/backend/src/storage/db/settings.ts
+++ b/backend/src/storage/db/settings.ts
@@ -28,10 +28,13 @@ function rowToSettings(row: any): GlobalSettings {
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
}
};
@@ -60,7 +63,8 @@ export function updateSettings(patch: Partial): GlobalSettings {
published_article_max_age_days=?, raw_item_max_age_days=?,
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=?
+ weather_wind_unit=?, weather_pressure_unit=?,
+ weather_current=?, weather_hourly=?, weather_daily=?, weather_alerts=?, weather_updated_at=?
WHERE id = 1`
).run(
merged.mergeStrictness,
@@ -85,9 +89,12 @@ export function updateSettings(patch: Partial): GlobalSettings {
merged.weather.latitude,
merged.weather.longitude,
merged.weather.unit,
+ merged.weather.windUnit,
+ merged.weather.pressureUnit,
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
JSON.stringify(merged.weather.hourly),
JSON.stringify(merged.weather.daily),
+ JSON.stringify(merged.weather.alerts),
merged.weather.updatedAt
);
return getSettings();
diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts
index 749eea2..8331859 100644
--- a/backend/src/storage/db/types.ts
+++ b/backend/src/storage/db/types.ts
@@ -272,9 +272,39 @@ export interface GlobalSettings {
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
- current: { temp: number; conditionText: string; icon: string } | null;
+ 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;
};
}
+
+export interface WeatherAlert {
+ id: string;
+ event: string;
+ headline: string;
+ severity: string;
+ expires: string;
+}
diff --git a/backend/src/weather/client.ts b/backend/src/weather/client.ts
index f226ef8..626f20f 100644
--- a/backend/src/weather/client.ts
+++ b/backend/src/weather/client.ts
@@ -53,6 +53,16 @@ export function wmoToCondition(code: number): WeatherCondition {
return WMO_CONDITIONS[code] ?? { text: 'Unknown', icon: '❔' };
}
+const COMPASS_POINTS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
+
+function degreesToCompass(degrees: number): string {
+ return COMPASS_POINTS[Math.round(degrees / 45) % 8];
+}
+
+function hPaToInHg(hpa: number): number {
+ return hpa * 0.0295299830714;
+}
+
export async function geocodeLocation(query: string): Promise {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
const res = await fetch(url);
@@ -69,8 +79,22 @@ export async function geocodeLocation(query: string): Promise {
}));
}
+export interface CurrentConditions {
+ temp: number;
+ feelsLike: number;
+ conditionText: string;
+ icon: string;
+ humidity: number;
+ precipitationChance: number;
+ windSpeed: number;
+ windDirection: string;
+ pressure: number;
+ sunrise: string;
+ sunset: string;
+}
+
export interface ForecastResult {
- current: { temp: number; conditionText: string; icon: string };
+ current: CurrentConditions;
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[];
}
@@ -78,31 +102,65 @@ export interface ForecastResult {
export async function fetchForecast(
latitude: number,
longitude: number,
- unit: 'celsius' | 'fahrenheit'
+ unit: 'celsius' | 'fahrenheit',
+ windUnit: 'mph' | 'kph',
+ pressureUnit: 'inHg' | 'hPa'
): Promise {
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`;
+ `¤t=temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m,wind_direction_10m,pressure_msl` +
+ `&hourly=temperature_2m,weather_code,precipitation_probability` +
+ `&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset` +
+ `&temperature_unit=${unit}&wind_speed_unit=${windUnit === 'kph' ? 'kmh' : 'mph'}&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[] };
+ current: {
+ temperature_2m: number;
+ apparent_temperature: number;
+ weather_code: number;
+ relative_humidity_2m: number;
+ wind_speed_10m: number;
+ wind_direction_10m: number;
+ pressure_msl: number;
+ };
+ hourly: { time: string[]; temperature_2m: number[]; weather_code: number[]; precipitation_probability: number[] };
+ daily: {
+ time: string[];
+ temperature_2m_max: number[];
+ temperature_2m_min: number[];
+ weather_code: number[];
+ sunrise: string[];
+ sunset: string[];
+ };
};
- 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.
+ // at or after now so the strip shown to the user starts from "now", not from midnight,
+ // and so the current hour's precipitation_probability can stand in for "right now"
+ // (there's no true instantaneous "chance of rain" measurement, current forecasts don't have one).
const now = Date.now();
const startIdx = Math.max(
0,
data.hourly.time.findIndex((t) => new Date(t).getTime() >= now)
);
+
+ const currentCondition = wmoToCondition(data.current.weather_code);
+ const pressure = pressureUnit === 'inHg' ? hPaToInHg(data.current.pressure_msl) : data.current.pressure_msl;
+ const current: CurrentConditions = {
+ temp: data.current.temperature_2m,
+ feelsLike: data.current.apparent_temperature,
+ conditionText: currentCondition.text,
+ icon: currentCondition.icon,
+ humidity: data.current.relative_humidity_2m,
+ precipitationChance: data.hourly.precipitation_probability[startIdx] ?? 0,
+ windSpeed: data.current.wind_speed_10m,
+ windDirection: degreesToCompass(data.current.wind_direction_10m),
+ pressure: pressureUnit === 'inHg' ? Math.round(pressure * 100) / 100 : Math.round(pressure),
+ sunrise: data.daily.sunrise[0],
+ sunset: data.daily.sunset[0]
+ };
+
const hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => {
const idx = startIdx + i;
const condition = wmoToCondition(data.hourly.weather_code[idx]);
@@ -122,3 +180,36 @@ export async function fetchForecast(
return { current, hourly, daily };
}
+
+export interface WeatherAlertResult {
+ id: string;
+ event: string;
+ headline: string;
+ severity: string;
+ expires: string;
+}
+
+// US National Weather Service — free, no key, no account, covers the US and territories
+// only. A non-US location will reliably fail this call; that's expected, not an error
+// (see poller.ts, which treats a failure here as "no alerts" rather than propagating it).
+export async function fetchActiveAlerts(latitude: number, longitude: number): Promise {
+ const url = `https://api.weather.gov/alerts/active?point=${latitude},${longitude}`;
+ const res = await fetch(url, {
+ headers: {
+ // NWS's API usage policy requires an identifying User-Agent on every request.
+ 'User-Agent': 'Homefeed/1.0 (self-hosted news aggregator)',
+ Accept: 'application/geo+json'
+ }
+ });
+ if (!res.ok) throw new Error(`NWS alerts API returned ${res.status}`);
+ const data = (await res.json()) as {
+ features: { id: string; properties: { event: string; headline: string; severity: string; expires: string } }[];
+ };
+ return data.features.map((f) => ({
+ id: f.id,
+ event: f.properties.event,
+ headline: f.properties.headline,
+ severity: f.properties.severity,
+ expires: f.properties.expires
+ }));
+}
diff --git a/backend/src/weather/poller.ts b/backend/src/weather/poller.ts
index 9cb7a1e..18ac676 100644
--- a/backend/src/weather/poller.ts
+++ b/backend/src/weather/poller.ts
@@ -1,9 +1,9 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
-import { fetchForecast } from './client.js';
+import { fetchForecast, fetchActiveAlerts } 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'
+// the weather location/units (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
export async function pollWeatherNow(): Promise {
const { weather } = settingsDb.getSettings();
@@ -11,13 +11,42 @@ export async function pollWeatherNow(): Promise {
// No location configured yet — not an error, just nothing to do.
return;
}
+
+ let forecastUpdate: Partial = {};
+ let forecastSucceeded = false;
try {
- const { current, hourly, daily } = await fetchForecast(weather.latitude, weather.longitude, weather.unit);
- settingsDb.updateSettings({
- weather: { ...weather, current, hourly, daily, updatedAt: new Date().toISOString() }
- });
+ const { current, hourly, daily } = await fetchForecast(
+ weather.latitude,
+ weather.longitude,
+ weather.unit,
+ weather.windUnit,
+ weather.pressureUnit
+ );
+ forecastUpdate = { current, hourly, daily };
+ forecastSucceeded = true;
} catch (err) {
// Leave the existing cache untouched — a stale forecast beats a blank widget.
- logger.error('weather', `Poll failed: ${(err as Error).message}`);
+ logger.error('weather', `Forecast poll failed: ${(err as Error).message}`);
}
+
+ // Fetched independently of the forecast — the NWS only covers the US, so this fails
+ // 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;
+ try {
+ alerts = await fetchActiveAlerts(weather.latitude, weather.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
+ }
+ });
}
diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts
index b42b866..5d36070 100644
--- a/frontend/src/lib/adminTypes.ts
+++ b/frontend/src/lib/adminTypes.ts
@@ -31,14 +31,39 @@ export interface WeatherDayEntry {
icon: string;
}
+export interface WeatherCurrentConditions {
+ temp: number;
+ feelsLike: number;
+ conditionText: string;
+ icon: string;
+ humidity: number;
+ precipitationChance: number;
+ windSpeed: number;
+ windDirection: string;
+ pressure: number;
+ sunrise: string;
+ sunset: string;
+}
+
+export interface WeatherAlert {
+ id: string;
+ event: string;
+ headline: string;
+ severity: string;
+ expires: string;
+}
+
export interface AdminWeatherSettings {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
- current: { temp: number; conditionText: string; icon: string } | null;
+ windUnit: 'mph' | 'kph';
+ pressureUnit: 'inHg' | 'hPa';
+ current: WeatherCurrentConditions | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
+ alerts: WeatherAlert[];
updatedAt: string | null;
}
diff --git a/frontend/src/lib/components/admin/WeatherTab.svelte b/frontend/src/lib/components/admin/WeatherTab.svelte
index f4ef51c..21a195c 100644
--- a/frontend/src/lib/components/admin/WeatherTab.svelte
+++ b/frontend/src/lib/components/admin/WeatherTab.svelte
@@ -56,6 +56,16 @@
{ label: '°F', value: 'fahrenheit' },
{ label: '°C', value: 'celsius' }
];
+
+ const windUnits: { label: string; value: 'mph' | 'kph' }[] = [
+ { label: 'mph', value: 'mph' },
+ { label: 'kph', value: 'kph' }
+ ];
+
+ const pressureUnits: { label: string; value: 'inHg' | 'hPa' }[] = [
+ { label: 'inHg', value: 'inHg' },
+ { label: 'hPa', value: 'hPa' }
+ ];
@@ -96,7 +106,8 @@
-
+
Temperature
+
{#each units as unit}
+ Wind speed
+
+ {#each windUnits as unit}
+ {
+ weather.windUnit = unit.value;
+ scheduleSave();
+ }}
+ >
+ {unit.label}
+
+ {/each}
+
+
+ Pressure
+
+ {#each pressureUnits as unit}
+ {
+ weather.pressureUnit = unit.value;
+ scheduleSave();
+ }}
+ >
+ {unit.label}
+
+ {/each}
+
+
{#if weather.current}
- Currently showing: {Math.round(weather.current.temp)}° · {weather.current.conditionText}
- (updated {timeAgo(weather.updatedAt ?? '')})
+ Currently showing: {Math.round(weather.current.temp)}° (feels like {Math.round(weather.current.feelsLike)}°) ·
+ {weather.current.conditionText} (updated {timeAgo(weather.updatedAt ?? '')})
{:else}
Not showing any data yet — configure a location above, it polls immediately.
{/if}
@@ -176,6 +219,11 @@
font-size: 12px;
color: var(--text-muted);
}
+ .field-label {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin: 12px 0 6px;
+ }
.pill-row {
display: flex;
gap: 8px;
diff --git a/frontend/src/lib/components/sidebar/WeatherWidget.svelte b/frontend/src/lib/components/sidebar/WeatherWidget.svelte
index b1263bf..e1cea08 100644
--- a/frontend/src/lib/components/sidebar/WeatherWidget.svelte
+++ b/frontend/src/lib/components/sidebar/WeatherWidget.svelte
@@ -5,13 +5,14 @@
- Weather
+ Weather{weather.locationName ? ` - ${weather.locationName}` : ''}
{#if weather.current}
{weather.current.icon}
{Math.round(weather.current.temp)}°{weather.unit === 'celsius' ? 'C' : 'F'}
{weather.current.conditionText}
+ Feels like {Math.round(weather.current.feelsLike)}°
{:else}
@@ -32,9 +33,13 @@
background: var(--surface-2);
}
.title {
+ display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.body {
display: flex;
@@ -58,6 +63,10 @@
font-size: 12px;
color: var(--text-secondary);
}
+ .feels-like {
+ font-size: 11px;
+ color: var(--text-muted);
+ }
.empty {
font-size: 12px;
color: var(--text-muted);
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 6a5993e..b2579ec 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -107,12 +107,37 @@ export interface WeatherDayEntry {
icon: string;
}
+export interface WeatherCurrentConditions {
+ temp: number;
+ feelsLike: number;
+ conditionText: string;
+ icon: string;
+ humidity: number;
+ precipitationChance: number;
+ windSpeed: number;
+ windDirection: string;
+ pressure: number;
+ sunrise: string;
+ sunset: string;
+}
+
+export interface WeatherAlert {
+ id: string;
+ event: string;
+ headline: string;
+ severity: string;
+ expires: string;
+}
+
export interface Weather {
locationName: string | null;
unit: 'celsius' | 'fahrenheit';
- current: { temp: number; conditionText: string; icon: string } | null;
+ windUnit: 'mph' | 'kph';
+ pressureUnit: 'inHg' | 'hPa';
+ current: WeatherCurrentConditions | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
+ alerts: WeatherAlert[];
updatedAt: string | null;
}
diff --git a/frontend/src/routes/weather/+page.svelte b/frontend/src/routes/weather/+page.svelte
index e0ace89..1cf32a7 100644
--- a/frontend/src/routes/weather/+page.svelte
+++ b/frontend/src/routes/weather/+page.svelte
@@ -20,12 +20,59 @@
{weather.current.icon}
-
{Math.round(weather.current.temp)}°{unitLabel}
+
+ {Math.round(weather.current.temp)}°{unitLabel}
+ Feels like {Math.round(weather.current.feelsLike)}°
+
{weather.current.conditionText}
Updated {timeAgo(weather.updatedAt ?? '')}
+
+
+ Humidity
+ {weather.current.humidity}%
+
+
+ Precip. chance
+ {weather.current.precipitationChance}%
+
+
+ Wind
+ {weather.current.windDirection} {Math.round(weather.current.windSpeed)} {weather.windUnit}
+
+
+ Pressure
+ {weather.current.pressure} {weather.pressureUnit}
+
+
+ Sunrise
+ {new Date(weather.current.sunrise).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
+
+
+ Sunset
+ {new Date(weather.current.sunset).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
+
+
+
+ {#if weather.alerts.length > 0}
+
+
Weather alerts
+
+ {#each weather.alerts as alert (alert.id)}
+
+
+ {alert.event}
+ Until {new Date(alert.expires).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
+
+
{alert.headline}
+
+ {/each}
+
+
+ {/if}
+
Hourly
@@ -88,10 +135,19 @@
display: flex;
flex-direction: column;
}
+ .temp-row {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ }
.temp {
font-size: 40px;
font-weight: 500;
}
+ .feels-like {
+ font-size: 13px;
+ color: var(--text-muted);
+ }
.condition {
font-size: 15px;
color: var(--text-secondary);
@@ -101,6 +157,29 @@
color: var(--text-muted);
margin-top: 4px;
}
+ .conditions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+ gap: 16px;
+ max-width: 640px;
+ margin-bottom: 28px;
+ padding: 16px;
+ background: var(--surface-1);
+ border-radius: 12px;
+ }
+ .stat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .stat-label {
+ font-size: 11px;
+ color: var(--text-muted);
+ }
+ .stat-value {
+ font-size: 15px;
+ font-weight: 500;
+ }
.section {
margin-bottom: 28px;
}
@@ -110,18 +189,55 @@
font-weight: 500;
margin-bottom: 12px;
}
- .hourly-strip {
+ .alerts-list {
display: flex;
- gap: 18px;
- overflow-x: auto;
- padding-bottom: 6px;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 640px;
+ }
+ .alert-row {
+ border-left: 3px solid var(--text-muted);
+ background: var(--surface-1);
+ border-radius: 0 var(--radius) var(--radius) 0;
+ padding: 10px 14px;
+ }
+ .alert-row.severity-extreme,
+ .alert-row.severity-severe {
+ border-left-color: var(--text-danger);
+ }
+ .alert-row.severity-moderate {
+ border-left-color: var(--border-accent);
+ }
+ .alert-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ }
+ .alert-event {
+ font-size: 13px;
+ font-weight: 500;
+ }
+ .alert-expires {
+ font-size: 11px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ }
+ .alert-headline {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin: 4px 0 0;
+ }
+ .hourly-strip {
+ display: grid;
+ grid-template-columns: repeat(12, 1fr);
+ gap: 14px 8px;
}
.hour-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
- flex-shrink: 0;
}
.hour-time {
font-size: 11px;
@@ -133,6 +249,11 @@
.hour-temp {
font-size: 13px;
}
+ @media (max-width: 640px) {
+ .hourly-strip {
+ grid-template-columns: repeat(6, 1fr);
+ }
+ }
.daily-list {
display: flex;
flex-direction: column;
From 57c49f68587e2d9e3bd927eb3c79d772680887ac Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 24 Jul 2026 23:47:36 +0000
Subject: [PATCH 5/6] Switch stock ticker source from Stooq to Yahoo Finance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stooq's public quote endpoint now gates every request behind a
client-side proof-of-work challenge (confirmed by manual curl
testing), which a plain server-side fetch can't pass and isn't
worth running a headless browser to solve. Yahoo's unofficial
/v8/finance/chart endpoint still works with just a browser-like
User-Agent header (also confirmed manually — bare curl/fetch UAs
get rate-limited immediately).
One request per ticker instead of one batched request (Yahoo's
batch quote endpoint needs a cookie+crumb handshake this one
doesn't), and change % is now computed against the real previous
close instead of the open-vs-close approximation Stooq's format
forced. Existing installs get their three default tickers
(Dow/S&P/Bitcoin) rewritten from Stooq to Yahoo symbol syntax
automatically; any ticker an admin added themselves is left alone.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
backend/src/stocks/client.ts | 83 +++++++++++--------
backend/src/stocks/poller.ts | 5 +-
backend/src/storage/db/index.ts | 19 ++++-
.../src/lib/components/admin/StocksTab.svelte | 8 +-
4 files changed, 70 insertions(+), 45 deletions(-)
diff --git a/backend/src/stocks/client.ts b/backend/src/stocks/client.ts
index 3e0eb98..637c475 100644
--- a/backend/src/stocks/client.ts
+++ b/backend/src/stocks/client.ts
@@ -1,11 +1,19 @@
-// 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.
+// Yahoo Finance's unofficial chart endpoint — 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/ 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.
+// Previously used Stooq's CSV quote endpoint, which started gating every request behind
+// a client-side proof-of-work challenge (compute a SHA-256 hashcash puzzle in JS, POST it
+// to /__verify) — not something a plain server-side fetch can pass, and not worth running
+// a headless browser to poll ticker prices. Confirmed via manual curl testing that Yahoo's
+// /v8/finance/chart/ endpoint still works with a plain fetch, but ONLY with a
+// browser-like User-Agent header — bare `curl`/`fetch` UAs get a 429 on the very first
+// request, before any real volume. This is an undocumented, unofficial API Yahoo could
+// change or wall off without notice, same caveat as Stooq — if it goes the same way,
+// there's no realistic simple-fetch alternative left; the fallback would be a provider
+// requiring a free API key.
+const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
export interface StockQuote {
price: number;
@@ -16,35 +24,38 @@ export async function fetchQuotes(symbols: string[]): Promise();
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();
- 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 });
- }
+ // No batch endpoint used here — Yahoo's multi-symbol /v7/finance/quote requires a
+ // cookie+crumb handshake first, while /v8/finance/chart/ (single symbol, no
+ // crumb needed) is the one confirmed to work with just a User-Agent. One request per
+ // ticker per poll is trivial at the scale of a sidebar widget (a handful of tickers,
+ // polled every 15 minutes).
+ await Promise.all(
+ symbols.map(async (symbol) => {
+ try {
+ const res = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`, {
+ headers: { 'User-Agent': USER_AGENT }
+ });
+ if (!res.ok) throw new Error(`Yahoo returned ${res.status}`);
+ const data = (await res.json()) as {
+ chart: {
+ result: { meta: { regularMarketPrice: number; previousClose?: number; chartPreviousClose?: number } }[] | null;
+ error: { description: string } | null;
+ };
+ };
+ if (data.chart.error) throw new Error(data.chart.error.description);
+ const meta = data.chart.result?.[0]?.meta;
+ if (!meta) throw new Error('No data returned for this symbol');
+ const previousClose = meta.previousClose ?? meta.chartPreviousClose;
+ if (previousClose === undefined) throw new Error('No previous close available for this symbol');
+ results.set(symbol, {
+ price: meta.regularMarketPrice,
+ changePercent: ((meta.regularMarketPrice - previousClose) / previousClose) * 100
+ });
+ } catch (err) {
+ results.set(symbol, err instanceof Error ? err : new Error(String(err)));
+ }
+ })
+ );
return results;
}
diff --git a/backend/src/stocks/poller.ts b/backend/src/stocks/poller.ts
index e81dbc4..84ccb26 100644
--- a/backend/src/stocks/poller.ts
+++ b/backend/src/stocks/poller.ts
@@ -3,8 +3,9 @@ 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.
+// 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.
export async function pollStocksNow(): Promise {
const tickers = stocksDb.listStockTickers();
if (tickers.length === 0) return;
diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts
index fecb500..3df0497 100644
--- a/backend/src/storage/db/index.ts
+++ b/backend/src/storage/db/index.ts
@@ -308,9 +308,9 @@ export function migrate() {
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']
+ ['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 (?, ?, ?, ?, ?)'
@@ -320,6 +320,19 @@ export function migrate() {
});
}
+ // Stocks switched data providers from Stooq (walled off behind a proof-of-work
+ // challenge) to Yahoo Finance, which uses different symbol syntax — rewrites only
+ // rows still holding exactly one of the three old Stooq-format default symbols we
+ // ourselves seeded, never touching a symbol the admin typed in themselves.
+ const stooqToYahooSymbols: [string, string][] = [
+ ['^dji', '^DJI'],
+ ['^spx', '^GSPC'],
+ ['btcusd', 'BTC-USD']
+ ];
+ for (const [oldSymbol, newSymbol] of stooqToYahooSymbols) {
+ db.prepare('UPDATE stock_tickers SET symbol = ? WHERE symbol = ?').run(newSymbol, oldSymbol);
+ }
+
// 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
// filterable tag: it's the homepage view, now scoped to only the articles whose
diff --git a/frontend/src/lib/components/admin/StocksTab.svelte b/frontend/src/lib/components/admin/StocksTab.svelte
index 6b3dff0..dfb7c37 100644
--- a/frontend/src/lib/components/admin/StocksTab.svelte
+++ b/frontend/src/lib/components/admin/StocksTab.svelte
@@ -49,16 +49,16 @@
{/if}
From 0a50ec7b48725fd132ab326f9212bddc098416c2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 25 Jul 2026 00:32:14 +0000
Subject: [PATCH 6/6] Label stock ticker % change with its interval
The change % is since the previous trading day's close (Yahoo's
own definition, same as any standard quote), but nothing on screen
said so. Sidebar widget now shows a "today" label next to the
Stocks header; admin Stocks tab gets an explanatory hint above the
list.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
---
frontend/src/lib/components/admin/StocksTab.svelte | 1 +
.../src/lib/components/sidebar/StocksWidget.svelte | 14 +++++++++++++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/frontend/src/lib/components/admin/StocksTab.svelte b/frontend/src/lib/components/admin/StocksTab.svelte
index dfb7c37..b6a7cda 100644
--- a/frontend/src/lib/components/admin/StocksTab.svelte
+++ b/frontend/src/lib/components/admin/StocksTab.svelte
@@ -44,6 +44,7 @@
{tickers.length} tickers
(showAdd = !showAdd)}>+ New ticker
+
Price and % change are today's — since the previous trading day's close.
{#if showAdd}
diff --git a/frontend/src/lib/components/sidebar/StocksWidget.svelte b/frontend/src/lib/components/sidebar/StocksWidget.svelte
index 2fa56fa..9dc0f83 100644
--- a/frontend/src/lib/components/sidebar/StocksWidget.svelte
+++ b/frontend/src/lib/components/sidebar/StocksWidget.svelte
@@ -5,7 +5,10 @@