@@ -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}");
@@ -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);
+ });
+ });
+ });
+ }
+});