Commit Graph

100 Commits

Author SHA1 Message Date
Claude 12f8e2b525 Add "Reissue an article" panel to the Retention admin tab
Wires up POST /api/admin/articles/:id/reissue (added alongside the
blank-article fix) as a UI panel instead of requiring curl: paste an
article ID, it deletes the article and requeues its source items for
re-publish. Verified live in a browser against a real backend/DB —
both the success path and the "no article with that ID" 404 case.
2026-07-27 19:21:32 +00:00
Claude 23be5086c5 Fix blank-article publishing bug + add per-article reissue tool
A quantized model can occasionally return just the delimiter scaffold
("---TITLE---" / "---TAGS---") with no real headline or article text
in between — parseResult treated that as a structurally valid response
and published a blank article with empty title/body but real sources
and a hero image attached. synthesizeArticle/synthesizeRecap now throw
on an empty parsed body instead, so the existing catch-and-retry logic
in runSynthesisCycle leaves the cluster unclustered for the next tick
rather than ever inserting one of these.

Also adds POST /api/admin/articles/:id/reissue to fix articles already
published this way: the existing per-source reissue tool explicitly
refuses to touch a multi-source article, which this failure mode always
produces (an empty synthesis only happens on an actual multi-item
merge — a single-item cluster publishes verbatim with no AI call at
all), so there was no way to recover one without this.
2026-07-27 19:15:26 +00:00
Claude ee3123aa94 Fix tag click-through 404, show next-recap time, tag every published article
- New /tag/[slug] page + GET /api/tag/:slug backend route so clicking a
  tag chip lists every article carrying that tag, instead of 404ing.
- /event/[id] now shows when the tracked event's next AI recap is due,
  computed from lastRecapAt + recapIntervalHours (now exposed on
  GET /api/events).
- publishDirect (single-source and format-direct articles) now gets
  tags too, via a new lightweight extractTags() call in synthesis.ts —
  previously only AI-merged articles were tagged at all. Scheduler only
  offers the provider through when Ollama is reachable, and categories
  with AI explicitly disabled still stay tag-free.
2026-07-27 18:28:26 +00:00
Claude 9f4f1d1b71 Add pipeline backlog/throughput dashboard to admin Logs tab
Admins had no visibility into how many articles were queued for AI
synthesis or waiting out the hold-before-publish window, nor how fast
Ollama could clear that backlog. GET /api/admin/pipeline-stats reports
a live backlog snapshot (items awaiting embedding, clusters on hold vs.
ready, items still held) computed straight from the DB with no AI
calls, plus real Ollama generate() throughput (tokens/sec, in-flight
call) tracked from actual requests, and estimates minutes-to-clear from
recent generate() call durations. Surfaced as a stat-tile dashboard atop
the Logs tab.
2026-07-27 15:28:38 +00:00
Claude 2bb8463c09 Give direct-publish items their own tick so a slow AI backlog can't block them
Reported: after clearing all articles/media and rescanning every
source, items in "No AI" categories weren't publishing instantly like
they should.

Root cause: runSynthesisCycle bundled three unrelated jobs into one
function, all guarded by a single reentrancy lock (added earlier this
session to stop the AI-merge path from racing itself into duplicate
articles): (1) YouTube/Nitter/Telegram direct-publish, (2) "No AI"
category direct-publish, (3) embed/cluster/AI-merge. A mass rescan
produces a big backlog of slow generate() calls for (3) — each one
can run minutes on this CPU-only hardware — and since the whole
function shared one guard, a newly-ingested "No AI" item had to wait
for that entire backlog to drain before its own (fast, no-AI-needed)
publish step even got a turn.

Split into two independently-scheduled, independently-guarded ticks:
runDirectPublishCycle (source-type-driven + "No AI"-category items,
regardless of Ollama's reachability) and runSynthesisCycle (now only
the embed/cluster/merge path). They operate on disjoint item sets, so
running them "concurrently" is safe — no risk of the duplicate-publish
race the shared guard was originally added to prevent.

