← Back to task

Commit 7da1c7e7

commit 7da1c7e717af3cadf927b5f6efb253f0d10423a8
Author: Ben Sima <ben@bensima.com>
Date:   Mon Dec 1 21:38:17 2025

    Make complexity badge editable on task detail page
    
    - Add ComplexityForm and ComplexityBadgePartial types
    - Add /tasks/:id/complexity POST endpoint
    - Add complexityBadgeWithForm component with dropdown
    - Add complexity dropdown JS for keyboard navigation
    - Add CSS styles for complexity dropdown
    - Always show complexity badge (Set Complexity if none)
    
    Task-Id: t-219

diff --git a/Omni/Jr/Web/Components.hs b/Omni/Jr/Web/Components.hs
index 7dd13c7d..4429f752 100644
--- a/Omni/Jr/Web/Components.hs
+++ b/Omni/Jr/Web/Components.hs
@@ -24,6 +24,7 @@ module Omni.Jr.Web.Components
     navbarDropdownJs,
     statusDropdownJs,
     priorityDropdownJs,
+    complexityDropdownJs,
     liveToggleJs,
 
     -- * Breadcrumbs
@@ -44,6 +45,10 @@ module Omni.Jr.Web.Components
     clickablePriorityBadge,
     priorityDropdownOptions,
     priorityOption,
+    complexityBadgeWithForm,
+    clickableComplexityBadge,
+    complexityDropdownOptions,
+    complexityOption,
 
     -- * Sorting
     sortDropdown,
@@ -188,6 +193,7 @@ pageHead title =
       ("" :: Text)
     Lucid.script_ [] statusDropdownJs
     Lucid.script_ [] priorityDropdownJs
+    Lucid.script_ [] complexityDropdownJs
     Lucid.script_ [] navbarDropdownJs
     Lucid.script_ [] liveToggleJs
 
@@ -362,6 +368,77 @@ priorityDropdownJs =
       "});"
     ]
 
+complexityDropdownJs :: Text
+complexityDropdownJs =
+  Text.unlines
+    [ "function toggleComplexityDropdown(el) {",
+      "  var container = el.parentElement;",
+      "  var isOpen = container.classList.toggle('open');",
+      "  el.setAttribute('aria-expanded', isOpen);",
+      "  if (isOpen) {",
+      "    var firstItem = container.querySelector('[role=\"menuitem\"]');",
+      "    if (firstItem) firstItem.focus();",
+      "  }",
+      "}",
+      "",
+      "function closeComplexityDropdown(container) {",
+      "  container.classList.remove('open');",
+      "  var badge = container.querySelector('[role=\"button\"]');",
+      "  if (badge) {",
+      "    badge.setAttribute('aria-expanded', 'false');",
+      "    badge.focus();",
+      "  }",
+      "}",
+      "",
+      "function handleComplexityKeydown(event, el) {",
+      "  if (event.key === 'Enter' || event.key === ' ') {",
+      "    event.preventDefault();",
+      "    toggleComplexityDropdown(el);",
+      "  } else if (event.key === 'Escape') {",
+      "    closeComplexityDropdown(el.parentElement);",
+      "  } else if (event.key === 'ArrowDown') {",
+      "    event.preventDefault();",
+      "    var container = el.parentElement;",
+      "    if (!container.classList.contains('open')) {",
+      "      toggleComplexityDropdown(el);",
+      "    } else {",
+      "      var firstItem = container.querySelector('[role=\"menuitem\"]');",
+      "      if (firstItem) firstItem.focus();",
+      "    }",
+      "  }",
+      "}",
+      "",
+      "function handleComplexityMenuItemKeydown(event) {",
+      "  var container = event.target.closest('.complexity-badge-dropdown');",
+      "  var items = container.querySelectorAll('[role=\"menuitem\"]');",
+      "  var currentIndex = Array.from(items).indexOf(event.target);",
+      "  ",
+      "  if (event.key === 'ArrowDown') {",
+      "    event.preventDefault();",
+      "    var next = (currentIndex + 1) % items.length;",
+      "    items[next].focus();",
+      "  } else if (event.key === 'ArrowUp') {",
+      "    event.preventDefault();",
+      "    var prev = (currentIndex - 1 + items.length) % items.length;",
+      "    items[prev].focus();",
+      "  } else if (event.key === 'Escape') {",
+      "    event.preventDefault();",
+      "    closeComplexityDropdown(container);",
+      "  } else if (event.key === 'Tab') {",
+      "    closeComplexityDropdown(container);",
+      "  }",
+      "}",
+      "",
+      "document.addEventListener('click', function(e) {",
+      "  var dropdowns = document.querySelectorAll('.complexity-badge-dropdown.open');",
+      "  dropdowns.forEach(function(d) {",
+      "    if (!d.contains(e.target)) {",
+      "      closeComplexityDropdown(d);",
+      "    }",
+      "  });",
+      "});"
+    ]
+
 liveToggleJs :: Text
 liveToggleJs =
   Text.unlines
