Split project page tasks table into per-status sections

Amolith created

Change summary

src/cmd/webui/project/mod.rs   | 419 +++++++++++++++++++++++++----------
src/cmd/webui/project/views.rs | 158 +++++++++---
templates/macros.html          | 219 +++++++++++-------
templates/project.html         | 108 ++------
4 files changed, 573 insertions(+), 331 deletions(-)

Detailed changes

src/cmd/webui/project/mod.rs πŸ”—

@@ -5,7 +5,7 @@ use axum::extract::{Path as AxumPath, Query, State};
 use axum::response::Response;
 
 use crate::db::Store;
-use crate::model::{Effort, Priority, Status, TaskId};
+use crate::model::{Effort, Priority, Status, Task, TaskId};
 use crate::score;
 
 use super::helpers::{error_response, friendly_date, friendly_status, list_projects_safe, render};
@@ -16,13 +16,44 @@ mod sorting;
 pub(super) mod views;
 
 use sorting::{SortField, SortOrder};
-use views::{ProjectTemplate, ScoredEntry, SortContext, TaskRow};
+use views::{ProjectTemplate, ScoredEntry, SectionState, SortContext, TaskRow};
 
 const PAGE_SIZE: usize = 25;
 
-#[derive(serde::Deserialize)]
+/// Query params for the project page. Each section has its own namespaced
+/// set of filter/sort/page params (prefixed ip_, open_, closed_).
+#[derive(serde::Deserialize, Default)]
 pub(super) struct ProjectQuery {
-    status: Option<String>,
+    // In-progress section.
+    ip_priority: Option<String>,
+    ip_effort: Option<String>,
+    ip_label: Option<String>,
+    ip_q: Option<String>,
+    ip_page: Option<usize>,
+    ip_sort: Option<String>,
+    ip_order: Option<String>,
+
+    // Open section.
+    open_priority: Option<String>,
+    open_effort: Option<String>,
+    open_label: Option<String>,
+    open_q: Option<String>,
+    open_page: Option<usize>,
+    open_sort: Option<String>,
+    open_order: Option<String>,
+
+    // Closed section.
+    closed_priority: Option<String>,
+    closed_effort: Option<String>,
+    closed_label: Option<String>,
+    closed_q: Option<String>,
+    closed_page: Option<usize>,
+    closed_sort: Option<String>,
+    closed_order: Option<String>,
+}
+
+/// Per-section query params extracted from the namespaced query string.
+struct SectionQuery {
     priority: Option<String>,
     effort: Option<String>,
     label: Option<String>,
@@ -32,15 +63,234 @@ pub(super) struct ProjectQuery {
     order: Option<String>,
 }
 
+impl SectionQuery {
+    /// Returns true if any param differs from defaults (used for the
+    /// `<details open>` heuristic).
+    fn has_user_params(&self) -> bool {
+        self.priority.is_some()
+            || self.effort.is_some()
+            || self.label.is_some()
+            || self.q.as_deref().is_some_and(|s| !s.is_empty())
+            || self.page.is_some_and(|p| p > 1)
+            || self.sort.is_some()
+            || self.order.is_some()
+    }
+}
+
+/// Build the preserve_qs for a given section β€” the full query string for the
+/// current page state, minus the given section's sort/order/page (those get
+/// rebuilt by sort and pagination links). Filter params for the target section
+/// are also excluded since the filter form will supply them.
+fn build_preserve_qs(query: &ProjectQuery, exclude_prefix: &str) -> String {
+    let mut parts = Vec::new();
+
+    for (prefix, sq) in [
+        ("ip_", extract_section(query, "ip_")),
+        ("open_", extract_section(query, "open_")),
+        ("closed_", extract_section(query, "closed_")),
+    ] {
+        if prefix == exclude_prefix {
+            // This section's params are managed by its own form/sort/pagination.
+            continue;
+        }
+        if let Some(ref p) = sq.priority {
+            parts.push(format!("{prefix}priority={p}"));
+        }
+        if let Some(ref e) = sq.effort {
+            parts.push(format!("{prefix}effort={e}"));
+        }
+        if let Some(ref l) = sq.label {
+            parts.push(format!("{prefix}label={l}"));
+        }
+        let search = sq.q.unwrap_or_default();
+        if !search.is_empty() {
+            parts.push(format!("{prefix}q={search}"));
+        }
+        let sort_field = sq
+            .sort
+            .as_deref()
+            .and_then(SortField::parse)
+            .unwrap_or(SortField::Priority);
+        let sort_order = sq
+            .order
+            .as_deref()
+            .and_then(SortOrder::parse)
+            .unwrap_or_else(|| sort_field.default_order());
+        if sort_field != SortField::Priority || sort_order != SortOrder::Asc {
+            parts.push(format!(
+                "{prefix}sort={}&{prefix}order={}",
+                sort_field.as_str(),
+                sort_order.as_str()
+            ));
+        }
+        if let Some(page) = sq.page {
+            if page > 1 {
+                parts.push(format!("{prefix}page={page}"));
+            }
+        }
+    }
+
+    parts.join("&")
+}
+
+/// Extract a section's query params from the flat ProjectQuery.
+fn extract_section(query: &ProjectQuery, prefix: &str) -> SectionQuery {
+    match prefix {
+        "ip_" => SectionQuery {
+            priority: query.ip_priority.clone(),
+            effort: query.ip_effort.clone(),
+            label: query.ip_label.clone(),
+            q: query.ip_q.clone(),
+            page: query.ip_page,
+            sort: query.ip_sort.clone(),
+            order: query.ip_order.clone(),
+        },
+        "open_" => SectionQuery {
+            priority: query.open_priority.clone(),
+            effort: query.open_effort.clone(),
+            label: query.open_label.clone(),
+            q: query.open_q.clone(),
+            page: query.open_page,
+            sort: query.open_sort.clone(),
+            order: query.open_order.clone(),
+        },
+        "closed_" => SectionQuery {
+            priority: query.closed_priority.clone(),
+            effort: query.closed_effort.clone(),
+            label: query.closed_label.clone(),
+            q: query.closed_q.clone(),
+            page: query.closed_page,
+            sort: query.closed_sort.clone(),
+            order: query.closed_order.clone(),
+        },
+        _ => SectionQuery {
+            priority: None,
+            effort: None,
+            label: None,
+            q: None,
+            page: None,
+            sort: None,
+            order: None,
+        },
+    }
+}
+
+/// Build a SectionState from tasks of a given status and a section's query
+/// params.
+fn build_section(
+    all_tasks: &[Task],
+    status: Status,
+    label: &'static str,
+    prefix: &str,
+    sq: &SectionQuery,
+    base_href: &str,
+    preserve_qs: String,
+) -> SectionState {
+    // All tasks with this status (unfiltered count).
+    let status_tasks: Vec<&Task> = all_tasks.iter().filter(|t| t.status == status).collect();
+    let total_count = status_tasks.len();
+
+    // Apply filters.
+    let mut filtered: Vec<&Task> = status_tasks;
+
+    if let Some(ref p) = sq.priority {
+        if !p.is_empty() {
+            if let Ok(parsed) = Priority::parse(p) {
+                filtered.retain(|t| t.priority == parsed);
+            }
+        }
+    }
+    if let Some(ref e) = sq.effort {
+        if !e.is_empty() {
+            if let Ok(parsed) = Effort::parse(e) {
+                filtered.retain(|t| t.effort == parsed);
+            }
+        }
+    }
+    if let Some(ref l) = sq.label {
+        if !l.is_empty() {
+            filtered.retain(|t| t.labels.iter().any(|x| x == l));
+        }
+    }
+    let search_term = sq.q.clone().unwrap_or_default();
+    if !search_term.is_empty() {
+        let q = search_term.to_ascii_lowercase();
+        filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
+    }
+
+    let filtered_count = filtered.len();
+
+    // Sort.
+    let sort_field = sq
+        .sort
+        .as_deref()
+        .and_then(SortField::parse)
+        .unwrap_or(SortField::Priority);
+    let sort_order = sq
+        .order
+        .as_deref()
+        .and_then(SortOrder::parse)
+        .unwrap_or_else(|| sort_field.default_order());
+    sorting::sort_tasks(&mut filtered, sort_field, sort_order);
+
+    // Pagination.
+    let total_pages = if filtered_count == 0 {
+        1
+    } else {
+        filtered_count.div_ceil(PAGE_SIZE)
+    };
+    let page = sq.page.unwrap_or(1).clamp(1, total_pages);
+    let start = (page - 1) * PAGE_SIZE;
+    let end = (start + PAGE_SIZE).min(filtered_count);
+
+    let tasks: Vec<TaskRow> = filtered[start..end]
+        .iter()
+        .map(|t| {
+            let s = t.status.as_str().to_string();
+            TaskRow {
+                full_id: t.id.as_str().to_string(),
+                short_id: t.id.short(),
+                status_display: friendly_status(&s),
+                status: s,
+                priority: t.priority.as_str().to_string(),
+                effort: t.effort.as_str().to_string(),
+                title: t.title.clone(),
+                created_at_display: friendly_date(&t.created_at),
+                created_at: t.created_at.clone(),
+            }
+        })
+        .collect();
+
+    let sort_ctx = SortContext {
+        base_href: base_href.to_string(),
+        prefix: prefix.to_string(),
+        field: sort_field.as_str().to_string(),
+        order: sort_order.as_str().to_string(),
+        preserve_qs,
+    };
+
+    SectionState {
+        label,
+        total_count,
+        filtered_count,
+        tasks,
+        sort_ctx,
+        filter_priority: sq.priority.clone(),
+        filter_effort: sq.effort.clone(),
+        filter_label: sq.label.clone(),
+        filter_search: search_term,
+        page,
+        total_pages,
+        pagination_pages: (1..=total_pages).collect(),
+        has_user_params: sq.has_user_params(),
+    }
+}
+
 pub(in crate::cmd::webui) async fn project_handler(
     State(state): State<AppState>,
     AxumPath(name): AxumPath<String>,
-    Query(mut query): Query<ProjectQuery>,
+    Query(query): Query<ProjectQuery>,
 ) -> Response {
-    // Default to showing open tasks when no status filter is specified.
-    if query.status.is_none() {
-        query.status = Some("open".to_string());
-    }
     let root = state.data_root.clone();
     let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
         let all_projects = list_projects_safe(&root);
@@ -116,111 +366,45 @@ pub(in crate::cmd::webui) async fn project_handler(
             })
             .collect();
 
-        // Apply filters.
-        let mut filtered: Vec<&crate::model::Task> = tasks.iter().collect();
-
-        if let Some(ref s) = query.status {
-            if !s.is_empty() {
-                if let Ok(parsed) = Status::parse(s) {
-                    filtered.retain(|t| t.status == parsed);
-                }
-            }
-        }
-        if let Some(ref p) = query.priority {
-            if !p.is_empty() {
-                if let Ok(parsed) = Priority::parse(p) {
-                    filtered.retain(|t| t.priority == parsed);
-                }
-            }
-        }
-        if let Some(ref e) = query.effort {
-            if !e.is_empty() {
-                if let Ok(parsed) = Effort::parse(e) {
-                    filtered.retain(|t| t.effort == parsed);
-                }
-            }
-        }
-        if let Some(ref l) = query.label {
-            if !l.is_empty() {
-                filtered.retain(|t| t.labels.iter().any(|x| x == l));
-            }
-        }
-        let search_term = query.q.clone().unwrap_or_default();
-        if !search_term.is_empty() {
-            let q = search_term.to_ascii_lowercase();
-            filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
-        }
+        let proj_name = store.project_name().to_string();
+        let base_href = format!("/projects/{proj_name}");
 
-        // 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());
-        sorting::sort_tasks(&mut filtered, sort_field, sort_order);
-
-        // Pagination.
-        let total = filtered.len();
-        let total_pages = if total == 0 {
-            1
-        } else {
-            total.div_ceil(PAGE_SIZE)
-        };
-        let page = query.page.unwrap_or(1).clamp(1, total_pages);
-        let start = (page - 1) * PAGE_SIZE;
-        let end = (start + PAGE_SIZE).min(total);
-
-        let page_tasks: Vec<TaskRow> = filtered[start..end]
-            .iter()
-            .map(|t| {
-                let status = t.status.as_str().to_string();
-                TaskRow {
-                    full_id: t.id.as_str().to_string(),
-                    short_id: t.id.short(),
-                    status_display: friendly_status(&status),
-                    status,
-                    priority: t.priority.as_str().to_string(),
-                    effort: t.effort.as_str().to_string(),
-                    title: t.title.clone(),
-                    created_at_display: friendly_date(&t.created_at),
-                    created_at: t.created_at.clone(),
-                }
-            })
-            .collect();
+        // Build each section with its own namespaced query params.
+        let ip_sq = extract_section(&query, "ip_");
+        let open_sq = extract_section(&query, "open_");
+        let closed_sq = extract_section(&query, "closed_");
 
-        // 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 ip_preserve = build_preserve_qs(&query, "ip_");
+        let open_preserve = build_preserve_qs(&query, "open_");
+        let closed_preserve = build_preserve_qs(&query, "closed_");
 
-        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,
-        };
+        let in_progress = build_section(
+            &tasks,
+            Status::InProgress,
+            "In progress",
+            "ip_",
+            &ip_sq,
+            &base_href,
+            ip_preserve,
+        );
+        let open = build_section(
+            &tasks,
+            Status::Open,
+            "Open",
+            "open_",
+            &open_sq,
+            &base_href,
+            open_preserve,
+        );
+        let closed = build_section(
+            &tasks,
+            Status::Closed,
+            "Closed",
+            "closed_",
+            &closed_sq,
+            &base_href,
+            closed_preserve,
+        );
 
         Ok(ProjectTemplate {
             all_projects,
@@ -230,17 +414,10 @@ pub(in crate::cmd::webui) async fn project_handler(
             stats_in_progress,
             stats_closed,
             next_up,
-            page_tasks,
             all_labels,
-            filter_status: query.status,
-            filter_priority: query.priority,
-            filter_effort: query.effort,
-            filter_label: query.label,
-            filter_search: search_term,
-            page,
-            total_pages,
-            pagination_pages: (1..=total_pages).collect(),
-            sort_ctx,
+            in_progress,
+            open,
+            closed,
         })
     })
     .await;

