Add sortable columns to web UI task table

Amolith created

Change summary

src/cmd/webui.rs       | 241 ++++++++++++++++++++++++++++++++++++++++++-
static/td.js           |  42 +++++++
templates/base.html    |  30 -----
templates/macros.html  |  30 +++++
templates/project.html |   6 
5 files changed, 310 insertions(+), 39 deletions(-)

Detailed changes

src/cmd/webui.rs πŸ”—

@@ -15,6 +15,111 @@ use crate::score;
 
 const PAGE_SIZE: usize = 25;
 
+// ---------------------------------------------------------------------------
+// Sort helpers
+// ---------------------------------------------------------------------------
+
+/// Columns the task table can be sorted by.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum SortField {
+    Id,
+    Status,
+    Priority,
+    Effort,
+    Title,
+    Created,
+}
+
+impl SortField {
+    fn parse(s: &str) -> Option<Self> {
+        match s {
+            "id" => Some(Self::Id),
+            "status" => Some(Self::Status),
+            "priority" => Some(Self::Priority),
+            "effort" => Some(Self::Effort),
+            "title" => Some(Self::Title),
+            "created" => Some(Self::Created),
+            _ => None,
+        }
+    }
+
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::Id => "id",
+            Self::Status => "status",
+            Self::Priority => "priority",
+            Self::Effort => "effort",
+            Self::Title => "title",
+            Self::Created => "created",
+        }
+    }
+
+    /// Sensible default direction when the user first clicks a column.
+    fn default_order(self) -> SortOrder {
+        match self {
+            // Newest first, alphabetical ascending for text fields.
+            Self::Created => SortOrder::Desc,
+            Self::Title | Self::Id => SortOrder::Asc,
+            // Highest priority/effort first; open before closed.
+            Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
+        }
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum SortOrder {
+    Asc,
+    Desc,
+}
+
+impl SortOrder {
+    fn parse(s: &str) -> Option<Self> {
+        match s {
+            "asc" => Some(Self::Asc),
+            "desc" => Some(Self::Desc),
+            _ => None,
+        }
+    }
+
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::Asc => "asc",
+            Self::Desc => "desc",
+        }
+    }
+}
+
+/// Map a `Status` to a numeric value for semantic sorting.
+/// Lower values sort first in ascending order: open β†’ in_progress β†’ closed.
+fn status_sort_key(s: db::Status) -> i32 {
+    match s {
+        db::Status::Open => 1,
+        db::Status::InProgress => 2,
+        db::Status::Closed => 3,
+    }
+}
+
+/// Apply the chosen sort field and direction to a filtered task list.
+fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
+    tasks.sort_by(|a, b| {
+        let cmp = match field {
+            SortField::Id => a.id.as_str().cmp(b.id.as_str()),
+            SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
+            SortField::Priority => a.priority.score().cmp(&b.priority.score()),
+            SortField::Effort => a.effort.score().cmp(&b.effort.score()),
+            SortField::Title => a
+                .title
+                .to_ascii_lowercase()
+                .cmp(&b.title.to_ascii_lowercase()),
+            SortField::Created => a.created_at.cmp(&b.created_at),
+        };
+        match order {
+            SortOrder::Asc => cmp,
+            SortOrder::Desc => cmp.reverse(),
+        }
+    });
+}
+
 /// 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.
@@ -120,6 +225,58 @@ struct BlockerRef {
     short_id: String,
 }
 
