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}