Verified directly: with a mock provider whose generate() call takes
3 seconds (standing in for a multi-minute real one), a "No AI"
category item published in 68ms — before the slow merge was even
close to finishing — while the merge itself still completed correctly
on its own schedule.
2026-07-27 14:49:09 +00:00
Claude afdb5ad036 Fix embed() calls silently timing out, dropping single-source items forever
Reported symptom: articles that never got AI-merged (single source,
nothing else to combine with) simply never published at all.

Root cause: the same default-5-minute-fetch-timeout bug fixed for
generate() earlier was never applied to embed(). Ollama serves one
inference request at a time (n_slots = 1) — an embed() call issued
while a slow generate() call is in flight has to wait in queue for
that same slot, and on this CPU-only hardware a generate() call can
easily run past 5 minutes. That wait alone was enough to trip Node's
default fetch timeout on the embed request.

embedPendingItems() catches that failure and just drops the item from
its result (logged, not thrown) — clusterItems() only ever sees items
that already have an embedding, so a dropped item never joins a
cluster, never gets assignCluster() called, and stays "unclustered"
forever, retried every cycle with the same failure for as long as
Ollama stays busy. An item that happened to embed during an idle
window still merges or publishes fine — which is exactly the split
reported: synthesized articles show up, standalone ones don't.

Fix: embed() now uses the same noTimeoutDispatcher already wired into
generate(). Verified the request completes correctly end-to-end
against a real HTTP server that delays its response.
2026-07-27 14:36:35 +00:00
Claude ecfcce9eb1 Have the model synthesize a real title instead of truncating the body
Every article title ended in "…" because there was never an actual
title — deriveTitle() just took the body's first paragraph and cut
it at 97 characters. The AI was never asked for a headline at all.

Both system prompts now ask for a response in three parts (headline,
then the article/recap, then tags), each separated by a delimiter.
parseResult() extracts all three; if the model doesn't follow the
format at all, it falls back to the old truncated-first-line
heuristic rather than breaking.

Delimiter matching is now a loose regex instead of an exact string —
production had already shown a small model reproducing "---TAGS---"
inexactly (e.g. "---\n\nTAGS---"), which the old exact-string split
missed entirely and leaked into the published body. Same tolerance
now applies to the new title delimiter.

publishCluster uses the synthesized title directly; publishEventRecap
uses it too, falling back to the previous "<event>: recap" format
only if the model returns an empty title.

Verified: exact-format output, sloppy-delimiter output, and
no-delimiter-at-all output all parse into sensible {title, body,
tags}; a full runSynthesisCycle pass against a mock provider
publishes an article with the real synthesized headline as its title.
2026-07-27 14:29:54 +00:00
Claude 1176bb4425 Add admin-configurable writing style for AI synthesis
The synthesis system prompts were previously the only "instructions"
the AI ever got, hardcoded and invisible from the admin panel — no
way to control tone, and no way to know what was actually being sent
without reading the source.

Adds a "Writing style" panel to the Merge tab: a preset dropdown
(Default/Casual/Formal) plus a free-text field for arbitrary
additional instructions (e.g. "keep paragraphs under 3 sentences").
Both are appended as an addendum to the existing base system prompts
in synthesis.ts — the structural rules (attribution, paragraph count,
tag format) are never overridden, only style on top of them. Applies
to AI-merged articles and event recaps; single-source items still
publish verbatim with no AI involved either way.

Backend: new global_settings.synthesis_style_preset (default) and
.synthesis_custom_instructions ('') columns, migrated in for existing
installs, threaded through settings.ts and into synthesizeArticle/
synthesizeRecap's system prompt construction.

Verified: settings round-trip through GET/PATCH /api/admin/settings
with correct defaults; a captured prompt confirms 'default' with no
custom text produces the exact original prompt unchanged, while
'casual' + custom text appends both correctly; migration against an
old-schema global_settings table adds both columns with correct
defaults.
2026-07-27 13:56:05 +00:00
Claude dae6a51db0 Skip the AI rewrite entirely for single-source clusters
A cluster of one item still went through synthesizeArticle to be
"lightly rewritten" — the only recent real-world example fabricated
a fake two-outlet merge out of one genuine article (see the
opaque-sourceId attribution fix). There's no actual synthesis to do
with one source, so the rewrite step only added risk (hallucinated
attribution, subtly altered facts) for no benefit.