+/// Sort context passed to the task_table macro.  When present, column headers
+/// become clickable links that set sort/order query params.
+struct SortContext {
+    /// Base URL for sort links (e.g. `/projects/myproj`).
+    base_href: String,
+    /// Current sort field.
+    field: String,
+    /// Current sort order ("asc" or "desc").
+    order: String,
+    /// Query string fragment for the current filters (without sort/order/page),
+    /// suitable for appending to hrefs.
+    filter_qs: String,
+}
+
+impl SortContext {
+    /// Build the href for a column header link.  Clicking the currently-active
+    /// column toggles direction; clicking a different column uses its default.
+    fn column_href(&self, col: &str) -> String {
+        let order = if col == self.field {
+            // Toggle current direction.
+            match self.order.as_str() {
+                "asc" => "desc",
+                _ => "asc",
+            }
+        } else {
+            // Use the column's sensible default.
+            SortField::parse(col)
+                .map(|f| f.default_order().as_str())
+                .unwrap_or("asc")
+        };
+        let mut qs = self.filter_qs.clone();
+        if !qs.is_empty() {
+            qs.push('&');
+        }
+        qs.push_str(&format!("sort={col}&order={order}"));
+        format!("{}?{qs}", self.base_href)
+    }
+
+    /// Return the arrow indicator for the active column, or empty string.
+    fn arrow(&self, col: &str) -> &str {
+        if col == self.field {
+            match self.order.as_str() {
+                "asc" => " ↑",
+                "desc" => " ↓",
+                _ => "",
+            }
+        } else {
+            ""
+        }
+    }
+}
+
 // ---------------------------------------------------------------------------
 // Askama templates
 // ---------------------------------------------------------------------------
@@ -152,12 +309,13 @@ struct ProjectTemplate {
     page: usize,
     total_pages: usize,
     pagination_pages: Vec<usize>,
+    sort_ctx: SortContext,
 }
 
 impl ProjectTemplate {
-    /// Build a pagination link preserving current filter query params.
-    fn pagination_href(&self, target_page: &usize) -> String {
-        let target_page = *target_page;
+    /// Build a query-string fragment containing the current filters (no sort,
+    /// no page).  Reused by both pagination and sort helpers.
+    fn filter_qs(&self) -> String {
         let mut parts = Vec::new();
         if let Some(ref s) = self.filter_status {
             parts.push(format!("status={s}"));
@@ -174,8 +332,22 @@ impl ProjectTemplate {
         if !self.filter_search.is_empty() {
             parts.push(format!("q={}", self.filter_search));
         }
-        parts.push(format!("page={target_page}"));
-        format!("/projects/{}?{}", self.project_name, parts.join("&"))
+        parts.join("&")
+    }
+
+    /// Build a pagination link preserving current filter and sort params.
+    fn pagination_href(&self, target_page: &usize) -> String {
+        let target_page = *target_page;
+        let mut qs = self.filter_qs();
+        if !qs.is_empty() {
+            qs.push('&');
+        }
+        qs.push_str(&format!(
+            "sort={}&order={}",
+            self.sort_ctx.field, self.sort_ctx.order
+        ));
+        qs.push_str(&format!("&page={target_page}"));
+        format!("/projects/{}?{qs}", self.project_name)
     }
 }
 
@@ -295,6 +467,8 @@ struct ProjectQuery {
     label: Option<String>,
     q: Option<String>,
     page: Option<usize>,
+    sort: Option<String>,
+    order: Option<String>,
 }
 
 async fn project_handler(
@@ -420,8 +594,18 @@ async fn project_handler(
             filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
         }
 
-        // Sort: priority score ascending, then created_at.
-        filtered.sort_by_key(|t| (t.priority.score(), t.created_at.clone()));
+        // Sort: user-selected column, or priority+created as default.
+        let sort_field = query
+            .sort
+            .as_deref()
+            .and_then(SortField::parse)
+            .unwrap_or(SortField::Priority);
+        let sort_order = query
+            .order
+            .as_deref()
+            .and_then(SortOrder::parse)
+            .unwrap_or_else(|| sort_field.default_order());
+        sort_tasks(&mut filtered, sort_field, sort_order);
 
         // Pagination.
         let total = filtered.len();
@@ -448,10 +632,39 @@ async fn project_handler(
             })
             .collect();
 
+        // Build filter query string for sort links (excludes sort/order/page).
+        let filter_qs = {
+            let mut parts = Vec::new();
+            if let Some(ref s) = query.status {
+                parts.push(format!("status={s}"));
+            }
+            if let Some(ref p) = query.priority {
+                parts.push(format!("priority={p}"));
+            }
+            if let Some(ref e) = query.effort {
+                parts.push(format!("effort={e}"));
+            }
+            if let Some(ref l) = query.label {
+                parts.push(format!("label={l}"));
+            }
+            if !search_term.is_empty() {
+                parts.push(format!("q={search_term}"));
+            }
+            parts.join("&")
+        };
+
+        let proj_name = store.project_name().to_string();
+        let sort_ctx = SortContext {
+            base_href: format!("/projects/{proj_name}"),
+            field: sort_field.as_str().to_string(),
+            order: sort_order.as_str().to_string(),
+            filter_qs,
+        };
+
         Ok(ProjectTemplate {
             all_projects,
             active_project: Some(name),
-            project_name: store.project_name().to_string(),
+            project_name: proj_name,
             stats_open,
             stats_in_progress,
             stats_closed,
@@ -466,6 +679,7 @@ async fn project_handler(
             page,
             total_pages,
             pagination_pages: (1..=total_pages).collect(),
+            sort_ctx,
         })
     })
     .await;
@@ -595,6 +809,16 @@ async fn static_js() -> impl IntoResponse {
     )
 }
 
+async fn static_td_js() -> impl IntoResponse {
+    (
+        [(
+            axum::http::header::CONTENT_TYPE,
+            "application/javascript; charset=utf-8",
+        )],
+        include_bytes!("../../static/td.js").as_slice(),
+    )
+}
+
 // ---------------------------------------------------------------------------
 // Entry point
 // ---------------------------------------------------------------------------
@@ -612,6 +836,7 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) ->
         .route("/static/oat.min.css", get(static_oat_css))
         .route("/static/td.css", get(static_td_css))
         .route("/static/oat.min.js", get(static_js))
