diff --git a/src/cmd/webui/project/mod.rs b/src/cmd/webui/project/mod.rs index 4180ef7bc0382db42565e53a6a4a1b74311942ad..1725ecbdaa608b80484a69abba56719111c29a34 100644 --- a/src/cmd/webui/project/mod.rs +++ b/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, + // In-progress section. + ip_priority: Option, + ip_effort: Option, + ip_label: Option, + ip_q: Option, + ip_page: Option, + ip_sort: Option, + ip_order: Option, + + // Open section. + open_priority: Option, + open_effort: Option, + open_label: Option, + open_q: Option, + open_page: Option, + open_sort: Option, + open_order: Option, + + // Closed section. + closed_priority: Option, + closed_effort: Option, + closed_label: Option, + closed_q: Option, + closed_page: Option, + closed_sort: Option, + closed_order: Option, +} + +/// Per-section query params extracted from the namespaced query string. +struct SectionQuery { priority: Option, effort: Option, label: Option, @@ -32,15 +63,234 @@ pub(super) struct ProjectQuery { order: Option, } +impl SectionQuery { + /// Returns true if any param differs from defaults (used for the + /// `
` 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 = 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, AxumPath(name): AxumPath, - Query(mut query): Query, + Query(query): Query, ) -> 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 { 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 = 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; diff --git a/src/cmd/webui/project/views.rs b/src/cmd/webui/project/views.rs index 3e097a43a97a5b3efb5bb9a0a3c1ee32bb00eca1..fc60bdf732a1a6471be5ba6ef3237dde537d7eda 100644 --- a/src/cmd/webui/project/views.rs +++ b/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, - pub(super) active_project: Option, - 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, - pub(super) page_tasks: Vec, - pub(super) all_labels: Vec, - pub(super) filter_status: Option, - pub(super) filter_priority: Option, - pub(super) filter_effort: Option, - pub(super) filter_label: Option, - pub(super) filter_search: String, - pub(super) page: usize, - pub(super) total_pages: usize, - pub(super) pagination_pages: Vec, - 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, + /// 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, + pub(in crate::cmd::webui) filter_effort: Option, + pub(in crate::cmd::webui) filter_label: Option, + 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, + /// 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, + pub(super) active_project: Option, + 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, + pub(super) all_labels: Vec, + 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 = §ion.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) + } } } diff --git a/templates/macros.html b/templates/macros.html index 038883715daeb28e028c0ce70304a075a90b4376..464a499139d68eb357352f06fa208815ebcdac30 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -1,91 +1,134 @@ -{% macro task_table(project_name, tasks, caption) %} -
- - - - - - - - - - - - - - {% for t in tasks %} - - - - - - - - - {% endfor %} - -
{{ caption }}
IDStatusPriorityEffortTitleCreated
{{ t.short_id }}{{ t.status }}{{ t.priority }}{{ t.effort }}{{ t.title }}
-
-{% endmacro %} +{% macro task_section(project_name, section, all_labels, redirect_url) %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {# 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::>() %} + {% if kv.len() == 2 %} + + {% endif %} + {% endfor %} + {% endif %} + + + + + + {% if section.tasks.is_empty() %} +

No tasks match the current filters.

+ {% else %} +
+ + + + + + + + + + + + + + {% for t in section.tasks %} + + + + + + + + + {% endfor %} + +
{{ section.label }} tasks for project
ID{{ section.sort_ctx.arrow("id") }}Priority{{ section.sort_ctx.arrow("priority") }}Effort{{ section.sort_ctx.arrow("effort") }}Title{{ section.sort_ctx.arrow("title") }}Created{{ section.sort_ctx.arrow("created") }}Change status
{{ t.short_id }}{{ t.priority }}{{ t.effort }}{{ t.title }} + + + + + + + + + {% if t.status != "open" %} + + {% endif %} + {% if t.status != "in_progress" %} + + {% endif %} + {% if t.status != "closed" %} + + {% endif %} +
+
-{% macro sortable_task_table(project_name, tasks, caption, sort_ctx) %} -
- - - - - - - - - - - - - - - {% for t in tasks %} - - - - - - - - - + {% if section.total_pages > 1 %} + -
{{ caption }}
ID{{ sort_ctx.arrow("id") }}Status{{ sort_ctx.arrow("status") }}Priority{{ sort_ctx.arrow("priority") }}Effort{{ sort_ctx.arrow("effort") }}Title{{ sort_ctx.arrow("title") }}Created{{ sort_ctx.arrow("created") }}Change status
{{ t.short_id }}{{ t.status }}{{ t.priority }}{{ t.effort }}{{ t.title }} - - - - - - - - - {% if t.status != "open" %} - - {% endif %} - {% if t.status != "in_progress" %} - - {% endif %} - {% if t.status != "closed" %} - - {% endif %} -
-
+ {% if section.page < section.total_pages %} + {% let next = section.page + 1 %} +
  • Next →
  • + {% endif %} + + + {% endif %} + {% endif %} {% endmacro %} diff --git a/templates/project.html b/templates/project.html index 0be3a0c147720eff24a6a7e44985eba0fb42950c..f1ce4b79a5c36d50c3ecdbc5a7d0cd932aa36631 100644 --- a/templates/project.html +++ b/templates/project.html @@ -66,19 +66,19 @@ {% if s.status != "open" %} {% endif %} {% if s.status != "in_progress" %} {% endif %} {% if s.status != "closed" %} {% endif %} @@ -90,83 +90,35 @@
    {% endif %} - - Tasks +{# Render sections: in-progress before open when it has tasks, otherwise open first #} +{% let redirect_url = self.mutation_redirect() %} -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - - - -
    +{% if in_progress.total_count > 0 %} +{# In progress first when there are tasks #} + 0 || in_progress.has_user_params %} open{% endif %} class="mt-4"> + {{ in_progress.label }} ({% if in_progress.filtered_count != in_progress.total_count %}{{ in_progress.filtered_count }} of {% endif %}{{ in_progress.total_count }}) + {% call macros::task_section(project_name, in_progress, all_labels, redirect_url) %}{% endcall %} + - {% if page_tasks.is_empty() %} -

    No tasks match the current filters.

    - {% else %} - {% call macros::sortable_task_table(project_name, page_tasks, "Task list for project", sort_ctx) %}{% endcall %} + 0 || open.has_user_params %} open{% endif %} class="mt-4"> + {{ open.label }} ({% if open.filtered_count != open.total_count %}{{ open.filtered_count }} of {% endif %}{{ open.total_count }}) + {% call macros::task_section(project_name, open, all_labels, redirect_url) %}{% endcall %} + +{% else %} +{# Open first when nothing is in progress #} + 0 || open.has_user_params %} open{% endif %} class="mt-4"> + {{ open.label }} ({% if open.filtered_count != open.total_count %}{{ open.filtered_count }} of {% endif %}{{ open.total_count }}) + {% call macros::task_section(project_name, open, all_labels, redirect_url) %}{% endcall %} + + + + {{ in_progress.label }} ({{ in_progress.total_count }}) + {% call macros::task_section(project_name, in_progress, all_labels, redirect_url) %}{% endcall %} + +{% endif %} - {% if total_pages > 1 %} - - {% endif %} - {% endif %} + + {{ closed.label }} ({% if closed.filtered_count != closed.total_count %}{{ closed.filtered_count }} of {% endif %}{{ closed.total_count }}) + {% call macros::task_section(project_name, closed, all_labels, redirect_url) %}{% endcall %} {% endblock %}