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