diff options
Diffstat (limited to 'Omni/Jr/Web.hs')
| -rw-r--r-- | Omni/Jr/Web.hs | 205 |
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 |
