Add tri-state status controls to web UI task pages

Amolith created

Change summary

src/cmd/webui.rs       | 67 +++++++++++++++++++++++++++++++------------
templates/macros.html  | 30 +++++++++++++++++-
templates/project.html | 32 +++++++++++++++++++++
templates/task.html    | 61 ++++++++++++++++++++++++++++++---------
4 files changed, 154 insertions(+), 36 deletions(-)

Detailed changes

src/cmd/webui.rs 🔗

@@ -121,6 +121,17 @@ fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
     });
 }
 
+/// Return a human-friendly status label (e.g. "In progress" instead of
+/// "in_progress").
+fn friendly_status(raw: &str) -> &'static str {
+    match raw {
+        "open" => "Open",
+        "in_progress" => "In progress",
+        "closed" => "Closed",
+        _ => "Open",
+    }
+}
+
 /// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
 /// for the noscript fallback.  Returns the original string unchanged on parse
 /// failure so the page still renders something sensible.
@@ -182,6 +193,8 @@ struct ScoredEntry {
     short_id: String,
     title: String,
     score: String,
+    status: String,
+    status_display: &'static str,
 }
 
 /// Minimal view-model for a task row in the project task table.
@@ -189,6 +202,7 @@ struct TaskRow {
     full_id: String,
     short_id: String,
     status: String,
+    status_display: &'static str,
     priority: String,
     effort: String,
     title: String,
@@ -557,6 +571,8 @@ async fn project_handler(
                 id: s.id,
                 title: s.title,
                 score: format!("{:.2}", s.score),
+                status: "open".to_string(),
+                status_display: friendly_status("open"),
             })
             .collect();
 
