mod.rs

  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}