summaryrefslogtreecommitdiff
path: root/Omni/Jr/Web.hs
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-11-26 07:54:57 -0500
committerBen Sima <ben@bensima.com>2025-11-26 07:54:57 -0500
commit60c97ba9fac9eb9298b1b448ed5494765a33be39 (patch)
treeff53762216cb038939659057ed4fe758dbf0c53e /Omni/Jr/Web.hs
parent30f6e16fe4fd3c9cbfcb39cd8053504ddd11167b (diff)
Use task title as commit subject, amp output as body
Fixes gitlint failures by using the pre-validated task title as the commit subject line, while preserving amp's output in the body for review context. Body lines are truncated to 72 chars for compliance.
Diffstat (limited to 'Omni/Jr/Web.hs')
-rw-r--r--Omni/Jr/Web.hs205
1 files changed, 198 insertions, 7 deletions
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index beef8bb..3ab0998 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -15,6 +15,7 @@ module Omni.Jr.Web
where
import Alpha
+import qualified Data.List as List
import qualified Data.Text as Text
import qualified Lucid
import qualified Network.Wai.Handler.Warp as Warp
@@ -30,11 +31,14 @@ defaultPort = 8080
type API =
Get '[Lucid.HTML] HomePage
+ :<|> "ready" :> Get '[Lucid.HTML] ReadyQueuePage
:<|> "tasks" :> Get '[Lucid.HTML] TaskListPage
:<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
:<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> PostRedirect
-newtype HomePage = HomePage ()
+data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task]
+
+newtype ReadyQueuePage = ReadyQueuePage [TaskCore.Task]
newtype TaskListPage = TaskListPage [TaskCore.Task]
@@ -53,18 +57,195 @@ instance FromForm StatusForm where
instance Lucid.ToHtml HomePage where
toHtmlRaw = Lucid.toHtml
- toHtml (HomePage ()) =
+ toHtml (HomePage stats readyTasks recentTasks) =
+ Lucid.doctypehtml_ <| do
+ Lucid.head_ <| do
+ Lucid.title_ "Jr Dashboard"
+ Lucid.meta_ [Lucid.charset_ "utf-8"]
+ Lucid.meta_
+ [ Lucid.name_ "viewport",
+ Lucid.content_ "width=device-width, initial-scale=1"
+ ]
+ Lucid.style_ homeStyles
+ Lucid.body_ <| do
+ Lucid.h1_ "Jr Dashboard"
+
+ Lucid.div_ [Lucid.class_ "actions"] <| do
+ Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "action-btn"] "View All Tasks"
+ Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "action-btn action-btn-primary"] "View Ready Queue"
+
+ Lucid.h2_ "Task Status"
+ Lucid.div_ [Lucid.class_ "stats-grid"] <| do
+ statCard "Open" (TaskCore.openTasks stats) "badge-open"
+ statCard "In Progress" (TaskCore.inProgressTasks stats) "badge-inprogress"
+ statCard "Review" (TaskCore.reviewTasks stats) "badge-review"
+ statCard "Approved" (TaskCore.approvedTasks stats) "badge-approved"
+ statCard "Done" (TaskCore.doneTasks stats) "badge-done"
+
+ Lucid.h2_ <| do
+ "Ready Queue "
+ Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"]
+ <| Lucid.toHtml ("(" <> tshow (length readyTasks) <> " tasks)")
+ if null readyTasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks ready for work."
+ else
+ Lucid.div_ [Lucid.class_ "task-list"]
+ <| traverse_ renderTaskCard (take 5 readyTasks)
+
+ Lucid.h2_ "Recent Activity"
+ if null recentTasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No recent tasks."
+ else
+ Lucid.div_ [Lucid.class_ "task-list"]
+ <| traverse_ renderTaskCard recentTasks
+ where
+ statCard :: (Monad m) => Text -> Int -> Text -> Lucid.HtmlT m ()
+ statCard label count badgeClass =
+ Lucid.div_ [Lucid.class_ ("stat-card " <> badgeClass)] <| do
+ Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml (tshow count))
+ Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
+
+ renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
+ renderTaskCard t =
+ Lucid.div_ [Lucid.class_ "task-card"] <| do
+ Lucid.div_ [Lucid.class_ "task-header"] <| do
+ Lucid.a_
+ [ Lucid.class_ "task-id",
+ Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
+ ]
+ (Lucid.toHtml (TaskCore.taskId t))
+ statusBadge (TaskCore.taskStatus t)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
+ Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
+
+ statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m ()
+ statusBadge status =
+ let (cls, label) = case status of
+ TaskCore.Open -> ("badge badge-open", "Open")
+ TaskCore.InProgress -> ("badge badge-inprogress", "In Progress")
+ TaskCore.Review -> ("badge badge-review", "Review")
+ TaskCore.Approved -> ("badge badge-approved", "Approved")
+ TaskCore.Done -> ("badge badge-done", "Done")
+ in Lucid.span_ [Lucid.class_ cls] label
+
+homeStyles :: Text
+homeStyles =
+ "* { box-sizing: border-box; } \
+ \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \
+ \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \
+ \h1 { margin: 0 0 16px 0; } \
+ \h2 { margin: 24px 0 12px 0; color: #374151; font-size: 18px; } \
+ \.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } \
+ \.action-btn { display: inline-block; padding: 10px 16px; background: white; \
+ \ border: 1px solid #d1d5db; border-radius: 6px; color: #374151; \
+ \ text-decoration: none; font-size: 14px; font-weight: 500; } \
+ \.action-btn:hover { background: #f9fafb; } \
+ \.action-btn-primary { background: #0066cc; color: white; border-color: #0066cc; } \
+ \.action-btn-primary:hover { background: #0052a3; } \
+ \.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; } \
+ \.stat-card { background: white; border-radius: 8px; padding: 16px; text-align: center; \
+ \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \
+ \.stat-count { font-size: 28px; font-weight: 700; } \
+ \.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; } \
+ \.stat-card.badge-open { border-left: 4px solid #f59e0b; } \
+ \.stat-card.badge-open .stat-count { color: #92400e; } \
+ \.stat-card.badge-inprogress { border-left: 4px solid #3b82f6; } \
+ \.stat-card.badge-inprogress .stat-count { color: #1e40af; } \
+ \.stat-card.badge-review { border-left: 4px solid #8b5cf6; } \
+ \.stat-card.badge-review .stat-count { color: #6b21a8; } \
+ \.stat-card.badge-approved { border-left: 4px solid #10b981; } \
+ \.stat-card.badge-approved .stat-count { color: #065f46; } \
+ \.stat-card.badge-done { border-left: 4px solid #10b981; } \
+ \.stat-card.badge-done .stat-count { color: #065f46; } \
+ \.ready-link { font-size: 14px; color: #0066cc; text-decoration: none; } \
+ \.ready-link:hover { text-decoration: underline; } \
+ \.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); } \
+ \.task-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; } \
+ \.task-id { font-family: monospace; color: #0066cc; text-decoration: none; \
+ \ font-size: 14px; padding: 4px 0; } \
+ \.task-id:hover { text-decoration: underline; } \
+ \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \
+ \ font-size: 12px; font-weight: 500; } \
+ \.badge-open { background: #fef3c7; color: #92400e; } \
+ \.badge-inprogress { background: #dbeafe; color: #1e40af; } \
+ \.badge-review { background: #ede9fe; color: #6b21a8; } \
+ \.badge-approved { background: #d1fae5; color: #065f46; } \
+ \.badge-done { background: #d1fae5; color: #065f46; } \
+ \.priority { font-size: 12px; color: #6b7280; } \
+ \.task-title { font-size: 16px; margin: 0; }"
+
+instance Lucid.ToHtml ReadyQueuePage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (ReadyQueuePage tasks) =
Lucid.doctypehtml_ <| do
Lucid.head_ <| do
- Lucid.title_ "Jr Web UI"
+ Lucid.title_ "Ready Queue - Jr Web UI"
Lucid.meta_ [Lucid.charset_ "utf-8"]
Lucid.meta_
[ Lucid.name_ "viewport",
Lucid.content_ "width=device-width, initial-scale=1"
]
+ Lucid.style_ readyStyles
Lucid.body_ <| do
- Lucid.h1_ "Jr Web UI"
- Lucid.p_ <| Lucid.a_ [Lucid.href_ "/tasks"] "View Tasks"
+ Lucid.p_ <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard"
+ Lucid.h1_ <| do
+ "Ready Queue "
+ Lucid.span_ [Lucid.class_ "count-badge"] (Lucid.toHtml (tshow (length tasks)))
+ if null tasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks are ready for work."
+ else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTask tasks
+ where
+ readyStyles :: Text
+ readyStyles =
+ "* { box-sizing: border-box; } \
+ \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \
+ \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \
+ \h1 { margin: 16px 0; } \
+ \.count-badge { background: #0066cc; color: white; padding: 4px 10px; \
+ \ border-radius: 12px; font-size: 14px; vertical-align: middle; } \
+ \.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); } \
+ \.task-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; } \
+ \.task-id { font-family: monospace; color: #0066cc; text-decoration: none; \
+ \ font-size: 14px; padding: 4px 0; } \
+ \.task-id:hover { text-decoration: underline; } \
+ \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \
+ \ font-size: 12px; font-weight: 500; } \
+ \.badge-open { background: #fef3c7; color: #92400e; } \
+ \.badge-inprogress { background: #dbeafe; color: #1e40af; } \
+ \.badge-review { background: #ede9fe; color: #6b21a8; } \
+ \.badge-approved { background: #d1fae5; color: #065f46; } \
+ \.badge-done { background: #d1fae5; color: #065f46; } \
+ \.priority { font-size: 12px; color: #6b7280; } \
+ \.task-title { font-size: 16px; margin: 0; }"
+
+ renderTask :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
+ renderTask t =
+ Lucid.div_ [Lucid.class_ "task-card"] <| do
+ Lucid.div_ [Lucid.class_ "task-header"] <| do
+ Lucid.a_
+ [ Lucid.class_ "task-id",
+ Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
+ ]
+ (Lucid.toHtml (TaskCore.taskId t))
+ statusBadge (TaskCore.taskStatus t)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
+ Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
+
+ statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m ()
+ statusBadge status =
+ let (cls, label) = case status of
+ TaskCore.Open -> ("badge badge-open", "Open")
+ TaskCore.InProgress -> ("badge badge-inprogress", "In Progress")
+ TaskCore.Review -> ("badge badge-review", "Review")
+ TaskCore.Approved -> ("badge badge-approved", "Approved")
+ TaskCore.Done -> ("badge badge-done", "Done")
+ in Lucid.span_ [Lucid.class_ cls] label
instance Lucid.ToHtml TaskListPage where
toHtmlRaw = Lucid.toHtml
@@ -315,10 +496,20 @@ api :: Proxy API
api = Proxy
server :: Server API
-server = homeHandler :<|> taskListHandler :<|> taskDetailHandler :<|> taskStatusHandler
+server = homeHandler :<|> readyQueueHandler :<|> taskListHandler :<|> taskDetailHandler :<|> taskStatusHandler
where
homeHandler :: Servant.Handler HomePage
- homeHandler = pure (HomePage ())
+ homeHandler = do
+ stats <- liftIO <| TaskCore.getTaskStats Nothing
+ readyTasks <- liftIO TaskCore.getReadyTasks
+ allTasks <- liftIO TaskCore.loadTasks
+ let recentTasks = take 5 <| List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) allTasks
+ pure (HomePage stats readyTasks recentTasks)
+
+ readyQueueHandler :: Servant.Handler ReadyQueuePage
+ readyQueueHandler = do
+ readyTasks <- liftIO TaskCore.getReadyTasks
+ pure (ReadyQueuePage readyTasks)
taskListHandler :: Servant.Handler TaskListPage
taskListHandler = do