← Back to task

Commit e709ef77

commit e709ef77c4158a66ba3e572e52ad866d4855fc21
Author: Ben Sima <ben@bensima.com>
Date:   Wed Nov 26 09:34:11 2025

    Add task list filters (status, priority, namespace)
    
    The build passes. Let me also verify that the filter functionality
    is co
    
    1. **API endpoint with query params** (lines 46-49): ✅ Already
    has `Quer 2. **Handler** (lines 776-781): ✅ Already receives
    and applies filters 3. **Filter form in HTML** (lines 295-330):
    ✅ Already has form with drop 4. **Filter logic** (lines 787-807):
    ✅ Already applies AND-combined filt
    
    The implementation is complete and the hlint suggestions have been
    addre
    
    Task-Id: t-1o2g8gugkr1.8

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 4fd77b6a..c32c716c 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -32,10 +32,21 @@ type PostRedirect = Verb 'POST 303 '[Lucid.HTML] (Headers '[Header "Location" Te
 defaultPort :: Warp.Port
 defaultPort = 8080
 
+data TaskFilters = TaskFilters
+  { filterStatus :: Maybe TaskCore.Status,
+    filterPriority :: Maybe TaskCore.Priority,
+    filterNamespace :: Maybe Text
+  }
+  deriving (Show, Eq)
+
 type API =
   Get '[Lucid.HTML] HomePage
     :<|> "ready" :> Get '[Lucid.HTML] ReadyQueuePage
-    :<|> "tasks" :> Get '[Lucid.HTML] TaskListPage
+    :<|> "tasks"
+      :> QueryParam "status" TaskCore.Status
+      :> QueryParam "priority" TaskCore.Priority
+      :> QueryParam "namespace" Text
+      :> Get '[Lucid.HTML] TaskListPage
     :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
     :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> PostRedirect
     :<|> "tasks" :> Capture "id" Text :> "review" :> Get '[Lucid.HTML] TaskReviewPage
@@ -46,7 +57,7 @@ data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task]
 
 newtype ReadyQueuePage = ReadyQueuePage [TaskCore.Task]
 
-newtype TaskListPage = TaskListPage [TaskCore.Task]
+data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters
 
 data TaskDetailPage
   = TaskDetailFound TaskCore.Task [TaskCore.Task]
@@ -267,7 +278,7 @@ instance Lucid.ToHtml ReadyQueuePage where
 
 instance Lucid.ToHtml TaskListPage where
   toHtmlRaw = Lucid.toHtml
-  toHtml (TaskListPage tasks) =
+  toHtml (TaskListPage tasks filters) =
     Lucid.doctypehtml_ <| do
       Lucid.head_ <| do
         Lucid.title_ "Tasks - Jr Web UI"
@@ -278,16 +289,84 @@ instance Lucid.ToHtml TaskListPage where
           ]
         Lucid.style_ styles
       Lucid.body_ <| do
-        Lucid.h1_ "Tasks"
-        Lucid.div_ [Lucid.class_ "task-list"] <| do
-          traverse_ renderTask tasks
+        Lucid.p_ <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard"
+        Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")")
+
+        Lucid.div_ [Lucid.class_ "filter-form"] <| do
+          Lucid.form_ [Lucid.method_ "GET", Lucid.action_ "/tasks"] <| do
+            Lucid.div_ [Lucid.class_ "filter-row"] <| do
+              Lucid.div_ [Lucid.class_ "filter-group"] <| do
+                Lucid.label_ [Lucid.for_ "status"] "Status:"
+                Lucid.select_ [Lucid.name_ "status", Lucid.id_ "status", Lucid.class_ "filter-select"] <| do
+                  Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterStatus filters)) "All"
+                  statusFilterOption TaskCore.Open (filterStatus filters)
+                  statusFilterOption TaskCore.InProgress (filterStatus filters)
+                  statusFilterOption TaskCore.Review (filterStatus filters)
+                  statusFilterOption TaskCore.Approved (filterStatus filters)
+                  statusFilterOption TaskCore.Done (filterStatus filters)
+
+              Lucid.div_ [Lucid.class_ "filter-group"] <| do
+                Lucid.label_ [Lucid.for_ "priority"] "Priority:"
+                Lucid.select_ [Lucid.name_ "priority", Lucid.id_ "priority", Lucid.class_ "filter-select"] <| do
+                  Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterPriority filters)) "All"
+                  priorityFilterOption TaskCore.P0 (filterPriority filters)
+                  priorityFilterOption TaskCore.P1 (filterPriority filters)
+                  priorityFilterOption TaskCore.P2 (filterPriority filters)
+                  priorityFilterOption TaskCore.P3 (filterPriority filters)
+                  priorityFilterOption TaskCore.P4 (filterPriority filters)
+
+              Lucid.div_ [Lucid.class_ "filter-group"] <| do
+                Lucid.label_ [Lucid.for_ "namespace"] "Namespace:"
+                Lucid.input_
+                  [ Lucid.type_ "text",
+                    Lucid.name_ "namespace",
+                    Lucid.id_ "namespace",
+                    Lucid.class_ "filter-input",
+                    Lucid.placeholder_ "e.g. Omni/Jr",
+                    Lucid.value_ (fromMaybe "" (filterNamespace filters))
+                  ]
+
+              Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter"
+              Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "clear-btn"] "Clear"
+
+        if null tasks
+          then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters."
+          else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTask tasks
     where
+      maybeSelected :: (Eq a) => Maybe a -> Maybe a -> [Lucid.Attribute]
+      maybeSelected opt current = [Lucid.selected_ "selected" | opt == current]
+
+      statusFilterOption :: (Monad m) => TaskCore.Status -> Maybe TaskCore.Status -> Lucid.HtmlT m ()
+      statusFilterOption s current =
+        let attrs = [Lucid.value_ (tshow s)] <> [Lucid.selected_ "selected" | Just s == current]
+         in Lucid.option_ attrs (Lucid.toHtml (tshow s))
+
+      priorityFilterOption :: (Monad m) => TaskCore.Priority -> Maybe TaskCore.Priority -> Lucid.HtmlT m ()
+      priorityFilterOption p current =
+        let attrs = [Lucid.value_ (tshow p)] <> [Lucid.selected_ "selected" | Just p == current]
+         in Lucid.option_ attrs (Lucid.toHtml (tshow p))
+
       styles :: Text
       styles =
         "* { box-sizing: border-box; } \
         \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \
-        \       margin: 0; padding: 16px; background: #f5f5f5; } \
-        \h1 { margin: 0 0 16px 0; } \
+        \       margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \
+        \h1 { margin: 16px 0; } \
+        \.filter-form { background: white; border-radius: 8px; padding: 16px; \
+        \               box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; } \
+        \.filter-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; } \
+        \.filter-group { display: flex; flex-direction: column; gap: 4px; } \
+        \.filter-group label { font-size: 12px; color: #6b7280; font-weight: 500; } \
+        \.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 4px; \
+        \                                font-size: 14px; min-width: 120px; } \
+        \.filter-input { min-width: 150px; } \
+        \.filter-btn { padding: 8px 16px; background: #0066cc; color: white; border: none; \
+        \              border-radius: 4px; font-size: 14px; cursor: pointer; } \
+        \.filter-btn:hover { background: #0052a3; } \
+        \.clear-btn { padding: 8px 16px; background: #6b7280; color: white; border: none; \
+        \             border-radius: 4px; font-size: 14px; cursor: pointer; text-decoration: none; } \
+        \.clear-btn:hover { background: #4b5563; } \
+        \.empty-msg { color: #6b7280; font-style: italic; } \
         \.task-list { display: flex; flex-direction: column; gap: 8px; } \
         \.task-card { background: white; border-radius: 8px; padding: 16px; \
         \             box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \
@@ -694,10 +773,38 @@ server =
       let sortedTasks = List.sortBy (compare `on` TaskCore.taskPriority) readyTasks
       pure (ReadyQueuePage sortedTasks)
 
-    taskListHandler :: Servant.Handler TaskListPage
-    taskListHandler = do
-      tasks <- liftIO TaskCore.loadTasks
-      pure (TaskListPage tasks)
+    taskListHandler :: Maybe TaskCore.Status -> Maybe TaskCore.Priority -> Maybe Text -> Servant.Handler TaskListPage
+    taskListHandler maybeStatus maybePriority maybeNamespace = do
+      allTasks <- liftIO TaskCore.loadTasks
+      let filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace)
+          filteredTasks = applyFilters filters allTasks
+      pure (TaskListPage filteredTasks filters)
+
+    emptyToNothing :: Maybe Text -> Maybe Text
+    emptyToNothing (Just t) | Text.null (Text.strip t) = Nothing
+    emptyToNothing x = x
+
+    applyFilters :: TaskFilters -> [TaskCore.Task] -> [TaskCore.Task]
+    applyFilters filters = filter matchesAllFilters
+      where
+        matchesAllFilters task =
+          matchesStatus task
+            && matchesPriority task
+            && matchesNamespace task
+
+        matchesStatus task = case filterStatus filters of
+          Nothing -> True
+          Just s -> TaskCore.taskStatus task == s
+
+        matchesPriority task = case filterPriority filters of
+          Nothing -> True
+          Just p -> TaskCore.taskPriority task == p
+
+        matchesNamespace task = case filterNamespace filters of
+          Nothing -> True
+          Just ns -> case TaskCore.taskNamespace task of
+            Nothing -> False
+            Just taskNs -> ns `Text.isPrefixOf` taskNs
 
     taskDetailHandler :: Text -> Servant.Handler TaskDetailPage
     taskDetailHandler tid = do
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index b28b402a..4f7a3d38 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -4,6 +4,7 @@
 {-# LANGUAGE NoImplicitPrelude #-}
 
 -- : dep sqids
+-- : dep http-api-data
 module Omni.Task.Core where
 
 import Alpha
@@ -22,6 +23,7 @@ import GHC.Generics ()
 import System.Directory (createDirectoryIfMissing, doesFileExist)
 import System.Environment (lookupEnv)
 import System.IO.Unsafe (unsafePerformIO)
+import Web.HttpApiData (FromHttpApiData (..))
 import qualified Web.Sqids as Sqids
 
 -- Core data types
@@ -113,6 +115,18 @@ instance ToJSON RetryContext
 
 instance FromJSON RetryContext
 
+-- HTTP API Instances (for Servant query params)
+
+instance FromHttpApiData Status where
+  parseQueryParam t = case readMaybe (T.unpack t) of
+    Just s -> Right s
+    Nothing -> Left ("Invalid status: " <> t)
+
+instance FromHttpApiData Priority where
+  parseQueryParam t = case readMaybe (T.unpack t) of
+    Just p -> Right p
+    Nothing -> Left ("Invalid priority: " <> t)
+
 -- SQLite Instances
 
 instance SQL.FromField TaskType where