src/cmd/webui/project/views.rs πŸ”—

@@ -25,18 +25,21 @@ pub(in crate::cmd::webui) struct TaskRow {
     pub(in crate::cmd::webui) created_at_display: String,
 }
 
-/// Sort context passed to the task_table macro. When present, column headers
-/// become clickable links that set sort/order query params.
+/// Sort context for a section's task table. Column headers become clickable
+/// links that set sort/order query params namespaced by the section prefix.
 pub(in crate::cmd::webui) struct SortContext {
     /// Base URL for sort links (e.g. `/projects/myproj`).
     pub(in crate::cmd::webui) base_href: String,
+    /// Query param prefix for this section (e.g. "ip_", "open_", "closed_").
+    pub(in crate::cmd::webui) prefix: String,
     /// Current sort field.
     pub(in crate::cmd::webui) field: String,
     /// Current sort order ("asc" or "desc").
     pub(in crate::cmd::webui) order: String,
-    /// Query string fragment for the current filters (without sort/order/page),
-    /// suitable for appending to hrefs.
-    pub(in crate::cmd::webui) filter_qs: String,
+    /// Full query string for the current page state (all sections' params),
+    /// excluding this section's sort/order/page. Used for building links
+    /// that preserve other sections' state.
+    pub(in crate::cmd::webui) preserve_qs: String,
 }
 
 impl SortContext {
@@ -55,11 +58,14 @@ impl SortContext {
                 .map(|f| f.default_order().as_str())
                 .unwrap_or("asc")
         };
-        let mut qs = self.filter_qs.clone();
+        let mut qs = self.preserve_qs.clone();
         if !qs.is_empty() {
             qs.push('&');
         }
-        qs.push_str(&format!("sort={col}&order={order}"));
+        qs.push_str(&format!(
+            "{}sort={col}&{}order={order}",
+            self.prefix, self.prefix
+        ));
         format!("{}?{qs}", self.base_href)
     }
 