priorityQueue.ts's runSynthesisCycle now routes a 1-item cluster to
publishDirect instead of publishCluster — same verbatim-text path
already used for youtube/nitter/telegram items and AI-disabled
categories. publishCluster is now only ever called with 2+ items, so
its doc comment and synthesis.ts's system prompt no longer reference
the single-source case.

Verified directly: a 1-item cluster now publishes with the original
body untouched and zero calls to the model, while a 2-item cluster
still goes through the AI merge path unchanged.
2026-07-27 13:41:18 +00:00
Claude 80fc31c844 Fix synthesis prompt labeling sources by opaque ID, causing hallucinated attribution
buildPrompt() labeled each source with item.sourceId — an internal
DB foreign key like "src-e8dbf745-..." — never the outlet's actual
name. The model had no real outlet to attribute to, so on a
single-source item it fell back to copying the illustrative example
names straight out of its own system prompt ("Reuters reported...",
"AP notes...") and fabricated a two-outlet merge out of one real
6abc article. The article's sources metadata (built separately from
real DB records) was correct the whole time; only the AI-written body
text invented sources that were never in the input.

synthesizeArticle now takes a sourceId->name map (built in publish.ts
via the same sources.getSource() lookup already used for the sources
metadata) and buildPrompt labels each entry with the real name.
SYSTEM_PROMPT no longer gives concrete example outlet names to copy —
it references "each source's exact name as given below" and
explicitly forbids attributing to any outlet not actually provided.

Verified directly: captured the exact prompt text sent to a mock
provider and confirmed it now contains the real source name and never
the raw internal id.
2026-07-27 13:37:45 +00:00
Claude a46ba5f4b0 Add 15-minute and 1-hour options to Hold before publish setting 2026-07-27 13:26:48 +00:00
Claude 683ca6c880 Fix scheduler racing itself into publishing duplicate articles
The synthesis tick fires every 60 seconds via setInterval with no
reentrancy guard. An item only gets marked "clustered" after its
article finishes synthesizing and publishing — so once generate()
calls started legitimately taking longer than 60 seconds (bigger
prompts + no client timeout, both from earlier fixes in this line of
work), the next tick would fire mid-generation, see the same item
still "unclustered", and synthesize + publish it again as a fresh,
differently-worded article. Repeated overlaps produced a run of
near-identical articles from the same single source item, seconds
apart.

everyTickSkippingOverlap() now guards all three scheduler intervals
(poll, synthesis, retention): a tick is skipped outright if the
previous invocation hasn't finished, rather than overlapping it.
Verified in isolation — a task slower than its own tick interval
never overlaps itself (measured max concurrency of 1).
2026-07-27 13:23:06 +00:00
Claude bfd7967bbb Fix synthesis fetch failures from Node's default 5-minute HTTP timeout
Nothing published for hours, every cluster failing with "fetch failed".
Ollama's own log showed the real story: requests being cancelled at
exactly 5m0s with a 500, not a model or server error. Node's global
fetch (undici) defaults to a 5-minute headers/body timeout, and CPU-only
prompt processing on the reference hardware (i5-6600K, no GPU, ~17
tok/s) legitimately takes longer than that once prompts carry full
article bodies instead of short blurbs (the previous fix in this same
line of work) — every generate() call past a few thousand tokens got
killed client-side before Ollama could finish.

OllamaProvider.generate() now passes a dedicated undici Agent with
headersTimeout/bodyTimeout disabled as the fetch dispatcher, so the
request runs as long as it actually needs to. Verified the failure
mode and the fix directly: a short-timeout dispatcher against a
deliberately slow server reproduces the exact same "fetch failed" /
UND_ERR_HEADERS_TIMEOUT error seen in production, and a zero-timeout
dispatcher completes the same slow request without issue.

