From 493e201363c88fc8245ea8d319c18b565abcd05d Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 16 Mar 2026 21:09:08 -0600 Subject: [PATCH] Add sortable columns to web UI task table --- 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(-) create mode 100644 static/td.js diff --git a/src/cmd/webui.rs b/src/cmd/webui.rs index fde7217c2ad3cef125ab5dcb93b2a9cb17486917..4685abcf3418489574ebdce181e2ac9c520e16cf 100644 --- a/src/cmd/webui.rs +++ b/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 { + 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 { + 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, + 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, q: Option, page: Option, + sort: Option, + order: Option, } 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}"); diff --git a/static/td.js b/static/td.js new file mode 100644 index 0000000000000000000000000000000000000000..08f4e2fb7681c113fc654847aa15c7b6959d44bf --- /dev/null +++ b/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