← Back to task

Commit 4d6895c2

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; }",