undici was already a transitive dependency (via jsdom); added directly
since ollama-provider.ts now imports from it.
2026-07-27 13:10:58 +00:00
Claude 53b11124d5 Add per-category "No AI" toggle to skip clustering/synthesis
Category priority admin pane gains a "No AI" checkbox alongside
Private/More. When set, items whose source falls under that category
skip embedding, clustering, and LLM synthesis entirely — each
publishes on its own, verbatim from its source (title + body/summary),
the same direct-publish path YouTube/Nitter/Telegram items always use.

Backend: new categories.disable_ai column (default off, migrated in
for existing installs), threaded through categories.ts CRUD and the
POST /api/admin/categories + PATCH /api/admin/settings routes.
priorityQueue.ts's runSynthesisCycle now partitions items three ways
before clustering: source-type direct (youtube/nitter/telegram),
category-disabled direct (new), then whatever's left goes through the
normal embed/cluster/synthesize pipeline.

Tracked-event recaps are a separate, already-existing per-event
toggle (TrackedEvent.recapIntervalHours) since events aren't tied to
a single category — unaffected by this change.
2026-07-27 04:10:10 +00:00
Claude 63d510df57 Use full article body, not just the RSS blurb, in synthesis prompts
buildPrompt() only ever sent ContentItem.summary (a ~500-char RSS
description) to the model, never .body (the full article text when
the feed provides <content:encoded>) — even though publishDirect
already preferred body over summary for the no-AI-merge path. A
single-source cluster was effectively asking the model to "lightly
rewrite" a one-paragraph blurb, which it did almost verbatim,
producing a short repeated synopsis instead of an actual article.

Now mirrors publishDirect's item.body || item.summary fallback. Body
is already HTML-stripped at ingestion (ingestion/adapters/base.ts),
so no new sanitization needed. The per-entry character budget added
in the previous truncation fix now does real work here, since full
bodies can be much longer than summaries.
2026-07-27 03:52:49 +00:00
Claude c45d7d5acf Fix silent Ollama prompt truncation in synthesis pipeline
Ollama was defaulting to a 4096-token context (vs. the model's 32768
training context) and silently truncating any oversized prompt by
dropping content from the middle, with no error surfaced anywhere —
observed losing ~53% of a merge-cluster prompt in production. Two
prompt builders (buildPrompt/buildRecapPrompt) concatenated all
source summaries/article bodies with no size cap, so a cluster with
enough sources (or a recap spanning enough articles) could easily
exceed the window.

Fix: OllamaProvider.generate() now always sends explicit num_ctx/
num_predict options (sized for CPU-only inference — i5-6600K, no GPU,
~17 tok/s prompt processing) instead of leaving Ollama to pick a
default. synthesis.ts now caps prompt size itself before it ever
reaches Ollama, giving each source/article an equal character budget
and trimming individual entries rather than dropping whole ones off
the end — every source stays at least partially represented and
attributable. Trims are logged via the existing admin log stream
instead of failing silently.
2026-07-27 03:37:15 +00:00
Claude b30657a161 Default nitterInstanceUrl to the public nitter.net instance
An empty default meant every fresh install started with no Nitter
instance configured at all, so the first Nitter source's prefill had
nothing to draw from. Default to nitter.net instead, a widely-used
public instance, while leaving the field fully editable for anyone who
wants to point at a self-hosted mirror or another public instance.
2026-07-26 22:58:36 +00:00
Claude 2714441a62 Make the Nitter instance admin-configurable in Connections
Every Nitter source previously required the admin to know and paste a
full RSS URL, with no central place showing or setting which instance
they were actually using. Add a nitterInstanceUrl setting (Connections
tab) with a disclaimer against hammering public instances, and prefill
new Nitter sources' feed URL from it — each source's own URL remains
fully editable and is still what's actually polled.

