1use std::collections::HashSet;
2
3use anyhow::Result;
4use axum::extract::{Path as AxumPath, Query, State};
5use axum::response::Response;
6
7use crate::db::Store;
8use crate::model::{Effort, Priority, Status, Task, TaskId};
9use crate::score;
10
11use super::helpers::{error_response, friendly_date, friendly_status, list_projects_safe, render};
12use super::AppState;
13
14pub(super) mod mutations;
15mod sorting;
16pub(super) mod views;
17
18use sorting::{SortField, SortOrder};
19use views::{ProjectTemplate, ScoredEntry, SectionState, SortContext, TaskRow};
20
21const PAGE_SIZE: usize = 25;
22
23/// Query params for the project page. Each section has its own namespaced
24/// set of filter/sort/page params (prefixed ip_, open_, closed_).
25#[derive(serde::Deserialize, Default)]
26pub(super) struct ProjectQuery {
27 // Next-up scoring mode.
28 next_mode: Option<String>,
29
30 // In-progress section.
31 ip_priority: Option<String>,
32 ip_effort: Option<String>,
33 ip_label: Option<String>,
34 #[serde(rename = "ip_type")]
35 ip_task_type: Option<String>,
36 ip_q: Option<String>,
37 ip_page: Option<usize>,
38 ip_sort: Option<String>,
39 ip_order: Option<String>,
40
41 // Open section.
42 open_priority: Option<String>,
43 open_effort: Option<String>,
44 open_label: Option<String>,
45 #[serde(rename = "open_type")]
46 open_task_type: Option<String>,
47 open_q: Option<String>,
48 open_page: Option<usize>,
49 open_sort: Option<String>,
50 open_order: Option<String>,
51
52 // Closed section.
53 closed_priority: Option<String>,
54 closed_effort: Option<String>,
55 closed_label: Option<String>,
56 #[serde(rename = "closed_type")]
57 closed_task_type: Option<String>,
58 closed_q: Option<String>,
59 closed_page: Option<usize>,
60 closed_sort: Option<String>,
61 closed_order: Option<String>,
62}
63
64/// Per-section query params extracted from the namespaced query string.
65struct SectionQuery {
66 priority: Option<String>,
67 effort: Option<String>,
68 label: Option<String>,
69 task_type: Option<String>,
70 q: Option<String>,
71 page: Option<usize>,
72 sort: Option<String>,
73 order: Option<String>,
74}
75
76impl SectionQuery {
77 /// Returns true if any param differs from defaults (used for the
78 /// `<details open>` heuristic).
79 fn has_user_params(&self) -> bool {
80 self.priority.is_some()
81 || self.effort.is_some()
82 || self.label.is_some()
83 || self.task_type.is_some()
84 || self.q.as_deref().is_some_and(|s| !s.is_empty())
85 || self.page.is_some_and(|p| p > 1)
86 || self.sort.is_some()
87 || self.order.is_some()
88 }
89}
90
91/// Build the preserve_qs for a given section — the full query string for the
92/// current page state, minus the given section's sort/order/page (those get
93/// rebuilt by sort and pagination links). Filter params for the target section
94/// are also excluded since the filter form will supply them.
95fn build_preserve_qs(query: &ProjectQuery, exclude_prefix: &str) -> String {
96 let mut parts = Vec::new();
97
98 // Carry next_mode through all section links.
99 if query.next_mode.as_deref() == Some("effort") {
100 parts.push("next_mode=effort".to_string());
101 }
102
103 for (prefix, sq) in [
104 ("ip_", extract_section(query, "ip_")),
105 ("open_", extract_section(query, "open_")),
106 ("closed_", extract_section(query, "closed_")),
107 ] {
108 if prefix == exclude_prefix {
109 // This section's params are managed by its own form/sort/pagination.
110 continue;
111 }
112 if let Some(ref p) = sq.priority {
113 parts.push(format!("{prefix}priority={p}"));
114 }
115 if let Some(ref e) = sq.effort {
116 parts.push(format!("{prefix}effort={e}"));
117 }
118 if let Some(ref l) = sq.label {
119 parts.push(format!("{prefix}label={l}"));
120 }
121 if let Some(ref tt) = sq.task_type {
122 parts.push(format!("{prefix}type={tt}"));
123 }
124 let search = sq.q.unwrap_or_default();
125 if !search.is_empty() {
126 parts.push(format!("{prefix}q={search}"));
127 }
128 let sort_field = sq
129 .sort
130 .as_deref()
131 .and_then(SortField::parse)
132 .unwrap_or(SortField::Priority);
133 let sort_order = sq
134 .order
135 .as_deref()
136 .and_then(SortOrder::parse)
137 .unwrap_or_else(|| sort_field.default_order());
138 if sort_field != SortField::Priority || sort_order != SortOrder::Asc {
139 parts.push(format!(
140 "{prefix}sort={}&{prefix}order={}",
141 sort_field.as_str(),
142 sort_order.as_str()
143 ));
144 }
145 if let Some(page) = sq.page {
146 if page > 1 {
147 parts.push(format!("{prefix}page={page}"));
148 }
149 }
150 }
151
152 parts.join("&")
153}
154
155/// Extract a section's query params from the flat ProjectQuery.
156fn extract_section(query: &ProjectQuery, prefix: &str) -> SectionQuery {
157 match prefix {
158 "ip_" => SectionQuery {
159 priority: query.ip_priority.clone(),
160 effort: query.ip_effort.clone(),
161 label: query.ip_label.clone(),
162 task_type: query.ip_task_type.clone(),
163 q: query.ip_q.clone(),
164 page: query.ip_page,
165 sort: query.ip_sort.clone(),
166 order: query.ip_order.clone(),
167 },
168 "open_" => SectionQuery {
169 priority: query.open_priority.clone(),
170 effort: query.open_effort.clone(),
171 label: query.open_label.clone(),
172 task_type: query.open_task_type.clone(),
173 q: query.open_q.clone(),
174 page: query.open_page,
175 sort: query.open_sort.clone(),
176 order: query.open_order.clone(),
177 },
178 "closed_" => SectionQuery {
179 priority: query.closed_priority.clone(),
180 effort: query.closed_effort.clone(),
181 label: query.closed_label.clone(),
182 task_type: query.closed_task_type.clone(),
183 q: query.closed_q.clone(),
184 page: query.closed_page,
185 sort: query.closed_sort.clone(),
186 order: query.closed_order.clone(),
187 },
188 _ => SectionQuery {
189 priority: None,
190 effort: None,
191 label: None,
192 task_type: None,
193 q: None,
194 page: None,
195 sort: None,
196 order: None,
197 },
198 }
199}
200
201/// Build a SectionState from tasks of a given status and a section's query
202/// params.
203fn build_section(
204 all_tasks: &[Task],
205 status: Status,
206 label: &'static str,
207 prefix: &str,
208 sq: &SectionQuery,
209 base_href: &str,
210 preserve_qs: String,
211) -> SectionState {
212 // All tasks with this status (unfiltered count).
213 let status_tasks: Vec<&Task> = all_tasks.iter().filter(|t| t.status == status).collect();
214 let total_count = status_tasks.len();
215
216 // Apply filters.
217 let mut filtered: Vec<&Task> = status_tasks;
218
219 if let Some(ref p) = sq.priority {
220 if !p.is_empty() {
221 if let Ok(parsed) = Priority::parse(p) {
222 filtered.retain(|t| t.priority == parsed);
223 }
224 }
225 }
226 if let Some(ref e) = sq.effort {
227 if !e.is_empty() {
228 if let Ok(parsed) = Effort::parse(e) {
229 filtered.retain(|t| t.effort == parsed);
230 }
231 }
232 }
233 if let Some(ref l) = sq.label {
234 if !l.is_empty() {
235 filtered.retain(|t| t.labels.iter().any(|x| x == l));
236 }
237 }
238 if let Some(ref tt) = sq.task_type {
239 if !tt.is_empty() {
240 filtered.retain(|t| t.task_type == *tt);
241 }
242 }
243 let search_term = sq.q.clone().unwrap_or_default();
244 if !search_term.is_empty() {
245 let q = search_term.to_ascii_lowercase();
246 filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
247 }
248
249 let filtered_count = filtered.len();
250
251 // Sort.
252 let sort_field = sq
253 .sort
254 .as_deref()
255 .and_then(SortField::parse)
256 .unwrap_or(SortField::Priority);
257 let sort_order = sq
258 .order
259 .as_deref()
260 .and_then(SortOrder::parse)
261 .unwrap_or_else(|| sort_field.default_order());
262 sorting::sort_tasks(&mut filtered, sort_field, sort_order);
263
264 // Pagination.
265 let total_pages = if filtered_count == 0 {
266 1
267 } else {
268 filtered_count.div_ceil(PAGE_SIZE)
269 };
270 let page = sq.page.unwrap_or(1).clamp(1, total_pages);
271 let start = (page - 1) * PAGE_SIZE;
272 let end = (start + PAGE_SIZE).min(filtered_count);
273
274 let tasks: Vec<TaskRow> = filtered[start..end]
275 .iter()
276 .map(|t| {
277 let s = t.status.as_str().to_string();
278 TaskRow {
279 full_id: t.id.as_str().to_string(),
280 short_id: t.id.short(),
281 status_display: friendly_status(&s),
282 status: s,
283 task_type: t.task_type.clone(),
284 priority: t.priority.as_str().to_string(),
285 effort: t.effort.as_str().to_string(),
286 title: t.title.clone(),
287 labels: t.labels.clone(),
288 created_at_display: friendly_date(&t.created_at),
289 created_at: t.created_at.clone(),
290 }
291 })
292 .collect();
293
294 let sort_ctx = SortContext {
295 base_href: base_href.to_string(),
296 prefix: prefix.to_string(),
297 field: sort_field.as_str().to_string(),
298 order: sort_order.as_str().to_string(),
299 preserve_qs,
300 };
301
302 SectionState {
303 label,
304 total_count,
305 filtered_count,
306 tasks,
307 sort_ctx,
308 filter_priority: sq.priority.clone(),
309 filter_effort: sq.effort.clone(),
310 filter_label: sq.label.clone(),
311 filter_type: sq.task_type.clone(),
312 filter_search: search_term,
313 page,
314 total_pages,
315 pagination_pages: (1..=total_pages).collect(),
316 has_user_params: sq.has_user_params(),
317 }
318}
319
320pub(in crate::cmd::webui) async fn project_handler(
321 State(state): State<AppState>,
322 AxumPath(name): AxumPath<String>,
323 Query(query): Query<ProjectQuery>,
324) -> Response {
325 let root = state.data_root.clone();
326 let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
327 let all_projects = list_projects_safe(&root);
328 let store = Store::open(&root, &name)?;
329 let tasks = store.list_tasks()?;
330
331 // Stats from the full unfiltered set.
332 let stats_open = tasks.iter().filter(|t| t.status == Status::Open).count();
333 let stats_in_progress = tasks
334 .iter()
335 .filter(|t| t.status == Status::InProgress)
336 .count();
337 let stats_closed = tasks.iter().filter(|t| t.status == Status::Closed).count();
338
339 // Collect distinct labels for the filter dropdown.
340 let mut label_set: HashSet<String> = HashSet::new();
341 for t in &tasks {
342 for l in &t.labels {
343 label_set.insert(l.clone());
344 }
345 }
346 let mut all_labels: Vec<String> = label_set.into_iter().collect();
347 all_labels.sort();
348
349 // Next-up scoring (top 5 open tasks).
350 let open_tasks: Vec<score::TaskInput> = tasks
351 .iter()
352 .filter(|t| t.status == Status::Open)
353 .map(|t| score::TaskInput {
354 id: t.id.as_str().to_string(),
355 title: t.title.clone(),
356 priority_score: t.priority.score(),
357 effort_score: t.effort.score(),
358 priority_label: t.priority.as_str().to_string(),
359 effort_label: t.effort.as_str().to_string(),
360 })
361 .collect();
362
363 let edges: Vec<(String, String)> = tasks
364 .iter()
365 .filter(|t| t.status == Status::Open)
366 .flat_map(|t| {
367 t.blockers
368 .iter()
369 .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
370 .collect::<Vec<_>>()
371 })
372 .collect();
373
374 let parents_with_open_children: HashSet<String> = tasks
375 .iter()
376 .filter(|t| t.status == Status::Open)
377 .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
378 .collect();
379
380 let next_mode = match query.next_mode.as_deref() {
381 Some("effort") => score::Mode::Effort,
382 _ => score::Mode::Impact,
383 };
384 let next_mode_str = match next_mode {
385 score::Mode::Effort => "effort",
386 score::Mode::Impact => "impact",
387 };
388
389 let scored = score::rank(
390 &open_tasks,
391 &edges,
392 &parents_with_open_children,
393 next_mode,
394 5,
395 );
396
397 // Build a lookup from task ID to labels for the Next Up display.
398 let labels_by_id: std::collections::HashMap<&str, &[String]> = tasks
399 .iter()
400 .map(|t| (t.id.as_str(), t.labels.as_slice()))
401 .collect();
402
403 let next_up: Vec<ScoredEntry> = scored
404 .into_iter()
405 .map(|s| {
406 let equation = match next_mode {
407 score::Mode::Impact => format!(
408 "({:.2} + 1.00) × {:.2} / {:.2}^0.25 = {:.2}",
409 s.downstream_score, s.priority_weight, s.effort_weight, s.score
410 ),
411 score::Mode::Effort => format!(
412 "({:.2} × 0.25 + 1.00) × {:.2} / {:.2}² = {:.2}",
413 s.downstream_score, s.priority_weight, s.effort_weight, s.score
414 ),
415 };
416 let task_word = if s.total_unblocked == 1 {
417 "task"
418 } else {
419 "tasks"
420 };
421 let unblocks_display = format!(
422 "Unblocks: {} {} ({} directly)",
423 s.total_unblocked, task_word, s.direct_unblocked
424 );
425 let labels = labels_by_id
426 .get(s.id.as_str())
427 .map(|ls| ls.to_vec())
428 .unwrap_or_default();
429 ScoredEntry {
430 short_id: TaskId::display_id(&s.id),
431 id: s.id,
432 title: s.title,
433 score: format!("{:.2}", s.score),
434 status: "open".to_string(),
435 status_display: friendly_status("open"),
436 equation,
437 unblocks_display,
438 labels,
439 }
440 })
441 .collect();
442
443 let proj_name = store.project_name().to_string();
444 let base_href = format!("/projects/{proj_name}");
445
446 // Build each section with its own namespaced query params.
447 let ip_sq = extract_section(&query, "ip_");
448 let open_sq = extract_section(&query, "open_");
449 let closed_sq = extract_section(&query, "closed_");
450
451 let ip_preserve = build_preserve_qs(&query, "ip_");
452 let open_preserve = build_preserve_qs(&query, "open_");
453 let closed_preserve = build_preserve_qs(&query, "closed_");
454
455 let in_progress = build_section(
456 &tasks,
457 Status::InProgress,
458 "In progress",
459 "ip_",
460 &ip_sq,
461 &base_href,
462 ip_preserve,
463 );
464 let open = build_section(
465 &tasks,
466 Status::Open,
467 "Open",
468 "open_",
469 &open_sq,
470 &base_href,
471 open_preserve,
472 );
473 let closed = build_section(
474 &tasks,
475 Status::Closed,
476 "Closed",
477 "closed_",
478 &closed_sq,
479 &base_href,
480 closed_preserve,
481 );
482
483 Ok(ProjectTemplate {
484 all_projects,
485 active_project: Some(name),
486 project_name: proj_name,
487 stats_open,
488 stats_in_progress,
489 stats_closed,
490 next_up,
491 next_mode: next_mode_str.to_string(),
492 all_labels,
493 in_progress,
494 open,
495 closed,
496 })
497 })
498 .await;
499
500 match result {
501 Ok(Ok(tmpl)) => render(tmpl),
502 Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
503 Err(e) => error_response(500, &format!("join error: {e}"), &[]),
504 }
505}