← Back to task

Commit d58b2f54

commit d58b2f547f474648edbacdf2ffdfdef6e019fe7d
Author: Ben Sima <ben@bensima.com>
Date:   Sat Nov 29 22:18:46 2025

    Add sorting options to task list pages
    
    The implementation is complete and all tests pass. Here's a summary
    of w
    
    1. Added `SortOrder` data type with 5 options: `SortNewest`,
    `SortOldest 2. Added helper functions: `parseSortOrder`,
    `sortOrderToParam`, `sortOr 3. Updated API routes to include `?sort=`
    query param for `/ready`, `/bl 4. Updated page data types to include
    `SortOrder` 5. Updated all list handlers to parse sort param and apply
    sorting 6. Added `sortDropdown` component that renders a dropdown
    with all sort 7. Added `sortOption` helper to render individual sort
    options with acti 8. Updated all `ToHtml` instances for list pages
    to render the sort drop
    
    1. Added `sortDropdownStyles` for the page header row and sort dropdown
    2. Added dark mode styles for the sort dropdown
    
    Task-Id: t-181

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 3380b93e..ece96ede 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -93,6 +93,42 @@ data TaskFilters = TaskFilters
 data TimeRange = Today | Week | Month | AllTime
   deriving (Show, Eq)
 
+data SortOrder
+  = SortNewest
+  | SortOldest
+  | SortUpdated
+  | SortPriorityHigh
+  | SortPriorityLow
+  deriving (Show, Eq)
+
+parseSortOrder :: Maybe Text -> SortOrder
+parseSortOrder (Just "oldest") = SortOldest
+parseSortOrder (Just "updated") = SortUpdated
+parseSortOrder (Just "priority-high") = SortPriorityHigh
+parseSortOrder (Just "priority-low") = SortPriorityLow
+parseSortOrder _ = SortNewest
+
+sortOrderToParam :: SortOrder -> Text
+sortOrderToParam SortNewest = "newest"
+sortOrderToParam SortOldest = "oldest"
+sortOrderToParam SortUpdated = "updated"
+sortOrderToParam SortPriorityHigh = "priority-high"
+sortOrderToParam SortPriorityLow = "priority-low"
+
+sortOrderLabel :: SortOrder -> Text
+sortOrderLabel SortNewest = "Newest First"
+sortOrderLabel SortOldest = "Oldest First"
+sortOrderLabel SortUpdated = "Recently Updated"
+sortOrderLabel SortPriorityHigh = "Priority (High to Low)"
+sortOrderLabel SortPriorityLow = "Priority (Low to High)"
+
+sortTasks :: SortOrder -> [TaskCore.Task] -> [TaskCore.Task]
+sortTasks SortNewest = List.sortBy (comparing (Down <. TaskCore.taskCreatedAt))
+sortTasks SortOldest = List.sortBy (comparing TaskCore.taskCreatedAt)
+sortTasks SortUpdated = List.sortBy (comparing (Down <. TaskCore.taskUpdatedAt))
+sortTasks SortPriorityHigh = List.sortBy (comparing TaskCore.taskPriority)
+sortTasks SortPriorityLow = List.sortBy (comparing (Down <. TaskCore.taskPriority))
+
 parseTimeRange :: Maybe Text -> TimeRange
 parseTimeRange (Just "today") = Today
 parseTimeRange (Just "week") = Week
@@ -170,22 +206,23 @@ computeMetricsFromActivities tasks activities =
 type API =
   QueryParam "range" Text :> Get '[Lucid.HTML] HomePage
     :<|> "style.css" :> Get '[CSS] LazyText.Text
-    :<|> "ready" :> Get '[Lucid.HTML] ReadyQueuePage
-    :<|> "blocked" :> Get '[Lucid.HTML] BlockedPage
-    :<|> "intervention" :> Get '[Lucid.HTML] InterventionPage
+    :<|> "ready" :> QueryParam "sort" Text :> Get '[Lucid.HTML] ReadyQueuePage
+    :<|> "blocked" :> QueryParam "sort" Text :> Get '[Lucid.HTML] BlockedPage
+    :<|> "intervention" :> QueryParam "sort" Text :> Get '[Lucid.HTML] InterventionPage
     :<|> "stats" :> QueryParam "epic" Text :> Get '[Lucid.HTML] StatsPage
     :<|> "tasks"
       :> QueryParam "status" Text
       :> QueryParam "priority" Text
       :> QueryParam "namespace" Text
       :> QueryParam "type" Text
+      :> QueryParam "sort" Text
       :> Get '[Lucid.HTML] TaskListPage
     :<|> "kb" :> Get '[Lucid.HTML] KBPage
     :<|> "kb" :> "create" :> ReqBody '[FormUrlEncoded] FactCreateForm :> PostRedirect
     :<|> "kb" :> Capture "id" Int :> Get '[Lucid.HTML] FactDetailPage
     :<|> "kb" :> Capture "id" Int :> "edit" :> ReqBody '[FormUrlEncoded] FactEditForm :> PostRedirect
     :<|> "kb" :> Capture "id" Int :> "delete" :> PostRedirect
-    :<|> "epics" :> Get '[Lucid.HTML] EpicsPage
+    :<|> "epics" :> QueryParam "sort" Text :> Get '[Lucid.HTML] EpicsPage
     :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
     :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> Post '[Lucid.HTML] StatusBadgePartial
     :<|> "tasks" :> Capture "id" Text :> "description" :> "view" :> Get '[Lucid.HTML] DescriptionViewPartial
@@ -206,6 +243,7 @@ type API =
       :> QueryParam "priority" Text
       :> QueryParam "namespace" Text
       :> QueryParam "type" Text
+      :> QueryParam "sort" Text
       :> Get '[Lucid.HTML] TaskListPartial
     :<|> "partials" :> "task" :> Capture "id" Text :> "metrics" :> Get '[Lucid.HTML] TaskMetricsPartial
 
@@ -219,13 +257,13 @@ instance MimeRender CSS LazyText.Text where
 
 data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] Bool TaskCore.AggregatedMetrics TimeRange UTCTime
 
-data ReadyQueuePage = ReadyQueuePage [TaskCore.Task] UTCTime
+data ReadyQueuePage = ReadyQueuePage [TaskCore.Task] SortOrder UTCTime
 
-data BlockedPage = BlockedPage [TaskCore.Task] UTCTime
+data BlockedPage = BlockedPage [TaskCore.Task] SortOrder UTCTime
 
-data InterventionPage = InterventionPage [TaskCore.Task] UTCTime
+data InterventionPage = InterventionPage [TaskCore.Task] SortOrder UTCTime
 
-data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters UTCTime
+data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters SortOrder UTCTime
 
 data TaskDetailPage
   = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) [GitCommit] (Maybe TaskCore.AggregatedMetrics) UTCTime
@@ -281,7 +319,7 @@ instance FromForm FactCreateForm where
     let confidence = fromRight "0.8" (lookupUnique "confidence" form)
     Right (FactCreateForm project content files confidence)
 
-data EpicsPage = EpicsPage [TaskCore.Task] [TaskCore.Task]
+data EpicsPage = EpicsPage [TaskCore.Task] [TaskCore.Task] SortOrder
 
 data RecentActivityNewPartial = RecentActivityNewPartial [TaskCore.Task] (Maybe Int)
 
@@ -542,6 +580,26 @@ statusBadge status =
         TaskCore.Done -> ("badge badge-done", "Done")
    in Lucid.span_ [Lucid.class_ cls] label
 
+sortDropdown :: (Monad m) => Text -> SortOrder -> Lucid.HtmlT m ()
+sortDropdown basePath currentSort =
+  Lucid.div_ [Lucid.class_ "sort-dropdown"] <| do
+    Lucid.span_ [Lucid.class_ "sort-label"] "Sort:"
+    Lucid.div_ [Lucid.class_ "sort-dropdown-wrapper navbar-dropdown"] <| do
+      Lucid.button_ [Lucid.class_ "sort-dropdown-btn navbar-dropdown-btn"]
+        <| Lucid.toHtml (sortOrderLabel currentSort <> " ▾")
+      Lucid.div_ [Lucid.class_ "sort-dropdown-content navbar-dropdown-content"] <| do
+        sortOption basePath SortNewest currentSort
+        sortOption basePath SortOldest currentSort
+        sortOption basePath SortUpdated currentSort
+        sortOption basePath SortPriorityHigh currentSort
+        sortOption basePath SortPriorityLow currentSort
+
+sortOption :: (Monad m) => Text -> SortOrder -> SortOrder -> Lucid.HtmlT m ()
+sortOption basePath option currentSort =
+  let cls = "sort-dropdown-item navbar-dropdown-item" <> if option == currentSort then " active" else ""
+      href = basePath <> "?sort=" <> sortOrderToParam option
+   in Lucid.a_ [Lucid.href_ href, Lucid.class_ cls] (Lucid.toHtml (sortOrderLabel option))
+
 multiColorProgressBar :: (Monad m) => TaskCore.TaskStats -> Lucid.HtmlT m ()
 multiColorProgressBar stats =
   let total = TaskCore.totalTasks stats
@@ -790,26 +848,30 @@ instance Lucid.ToHtml HomePage where
 
 instance Lucid.ToHtml ReadyQueuePage where
   toHtmlRaw = Lucid.toHtml
-  toHtml (ReadyQueuePage tasks _now) =
+  toHtml (ReadyQueuePage tasks currentSort _now) =
     let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Ready Queue" Nothing]
      in Lucid.doctypehtml_ <| do
           pageHead "Ready Queue - Jr"
           pageBodyWithCrumbs crumbs <| do
             Lucid.div_ [Lucid.class_ "container"] <| do
-              Lucid.h1_ <| Lucid.toHtml ("Ready Queue (" <> tshow (length tasks) <> " tasks)")
+              Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+                Lucid.h1_ <| Lucid.toHtml ("Ready Queue (" <> tshow (length tasks) <> " tasks)")
+                sortDropdown "/ready" currentSort
               if null tasks
                 then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks are ready for work."
                 else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks
 
 instance Lucid.ToHtml BlockedPage where
   toHtmlRaw = Lucid.toHtml
-  toHtml (BlockedPage tasks _now) =
+  toHtml (BlockedPage tasks currentSort _now) =
     let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Blocked" Nothing]
      in Lucid.doctypehtml_ <| do
           pageHead "Blocked Tasks - Jr"
           pageBodyWithCrumbs crumbs <| do
             Lucid.div_ [Lucid.class_ "container"] <| do
-              Lucid.h1_ <| Lucid.toHtml ("Blocked Tasks (" <> tshow (length tasks) <> " tasks)")
+              Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+                Lucid.h1_ <| Lucid.toHtml ("Blocked Tasks (" <> tshow (length tasks) <> " tasks)")
+                sortDropdown "/blocked" currentSort
               Lucid.p_ [Lucid.class_ "info-msg"] "Tasks with unmet blocking dependencies."
               if null tasks
                 then Lucid.p_ [Lucid.class_ "empty-msg"] "No blocked tasks."
@@ -817,13 +879,15 @@ instance Lucid.ToHtml BlockedPage where
 
 instance Lucid.ToHtml InterventionPage where
   toHtmlRaw = Lucid.toHtml
-  toHtml (InterventionPage tasks _now) =
+  toHtml (InterventionPage tasks currentSort _now) =
     let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Needs Intervention" Nothing]
      in Lucid.doctypehtml_ <| do
           pageHead "Needs Intervention - Jr"
           pageBodyWithCrumbs crumbs <| do
             Lucid.div_ [Lucid.class_ "container"] <| do
-              Lucid.h1_ <| Lucid.toHtml ("Needs Intervention (" <> tshow (length tasks) <> " tasks)")
+              Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+                Lucid.h1_ <| Lucid.toHtml ("Needs Intervention (" <> tshow (length tasks) <> " tasks)")
+                sortDropdown "/intervention" currentSort
               Lucid.p_ [Lucid.class_ "info-msg"] "Tasks that have failed 3+ times and need human help."
               if null tasks
                 then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks need intervention."
@@ -1031,13 +1095,15 @@ instance Lucid.ToHtml FactDetailPage where
 
 instance Lucid.ToHtml EpicsPage where
   toHtmlRaw = Lucid.toHtml
-  toHtml (EpicsPage epics allTasks) =
+  toHtml (EpicsPage epics allTasks currentSort) =
     let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Epics" Nothing]
      in Lucid.doctypehtml_ <| do
           pageHead "Epics - Jr"
           pageBodyWithCrumbs crumbs <| do
             Lucid.div_ [Lucid.class_ "container"] <| do
-              Lucid.h1_ <| Lucid.toHtml ("Epics (" <> tshow (length epics) <> ")")
+              Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+                Lucid.h1_ <| Lucid.toHtml ("Epics (" <> tshow (length epics) <> ")")
+                sortDropdown "/epics" currentSort
               Lucid.p_ [Lucid.class_ "info-msg"] "All epics (large, multi-task projects)."
               if null epics
                 then Lucid.p_ [Lucid.class_ "empty-msg"] "No epics found."
@@ -1112,13 +1178,15 @@ getDescendants allTasks parentId =
 
 instance Lucid.ToHtml TaskListPage where
   toHtmlRaw = Lucid.toHtml
-  toHtml (TaskListPage tasks filters _now) =
+  toHtml (TaskListPage tasks filters currentSort _now) =
     let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" Nothing]
      in Lucid.doctypehtml_ <| do
           pageHead "Tasks - Jr"
           pageBodyWithCrumbs crumbs <| do
             Lucid.div_ [Lucid.class_ "container"] <| do
-              Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")")
+              Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+                Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")")
+                sortDropdown "/tasks" currentSort
 
               Lucid.div_ [Lucid.class_ "filter-form"] <| do
                 Lucid.form_
@@ -2150,26 +2218,29 @@ server =
           hasMoreRecent = length filteredTasks > 5
       pure (HomePage stats readyTasks recentTasks hasMoreRecent globalMetrics range now)
 
-    readyQueueHandler :: Servant.Handler ReadyQueuePage
-    readyQueueHandler = do
+    readyQueueHandler :: Maybe Text -> Servant.Handler ReadyQueuePage
+    readyQueueHandler maybeSortText = do
       now <- liftIO getCurrentTime
       readyTasks <- liftIO TaskCore.getReadyTasks
-      let sortedTasks = List.sortBy (compare `on` TaskCore.taskPriority) readyTasks
-      pure (ReadyQueuePage sortedTasks now)
+      let sortOrder = parseSortOrder maybeSortText
+          sortedTasks = sortTasks sortOrder readyTasks
+      pure (ReadyQueuePage sortedTasks sortOrder now)
 
-    blockedHandler :: Servant.Handler BlockedPage
-    blockedHandler = do
+    blockedHandler :: Maybe Text -> Servant.Handler BlockedPage
+    blockedHandler maybeSortText = do
       now <- liftIO getCurrentTime
       blockedTasks <- liftIO TaskCore.getBlockedTasks
-      let sortedTasks = List.sortBy (compare `on` TaskCore.taskPriority) blockedTasks
-      pure (BlockedPage sortedTasks now)
+      let sortOrder = parseSortOrder maybeSortText
+          sortedTasks = sortTasks sortOrder blockedTasks
+      pure (BlockedPage sortedTasks sortOrder now)
 
-    interventionHandler :: Servant.Handler InterventionPage
-    interventionHandler = do
+    interventionHandler :: Maybe Text -> Servant.Handler InterventionPage
+    interventionHandler maybeSortText = do
       now <- liftIO getCurrentTime
       interventionTasks <- liftIO TaskCore.getInterventionTasks
-      let sortedTasks = List.sortBy (compare `on` TaskCore.taskPriority) interventionTasks
-      pure (InterventionPage sortedTasks now)
+      let sortOrder = parseSortOrder maybeSortText
+          sortedTasks = sortTasks sortOrder interventionTasks
+      pure (InterventionPage sortedTasks sortOrder now)
 
     statsHandler :: Maybe Text -> Servant.Handler StatsPage
     statsHandler maybeEpic = do
@@ -2177,16 +2248,17 @@ server =
       stats <- liftIO <| TaskCore.getTaskStats epicId
       pure (StatsPage stats epicId)
 
-    taskListHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPage
-    taskListHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText = do
+    taskListHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPage
+    taskListHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText maybeSortText = do
       now <- liftIO getCurrentTime
       allTasks <- liftIO TaskCore.loadTasks
       let maybeStatus = parseStatus =<< emptyToNothing maybeStatusText
           maybePriority = parsePriority =<< emptyToNothing maybePriorityText
           maybeType = parseTaskType =<< emptyToNothing maybeTypeText
           filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) maybeType
-          filteredTasks = applyFilters filters allTasks
-      pure (TaskListPage filteredTasks filters now)
+          sortOrder = parseSortOrder maybeSortText
+          filteredTasks = sortTasks sortOrder (applyFilters filters allTasks)
+      pure (TaskListPage filteredTasks filters sortOrder now)
 
     kbHandler :: Servant.Handler KBPage
     kbHandler = do
@@ -2220,12 +2292,13 @@ server =
       liftIO (Fact.deleteFact fid)
       pure <| addHeader "/kb" NoContent
 
-    epicsHandler :: Servant.Handler EpicsPage
-    epicsHandler = do
+    epicsHandler :: Maybe Text -> Servant.Handler EpicsPage
+    epicsHandler maybeSortText = do
       allTasks <- liftIO TaskCore.loadTasks
       let epicTasks = filter (\t -> TaskCore.taskType t == TaskCore.Epic) allTasks
-          sortedEpics = List.sortBy (compare `on` TaskCore.taskPriority) epicTasks
-      pure (EpicsPage sortedEpics allTasks)
+          sortOrder = parseSortOrder maybeSortText
+          sortedEpics = sortTasks sortOrder epicTasks
+      pure (EpicsPage sortedEpics allTasks sortOrder)
 
     parseStatus :: Text -> Maybe TaskCore.Status
     parseStatus = readMaybe <. Text.unpack
@@ -2394,14 +2467,15 @@ server =
       readyTasks <- liftIO TaskCore.getReadyTasks
       pure (ReadyCountPartial (length readyTasks))
 
-    taskListPartialHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPartial
-    taskListPartialHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText = do
+    taskListPartialHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPartial
+    taskListPartialHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText maybeSortText = do
       allTasks <- liftIO TaskCore.loadTasks
       let maybeStatus = parseStatus =<< emptyToNothing maybeStatusText
           maybePriority = parsePriority =<< emptyToNothing maybePriorityText
           maybeType = parseTaskType =<< emptyToNothing maybeTypeText
           filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) maybeType
-          filteredTasks = applyFilters filters allTasks
+          sortOrder = parseSortOrder maybeSortText
+          filteredTasks = sortTasks sortOrder (applyFilters filters allTasks)
       pure (TaskListPartial filteredTasks)
 
     taskMetricsPartialHandler :: Text -> Servant.Handler TaskMetricsPartial
diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs
index e0cc51e7..02352ec4 100644
--- a/Omni/Jr/Web/Style.hs
+++ b/Omni/Jr/Web/Style.hs
@@ -37,6 +37,7 @@ stylesheet = do
   retryBannerStyles
   taskMetaStyles
   timeFilterStyles
+  sortDropdownStyles
   responsiveStyles
   darkModeStyles
 
@@ -1217,6 +1218,52 @@ timeFilterStyles = do
     backgroundColor "#0055aa"
     borderColor "#0055aa"
 
+sortDropdownStyles :: Css
+sortDropdownStyles = do
+  ".page-header-row" ? do
+    display flex
+    alignItems center
+    justifyContent spaceBetween
+    flexWrap Flexbox.wrap
+    Stylesheet.key "gap" ("12px" :: Text)
+    marginBottom (px 8)
+  ".page-header-row" |> "h1" ? do
+    margin (px 0) (px 0) (px 0) (px 0)
+  ".sort-dropdown" ? do
+    display flex
+    alignItems center
+    Stylesheet.key "gap" ("6px" :: Text)
+    fontSize (px 13)
+  ".sort-label" ? do
+    color "#6b7280"
+    fontWeight (weight 500)
+  ".sort-dropdown-wrapper" ? do
+    position relative
+  ".sort-dropdown-btn" ? do
+    padding (px 4) (px 10) (px 4) (px 10)
+    fontSize (px 13)
+    fontWeight (weight 500)
+    border (px 1) solid "#d0d0d0"
+    borderRadius (px 4) (px 4) (px 4) (px 4)
+    backgroundColor white
+    color "#374151"
+    cursor pointer
+    transition "all" (ms 150) ease (sec 0)
+    whiteSpace nowrap
+  ".sort-dropdown-btn" # hover ? do
+    borderColor "#999"
+    backgroundColor "#f3f4f6"
+  ".sort-dropdown-content" ? do
+    minWidth (px 160)
+    right (px 0)
+    left auto
+  ".sort-dropdown-item" ? do
+    padding (px 8) (px 12) (px 8) (px 12)
+    fontSize (px 13)
+  ".sort-dropdown-item.active" ? do
+    backgroundColor "#e0f2fe"
+    fontWeight (weight 600)
+
 taskMetaStyles :: Css
 taskMetaStyles = do
   ".task-meta" ? do
@@ -1489,6 +1536,16 @@ darkModeStyles =
     ".time-filter-btn.active" # hover ? do
       backgroundColor "#2563eb"
       borderColor "#2563eb"
+    ".sort-label" ? color "#9ca3af"
+    ".sort-dropdown-btn" ? do
+      backgroundColor "#374151"
+      borderColor "#4b5563"
+      color "#d1d5db"
+    ".sort-dropdown-btn" # hover ? do
+      backgroundColor "#4b5563"
+      borderColor "#6b7280"
+    ".sort-dropdown-item.active" ? do
+      backgroundColor "#1e3a5f"
     -- Responsive dark mode: dropdown content needs background on mobile
     query Media.screen [Media.maxWidth (px 600)] <| do
       ".navbar-dropdown-content" ? do