commit 4d6895c2467fb5c6bf2b9cf034924372353286ac
Author: Coder Agent <coder@agents.omni>
Date: Thu Feb 19 14:13:27 2026
Newsreader: add slow feed digest view
Task-Id: t-646
diff --git a/Omni/Newsreader/Db.hs b/Omni/Newsreader/Db.hs
index dcc766b1..d991c4a7 100644
--- a/Omni/Newsreader/Db.hs
+++ b/Omni/Newsreader/Db.hs
@@ -17,6 +17,9 @@ module Omni.Newsreader.Db
getDbPath,
initDb,
+ -- * Feed Queries
+ getSlowFeeds,
+
-- * Schema Management
initSchema,
schemaVersion,
@@ -27,6 +30,8 @@ import Alpha
import Data.String (fromString)
import qualified Data.Text as T
import qualified Database.SQLite.Simple as SQL
+import qualified Omni.Newsreader.Article as Article
+import qualified Omni.Newsreader.Feed as Feed
import qualified System.Directory as Directory
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, (</>))
@@ -62,6 +67,48 @@ initDb = do
initSchema conn
putText "Database initialized successfully"
+-- | Get feeds that have gone stale for at least N days, with up to
+-- three of their most recent articles.
+--
+-- Includes:
+-- - Feeds with articles where the newest article is older than threshold.
+-- - Feeds with no articles that have never been fetched and are older than 7 days.
+getSlowFeeds :: SQL.Connection -> Int -> IO [(Feed.Feed, [Article.Article])]
+getSlowFeeds conn thresholdDays = do
+ feeds <- SQL.query conn slowFeedsQuery (SQL.Only thresholdModifier)
+ forM feeds <| \feed -> do
+ articles <- case Feed.feedId feed of
+ Nothing -> pure []
+ Just fid -> SQL.query conn slowFeedArticlesQuery (SQL.Only fid)
+ pure (feed, articles)
+ where
+ thresholdModifier = "-" <> tshow (max 0 thresholdDays) <> " days"
+
+-- | Query for stale feed candidates ordered by most stale first.
+slowFeedsQuery :: SQL.Query
+slowFeedsQuery =
+ "SELECT f.id, f.url, f.title, f.description, f.last_fetched, f.created_at \
+ \FROM feeds f \
+ \LEFT JOIN articles a ON a.feed_id = f.id \
+ \GROUP BY f.id \
+ \HAVING \
+ \ (COUNT(a.id) > 0 AND MAX(COALESCE(a.published_at, a.fetched_at)) < datetime('now', ?)) \
+ \ OR (COUNT(a.id) = 0 AND f.last_fetched IS NULL AND f.created_at < datetime('now', '-7 days')) \
+ \ORDER BY \
+ \ CASE \
+ \ WHEN COUNT(a.id) = 0 THEN f.created_at \
+ \ ELSE MAX(COALESCE(a.published_at, a.fetched_at)) \
+ \ END ASC"
+
+-- | Query for up to three recent articles from a feed.
+slowFeedArticlesQuery :: SQL.Query
+slowFeedArticlesQuery =
+ "SELECT id, feed_id, url, title, content, published_at, fetched_at, embedding_vector \
+ \FROM articles \
+ \WHERE feed_id = ? \
+ \ORDER BY published_at DESC NULLS LAST, fetched_at DESC \
+ \LIMIT 3"
+
-- | Current schema version.
-- Increment when making schema changes.
schemaVersion :: Int
diff --git a/Omni/Newsreader/Web.hs b/Omni/Newsreader/Web.hs
index 4514671d..6a4cd8eb 100644
--- a/Omni/Newsreader/Web.hs
+++ b/Omni/Newsreader/Web.hs
@@ -41,6 +41,7 @@ import qualified Network.Wai as Wai
import qualified Numeric
import qualified Omni.Newsreader.Article as Article
import qualified Omni.Newsreader.Cluster as Cluster
+import qualified Omni.Newsreader.Db as Db
import qualified Omni.Newsreader.Digest as Digest
import qualified Omni.Newsreader.Feed as Feed
import qualified Omni.Newsreader.Ingest as Ingest
@@ -67,6 +68,7 @@ app basePath conn req respond = do
("GET", ["topics"]) -> topicsPage p' conn respond
("GET", ["article", aid]) -> articlePage p' conn aid respond
("GET", ["feeds"]) -> feedsPage p' conn respond
+ ("GET", ["slow-feeds"]) -> slowFeedsPage p' conn respond
("GET", ["search"]) -> searchPage p' conn req respond
-- API
("GET", ["api", "articles"]) -> apiArticles conn req respond
@@ -85,6 +87,11 @@ p :: Text -> Text -> Text
p "" path = path
p base path = base <> path
+-- | Threshold for considering a feed "slow" if it has not surfaced in
+-- the main river recently.
+slowFeedThresholdDays :: Int
+slowFeedThresholdDays = 14
+
-- ============================================================================
-- HTML Pages
-- ============================================================================
@@ -106,6 +113,36 @@ riverPage p' conn req respond = do
then L.p_ [L.class_ "empty-state"] "No articles yet. Add some feeds to get started."
else forM_ articles <| \art -> articleCard p' feedMap art
+-- | Digest view for slow-moving feeds that haven't shown up recently.
+slowFeedsPage :: Pfx -> SQL.Connection -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
+slowFeedsPage p' conn respond = do
+ slowFeeds <- Db.getSlowFeeds conn slowFeedThresholdDays
+ let feedMap =
+ Map.fromList
+ [ (fid, feed)
+ | (feed, _) <- slowFeeds,
+ Just fid <- [Feed.feedId feed]
+ ]
+ respond <| htmlResponse HTTP.status200 <| shell "slow feeds" <| do
+ newsSubNav p'
+ L.div_ [L.class_ "container-narrow"] <| do
+ L.h1_ "Slow Feed Digest"
+ L.p_ [L.class_ "muted"]
+ <| L.toHtml ("Feeds with no recent articles in the last " <> tshow slowFeedThresholdDays <> " days." :: Text)
+ if null slowFeeds
+ then L.p_ [L.class_ "empty-state"] "No slow feeds right now."
+ else
+ forM_ slowFeeds <| \(feed, articles) -> do
+ let title = fromMaybe (Feed.feedUrl feed) (Feed.feedTitle feed)
+ L.section_ [L.class_ "nr-slow-feed"] <| do
+ L.h2_ [L.class_ "nr-slow-feed-title"] <| do
+ L.toHtml title
+ L.toHtml (" · " :: Text)
+ L.a_ [L.href_ (Feed.feedUrl feed), L.target_ "_blank"] "source ↗"
+ if null articles
+ then L.p_ [L.class_ "empty-state"] "No articles fetched yet."
+ else forM_ articles <| \art -> articleCard p' feedMap art
+
-- | Topics page: keyword-based clusters.
topicsPage :: Pfx -> SQL.Connection -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
topicsPage p' conn respond = do
@@ -334,6 +371,7 @@ newsSubNav :: Pfx -> L.Html ()
newsSubNav p' =
L.div_ [L.class_ "nr-subnav"] <| do
L.a_ [L.href_ (p' "/topics"), L.class_ "nr-subnav-link"] "topics"
+ L.a_ [L.href_ (p' "/slow-feeds"), L.class_ "nr-subnav-link"] "Slow Feeds"
L.a_ [L.href_ (p' "/feeds"), L.class_ "nr-subnav-link"] "feeds"
L.a_ [L.href_ (p' "/search"), L.class_ "nr-subnav-link"] "search"
@@ -381,6 +419,12 @@ newsCss =
".nr-article .content blockquote { border-left: 3px solid #5a9aa6; padding-left: 16px; color: #8f8886; margin: 1em 0; }",
".nr-article .content a { text-underline-offset: 3px; }",
"",
+ -- Slow feeds
+ ".nr-slow-feed { margin-bottom: 20px; }",
+ ".nr-slow-feed-title { font-size: 0.95rem; margin: 10px 0 6px; color: #8f8886; display: flex; gap: 6px; align-items: baseline; }",
+ ".nr-slow-feed-title a { font-size: 12px; color: #6b6568; }",
+ ".nr-slow-feed-title a:hover { color: #efd5c5; text-decoration: none; }",
+ "",
-- Topics
".nr-topic { margin-bottom: 20px; }",
".nr-topic h2 { font-size: 1rem; margin-bottom: 6px; }",