@@ -77,64 +83,128 @@ impl SortContext {
     }
 }
 
-#[derive(Template)]
-#[template(path = "project.html")]
-pub(super) struct ProjectTemplate {
-    pub(super) all_projects: Vec<String>,
-    pub(super) active_project: Option<String>,
-    pub(super) project_name: String,
-    pub(super) stats_open: usize,
-    pub(super) stats_in_progress: usize,
-    pub(super) stats_closed: usize,
-    pub(super) next_up: Vec<ScoredEntry>,
-    pub(super) page_tasks: Vec<TaskRow>,
-    pub(super) all_labels: Vec<String>,
-    pub(super) filter_status: Option<String>,
-    pub(super) filter_priority: Option<String>,
-    pub(super) filter_effort: Option<String>,
-    pub(super) filter_label: Option<String>,
-    pub(super) filter_search: String,
-    pub(super) page: usize,
-    pub(super) total_pages: usize,
-    pub(super) pagination_pages: Vec<usize>,
-    pub(super) sort_ctx: SortContext,
+/// All the state needed to render one status section (in-progress, open, or
+/// closed) on the project page.
+pub(in crate::cmd::webui) struct SectionState {
+    /// Human-friendly label (e.g. "In progress").
+    pub(in crate::cmd::webui) label: &'static str,
+    /// Total tasks with this status (unfiltered).
+    pub(in crate::cmd::webui) total_count: usize,
+    /// Tasks matching current filters.
+    pub(in crate::cmd::webui) filtered_count: usize,
+    /// Task rows for the current page.
+    pub(in crate::cmd::webui) tasks: Vec<TaskRow>,
+    /// Sort context for this section's table.
+    pub(in crate::cmd::webui) sort_ctx: SortContext,
+    /// Current filter values for this section.
+    pub(in crate::cmd::webui) filter_priority: Option<String>,
+    pub(in crate::cmd::webui) filter_effort: Option<String>,
+    pub(in crate::cmd::webui) filter_label: Option<String>,
+    pub(in crate::cmd::webui) filter_search: String,
+    /// Current page number (1-indexed).
+    pub(in crate::cmd::webui) page: usize,
+    pub(in crate::cmd::webui) total_pages: usize,
+    pub(in crate::cmd::webui) pagination_pages: Vec<usize>,
+    /// Whether any non-default params are set for this section (used for
+    /// details open heuristic).
+    pub(in crate::cmd::webui) has_user_params: bool,
 }
 
-impl ProjectTemplate {
-    /// 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 {
+impl SectionState {
+    /// Build a query-string fragment for this section's current filters
+    /// (excludes sort, order, page).
+    fn section_filter_qs(&self) -> String {
+        let prefix = &self.sort_ctx.prefix;
         let mut parts = Vec::new();
-        if let Some(ref s) = self.filter_status {
-            parts.push(format!("status={s}"));
-        }
         if let Some(ref p) = self.filter_priority {
-            parts.push(format!("priority={p}"));
+            parts.push(format!("{prefix}priority={p}"));
         }
         if let Some(ref e) = self.filter_effort {
-            parts.push(format!("effort={e}"));
+            parts.push(format!("{prefix}effort={e}"));
         }
         if let Some(ref l) = self.filter_label {
-            parts.push(format!("label={l}"));
+            parts.push(format!("{prefix}label={l}"));
         }
         if !self.filter_search.is_empty() {
-            parts.push(format!("q={}", self.filter_search));
+            parts.push(format!("{prefix}q={}", self.filter_search));
         }
         parts.join("&")
     }
 
-    /// Build a pagination link preserving current filter and sort params.
+    /// Build a pagination link for this section, preserving all page state.
     fn pagination_href(&self, target_page: &usize) -> String {
         let target_page = *target_page;
-        let mut qs = self.filter_qs();
+        let prefix = &self.sort_ctx.prefix;
+        let mut qs = self.sort_ctx.preserve_qs.clone();
+        // Add this section's filter params.
+        let fqs = self.section_filter_qs();
+        if !fqs.is_empty() {
+            if !qs.is_empty() {
+                qs.push('&');
+            }
+            qs.push_str(&fqs);
+        }
+        // Add sort/order for this section.
         if !qs.is_empty() {
             qs.push('&');
         }
         qs.push_str(&format!(
-            "sort={}&order={}",
+            "{prefix}sort={}&{prefix}order={}",
             self.sort_ctx.field, self.sort_ctx.order
         ));
-        qs.push_str(&format!("&page={target_page}"));
-        format!("/projects/{}?{qs}", self.project_name)
+        qs.push_str(&format!("&{prefix}page={target_page}"));
+        format!("{}?{qs}", self.sort_ctx.base_href)
+    }
+}
+
+#[derive(Template)]
+#[template(path = "project.html")]
+pub(super) struct ProjectTemplate {
+    pub(super) all_projects: Vec<String>,
+    pub(super) active_project: Option<String>,
+    pub(super) project_name: String,
+    pub(super) stats_open: usize,
+    pub(super) stats_in_progress: usize,
+    pub(super) stats_closed: usize,
+    pub(super) next_up: Vec<ScoredEntry>,
+    pub(super) all_labels: Vec<String>,
+    pub(super) in_progress: SectionState,
+    pub(super) open: SectionState,
+    pub(super) closed: SectionState,
+}
+
+impl ProjectTemplate {
+    /// Build the complete current-page query string (all sections) for use
+    /// in mutation redirect hidden fields.
+    fn full_current_qs(&self) -> String {
+        let mut parts = Vec::new();
+        for section in [&self.in_progress, &self.open, &self.closed] {
+            let prefix = &section.sort_ctx.prefix;
+            let fqs = section.section_filter_qs();
+            if !fqs.is_empty() {
+                parts.push(fqs);
+            }
+            if section.sort_ctx.field != "priority" || section.sort_ctx.order != "asc" {
+                parts.push(format!(
+                    "{prefix}sort={}&{prefix}order={}",
+                    section.sort_ctx.field, section.sort_ctx.order
+                ));
+            }
+            if section.page > 1 {
+                parts.push(format!("{prefix}page={}", section.page));
+            }
+        }
+        parts.join("&")
+    }
+
+    /// Build the full redirect URL for mutation forms, preserving current
+    /// page state.
+    fn mutation_redirect(&self) -> String {
+        let qs = self.full_current_qs();
+        if qs.is_empty() {
+            format!("/projects/{}", self.project_name)
+        } else {
+            format!("/projects/{}?{qs}", self.project_name)
+        }
     }
 }

templates/macros.html πŸ”—

@@ -1,91 +1,134 @@
-{% macro task_table(project_name, tasks, caption) %}
-<div class="table">
-  <table>
-    <caption class="sr-only">{{ caption }}</caption>
-    <thead>
-      <tr>
-        <th scope="col">ID</th>
-        <th scope="col">Status</th>
-        <th scope="col">Priority</th>
-        <th scope="col">Effort</th>
-        <th scope="col">Title</th>
-        <th scope="col">Created</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 == "open" %} success{% elif t.status == "in_progress" %} warning{% 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 %}
+{% macro task_section(project_name, section, all_labels, redirect_url) %}
+  <form method="get" action="/projects/{{ project_name }}" class="hstack gap-2 mt-2 mb-4">
+    <div data-field>
+      <label for="{{ section.sort_ctx.prefix }}filter-priority">Priority</label>
+      <select id="{{ section.sort_ctx.prefix }}filter-priority" name="{{ section.sort_ctx.prefix }}priority" aria-label="Filter by priority">
+        <option value="">All</option>
+        <option value="high"{% if section.filter_priority.as_deref() == Some("high") %} selected{% endif %}>High</option>
+        <option value="medium"{% if section.filter_priority.as_deref() == Some("medium") %} selected{% endif %}>Medium</option>
+        <option value="low"{% if section.filter_priority.as_deref() == Some("low") %} selected{% endif %}>Low</option>
+      </select>
+    </div>
+    <div data-field>
+      <label for="{{ section.sort_ctx.prefix }}filter-effort">Effort</label>
+      <select id="{{ section.sort_ctx.prefix }}filter-effort" name="{{ section.sort_ctx.prefix }}effort" aria-label="Filter by effort">
+        <option value="">All</option>
+        <option value="low"{% if section.filter_effort.as_deref() == Some("low") %} selected{% endif %}>Low</option>
+        <option value="medium"{% if section.filter_effort.as_deref() == Some("medium") %} selected{% endif %}>Medium</option>
+        <option value="high"{% if section.filter_effort.as_deref() == Some("high") %} selected{% endif %}>High</option>
+      </select>
+    </div>
+    <div data-field>
+      <label for="{{ section.sort_ctx.prefix }}filter-label">Label</label>
+      <select id="{{ section.sort_ctx.prefix }}filter-label" name="{{ section.sort_ctx.prefix }}label" aria-label="Filter by label">
+        <option value="">All</option>
+        {% for l in all_labels %}
+        <option value="{{ l }}"{% if section.filter_label.as_deref() == Some(l.as_str()) %} selected{% endif %}>{{ l }}</option>
+        {% endfor %}
+      </select>
+    </div>
+    <div data-field>
+      <label for="{{ section.sort_ctx.prefix }}filter-search">Search</label>
+      <input type="text" id="{{ section.sort_ctx.prefix }}filter-search" name="{{ section.sort_ctx.prefix }}q" value="{{ section.filter_search }}" placeholder="Search titles…" aria-label="Search tasks by title">
+    </div>
+    {# Preserve other sections' state as hidden fields when this form submits #}
+    {% let preserve = section.sort_ctx.preserve_qs %}
+    {% if !preserve.is_empty() %}
+    {% for pair in preserve.split('&') %}
+    {% let kv = pair.splitn(2, '=').collect::<Vec<_>>() %}
+    {% if kv.len() == 2 %}
+    <input type="hidden" name="{{ kv[0] }}" value="{{ kv[1] }}">
+    {% endif %}
+    {% endfor %}
+    {% endif %}
+    <input type="hidden" name="{{ section.sort_ctx.prefix }}sort" value="{{ section.sort_ctx.field }}">
+    <input type="hidden" name="{{ section.sort_ctx.prefix }}order" value="{{ section.sort_ctx.order }}">
+    <button type="submit" class="outline">Filter</button>
+  </form>
+
+  {% if section.tasks.is_empty() %}
+  <p class="text-light">No tasks match the current filters.</p>
+  {% else %}
+  <div class="table">
+    <table>
+      <caption class="sr-only">{{ section.label }} tasks for project</caption>
+      <thead>
+        <tr>
+          <th scope="col"><a href="{{ section.sort_ctx.column_href("id") }}">ID{{ section.sort_ctx.arrow("id") }}</a></th>
+          <th scope="col"><a href="{{ section.sort_ctx.column_href("priority") }}">Priority{{ section.sort_ctx.arrow("priority") }}</a></th>
+          <th scope="col"><a href="{{ section.sort_ctx.column_href("effort") }}">Effort{{ section.sort_ctx.arrow("effort") }}</a></th>
+          <th scope="col"><a href="{{ section.sort_ctx.column_href("title") }}">Title{{ section.sort_ctx.arrow("title") }}</a></th>
+          <th scope="col"><a href="{{ section.sort_ctx.column_href("created") }}">Created{{ section.sort_ctx.arrow("created") }}</a></th>
+          <th scope="col">Change status</th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for t in section.tasks %}
+        <tr>
+          <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></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>
+          <td>
+            <ot-dropdown>
+              <button popovertarget="{{ section.sort_ctx.prefix }}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="{{ section.sort_ctx.prefix }}status-{{ t.short_id }}">
+                <button role="menuitemradio" aria-checked="{{ t.status == "open" }}" {% if t.status == "open" %}disabled{% else %}form="{{ section.sort_ctx.prefix }}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="{{ section.sort_ctx.prefix }}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="{{ section.sort_ctx.prefix }}set-closed-{{ t.short_id }}"{% endif %}>Closed</button>
+              </menu>
+            </ot-dropdown>
+            {% if t.status != "open" %}
+            <form id="{{ section.sort_ctx.prefix }}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="{{ redirect_url }}">
+            </form>
+            {% endif %}
+            {% if t.status != "in_progress" %}
+            <form id="{{ section.sort_ctx.prefix }}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="{{ redirect_url }}">
+            </form>
+            {% endif %}
+            {% if t.status != "closed" %}
+            <form id="{{ section.sort_ctx.prefix }}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="{{ redirect_url }}">
+            </form>
+            {% endif %}
+          </td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
 
-{% 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>
-        <th scope="col">Change status</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 == "open" %} success{% elif t.status == "in_progress" %} warning{% 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>
-        <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 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>
-      </tr>
+  {% if section.total_pages > 1 %}
+  <nav aria-label="{{ section.label }} task list pagination" class="mt-4">
+    <menu class="buttons">
+      {% if section.page > 1 %}
+      {% let prev = section.page - 1 %}
+      <li><a href="{{ section.pagination_href(prev) }}" class="button outline small">← Previous</a></li>
+      {% endif %}
+      {% for p in section.pagination_pages %}
+      <li>
+        {% if *p == section.page %}
+        <a href="{{ section.pagination_href(p) }}" class="button small" aria-current="page">{{ p }}</a>
+        {% else %}
+        <a href="{{ section.pagination_href(p) }}" class="button outline small">{{ p }}</a>
+        {% endif %}
+      </li>
       {% endfor %}
-    </tbody>
-  </table>
-</div>
+      {% if section.page < section.total_pages %}
+      {% let next = section.page + 1 %}
+      <li><a href="{{ section.pagination_href(next) }}" class="button outline small">Next β†’</a></li>
+      {% endif %}
+    </menu>
+  </nav>
+  {% endif %}
+  {% endif %}
 {% endmacro %}

templates/project.html πŸ”—

@@ -66,19 +66,19 @@
             {% 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 }}">
+              <input type="hidden" name="redirect" value="{{ self.mutation_redirect() }}">
             </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 }}">