@@ -774,6 +851,86 @@ priorityOption opt currentPriority tid =
             ]
             (Lucid.toHtml label)
 
+-- * Complexity badge with form
+
+complexityBadgeWithForm :: (Monad m) => Maybe Int -> Text -> Lucid.HtmlT m ()
+complexityBadgeWithForm complexity tid =
+  Lucid.div_
+    [ Lucid.id_ "complexity-badge-container",
+      Lucid.class_ "complexity-badge-dropdown"
+    ]
+    <| do
+      clickableComplexityBadge complexity tid
+      complexityDropdownOptions complexity tid
+
+clickableComplexityBadge :: (Monad m) => Maybe Int -> Text -> Lucid.HtmlT m ()
+clickableComplexityBadge complexity _tid =
+  let (cls, label) = case complexity of
+        Nothing -> ("badge badge-complexity-none complexity-badge-clickable", "Set Complexity" :: Text)
+        Just 1 -> ("badge badge-complexity-1 complexity-badge-clickable", "ℂ 1")
+        Just 2 -> ("badge badge-complexity-2 complexity-badge-clickable", "ℂ 2")
+        Just 3 -> ("badge badge-complexity-3 complexity-badge-clickable", "ℂ 3")
+        Just 4 -> ("badge badge-complexity-4 complexity-badge-clickable", "ℂ 4")
+        Just 5 -> ("badge badge-complexity-5 complexity-badge-clickable", "ℂ 5")
+        Just _ -> ("badge badge-complexity-none complexity-badge-clickable", "Invalid")
+   in Lucid.span_
+        [ Lucid.class_ cls,
+          Lucid.tabindex_ "0",
+          Lucid.role_ "button",
+          Lucid.makeAttribute "aria-haspopup" "true",
+          Lucid.makeAttribute "aria-expanded" "false",
+          Lucid.makeAttribute "onclick" "toggleComplexityDropdown(this)",
+          Lucid.makeAttribute "onkeydown" "handleComplexityKeydown(event, this)"
+        ]
+        <| do
+          Lucid.toHtml label
+          Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾"
+
+complexityDropdownOptions :: (Monad m) => Maybe Int -> Text -> Lucid.HtmlT m ()
+complexityDropdownOptions currentComplexity tid =
+  Lucid.div_
+    [ Lucid.class_ "complexity-dropdown-menu",
+      Lucid.role_ "menu",
+      Lucid.makeAttribute "aria-label" "Change task complexity"
+    ]
+    <| do
+      complexityOption Nothing currentComplexity tid
+      complexityOption (Just 1) currentComplexity tid
+      complexityOption (Just 2) currentComplexity tid
+      complexityOption (Just 3) currentComplexity tid
+      complexityOption (Just 4) currentComplexity tid
+      complexityOption (Just 5) currentComplexity tid
+
+complexityOption :: (Monad m) => Maybe Int -> Maybe Int -> Text -> Lucid.HtmlT m ()
+complexityOption opt currentComplexity tid =
+  let (cls, label, val) = case opt of
+        Nothing -> ("badge badge-complexity-none", "None" :: Text, "none" :: Text)
+        Just 1 -> ("badge badge-complexity-1", "ℂ 1", "1")
+        Just 2 -> ("badge badge-complexity-2", "ℂ 2", "2")
+        Just 3 -> ("badge badge-complexity-3", "ℂ 3", "3")
+        Just 4 -> ("badge badge-complexity-4", "ℂ 4", "4")
+        Just 5 -> ("badge badge-complexity-5", "ℂ 5", "5")
+        Just _ -> ("badge badge-complexity-none", "Invalid", "none")
+      isSelected = opt == currentComplexity
+      optClass = cls <> " complexity-dropdown-option" <> if isSelected then " selected" else ""
+   in Lucid.form_
+        [ Lucid.class_ "complexity-option-form",
+          Lucid.role_ "none",
+          Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/complexity"),
+          Lucid.makeAttribute "hx-target" "#complexity-badge-container",
+          Lucid.makeAttribute "hx-swap" "outerHTML"
+        ]
+        <| do
+          Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "complexity", Lucid.value_ val]
+          Lucid.button_
+            [ Lucid.type_ "submit",
+              Lucid.class_ optClass,
+              Lucid.role_ "menuitem",
+              Lucid.tabindex_ "-1",
+              Lucid.makeAttribute "onkeydown" "handleComplexityMenuItemKeydown(event)"
+            ]
+            (Lucid.toHtml label)
+
 -- * Task rendering
 
 renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
