← Back to task

Commit 7f3b9cb7

commit 7f3b9cb7bfce8b55e515f6be1bf0cb917d570c4a
Author: Ben Sima <ben@bensima.com>
Date:   Sat Nov 29 18:17:07 2025

    Fix Recent Activity pagination and refresh bugs
    
    Build and tests pass. The implementation is complete:
    
    **Summary of changes to
    [Omni/Jr/Web.hs](file:///home/ben/omni/Omni/Jr/W
    
    1. Added `Data.Time.Clock.POSIX` import for `posixSecondsToUTCTime` and
    
    2. Replaced `RecentActivityPartial` with two new types:
       - `RecentActivityNewPartial` - for auto-refresh (prepends new items)
       - `RecentActivityMorePartial` - for "Load More" pagination
    
    3. Updated API routes:
       - `/partials/recent-activity-new?since=<unix-ts>` - returns new
       items - `/partials/recent-activity-more?offset=<n>` - returns
       paginated old
    
    4. Updated `HomePage` render with proper HTMX attributes:
       - Added `#recent-activity` ID and `data-newest-ts` attribute
       - Added `#activity-list` ID for the list container - Added
       `#activity-load-more` ID for the button - Updated hx-targets and
       hx-swap attributes
    
    5. Added `taskToUnixTs` helper function
    
    6. Created two new ToHtml instances with OOB updates for button
    replacem
    
    Task-Id: t-169

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 607d9d94..f65f0015 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -22,6 +22,7 @@ import qualified Data.Text as Text
 import qualified Data.Text.Lazy as LazyText
 import qualified Data.Text.Lazy.Encoding as LazyText
 import Data.Time (NominalDiffTime, UTCTime, defaultTimeLocale, diffUTCTime, formatTime, getCurrentTime)
+import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
 import qualified Lucid
 import qualified Lucid.Base as Lucid
 import qualified Network.Wai.Handler.Warp as Warp
@@ -106,7 +107,8 @@ type API =
     :<|> "tasks" :> Capture "id" Text :> "accept" :> PostRedirect
     :<|> "tasks" :> Capture "id" Text :> "reject" :> ReqBody '[FormUrlEncoded] RejectForm :> PostRedirect
     :<|> "tasks" :> Capture "id" Text :> "reset-retries" :> PostRedirect
-    :<|> "partials" :> "recent-activity" :> QueryParam "offset" Int :> Get '[Lucid.HTML] RecentActivityPartial
+    :<|> "partials" :> "recent-activity-new" :> QueryParam "since" Int :> Get '[Lucid.HTML] RecentActivityNewPartial
+    :<|> "partials" :> "recent-activity-more" :> QueryParam "offset" Int :> Get '[Lucid.HTML] RecentActivityMorePartial
     :<|> "partials" :> "ready-count" :> Get '[Lucid.HTML] ReadyCountPartial
     :<|> "partials"
       :> "task-list"
@@ -191,7 +193,9 @@ instance FromForm FactCreateForm where
 
 data EpicsPage = EpicsPage [TaskCore.Task] [TaskCore.Task]
 
-data RecentActivityPartial = RecentActivityPartial [TaskCore.Task] Int Bool UTCTime
+data RecentActivityNewPartial = RecentActivityNewPartial [TaskCore.Task] (Maybe Int)
+
+data RecentActivityMorePartial = RecentActivityMorePartial [TaskCore.Task] Int Bool
 
 newtype ReadyCountPartial = ReadyCountPartial Int
 
@@ -545,22 +549,26 @@ instance Lucid.ToHtml HomePage where
                 <| traverse_ renderListGroupItem (take 5 readyTasks)
 
           Lucid.h2_ "Recent Activity"
+          let newestTimestamp = maybe 0 taskToUnixTs (head recentTasks)
           Lucid.div_
             [ Lucid.class_ "recent-activity",
-              Lucid.makeAttribute "hx-get" "/partials/recent-activity",
-              Lucid.makeAttribute "hx-trigger" "every 10s"
+              Lucid.id_ "recent-activity",
+              Lucid.makeAttribute "data-newest-ts" (tshow newestTimestamp),
+              Lucid.makeAttribute "hx-get" "/partials/recent-activity-new",
+              Lucid.makeAttribute "hx-trigger" "every 10s",
+              Lucid.makeAttribute "hx-vals" "js:{since: this.dataset.newestTs}",
+              Lucid.makeAttribute "hx-target" "#activity-list",
+              Lucid.makeAttribute "hx-swap" "afterbegin"
             ]
             <| do
-              if null recentTasks
-                then Lucid.p_ [Lucid.class_ "empty-msg"] "No recent tasks."
-                else
-                  Lucid.div_ [Lucid.class_ "list-group"]
-                    <| traverse_ renderListGroupItem recentTasks
+              Lucid.div_ [Lucid.id_ "activity-list", Lucid.class_ "list-group"]
+                <| traverse_ renderListGroupItem recentTasks
               when hasMoreRecent
                 <| Lucid.button_
-                  [ Lucid.class_ "btn btn-secondary load-more-btn",
-                    Lucid.makeAttribute "hx-get" "/partials/recent-activity?offset=5",
-                    Lucid.makeAttribute "hx-target" "closest .recent-activity",
+                  [ Lucid.id_ "activity-load-more",
+                    Lucid.class_ "btn btn-secondary load-more-btn",
+                    Lucid.makeAttribute "hx-get" "/partials/recent-activity-more?offset=5",
+                    Lucid.makeAttribute "hx-target" "#activity-list",
                     Lucid.makeAttribute "hx-swap" "beforeend"
                   ]
                   "Load More"
@@ -1607,22 +1615,36 @@ instance Lucid.ToHtml StatsPage where
                     ""
               Lucid.span_ [Lucid.class_ "stats-count"] (Lucid.toHtml (tshow count))
 
-instance Lucid.ToHtml RecentActivityPartial where
+instance Lucid.ToHtml RecentActivityNewPartial where
   toHtmlRaw = Lucid.toHtml
-  toHtml (RecentActivityPartial recentTasks nextOffset hasMore _now) =
-    if null recentTasks
-      then Lucid.p_ [Lucid.class_ "empty-msg"] "No recent tasks."
-      else do
-        Lucid.div_ [Lucid.class_ "list-group"]
-          <| traverse_ renderListGroupItem recentTasks
-        when hasMore
-          <| Lucid.button_
-            [ Lucid.class_ "btn btn-secondary load-more-btn",
-              Lucid.makeAttribute "hx-get" ("/partials/recent-activity?offset=" <> tshow nextOffset),
-              Lucid.makeAttribute "hx-target" "closest .recent-activity",
-              Lucid.makeAttribute "hx-swap" "beforeend"
-            ]
-            "Load More"
+  toHtml (RecentActivityNewPartial tasks maybeNewestTs) = do
+    traverse_ renderListGroupItem tasks
+    case maybeNewestTs of
+      Nothing -> pure ()
+      Just ts ->
+        Lucid.div_
+          [ Lucid.id_ "recent-activity",
+            Lucid.makeAttribute "data-newest-ts" (tshow ts),
+            Lucid.makeAttribute "hx-swap-oob" "attributes:#recent-activity data-newest-ts"
+          ]
+          ""
+
+instance Lucid.ToHtml RecentActivityMorePartial where
+  toHtmlRaw = Lucid.toHtml
+  toHtml (RecentActivityMorePartial tasks nextOffset hasMore) = do
+    traverse_ renderListGroupItem tasks
+    if hasMore
+      then
+        Lucid.button_
+          [ Lucid.id_ "activity-load-more",
+            Lucid.class_ "btn btn-secondary load-more-btn",
+            Lucid.makeAttribute "hx-get" ("/partials/recent-activity-more?offset=" <> tshow nextOffset),
+            Lucid.makeAttribute "hx-target" "#activity-list",
+            Lucid.makeAttribute "hx-swap" "beforeend",
+            Lucid.makeAttribute "hx-swap-oob" "true"
+          ]
+          "Load More"
+      else Lucid.span_ [Lucid.id_ "activity-load-more", Lucid.makeAttribute "hx-swap-oob" "true"] ""
 
 instance Lucid.ToHtml ReadyCountPartial where
   toHtmlRaw = Lucid.toHtml
@@ -1873,7 +1895,8 @@ server =
     :<|> taskAcceptHandler
     :<|> taskRejectHandler
     :<|> taskResetRetriesHandler
-    :<|> recentActivityHandler
+    :<|> recentActivityNewHandler
+    :<|> recentActivityMoreHandler
     :<|> readyCountHandler
     :<|> taskListPartialHandler
     :<|> taskMetricsPartialHandler
@@ -2094,9 +2117,17 @@ server =
         TaskCore.updateTaskStatus tid TaskCore.Open []
       pure <| addHeader ("/tasks/" <> tid) NoContent
 
-    recentActivityHandler :: Maybe Int -> Servant.Handler RecentActivityPartial
-    recentActivityHandler maybeOffset = do
-      now <- liftIO getCurrentTime
+    recentActivityNewHandler :: Maybe Int -> Servant.Handler RecentActivityNewPartial
+    recentActivityNewHandler maybeSince = do
+      allTasks <- liftIO TaskCore.loadTasks
+      let sinceTime = maybe (posixSecondsToUTCTime 0) (posixSecondsToUTCTime <. fromIntegral) maybeSince
+          sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) allTasks
+          newTasks = filter (\t -> TaskCore.taskUpdatedAt t > sinceTime) sortedTasks
+          newestTs = maybe maybeSince (Just <. taskToUnixTs) (head newTasks)
+      pure (RecentActivityNewPartial newTasks newestTs)
+
+    recentActivityMoreHandler :: Maybe Int -> Servant.Handler RecentActivityMorePartial
+    recentActivityMoreHandler maybeOffset = do
       allTasks <- liftIO TaskCore.loadTasks
       let offset = fromMaybe 0 maybeOffset
           pageSize = 5
@@ -2104,7 +2135,7 @@ server =
           pageTasks = take pageSize <| drop offset sortedTasks
           hasMore = length sortedTasks > offset + pageSize
           nextOffset = offset + pageSize
-      pure (RecentActivityPartial pageTasks nextOffset hasMore now)
+      pure (RecentActivityMorePartial pageTasks nextOffset hasMore)
 
     readyCountHandler :: Servant.Handler ReadyCountPartial
     readyCountHandler = do
@@ -2128,6 +2159,9 @@ server =
       maybeRetry <- liftIO (TaskCore.getRetryContext tid)
       pure (TaskMetricsPartial tid activities maybeRetry now)
 
+taskToUnixTs :: TaskCore.Task -> Int
+taskToUnixTs t = round (utcTimeToPOSIXSeconds (TaskCore.taskUpdatedAt t))
+
 getReviewInfo :: Text -> IO ReviewInfo
 getReviewInfo tid = do
   maybeCommit <- findCommitForTask tid