+              <input type="hidden" name="redirect" value="{{ self.mutation_redirect() }}">
             </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 }}">
+              <input type="hidden" name="redirect" value="{{ self.mutation_redirect() }}">
             </form>
             {% endif %}
           </td>
@@ -90,83 +90,35 @@
 </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") || sort_ctx.field != "priority" || sort_ctx.order != "asc" %} open{% endif %} class="mt-4">
-  <summary>Tasks</summary>
+{# Render sections: in-progress before open when it has tasks, otherwise open first #}
+{% let redirect_url = self.mutation_redirect() %}
 
-  <form method="get" action="/projects/{{ project_name }}" class="hstack gap-2 mt-2 mb-4">
-    <div data-field>
-      <label for="filter-status">Status</label>
-      <select id="filter-status" name="status" aria-label="Filter by status">
-        <option value="">All</option>
-        <option value="open"{% if filter_status.as_deref() == Some("open") %} selected{% endif %}>Open</option>
-        <option value="in_progress"{% if filter_status.as_deref() == Some("in_progress") %} selected{% endif %}>In progress</option>
-        <option value="closed"{% if filter_status.as_deref() == Some("closed") %} selected{% endif %}>Closed</option>
-      </select>
-    </div>
-    <div data-field>
-      <label for="filter-priority">Priority</label>
-      <select id="filter-priority" name="priority" aria-label="Filter by priority">
-        <option value="">All</option>
-        <option value="high"{% if filter_priority.as_deref() == Some("high") %} selected{% endif %}>High</option>
-        <option value="medium"{% if filter_priority.as_deref() == Some("medium") %} selected{% endif %}>Medium</option>
-        <option value="low"{% if filter_priority.as_deref() == Some("low") %} selected{% endif %}>Low</option>
-      </select>
-    </div>
-    <div data-field>
-      <label for="filter-effort">Effort</label>
-      <select id="filter-effort" name="effort" aria-label="Filter by effort">
-        <option value="">All</option>
-        <option value="low"{% if filter_effort.as_deref() == Some("low") %} selected{% endif %}>Low</option>
-        <option value="medium"{% if filter_effort.as_deref() == Some("medium") %} selected{% endif %}>Medium</option>
-        <option value="high"{% if filter_effort.as_deref() == Some("high") %} selected{% endif %}>High</option>
-      </select>
-    </div>
-    <div data-field>
-      <label for="filter-label">Label</label>
-      <select id="filter-label" name="label" aria-label="Filter by label">
-        <option value="">All</option>
-        {% for l in all_labels %}
-        <option value="{{ l }}"{% if filter_label.as_deref() == Some(l.as_str()) %} selected{% endif %}>{{ l }}</option>
-        {% endfor %}
-      </select>
-    </div>
-    <div data-field>
-      <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 in_progress.total_count > 0 %}
+{# In progress first when there are tasks #}
+<details{% if in_progress.total_count > 0 || in_progress.has_user_params %} open{% endif %} class="mt-4">
+  <summary>{{ in_progress.label }} ({% if in_progress.filtered_count != in_progress.total_count %}{{ in_progress.filtered_count }} of {% endif %}{{ in_progress.total_count }})</summary>
+  {% call macros::task_section(project_name, in_progress, all_labels, redirect_url) %}{% endcall %}
+</details>
 
-  {% if page_tasks.is_empty() %}
-  <p class="text-light">No tasks match the current filters.</p>
-  {% else %}
-  {% call macros::sortable_task_table(project_name, page_tasks, "Task list for project", sort_ctx) %}{% endcall %}
+<details{% if open.total_count > 0 || open.has_user_params %} open{% endif %} class="mt-4">
+  <summary>{{ open.label }} ({% if open.filtered_count != open.total_count %}{{ open.filtered_count }} of {% endif %}{{ open.total_count }})</summary>
+  {% call macros::task_section(project_name, open, all_labels, redirect_url) %}{% endcall %}
+</details>
+{% else %}
+{# Open first when nothing is in progress #}
+<details{% if open.total_count > 0 || open.has_user_params %} open{% endif %} class="mt-4">
+  <summary>{{ open.label }} ({% if open.filtered_count != open.total_count %}{{ open.filtered_count }} of {% endif %}{{ open.total_count }})</summary>
+  {% call macros::task_section(project_name, open, all_labels, redirect_url) %}{% endcall %}
+</details>
+
+<details{% if in_progress.has_user_params %} open{% endif %} class="mt-4">
+  <summary>{{ in_progress.label }} ({{ in_progress.total_count }})</summary>
+  {% call macros::task_section(project_name, in_progress, all_labels, redirect_url) %}{% endcall %}
+</details>
+{% endif %}
 
-  {% if total_pages > 1 %}
-  <nav aria-label="Task list pagination" class="mt-4">
-    <menu class="buttons">
-      {% if page > 1 %}
-      {% let prev = page - 1 %}
-      <li><a href="{{ self.pagination_href(prev) }}" class="button outline small">← Previous</a></li>
-      {% endif %}
-      {% for p in pagination_pages %}
-      <li>
-        {% if *p == page %}
-        <a href="{{ self.pagination_href(p) }}" class="button small" aria-current="page">{{ p }}</a>
-        {% else %}
-        <a href="{{ self.pagination_href(p) }}" class="button outline small">{{ p }}</a>
-        {% endif %}
-      </li>
-      {% endfor %}
-      {% if page < total_pages %}
-      {% let next = page + 1 %}
-      <li><a href="{{ self.pagination_href(next) }}" class="button outline small">Next β†’</a></li>
-      {% endif %}
-    </menu>
-  </nav>
-  {% endif %}
-  {% endif %}
+<details{% if closed.has_user_params %} open{% endif %} class="mt-4">
+  <summary>{{ closed.label }} ({% if closed.filtered_count != closed.total_count %}{{ closed.filtered_count }} of {% endif %}{{ closed.total_count }})</summary>
+  {% call macros::task_section(project_name, closed, all_labels, redirect_url) %}{% endcall %}
 </details>
 {% endblock %}