+        .route("/static/td.js", get(static_td_js))
         .with_state(state);
 
     let addr = format!("{host}:{port}");

static/td.js πŸ”—

@@ -0,0 +1,42 @@
+console.log("[td] td.js loaded");
+
+document.addEventListener("DOMContentLoaded", () => {
+  console.log("[td] DOMContentLoaded fired");
+
+  const timeEls = document.querySelectorAll("time[datetime]");
+  console.log("[td] Found %d <time> elements", timeEls.length);
+  timeEls.forEach((el, i) => {
+    const raw = el.getAttribute("datetime");
+    const d = new Date(raw);
+    const before = el.textContent;
+    console.log("[td] time[%d] raw=%s parsed=%s valid=%s before=%s", i, raw, d.toISOString?.() ?? d, !isNaN(d), before);
+    if (!isNaN(d)) {
+      el.textContent = d.toLocaleString(undefined, {
+        day: "numeric", month: "short", year: "numeric",
+        hour: "2-digit", minute: "2-digit"
+      });
+      console.log("[td] time[%d] after=%s", i, el.textContent);
+    } else {
+      console.warn("[td] Could not parse datetime: %s", raw);
+    }
+  });
+
+  // Reveal copy-to-clipboard buttons only when the API is available
+  // (requires a secure context: HTTPS or localhost).
+  console.log("[td] navigator.clipboard available: %s", !!navigator.clipboard);
+  if (navigator.clipboard) {
+    const copyIcon = '<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"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
+    const checkIcon = '<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="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
+    const copyBtns = document.querySelectorAll(".js-copy-id");
+    console.log("[td] Found %d copy buttons", copyBtns.length);
+    copyBtns.forEach(btn => {
+      btn.hidden = false;
+      btn.addEventListener("click", () => {
+        navigator.clipboard.writeText(btn.dataset.copy).then(() => {
+          btn.innerHTML = checkIcon;
+          setTimeout(() => { btn.innerHTML = copyIcon; }, 1500);
+        });
+      });
+    });
+  }
+});

templates/base.html πŸ”—

@@ -7,35 +7,7 @@
   <link rel="stylesheet" href="/static/oat.min.css">
   <link rel="stylesheet" href="/static/td.css">
   <script src="/static/oat.min.js" defer></script>
