summaryrefslogtreecommitdiff
path: root/Omni/Jr/Web.hs
diff options
context:
space:
mode:
Diffstat (limited to 'Omni/Jr/Web.hs')
-rw-r--r--Omni/Jr/Web.hs131
1 files changed, 119 insertions, 12 deletions
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 4fd77b6..c32c716 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