Also relocates the existing tweet-media-mode and fxtwitter base URL
settings from Retention into this same Connections panel, so all
Nitter-related configuration lives in one place.
2026-07-26 22:20:07 +00:00
Claude a949a6a684 Fix /more silently hiding spillover categories/tracked items with no articles yet
Sections were only rendered when their preview fetch returned at least
one article, so a freshly-marked-spillover tracked item with nothing
published yet looked like it wasn't there at all. Now every spillover
section always renders its name/link, with a "No stories here yet."
placeholder — matching the empty state InfiniteFeed already uses on the
home and category feeds.
2026-07-26 19:48:25 +00:00
Claude fee0814fa3 Widen Sources row actions column so the delete button isn't clipped
Six icon buttons (star, edit, enable, clear, reissue, delete) had outgrown
the fixed 120px actions column added when reissue landed; the row's
overflow:hidden container clipped delete off the edge entirely.
2026-07-26 19:40:03 +00:00
Claude 717cdca44b Move widget reorder arrows next to the Active/Disabled badge 2026-07-26 19:11:09 +00:00
Claude 9e13e95f35 Simplify Widgets tab: state button instead of checkbox, add drag-free up/down reordering
Drops the checkbox/tooltip/hint-paragraph combo for a single click-to-toggle
Active/Disabled badge (same pattern as EventsTab's Active/Paused). Widget
order is now admin-sortable via up/down arrows and persisted as
widgetOrder, with Sidebar.svelte rendering widgets in that exact sequence
instead of a fixed hardcoded order.
2026-07-26 19:05:24 +00:00
Claude 9ca5bf566d Update Widgets tab copy to reflect that disabling actually stops polling
The "Hidden" tag, checkbox tooltip, and intro hint previously only described
sidebar visibility, which undersold what disabling now does (scheduler.ts
gates the actual poller on this flag too). Tag is now "Off", tooltips say
"stops polling and hides from the sidebar" / "resumes polling and shows in
the sidebar", and the intro hint spells out both effects plus the immediate
re-poll on re-enable. Bookmarks (no backend poller) gets its own accurate,
narrower wording via a new hasBackendPoller prop on WidgetSection instead of
inheriting a claim that doesn't apply to it.
2026-07-26 18:50:08 +00:00
Claude 2f083feb76 Gate widget pollers on their Widgets-tab enabled flag, not just sidebar visibility
Disabling Weather/Stocks/PoE2 in the Widgets tab previously only hid the
sidebar box — the backend kept polling on schedule regardless. scheduler.ts
now skips both the interval tick and the startup-immediate poll for any
widget currently disabled, so there's no outbound call for something nobody's
displaying. Re-enabling a widget (via PATCH /api/admin/settings) triggers an
immediate poll instead of waiting out its normal cadence, mirroring the
existing weather-location-change behavior.

Verified live: booted with poe2 disabled and confirmed no poll fired at
startup (previously always immediate), then re-enabled via PATCH and
confirmed the poll fired immediately.
2026-07-26 18:40:50 +00:00
Claude 175794cfc3 Simplify Tracked Items row to just the source count
Drop the keyword-match list and recap-status text from the row view — both
are still visible/editable in the edit panel, the row just needed the source
count.
2026-07-26 18:35:23 +00:00
Claude bfe776b331 Move More>> toggle to edit-only, show source count instead of names in list
The row view now shows "N sources active" instead of listing every attached
source's name, and the "More »" spillover checkbox only appears in the edit
panel — it's no longer duplicated inline on every row.
2026-07-26 18:22:53 +00:00
Claude 199177a3d2 Replace cadence lock with a real off/1h/3h/6h/12h/24h recap interval
Undoes the previous lock — some tracked items (a commit feed, a torrent feed)
are just organizing sources under a nav entry and never want an AI recap at
all, so the control needs to be both live and able to express "never."

Replaces the old cadence ('continuous'/'daily'/'hourly'/'custom') + cadenceTime
model with a single recapIntervalHours: 1 | 3 | 6 | 12 | 24 | null field (null
= off), which also simplifies eventsRecap.ts's isDue() down to one comparison.
Off by default for new items. The "Show in More »" toggle is now available
both inline on the row and inside the edit panel.
2026-07-26 18:16:34 +00:00
Claude 1068a4a5f7 Rename Tracked Events to Tracked Items, add More>> spillover, lock recap cadence
- Tracked items get the same isSpillover flag as categories: a per-item "More"
  checkbox collapses it into the "More »" nav tab instead of its own top-level
  tab, mirroring Category.isSpillover end to end (schema, CRUD, public API,
  nav partitioning, /more page).
- Renamed user-facing "Tracked events" text to "Tracked items" (admin tab,
  toolbar, buttons, placeholders, /event/:id subtitle) — the underlying
  TrackedEvent/events data model and routes are unchanged.
- Locked the per-item recap cadence dropdown (previously a live
  Continuous/Daily/Hourly picker) and moved it to its own labeled section with
  an explanatory hint: reading eventsRecap.ts confirmed Continuous and Hourly
  currently behave identically, so the live choice was more confusing than
  useful. New items are fixed to Continuous; existing items show their real
  cadence read-only.
2026-07-26 17:59:56 +00:00
Claude 20f9267594 Remove dead defaultPollIntervalMinutes global setting
Confirmed dead: every source's pollIntervalMinutes is NOT NULL DEFAULT 15 and
the admin form only ever submits 5/15/60, so sourcesDueForPoll()'s fallback to
the global default could never actually trigger. Removes the setting end to
end (schema, settings.ts, scheduler/poller/sources signatures, admin types,
mock backend fixture) and drops the now-single-item "Poll interval" panel from
the Sources & Merge tab.
2026-07-26 17:31:52 +00:00
Claude e7367bbb86 Combine Merge and Sources admin tabs, add explanation to Poll interval
Category priority and Sources now live at the top of the combined tab, each
collapsed by default behind the same CollapsibleSection pattern used for the
Widgets tab. Merge strictness and the rest of the synthesis settings sit below,
uncollapsed. The Poll interval panel's hint now explains that it's a fallback
only — every source sets its own poll interval, so this global default has no
effect in current usage.
2026-07-26 17:24:11 +00:00
Claude e6ac6cd061 Consolidate Weather/Stocks/Bookmarks/PoE2 into a single admin "Widgets" tab
Each widget now has an independent enable/disable checkbox that gates its
visibility in the sidebar (new global_settings.widgets columns + GET /api/widgets),
and is collapsed by default behind a WidgetSection wrapper so the tab stays
manageable as more widgets get added. The four existing tab components
(WeatherTab/StocksTab/BookmarksTab/Poe2Tab) are reused unmodified as each
section's expanded content.
2026-07-26 17:09:26 +00:00
Claude 24bf5afb07 Act on review findings: dead code, poe2_primary_currency_name, inefficiencies, custom source type
- Delete dead ArticleCard.svelte and five zero-call-site backend exports
  (getTagsByIds, unclusteredItemsForSources, itemsByCluster, deleteAllContentItems,
  latestArticleInThread)
- Drop poe2_primary_currency_name from the schema entirely (dev DB, no migration
  concerns)
- settings.ts: bind named params instead of 33 positional ?s to remove the
  reorder-and-silently-corrupt risk
- Remove the long-dead stooqToYahooSymbols unconditional startup rewrite
- Share direct-publish logic between runPassthroughCycle/runSynthesisCycle via a new
  publishItemsDirect helper, and cache sourcesDb.listSources() once per synthesis tick
  instead of a per-item getSource() call
- Remove the 'custom' source type (had no adapter of its own, aliased to api)
2026-07-26 16:45:46 +00:00
Claude c217545968 Add full program review: dead code, inefficiency, and UI consistency audit
Written report only, no code changes — catalogs findings across backend,
frontend, admin panel, and cross-system interactions for follow-up triage.
2026-07-26 16:05:55 +00:00
Claude d0bf6d18bc Auto-refresh sidebar data so a stale tab picks up backend polls
Verified the PoE2 24h-change computation itself is correct (simulated
24+ hours of real hourly polling against the actual recordRate/
rateAtOrBefore/markPolled functions — change24h starts populating
right on schedule at hour 24). The real problem: weather/stocks/poe2/
bookmarks are only fetched once when the layout first loads.
SvelteKit never re-runs a load() on its own — only on navigation or
explicit invalidation — so a browser tab left open never sees any of
those widgets update no matter how long the backend has been polling.

Named the layout's load() dependency ('app:sidebar') and invalidate
just that on a 5-minute timer, rather than invalidateAll() — which
would also re-run page-level loads and could reset things like the
home feed's own pagination state on every refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-26 00:00:11 +00:00
Claude 0ff8da8363 Weather: label which day each hourly forecast entry belongs to
The hourly strip covers the next 24h starting from "now", which
almost always crosses a day boundary partway through — a flat
12-hour-per-row grid gave no indication of where "today" ends and
"tomorrow" begins. Grouped the hours by calendar day instead, with a
"Today"/"Tomorrow"/weekday label above each group.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 23:07:51 +00:00
Claude 4066c48412 Sidebar: sync scroll instead of a separate inner scrollbar
The previous fix (max-height + overflow-y:auto) made every widget
reachable but required scrolling the sidebar itself independently of
the article column — two different scroll regions felt janky.

Replaced it with a three-layer structure: a plain spacer sized to the
sidebar's full natural height (reserving the right amount of page
scroll room), a sticky+clipped viewport box, and a content wrapper
translated upward via a scroll listener. The translation amount is
driven by how far the page has scrolled past the point where the
sidebar started sticking, clamped to the overflow amount — so
scrolling the article feed down reveals more of the sidebar in
lockstep, and scrolling back up reverses it, all through the single
page scrollbar. Short sidebars that already fit the viewport are
unaffected (reveal range is zero, so it behaves exactly like plain
sticky-to-top as before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 03:50:18 +00:00
Claude 4d9b11789f Cap sidebar height so every widget stays reachable
Weather + Stocks + PoE2 + Bookmarks stacked can be taller than the
viewport, especially on categories with a short article list. A
sticky element taller than the viewport gets its overflow glued
below the fold for the whole scroll (position: sticky doesn't clip
or scroll an oversized element, it just pins the top and leaves the
excess off-screen until the container's bottom edge finally arrives).
Capping the sidebar's height to the viewport and scrolling it
internally keeps every widget reachable instead of some of them being
permanently cut off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 03:34:46 +00:00
Claude b4f317e3d5 Fix 7-day forecast showing yesterday: UTC date-parsing bug
new Date("YYYY-MM-DD") parses bare date strings as UTC midnight per
spec, then toLocaleDateString() rendered that in the browser's local
timezone — for any zone behind UTC (all of the Americas), that rolls
the displayed date back a full day, making "today" look like it
already passed. Added parseDateOnly() (year/month/day constructor,
builds the date in local time instead of round-tripping through UTC)
and use it for the daily forecast headings.

Also switched the heading format to "Friday July, 24th, 2026" per
request, which needed the row layout to drop its fixed 40px width for
the old "Thu" abbreviation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 03:27:47 +00:00
Claude 07d7919f10 PoE2 widget: show "—" instead of nothing when 24h change is pending
Pairs need 24h of accumulated poll history before a change% exists
(self-computed, since poe.ninja doesn't expose that window) — the
sidebar widget was silently omitting the change badge in that case
while the admin tab already showed "24h —", so a freshly tracked (or
freshly deployed) pair looked broken instead of just pending. Also
fixed the up/down color classes defaulting a null change to green via
`?? 0` — the placeholder now renders neutral, not falsely positive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 03:18:17 +00:00
Claude 37ac84c414 PoE2: drop 1h/7d change, remove icons, link panel to poe.ninja
Simplify to 24h-only change per pair (both directions) since that's
all that's needed. Icons weren't adding anything to the display, so
they're gone from the schema, API, and both components. The sidebar
widget and admin tab's underlying data model both got smaller as a
result — fewer columns, fewer fields, less to render.

The sidebar panel now links out to poe.ninja's own currency page for
the currently tracked league (https://poe.ninja/poe2/economy/{league
slug}/currency), matching the existing pattern of Weather's widget
linking to its own detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 03:11:37 +00:00
Claude 0a57e687a5 Slow PoE2 poller to 1 hour — matches poe.ninja's own refresh rate
poe.ninja's overview data doesn't update faster than hourly, so
polling every 15 minutes was just re-fetching the same numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 02:54:23 +00:00
Claude a51387902c Rework PoE2 module: pairwise currency exchange rates, 1h/24h/7d change
PoE2's economy is inherently pairwise (Exalted vs Chaos, Divine vs
Exalted), not everything quoted in one reference currency, so the
watchlist now tracks admin-picked currency pairs and shows both
directions with 1h/24h/7d change. poe.ninja doesn't expose per-pair
rates or multiple change windows, so both are self-computed: any
pair's rate comes from dividing the two currencies' primaryValue
(same reference currency cancels out), and change% is derived from
our own poll-history snapshots rather than poe.ninja's fixed 7-day
sparkline. The inverse direction's change is exact closed-form math
from the forward change, not a sign-flip approximation.

The old single-currency watchlist schema can't be mapped onto pairs,
so migrate() drops and rebuilds poe2_watchlist when it detects the
old shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 02:48:28 +00:00
Claude 6d5d74b9bb Add PoE2 sidebar module: currency exchange watchlist
Tracks Path of Exile 2 currency values via poe.ninja's public economy
API, mirroring the existing Weather/Stocks sidebar modules. Always
follows the current challenge league (auto-detected, no admin picker).
Admin browses and picks currencies from a live list rather than typing
symbols, since currency ids are opaque. Change % is a 7-day window,
labeled accordingly to avoid the same interval ambiguity Stocks had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 01:54:55 +00:00
Claude 0a50ec7b48 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-25 00:32:14 +00:00
Claude 57c49f6858 Switch stock ticker source from Stooq to Yahoo Finance
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-24 23:47:36 +00:00
Claude b83640e980 Expand weather: feels-like, alerts, full current conditions, 2-row hourly
Sidebar widget now reads "Weather - <Location>" 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-24 22:52:53 +00:00
Claude a45a813a41 Add sidebar: Weather, Stocks, and Bookmarks widgets
A persistent right-hand sidebar (hidden only on /admin/**) with three
independent widgets: current-conditions weather (Open-Meteo, no API
key) linking to a new /weather forecast page; Dow/S&P/crypto/stock
tickers (Stooq, polled every 15 minutes); and admin-curated bookmark
links reusing the existing private-access lock per entry.

Weather and stocks are each self-contained modules (client + poller)
under backend/src/weather and backend/src/stocks, mirroring how
telegram/ is separated from the rest of the ingestion pipeline, so
either can be modified or removed independently. Bookmarks has no
external service, so it follows the plainer categories/events
DB-module + CRUD-route pattern instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-24 22:14:50 +00:00
Claude dbc922f3bd 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-24 17:40:36 +00:00
Claude 8e1ba82eb4 Add category nav spillover: "More »" tab + digest page
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-24 17:08:55 +00:00
Claude fbb879effe Rearchitect tracked events: a real displayed category, recap is additive
Previously a tracked event withheld all matching items from ever
publishing individually — they sat unclustered until the recap job fired,
which then bundled them into one article and discarded the raw items.
Visitors saw nothing from an event until a recap happened, and never saw
the underlying pieces at all.

Matching items now publish through the exact same pipeline as everything
else (individually or merged with same-story coverage via the normal
clustering pipeline), just tagged with the event's id via a new
publishDirect/publishCluster opts.eventId. An item whose source is
assigned to an event but doesn't match its keyword filter still
publishes normally, just without the tag, instead of being dropped.

The recap is now a periodic *additional* AI-written summary of everything
already published under the event since the last recap (new
synthesizeRecap prompt + publishEventRecap, reading MergedArticle bodies
rather than raw feed items) — never a replacement. Added a new
MergedArticle.isRecap flag so the two are visually distinguishable.

Frontend: a tracked event is now a real displayed category — new
/event/[id] page (mirrors /category/[name]), active events appended to
the site nav, and a "🧵 AI Recap" marker on recap articles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-23 23:25:36 +00:00