-  <script defer>
-    document.addEventListener("DOMContentLoaded", () => {
-      document.querySelectorAll("time[datetime]").forEach(el => {
-        const d = new Date(el.getAttribute("datetime"));
-        if (!isNaN(d)) {
-          el.textContent = d.toLocaleString(undefined, {
-            day: "numeric", month: "short", year: "numeric",
-            hour: "2-digit", minute: "2-digit"
-          });
-        }
-      });
-
-      // Reveal copy-to-clipboard buttons only when the API is available
-      // (requires a secure context: HTTPS or localhost).
-      if (navigator.clipboard) {
-        const copyIcon = '<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"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
-        const checkIcon = '<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="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
-        document.querySelectorAll(".js-copy-id").forEach(btn => {
-          btn.hidden = false;
-          btn.addEventListener("click", () => {
-            navigator.clipboard.writeText(btn.dataset.copy).then(() => {
-              btn.innerHTML = checkIcon;
-              setTimeout(() => { btn.innerHTML = copyIcon; }, 1500);
-            });
-          });
-        });
-      }
-    });
-  </script>
+  <script src="/static/td.js" defer></script>
 </head>
 <body data-sidebar-layout>
   <a href="#main-content" class="skip-link">Skip to main content</a>

templates/macros.html πŸ”—

@@ -27,3 +27,33 @@
   </table>
 </div>
 {% endmacro %}
+
+{% macro sortable_task_table(project_name, tasks, caption, sort_ctx) %}
+<div class="table">
+  <table>
+    <caption class="sr-only">{{ caption }}</caption>
+    <thead>
+      <tr>
+        <th scope="col"><a href="{{ sort_ctx.column_href("id") }}">ID{{ sort_ctx.arrow("id") }}</a></th>
+        <th scope="col"><a href="{{ sort_ctx.column_href("status") }}">Status{{ sort_ctx.arrow("status") }}</a></th>
+        <th scope="col"><a href="{{ sort_ctx.column_href("priority") }}">Priority{{ sort_ctx.arrow("priority") }}</a></th>
+        <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>
+      </tr>
+    </thead>
+    <tbody>
+      {% for t in tasks %}
+      <tr>
+        <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></td>
+        <td><span class="badge{% if t.status == "closed" %} success{% elif t.status == "in_progress" %} secondary{% endif %}">{{ t.status }}</span></td>
+        <td>{{ t.priority }}</td>
+        <td>{{ t.effort }}</td>
+        <td>{{ t.title }}</td>
+        <td><time datetime="{{ t.created_at }}">{{ t.created_at_display }}</time></td>
+      </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+</div>
+{% endmacro %}

templates/project.html πŸ”—

@@ -58,7 +58,7 @@
 </details>
 {% endif %}
 
-<details{% if next_up.is_empty() || filter_priority.is_some() || filter_effort.is_some() || filter_label.is_some() || !filter_search.is_empty() || filter_status.as_deref() != Some("open") %} open{% endif %} class="mt-4">
+<details{% if next_up.is_empty() || filter_priority.is_some() || filter_effort.is_some() || filter_label.is_some() || !filter_search.is_empty() || filter_status.as_deref() != Some("open") || sort_ctx.field != "priority" || sort_ctx.order != "asc" %} open{% endif %} class="mt-4">
   <summary>Tasks</summary>
 
   <form method="get" action="/projects/{{ project_name }}" class="hstack gap-2 mt-2 mb-4">
@@ -102,13 +102,15 @@
       <label for="filter-search">Search</label>
       <input type="text" id="filter-search" name="q" value="{{ filter_search }}" placeholder="Search titles…" aria-label="Search tasks by title">
     </div>
+    <input type="hidden" name="sort" value="{{ sort_ctx.field }}">
+    <input type="hidden" name="order" value="{{ sort_ctx.order }}">
     <button type="submit" class="outline">Filter</button>
   </form>
 
   {% if page_tasks.is_empty() %}
   <p class="text-light">No tasks match the current filters.</p>
   {% else %}
-  {% call macros::task_table(project_name, page_tasks, "Task list for project") %}{% endcall %}
+  {% call macros::sortable_task_table(project_name, page_tasks, "Task list for project", sort_ctx) %}{% endcall %}
 
   {% if total_pages > 1 %}
   <nav aria-label="Task list pagination" class="mt-4">