diff --git a/Omni/Jr/Web/Handlers.hs b/Omni/Jr/Web/Handlers.hs
index 5b542dd0..9dd5847c 100644
--- a/Omni/Jr/Web/Handlers.hs
+++ b/Omni/Jr/Web/Handlers.hs
@@ -60,6 +60,7 @@ type API =
     :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
     :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> Post '[Lucid.HTML] StatusBadgePartial
     :<|> "tasks" :> Capture "id" Text :> "priority" :> ReqBody '[FormUrlEncoded] PriorityForm :> Post '[Lucid.HTML] PriorityBadgePartial
+    :<|> "tasks" :> Capture "id" Text :> "complexity" :> ReqBody '[FormUrlEncoded] ComplexityForm :> Post '[Lucid.HTML] ComplexityBadgePartial
     :<|> "tasks" :> Capture "id" Text :> "description" :> "view" :> Get '[Lucid.HTML] DescriptionViewPartial
     :<|> "tasks" :> Capture "id" Text :> "description" :> "edit" :> Get '[Lucid.HTML] DescriptionEditPartial
     :<|> "tasks" :> Capture "id" Text :> "description" :> ReqBody '[FormUrlEncoded] DescriptionForm :> Post '[Lucid.HTML] DescriptionViewPartial
@@ -106,6 +107,7 @@ server =
     :<|> taskDetailHandler
     :<|> taskStatusHandler
     :<|> taskPriorityHandler
+    :<|> taskComplexityHandler
     :<|> descriptionViewHandler
     :<|> descriptionEditHandler
     :<|> descriptionPostHandler
@@ -298,6 +300,11 @@ server =
       _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskPriority = newPriority})
       pure (PriorityBadgePartial newPriority tid)
 
+    taskComplexityHandler :: Text -> ComplexityForm -> Servant.Handler ComplexityBadgePartial
+    taskComplexityHandler tid (ComplexityForm newComplexity) = do
+      _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskComplexity = newComplexity})
+      pure (ComplexityBadgePartial newComplexity tid)
+
     descriptionViewHandler :: Text -> Servant.Handler DescriptionViewPartial
     descriptionViewHandler tid = do
       tasks <- liftIO TaskCore.loadTasks
