handlers.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::{self, Store, TaskId};
  8use crate::score;
  9
 10use super::helpers::{
 11    error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown,
 12    sort_tasks, SortField, SortOrder,
 13};
 14use super::views::{
 15    BlockerRef, IndexTemplate, LogView, ProjectCard, ProjectTemplate, ScoredEntry, SortContext,
 16    TaskRow, TaskTemplate, TaskView,
 17};
 18use super::AppState;
 19
 20const PAGE_SIZE: usize = 25;
 21
 22pub(super) async fn index_handler(State(state): State<AppState>) -> Response {
 23    let root = state.data_root.clone();
 24    let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
 25        let projects = list_projects_safe(&root);
 26        let mut cards = Vec::with_capacity(projects.len());
 27
 28        for name in &projects {
 29            match Store::open(&root, name) {
 30                Ok(store) => {
 31                    let tasks = store.list_tasks()?;
 32                    let open = tasks
 33                        .iter()
 34                        .filter(|t| t.status == db::Status::Open)
 35                        .count();
 36                    let in_progress = tasks
 37                        .iter()
 38                        .filter(|t| t.status == db::Status::InProgress)
 39                        .count();
 40                    let closed = tasks
 41                        .iter()
 42                        .filter(|t| t.status == db::Status::Closed)
 43                        .count();
 44                    cards.push(ProjectCard::Ok {
 45                        name: name.clone(),
 46                        open,
 47                        in_progress,
 48                        closed,
 49                        total: tasks.len(),
 50                    });
 51                }
 52                Err(e) => {
 53                    cards.push(ProjectCard::Err {
 54                        name: name.clone(),
 55                        error: format!("{e}"),
 56                    });
 57                }
 58            }
 59        }
 60
 61        Ok(IndexTemplate {
 62            all_projects: projects,
 63            active_project: None,
 64            projects: cards,
 65        })
 66    })
 67    .await;
 68
 69    match result {
 70        Ok(Ok(tmpl)) => render(tmpl),
 71        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
 72        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
 73    }
 74}
 75
 76#[derive(serde::Deserialize)]
 77pub(super) struct ProjectQuery {
 78    status: Option<String>,
 79    priority: Option<String>,
 80    effort: Option<String>,
 81    label: Option<String>,
 82    q: Option<String>,
 83    page: Option<usize>,
 84    sort: Option<String>,
 85    order: Option<String>,
 86}
 87
 88pub(super) async fn project_handler(
 89    State(state): State<AppState>,
 90    AxumPath(name): AxumPath<String>,
 91    Query(mut query): Query<ProjectQuery>,
 92) -> Response {
 93    // Default to showing open tasks when no status filter is specified.
 94    if query.status.is_none() {
 95        query.status = Some("open".to_string());
 96    }
 97    let root = state.data_root.clone();
 98    let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
 99        let all_projects = list_projects_safe(&root);
100        let store = Store::open(&root, &name)?;
101        let tasks = store.list_tasks()?;
102
103        // Stats from the full unfiltered set.
104        let stats_open = tasks
105            .iter()
106            .filter(|t| t.status == db::Status::Open)
107            .count();
108        let stats_in_progress = tasks
109            .iter()
110            .filter(|t| t.status == db::Status::InProgress)
111            .count();
112        let stats_closed = tasks
113            .iter()
114            .filter(|t| t.status == db::Status::Closed)
115            .count();
116
117        // Collect distinct labels for the filter dropdown.
118        let mut label_set: HashSet<String> = HashSet::new();
119        for t in &tasks {
120            for l in &t.labels {
121                label_set.insert(l.clone());
122            }
123        }
124        let mut all_labels: Vec<String> = label_set.into_iter().collect();
125        all_labels.sort();
126
127        // Next-up scoring (top 5 open tasks).
128        let open_tasks: Vec<score::TaskInput> = tasks
129            .iter()
130            .filter(|t| t.status == db::Status::Open)
131            .map(|t| score::TaskInput {
132                id: t.id.as_str().to_string(),
133                title: t.title.clone(),
134                priority_score: t.priority.score(),
135                effort_score: t.effort.score(),
136                priority_label: db::priority_label(t.priority).to_string(),
137                effort_label: db::effort_label(t.effort).to_string(),
138            })
139            .collect();
140
141        let edges: Vec<(String, String)> = tasks
142            .iter()
143            .filter(|t| t.status == db::Status::Open)
144            .flat_map(|t| {
145                t.blockers
146                    .iter()
147                    .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
148                    .collect::<Vec<_>>()
149            })
150            .collect();
151
152        let parents_with_open_children: HashSet<String> = tasks
153            .iter()
154            .filter(|t| t.status == db::Status::Open)
155            .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
156            .collect();
157
158        let scored = score::rank(
159            &open_tasks,
160            &edges,
161            &parents_with_open_children,
162            score::Mode::Impact,
163            5,
164        );
165
166        let next_up: Vec<ScoredEntry> = scored
167            .into_iter()
168            .map(|s| ScoredEntry {
169                short_id: TaskId::display_id(&s.id),
170                id: s.id,
171                title: s.title,
172                score: format!("{:.2}", s.score),
173                status: "open".to_string(),
174                status_display: friendly_status("open"),
175            })
176            .collect();
177
178        // Apply filters.
179        let mut filtered: Vec<&db::Task> = tasks.iter().collect();
180
181        if let Some(ref s) = query.status {
182            if !s.is_empty() {
183                if let Ok(parsed) = db::parse_status(s) {
184                    filtered.retain(|t| t.status == parsed);
185                }
186            }
187        }
188        if let Some(ref p) = query.priority {
189            if !p.is_empty() {
190                if let Ok(parsed) = db::parse_priority(p) {
191                    filtered.retain(|t| t.priority == parsed);
192                }
193            }
194        }
195        if let Some(ref e) = query.effort {
196            if !e.is_empty() {
197                if let Ok(parsed) = db::parse_effort(e) {
198                    filtered.retain(|t| t.effort == parsed);
199                }
200            }
201        }
202        if let Some(ref l) = query.label {
203            if !l.is_empty() {
204                filtered.retain(|t| t.labels.iter().any(|x| x == l));
205            }
206        }
207        let search_term = query.q.clone().unwrap_or_default();
208        if !search_term.is_empty() {
209            let q = search_term.to_ascii_lowercase();
210            filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
211        }
212
213        // Sort: user-selected column, or priority+created as default.
214        let sort_field = query
215            .sort
216            .as_deref()
217            .and_then(SortField::parse)
218            .unwrap_or(SortField::Priority);
219        let sort_order = query
220            .order
221            .as_deref()
222            .and_then(SortOrder::parse)
223            .unwrap_or_else(|| sort_field.default_order());
224        sort_tasks(&mut filtered, sort_field, sort_order);
225
226        // Pagination.
227        let total = filtered.len();
228        let total_pages = if total == 0 {
229            1
230        } else {
231            total.div_ceil(PAGE_SIZE)
232        };
233        let page = query.page.unwrap_or(1).clamp(1, total_pages);
234        let start = (page - 1) * PAGE_SIZE;
235        let end = (start + PAGE_SIZE).min(total);
236
237        let page_tasks: Vec<TaskRow> = filtered[start..end]
238            .iter()
239            .map(|t| {
240                let status = db::status_label(t.status).to_string();
241                TaskRow {
242                    full_id: t.id.as_str().to_string(),
243                    short_id: t.id.short(),
244                    status_display: friendly_status(&status),
245                    status,
246                    priority: db::priority_label(t.priority).to_string(),
247                    effort: db::effort_label(t.effort).to_string(),
248                    title: t.title.clone(),
249                    created_at_display: friendly_date(&t.created_at),
250                    created_at: t.created_at.clone(),
251                }
252            })
253            .collect();
254
255        // Build filter query string for sort links (excludes sort/order/page).
256        let filter_qs = {
257            let mut parts = Vec::new();
258            if let Some(ref s) = query.status {
259                parts.push(format!("status={s}"));
260            }
261            if let Some(ref p) = query.priority {
262                parts.push(format!("priority={p}"));
263            }
264            if let Some(ref e) = query.effort {
265                parts.push(format!("effort={e}"));
266            }
267            if let Some(ref l) = query.label {
268                parts.push(format!("label={l}"));
269            }
270            if !search_term.is_empty() {
271                parts.push(format!("q={search_term}"));
272            }
273            parts.join("&")
274        };
275
276        let proj_name = store.project_name().to_string();
277        let sort_ctx = SortContext {
278            base_href: format!("/projects/{proj_name}"),
279            field: sort_field.as_str().to_string(),
280            order: sort_order.as_str().to_string(),
281            filter_qs,
282        };
283
284        Ok(ProjectTemplate {
285            all_projects,
286            active_project: Some(name),
287            project_name: proj_name,
288            stats_open,
289            stats_in_progress,
290            stats_closed,
291            next_up,
292            page_tasks,
293            all_labels,
294            filter_status: query.status,
295            filter_priority: query.priority,
296            filter_effort: query.effort,
297            filter_label: query.label,
298            filter_search: search_term,
299            page,
300            total_pages,
301            pagination_pages: (1..=total_pages).collect(),
302            sort_ctx,
303        })
304    })
305    .await;
306
307    match result {
308        Ok(Ok(tmpl)) => render(tmpl),
309        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
310        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
311    }
312}
313
314pub(super) async fn task_handler(
315    State(state): State<AppState>,
316    AxumPath((name, id)): AxumPath<(String, String)>,
317) -> Response {
318    let root = state.data_root.clone();
319    let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
320        let all_projects = list_projects_safe(&root);
321        let store = Store::open(&root, &name)?;
322
323        let task_id = db::resolve_task_id(&store, &id, false)?;
324        let task = store
325            .get_task(&task_id, false)?
326            .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
327
328        // Partition blockers.
329        let partition = db::partition_blockers(&store, &task.blockers)?;
330        let blockers_open: Vec<BlockerRef> = partition
331            .open
332            .iter()
333            .map(|b| BlockerRef {
334                full_id: b.as_str().to_string(),
335                short_id: b.short(),
336            })
337            .collect();
338        let blockers_resolved: Vec<BlockerRef> = partition
339            .resolved
340            .iter()
341            .map(|b| BlockerRef {
342                full_id: b.as_str().to_string(),
343                short_id: b.short(),
344            })
345            .collect();
346
347        // Find subtasks.
348        let all_tasks = store.list_tasks()?;
349        let subtasks: Vec<TaskRow> = all_tasks
350            .iter()
351            .filter(|t| t.parent.as_ref() == Some(&task_id))
352            .map(|t| {
353                let status = db::status_label(t.status).to_string();
354                TaskRow {
355                    full_id: t.id.as_str().to_string(),
356                    short_id: t.id.short(),
357                    status_display: friendly_status(&status),
358                    status,
359                    priority: db::priority_label(t.priority).to_string(),
360                    effort: db::effort_label(t.effort).to_string(),
361                    title: t.title.clone(),
362                    created_at_display: friendly_date(&t.created_at),
363                    created_at: t.created_at.clone(),
364                }
365            })
366            .collect();
367
368        let task_view = TaskView {
369            full_id: task.id.as_str().to_string(),
370            short_id: task.id.short(),
371            title: task.title.clone(),
372            description: render_markdown(&task.description),
373            task_type: task.task_type.clone(),
374            status: db::status_label(task.status).to_string(),
375            priority: db::priority_label(task.priority).to_string(),
376            effort: db::effort_label(task.effort).to_string(),
377            created_at_display: friendly_date(&task.created_at),
378            created_at: task.created_at.clone(),
379            updated_at_display: friendly_date(&task.updated_at),
380            updated_at: task.updated_at.clone(),
381            labels: task.labels.clone(),
382            logs: task
383                .logs
384                .iter()
385                .map(|l| LogView {
386                    timestamp_display: friendly_date(&l.timestamp),
387                    timestamp: l.timestamp.clone(),
388                    message: render_markdown(&l.message),
389                })
390                .collect(),
391        };
392
393        Ok(TaskTemplate {
394            all_projects,
395            active_project: Some(name),
396            project_name: store.project_name().to_string(),
397            task: task_view,
398            blockers_open,
399            blockers_resolved,
400            subtasks,
401        })
402    })
403    .await;
404
405    match result {
406        Ok(Ok(tmpl)) => render(tmpl),
407        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
408        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
409    }
410}