@@ -621,15 +637,19 @@ async fn project_handler(
 
         let page_tasks: Vec<TaskRow> = filtered[start..end]
             .iter()
-            .map(|t| TaskRow {
-                full_id: t.id.as_str().to_string(),
-                short_id: t.id.short(),
-                status: db::status_label(t.status).to_string(),
-                priority: db::priority_label(t.priority).to_string(),
-                effort: db::effort_label(t.effort).to_string(),
-                title: t.title.clone(),
-                created_at_display: friendly_date(&t.created_at),
-                created_at: t.created_at.clone(),
+            .map(|t| {
+                let status = db::status_label(t.status).to_string();
+                TaskRow {
+                    full_id: t.id.as_str().to_string(),
+                    short_id: t.id.short(),
+                    status_display: friendly_status(&status),
+                    status,
+                    priority: db::priority_label(t.priority).to_string(),
+                    effort: db::effort_label(t.effort).to_string(),
+                    title: t.title.clone(),
+                    created_at_display: friendly_date(&t.created_at),
+                    created_at: t.created_at.clone(),
+                }
             })
             .collect();
 
@@ -730,15 +750,19 @@ async fn task_handler(
         let subtasks: Vec<TaskRow> = all_tasks
             .iter()
             .filter(|t| t.parent.as_ref() == Some(&task_id))
-            .map(|t| TaskRow {
-                full_id: t.id.as_str().to_string(),
-                short_id: t.id.short(),
-                status: db::status_label(t.status).to_string(),
-                priority: db::priority_label(t.priority).to_string(),
-                effort: db::effort_label(t.effort).to_string(),
-                title: t.title.clone(),
-                created_at_display: friendly_date(&t.created_at),
-                created_at: t.created_at.clone(),
+            .map(|t| {
+                let status = db::status_label(t.status).to_string();
+                TaskRow {
+                    full_id: t.id.as_str().to_string(),
+                    short_id: t.id.short(),
+                    status_display: friendly_status(&status),
+                    status,
+                    priority: db::priority_label(t.priority).to_string(),
+                    effort: db::effort_label(t.effort).to_string(),
+                    title: t.title.clone(),
+                    created_at_display: friendly_date(&t.created_at),
+                    created_at: t.created_at.clone(),
+                }
             })
             .collect();
 
@@ -872,6 +896,8 @@ struct UpdateForm {
     priority: Option<String>,
     #[serde(default)]
     effort: Option<String>,
+    #[serde(default)]
+    redirect: Option<String>,
 }
 
 #[derive(serde::Deserialize)]
@@ -1015,7 +1041,10 @@ async fn update_handler(
             },
         )?;
 
-        let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
+        let redirect = form
+            .redirect
+            .filter(|r| r.starts_with('/'))
+            .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str()));
         Ok((task, redirect))
     })
     .await;

templates/macros.html 🔗

@@ -40,7 +40,7 @@
         <th scope="col"><a href="{{ sort_ctx.column_href("effort") }}">Effort{{ sort_ctx.arrow("effort") }}</a></th>
         <th scope="col"><a href="{{ sort_ctx.column_href("title") }}">Title{{ sort_ctx.arrow("title") }}</a></th>
         <th scope="col"><a href="{{ sort_ctx.column_href("created") }}">Created{{ sort_ctx.arrow("created") }}</a></th>
-        <th scope="col"><span class="sr-only">Actions</span></th>
+        <th scope="col">Change status</th>
       </tr>
     </thead>
     <tbody>
@@ -53,9 +53,33 @@
         <td>{{ t.title }}</td>
         <td><time datetime="{{ t.created_at }}">{{ t.created_at_display }}</time></td>
         <td>
+          <ot-dropdown>
+            <button popovertarget="status-{{ t.short_id }}" class="outline small" aria-label="Change status of {{ t.short_id }}, currently {{ t.status_display }}">
+              {{ t.status_display }}
+              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
+            </button>
+            <menu popover id="status-{{ t.short_id }}">
+              <button role="menuitemradio" aria-checked="{{ t.status == "open" }}" {% if t.status == "open" %}disabled{% else %}form="set-open-{{ t.short_id }}"{% endif %}>Open</button>
+              <button role="menuitemradio" aria-checked="{{ t.status == "in_progress" }}" {% if t.status == "in_progress" %}disabled{% else %}form="set-in-progress-{{ t.short_id }}"{% endif %}>In progress</button>
+              <button role="menuitemradio" aria-checked="{{ t.status == "closed" }}" {% if t.status == "closed" %}disabled{% else %}form="set-closed-{{ t.short_id }}"{% endif %}>Closed</button>
+            </menu>
+          </ot-dropdown>
+          {% if t.status != "open" %}
+          <form id="set-open-{{ t.short_id }}" method="post" action="/projects/{{ project_name }}/tasks/{{ t.full_id }}" hidden>
+            <input type="hidden" name="status" value="open">
+            <input type="hidden" name="redirect" value="/projects/{{ project_name }}">
+          </form>
+          {% endif %}
+          {% if t.status != "in_progress" %}
+          <form id="set-in-progress-{{ t.short_id }}" method="post" action="/projects/{{ project_name }}/tasks/{{ t.full_id }}" hidden>
+            <input type="hidden" name="status" value="in_progress">
+            <input type="hidden" name="redirect" value="/projects/{{ project_name }}">
+          </form>
+          {% endif %}
           {% if t.status != "closed" %}
-          <form method="post" action="/projects/{{ project_name }}/tasks/{{ t.full_id }}/done">
-            <button type="submit" class="outline small" aria-label="Mark {{ t.short_id }} done"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button>
+          <form id="set-closed-{{ t.short_id }}" method="post" action="/projects/{{ project_name }}/tasks/{{ t.full_id }}" hidden>
+            <input type="hidden" name="status" value="closed">
+            <input type="hidden" name="redirect" value="/projects/{{ project_name }}">
           </form>
           {% endif %}
         </td>

templates/project.html 🔗

@@ -41,6 +41,7 @@
           <th scope="col">ID</th>
           <th scope="col">Score</th>
           <th scope="col">Title</th>
+          <th scope="col">Change status</th>
         </tr>
       </thead>
       <tbody>
@@ -50,6 +51,37 @@
           <td><a href="/projects/{{ project_name }}/tasks/{{ s.id }}"><code>{{ s.short_id }}</code></a></td>
           <td>{{ s.score }}</td>
           <td>{{ s.title }}</td>
+          <td>
+            <ot-dropdown>
+              <button popovertarget="next-status-{{ s.short_id }}" class="outline small" aria-label="Change status of {{ s.short_id }}, currently {{ s.status_display }}">
+                {{ s.status_display }}
+                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
+              </button>
+              <menu popover id="next-status-{{ s.short_id }}">
+                <button role="menuitemradio" aria-checked="{{ s.status == "open" }}" {% if s.status == "open" %}disabled{% else %}form="next-set-open-{{ s.short_id }}"{% endif %}>Open</button>
+                <button role="menuitemradio" aria-checked="{{ s.status == "in_progress" }}" {% if s.status == "in_progress" %}disabled{% else %}form="next-set-in-progress-{{ s.short_id }}"{% endif %}>In progress</button>
+                <button role="menuitemradio" aria-checked="{{ s.status == "closed" }}" {% if s.status == "closed" %}disabled{% else %}form="next-set-closed-{{ s.short_id }}"{% endif %}>Closed</button>
+              </menu>
+            </ot-dropdown>
+            {% if s.status != "open" %}
+            <form id="next-set-open-{{ s.short_id }}" method="post" action="/projects/{{ project_name }}/tasks/{{ s.id }}" hidden>
+              <input type="hidden" name="status" value="open">
+              <input type="hidden" name="redirect" value="/projects/{{ project_name }}">
+            </form>
+            {% endif %}
+            {% if s.status != "in_progress" %}
+            <form id="next-set-in-progress-{{ s.short_id }}" method="post" action="/projects/{{ project_name }}/tasks/{{ s.id }}" hidden>
+              <input type="hidden" name="status" value="in_progress">
+              <input type="hidden" name="redirect" value="/projects/{{ project_name }}">
+            </form>
+            {% endif %}
+            {% if s.status != "closed" %}
+            <form id="next-set-closed-{{ s.short_id }}" method="post" action="/projects/{{ project_name }}/tasks/{{ s.id }}" hidden>
+              <input type="hidden" name="status" value="closed">
+              <input type="hidden" name="redirect" value="/projects/{{ project_name }}">
+            </form>
+            {% endif %}
+          </td>
         </tr>
         {% endfor %}
       </tbody>

templates/task.html 🔗

@@ -33,18 +33,47 @@
   </footer>
 
   <div class="hstack gap-2 mt-4">
-    {% if task.status != "closed" %}
-    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/done">
-      <button type="submit" class="small">Mark done</button>
+    <ot-dropdown>
+      <button popovertarget="status-menu" class="outline small">
+        Change status
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
+      </button>
+      <menu popover id="status-menu">
+        <button role="menuitemradio" aria-checked="{{ task.status == "open" }}" {% if task.status == "open" %}disabled{% else %}form="set-open"{% endif %}>Open</button>
+        <button role="menuitemradio" aria-checked="{{ task.status == "in_progress" }}" {% if task.status == "in_progress" %}disabled{% else %}form="set-in-progress"{% endif %}>In progress</button>
+        <button role="menuitemradio" aria-checked="{{ task.status == "closed" }}" {% if task.status == "closed" %}disabled{% else %}form="set-closed"{% endif %}>Closed</button>
+      </menu>
+    </ot-dropdown>
+    {% if task.status != "open" %}
+    <form id="set-open" method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}" hidden>
+      <input type="hidden" name="status" value="open">
     </form>