diff --git a/Omni/Jr/Web/Pages.hs b/Omni/Jr/Web/Pages.hs
index 2fbbc00b..b3cc8ea8 100644
--- a/Omni/Jr/Web/Pages.hs
+++ b/Omni/Jr/Web/Pages.hs
@@ -18,7 +18,7 @@ import qualified Lucid.Base as Lucid
 import Numeric (showFFloat)
 import Omni.Jr.Web.Components
   ( Breadcrumb (..),
-    complexityBadge,
+    complexityBadgeWithForm,
     metaSep,
     multiColorProgressBar,
     pageBody,
@@ -567,11 +567,8 @@ instance Lucid.ToHtml TaskDetailPage where
                     statusBadgeWithForm (TaskCore.taskStatus task) (TaskCore.taskId task)
                     metaSep
                     priorityBadgeWithForm (TaskCore.taskPriority task) (TaskCore.taskId task)
-                    case TaskCore.taskComplexity task of
-                      Nothing -> pure ()
-                      Just c -> do
-                        metaSep
-                        complexityBadge c
+                    metaSep
+                    complexityBadgeWithForm (TaskCore.taskComplexity task) (TaskCore.taskId task)
                     case TaskCore.taskNamespace task of
                       Nothing -> pure ()
                       Just ns -> do
diff --git a/Omni/Jr/Web/Partials.hs b/Omni/Jr/Web/Partials.hs
index 25a4d1e0..2660441b 100644
--- a/Omni/Jr/Web/Partials.hs
+++ b/Omni/Jr/Web/Partials.hs
@@ -18,6 +18,7 @@ import Numeric (showFFloat)
 import Omni.Jr.Web.Components
   ( aggregateCostMetrics,
     commentForm,
+    complexityBadgeWithForm,
     formatCostHeader,
     formatTokensHeader,
     metaSep,
@@ -33,6 +34,7 @@ import Omni.Jr.Web.Components
   )
 import Omni.Jr.Web.Types
   ( AgentEventsPartial (..),
+    ComplexityBadgePartial (..),
     DescriptionEditPartial (..),
     DescriptionViewPartial (..),
     PriorityBadgePartial (..),
@@ -92,6 +94,11 @@ instance Lucid.ToHtml PriorityBadgePartial where
   toHtml (PriorityBadgePartial priority tid) =
     priorityBadgeWithForm priority tid
 
+instance Lucid.ToHtml ComplexityBadgePartial where
+  toHtmlRaw = Lucid.toHtml
+  toHtml (ComplexityBadgePartial complexity tid) =
+    complexityBadgeWithForm complexity tid
+
 instance Lucid.ToHtml TaskListPartial where
   toHtmlRaw = Lucid.toHtml
   toHtml (TaskListPartial tasks) =
diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs
index c385ac7c..0f4b3008 100644
--- a/Omni/Jr/Web/Style.hs
+++ b/Omni/Jr/Web/Style.hs
@@ -714,6 +714,54 @@ statusBadges = do
   ".badge-complexity-5" ? do
     backgroundColor "#fee2e2"
     color "#991b1b"
+  ".badge-complexity-none" ? do
+    backgroundColor "#f3f4f6"
+    color "#6b7280"
+  ".complexity-badge-dropdown" ? do
+    position relative
+    display inlineBlock
+  ".complexity-badge-clickable" ? do
+    cursor pointer
+    Stylesheet.key "user-select" ("none" :: Text)
+  ".complexity-badge-clickable" # hover ? do
+    opacity 0.85
+  ".complexity-dropdown-menu" ? do
+    display none
+    position absolute
+    left (px 0)
+    top (pct 100)
+    marginTop (px 2)
+    backgroundColor white
+    borderRadius (px 4) (px 4) (px 4) (px 4)
+    Stylesheet.key "box-shadow" ("0 2px 8px rgba(0,0,0,0.15)" :: Text)
+    zIndex 100
+    padding (px 4) (px 4) (px 4) (px 4)
+    minWidth (px 100)
+  ".complexity-badge-dropdown.open" |> ".complexity-dropdown-menu" ? do
+    display block
+  ".complexity-option-form" ? do
+    margin (px 0) (px 0) (px 0) (px 0)
+    padding (px 0) (px 0) (px 0) (px 0)
+  ".complexity-dropdown-option" ? do
+    display block
+    width (pct 100)
+    textAlign (alignSide sideLeft)
+    margin (px 2) (px 0) (px 2) (px 0)
+    border (px 0) none transparent
+    cursor pointer
+    transition "opacity" (ms 150) ease (sec 0)
+  ".complexity-dropdown-option" # hover ? do
+    opacity 0.7
+  ".complexity-dropdown-option" # focus ? do
+    opacity 0.85
+    Stylesheet.key "outline" ("2px solid #0066cc" :: Text)
+    Stylesheet.key "outline-offset" ("1px" :: Text)
+  ".complexity-dropdown-option.selected" ? do
+    Stylesheet.key "outline" ("2px solid #0066cc" :: Text)
+    Stylesheet.key "outline-offset" ("1px" :: Text)
+  ".complexity-badge-clickable" # focus ? do
+    Stylesheet.key "outline" ("2px solid #0066cc" :: Text)
+    Stylesheet.key "outline-offset" ("2px" :: Text)
 
 buttonStyles :: Css
 buttonStyles = do
diff --git a/Omni/Jr/Web/Types.hs b/Omni/Jr/Web/Types.hs
index 85ea0f0a..93c8d855 100644
--- a/Omni/Jr/Web/Types.hs
+++ b/Omni/Jr/Web/Types.hs
@@ -43,6 +43,7 @@ module Omni.Jr.Web.Types
     ReadyCountPartial (..),
     StatusBadgePartial (..),
     PriorityBadgePartial (..),
+    ComplexityBadgePartial (..),
     TaskListPartial (..),
     TaskMetricsPartial (..),
     AgentEventsPartial (..),
@@ -53,6 +54,7 @@ module Omni.Jr.Web.Types
     RejectForm (..),
     StatusForm (..),
     PriorityForm (..),
+    ComplexityForm (..),
     DescriptionForm (..),
     NotesForm (..),
     CommentForm (..),
@@ -288,6 +290,8 @@ data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text
 
 data PriorityBadgePartial = PriorityBadgePartial TaskCore.Priority Text
 
+data ComplexityBadgePartial = ComplexityBadgePartial (Maybe Int) Text
+
 newtype TaskListPartial = TaskListPartial [TaskCore.Task]
 
 data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) UTCTime
@@ -321,6 +325,17 @@ instance FromForm PriorityForm where
       Just p -> Right (PriorityForm p)
       Nothing -> Left "Invalid priority"
 
+newtype ComplexityForm = ComplexityForm (Maybe Int)
+
+instance FromForm ComplexityForm where
+  fromForm form = do
+    complexityText <- parseUnique "complexity" form
+    if complexityText == "none"
+      then Right (ComplexityForm Nothing)
+      else case readMaybe (Text.unpack complexityText) of
+        Just c | c >= 1 && c <= 5 -> Right (ComplexityForm (Just c))
+        _ -> Left "Invalid complexity"
+
 newtype DescriptionForm = DescriptionForm Text
 
 instance FromForm DescriptionForm where