-    {% else %}
-    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/reopen">
-      <button type="submit" class="outline small">Reopen</button>
+    {% endif %}
+    {% if task.status != "in_progress" %}
+    <form id="set-in-progress" method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}" hidden>
+      <input type="hidden" name="status" value="in_progress">
     </form>
     {% endif %}
-    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/delete" onsubmit="return confirm('Delete this task?')">
-      <button type="submit" class="outline small danger">Delete</button>
+    {% if task.status != "closed" %}
+    <form id="set-closed" method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}" hidden>
+      <input type="hidden" name="status" value="closed">
     </form>
+    {% endif %}
+    <ot-dropdown>
+      <button popovertarget="confirm-delete" class="outline small danger">Delete</button>
+      <article class="card" popover id="confirm-delete">
+        <header>
+          <h4>Are you sure?</h4>
+          <p>This will delete <strong>{{ task.short_id }}</strong>.</p>
+        </header>
+        <br>
+        <footer class="hstack gap-2">
+          <button class="outline small" popovertarget="confirm-delete">Cancel</button>
+          <button data-variant="danger" class="small" form="delete-task" type="submit">Delete</button>
+        </footer>
+      </article>
+    </ot-dropdown>
+    <form id="delete-task" method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/delete" hidden></form>
   </div>
 
   {% if !task.labels.is_empty() %}
@@ -63,10 +92,12 @@
   {% endif %}
 
   <section class="mt-4" aria-label="Add label">
-    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/labels" class="hstack gap-2">
+    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/labels">
       <input type="hidden" name="action" value="add">
-      <input type="text" name="label" placeholder="Label…" required aria-label="New label">
-      <button type="submit" class="outline small">Add label</button>
+      <fieldset class="group">
+        <input type="text" name="label" placeholder="Label…" required aria-label="New label">
+        <button type="submit">Add label</button>
+      </fieldset>
     </form>
   </section>
 
@@ -109,10 +140,12 @@
     {% endfor %}
   </ul>
   {% endif %}
-  <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/deps" class="hstack gap-2 mt-2">
+  <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/deps" class="mt-2">
     <input type="hidden" name="action" value="add">
-    <input type="text" name="blocker" placeholder="Task ID…" required aria-label="Blocker task ID">
-    <button type="submit" class="outline small">Add blocker</button>
+    <fieldset class="group">
+      <input type="text" name="blocker" placeholder="Task ID…" required aria-label="Blocker task ID">
+      <button type="submit">Add blocker</button>
+    </fieldset>
   </form>
 </article>