1use std::collections::HashSet;
2use std::path::Path;
3use std::sync::Arc;
4
5use anyhow::Result;
6use askama::Template;
7use axum::extract::{Path as AxumPath, Query, State};
8use axum::http::StatusCode;
9use axum::response::{Html, IntoResponse, Response};
10use axum::routing::get;
11use axum::Router;
12
13use crate::db::{self, Store, TaskId};
14use crate::score;
15
16const PAGE_SIZE: usize = 25;
17
18// ---------------------------------------------------------------------------
19// Shared state
20// ---------------------------------------------------------------------------
21
22#[derive(Clone)]
23struct AppState {
24 data_root: Arc<std::path::PathBuf>,
25}
26
27// ---------------------------------------------------------------------------
28// Template view-models
29// ---------------------------------------------------------------------------
30
31/// A project card on the root page — either healthy or failed.
32enum ProjectCard {
33 Ok {
34 name: String,
35 open: usize,
36 in_progress: usize,
37 closed: usize,
38 total: usize,
39 },
40 Err {
41 name: String,
42 error: String,
43 },
44}
45
46/// Minimal view-model for a scored task in the "Next Up" table.
47struct ScoredEntry {
48 id: String,
49 short_id: String,
50 title: String,
51 score: String,
52}
53
54/// Minimal view-model for a task row in the project task table.
55struct TaskRow {
56 full_id: String,
57 short_id: String,
58 status: String,
59 priority: String,
60 effort: String,
61 title: String,
62}
63
64/// View-model for the task detail page.
65struct TaskView {
66 full_id: String,
67 short_id: String,
68 title: String,
69 description: String,
70 task_type: String,
71 status: String,
72 priority: String,
73 effort: String,
74 created_at: String,
75 updated_at: String,
76 labels: Vec<String>,
77 logs: Vec<LogView>,
78}
79
80struct LogView {
81 timestamp: String,
82 message: String,
83}
84
85/// A blocker reference for the task detail page.
86struct BlockerRef {
87 full_id: String,
88 short_id: String,
89}
90
91// ---------------------------------------------------------------------------
92// Askama templates
93// ---------------------------------------------------------------------------
94
95#[derive(Template)]
96#[template(path = "index.html")]
97struct IndexTemplate {
98 all_projects: Vec<String>,
99 active_project: Option<String>,
100 projects: Vec<ProjectCard>,
101}
102
103#[derive(Template)]
104#[template(path = "project.html")]
105struct ProjectTemplate {
106 all_projects: Vec<String>,
107 active_project: Option<String>,
108 project_name: String,
109 stats_open: usize,
110 stats_in_progress: usize,
111 stats_closed: usize,
112 next_up: Vec<ScoredEntry>,
113 page_tasks: Vec<TaskRow>,
114 all_labels: Vec<String>,
115 filter_status: Option<String>,
116 filter_priority: Option<String>,
117 filter_effort: Option<String>,
118 filter_label: Option<String>,
119 filter_search: String,
120 page: usize,
121 total_pages: usize,
122 pagination_pages: Vec<usize>,
123}
124
125impl ProjectTemplate {
126 /// Build a pagination link preserving current filter query params.
127 fn pagination_href(&self, target_page: &usize) -> String {
128 let target_page = *target_page;
129 let mut parts = Vec::new();
130 if let Some(ref s) = self.filter_status {
131 parts.push(format!("status={s}"));
132 }
133 if let Some(ref p) = self.filter_priority {
134 parts.push(format!("priority={p}"));
135 }
136 if let Some(ref e) = self.filter_effort {
137 parts.push(format!("effort={e}"));
138 }
139 if let Some(ref l) = self.filter_label {
140 parts.push(format!("label={l}"));
141 }
142 if !self.filter_search.is_empty() {
143 parts.push(format!("q={}", self.filter_search));
144 }
145 parts.push(format!("page={target_page}"));
146 format!("/projects/{}?{}", self.project_name, parts.join("&"))
147 }
148}
149
150#[derive(Template)]
151#[template(path = "task.html")]
152struct TaskTemplate {
153 all_projects: Vec<String>,
154 active_project: Option<String>,
155 project_name: String,
156 task: TaskView,
157 blockers_open: Vec<BlockerRef>,
158 blockers_resolved: Vec<BlockerRef>,
159 subtasks: Vec<TaskRow>,
160}
161
162#[derive(Template)]
163#[template(path = "error.html")]
164struct ErrorTemplate {
165 all_projects: Vec<String>,
166 active_project: Option<String>,
167 status_code: u16,
168 message: String,
169}
170
171// ---------------------------------------------------------------------------
172// Response helpers
173// ---------------------------------------------------------------------------
174
175fn render(tmpl: impl Template) -> Response {
176 match tmpl.render() {
177 Ok(html) => Html(html).into_response(),
178 Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
179 }
180}
181
182fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
183 let body = ErrorTemplate {
184 all_projects: all_projects.to_vec(),
185 active_project: None,
186 status_code: code,
187 message: msg.to_string(),
188 };
189 let html = body
190 .render()
191 .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
192 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
193 (status, Html(html)).into_response()
194}
195
196fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
197 db::list_projects_in(root).unwrap_or_default()
198}
199
200// ---------------------------------------------------------------------------
201// Route handlers
202// ---------------------------------------------------------------------------
203
204async fn index_handler(State(state): State<AppState>) -> Response {
205 let root = state.data_root.clone();
206 let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
207 let projects = list_projects_safe(&root);
208 let mut cards = Vec::with_capacity(projects.len());
209
210 for name in &projects {
211 match Store::open(&root, name) {
212 Ok(store) => {
213 let tasks = store.list_tasks()?;
214 let open = tasks
215 .iter()
216 .filter(|t| t.status == db::Status::Open)
217 .count();
218 let in_progress = tasks
219 .iter()
220 .filter(|t| t.status == db::Status::InProgress)
221 .count();
222 let closed = tasks
223 .iter()
224 .filter(|t| t.status == db::Status::Closed)
225 .count();
226 cards.push(ProjectCard::Ok {
227 name: name.clone(),
228 open,
229 in_progress,
230 closed,
231 total: tasks.len(),
232 });
233 }
234 Err(e) => {
235 cards.push(ProjectCard::Err {
236 name: name.clone(),
237 error: format!("{e}"),
238 });
239 }
240 }
241 }
242
243 Ok(IndexTemplate {
244 all_projects: projects,
245 active_project: None,
246 projects: cards,
247 })
248 })
249 .await;
250
251 match result {
252 Ok(Ok(tmpl)) => render(tmpl),
253 Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
254 Err(e) => error_response(500, &format!("join error: {e}"), &[]),
255 }
256}
257
258#[derive(serde::Deserialize)]
259struct ProjectQuery {
260 status: Option<String>,
261 priority: Option<String>,
262 effort: Option<String>,
263 label: Option<String>,
264 q: Option<String>,
265 page: Option<usize>,
266}
267
268async fn project_handler(
269 State(state): State<AppState>,
270 AxumPath(name): AxumPath<String>,
271 Query(query): Query<ProjectQuery>,
272) -> Response {
273 let root = state.data_root.clone();
274 let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
275 let all_projects = list_projects_safe(&root);
276 let store = Store::open(&root, &name)?;
277 let tasks = store.list_tasks()?;
278
279 // Stats from the full unfiltered set.
280 let stats_open = tasks
281 .iter()
282 .filter(|t| t.status == db::Status::Open)
283 .count();
284 let stats_in_progress = tasks
285 .iter()
286 .filter(|t| t.status == db::Status::InProgress)
287 .count();
288 let stats_closed = tasks
289 .iter()
290 .filter(|t| t.status == db::Status::Closed)
291 .count();
292
293 // Collect distinct labels for the filter dropdown.
294 let mut label_set: HashSet<String> = HashSet::new();
295 for t in &tasks {
296 for l in &t.labels {
297 label_set.insert(l.clone());
298 }
299 }
300 let mut all_labels: Vec<String> = label_set.into_iter().collect();
301 all_labels.sort();
302
303 // Next-up scoring (top 5 open tasks).
304 let open_tasks: Vec<score::TaskInput> = tasks
305 .iter()
306 .filter(|t| t.status == db::Status::Open)
307 .map(|t| score::TaskInput {
308 id: t.id.as_str().to_string(),
309 title: t.title.clone(),
310 priority_score: t.priority.score(),
311 effort_score: t.effort.score(),
312 priority_label: db::priority_label(t.priority).to_string(),
313 effort_label: db::effort_label(t.effort).to_string(),
314 })
315 .collect();
316
317 let edges: Vec<(String, String)> = tasks
318 .iter()
319 .filter(|t| t.status == db::Status::Open)
320 .flat_map(|t| {
321 t.blockers
322 .iter()
323 .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
324 .collect::<Vec<_>>()
325 })
326 .collect();
327
328 let parents_with_open_children: HashSet<String> = tasks
329 .iter()
330 .filter(|t| t.status == db::Status::Open)
331 .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
332 .collect();
333
334 let scored = score::rank(
335 &open_tasks,
336 &edges,
337 &parents_with_open_children,
338 score::Mode::Impact,
339 5,
340 );
341
342 let next_up: Vec<ScoredEntry> = scored
343 .into_iter()
344 .map(|s| ScoredEntry {
345 short_id: TaskId::display_id(&s.id),
346 id: s.id,
347 title: s.title,
348 score: format!("{:.2}", s.score),
349 })
350 .collect();
351
352 // Apply filters.
353 let mut filtered: Vec<&db::Task> = tasks.iter().collect();
354
355 if let Some(ref s) = query.status {
356 if !s.is_empty() {
357 if let Ok(parsed) = db::parse_status(s) {
358 filtered.retain(|t| t.status == parsed);
359 }
360 }
361 }
362 if let Some(ref p) = query.priority {
363 if !p.is_empty() {
364 if let Ok(parsed) = db::parse_priority(p) {
365 filtered.retain(|t| t.priority == parsed);
366 }
367 }
368 }
369 if let Some(ref e) = query.effort {
370 if !e.is_empty() {
371 if let Ok(parsed) = db::parse_effort(e) {
372 filtered.retain(|t| t.effort == parsed);
373 }
374 }
375 }
376 if let Some(ref l) = query.label {
377 if !l.is_empty() {
378 filtered.retain(|t| t.labels.iter().any(|x| x == l));
379 }
380 }
381 let search_term = query.q.clone().unwrap_or_default();
382 if !search_term.is_empty() {
383 let q = search_term.to_ascii_lowercase();
384 filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
385 }
386
387 // Sort: priority score ascending, then created_at.
388 filtered.sort_by_key(|t| (t.priority.score(), t.created_at.clone()));
389
390 // Pagination.
391 let total = filtered.len();
392 let total_pages = if total == 0 {
393 1
394 } else {
395 total.div_ceil(PAGE_SIZE)
396 };
397 let page = query.page.unwrap_or(1).clamp(1, total_pages);
398 let start = (page - 1) * PAGE_SIZE;
399 let end = (start + PAGE_SIZE).min(total);
400
401 let page_tasks: Vec<TaskRow> = filtered[start..end]
402 .iter()
403 .map(|t| TaskRow {
404 full_id: t.id.as_str().to_string(),
405 short_id: t.id.short(),
406 status: db::status_label(t.status).to_string(),
407 priority: db::priority_label(t.priority).to_string(),
408 effort: db::effort_label(t.effort).to_string(),
409 title: t.title.clone(),
410 })
411 .collect();
412
413 Ok(ProjectTemplate {
414 all_projects,
415 active_project: Some(name),
416 project_name: store.project_name().to_string(),
417 stats_open,
418 stats_in_progress,
419 stats_closed,
420 next_up,
421 page_tasks,
422 all_labels,
423 filter_status: query.status,
424 filter_priority: query.priority,
425 filter_effort: query.effort,
426 filter_label: query.label,
427 filter_search: search_term,
428 page,
429 total_pages,
430 pagination_pages: (1..=total_pages).collect(),
431 })
432 })
433 .await;
434
435 match result {
436 Ok(Ok(tmpl)) => render(tmpl),
437 Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
438 Err(e) => error_response(500, &format!("join error: {e}"), &[]),
439 }
440}
441
442async fn task_handler(
443 State(state): State<AppState>,
444 AxumPath((name, id)): AxumPath<(String, String)>,
445) -> Response {
446 let root = state.data_root.clone();
447 let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
448 let all_projects = list_projects_safe(&root);
449 let store = Store::open(&root, &name)?;
450
451 let task_id = db::resolve_task_id(&store, &id, false)?;
452 let task = store
453 .get_task(&task_id, false)?
454 .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
455
456 // Partition blockers.
457 let partition = db::partition_blockers(&store, &task.blockers)?;
458 let blockers_open: Vec<BlockerRef> = partition
459 .open
460 .iter()
461 .map(|b| BlockerRef {
462 full_id: b.as_str().to_string(),
463 short_id: b.short(),
464 })
465 .collect();
466 let blockers_resolved: Vec<BlockerRef> = partition
467 .resolved
468 .iter()
469 .map(|b| BlockerRef {
470 full_id: b.as_str().to_string(),
471 short_id: b.short(),
472 })
473 .collect();
474
475 // Find subtasks.
476 let all_tasks = store.list_tasks()?;
477 let subtasks: Vec<TaskRow> = all_tasks
478 .iter()
479 .filter(|t| t.parent.as_ref() == Some(&task_id))
480 .map(|t| TaskRow {
481 full_id: t.id.as_str().to_string(),
482 short_id: t.id.short(),
483 status: db::status_label(t.status).to_string(),
484 priority: db::priority_label(t.priority).to_string(),
485 effort: db::effort_label(t.effort).to_string(),
486 title: t.title.clone(),
487 })
488 .collect();
489
490 let task_view = TaskView {
491 full_id: task.id.as_str().to_string(),
492 short_id: task.id.short(),
493 title: task.title.clone(),
494 description: task.description.clone(),
495 task_type: task.task_type.clone(),
496 status: db::status_label(task.status).to_string(),
497 priority: db::priority_label(task.priority).to_string(),
498 effort: db::effort_label(task.effort).to_string(),
499 created_at: task.created_at.clone(),
500 updated_at: task.updated_at.clone(),
501 labels: task.labels.clone(),
502 logs: task
503 .logs
504 .iter()
505 .map(|l| LogView {
506 timestamp: l.timestamp.clone(),
507 message: l.message.clone(),
508 })
509 .collect(),
510 };
511
512 Ok(TaskTemplate {
513 all_projects,
514 active_project: Some(name),
515 project_name: store.project_name().to_string(),
516 task: task_view,
517 blockers_open,
518 blockers_resolved,
519 subtasks,
520 })
521 })
522 .await;
523
524 match result {
525 Ok(Ok(tmpl)) => render(tmpl),
526 Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
527 Err(e) => error_response(500, &format!("join error: {e}"), &[]),
528 }
529}
530
531async fn static_oat_css() -> impl IntoResponse {
532 (
533 [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
534 include_bytes!("../../static/oat.min.css").as_slice(),
535 )
536}
537
538async fn static_td_css() -> impl IntoResponse {
539 (
540 [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
541 include_bytes!("../../static/td.css").as_slice(),
542 )
543}
544
545async fn static_js() -> impl IntoResponse {
546 (
547 [(
548 axum::http::header::CONTENT_TYPE,
549 "application/javascript; charset=utf-8",
550 )],
551 include_bytes!("../../static/oat.min.js").as_slice(),
552 )
553}
554
555// ---------------------------------------------------------------------------
556// Entry point
557// ---------------------------------------------------------------------------
558
559pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> {
560 let data_root = db::data_root()?;
561 let state = AppState {
562 data_root: Arc::new(data_root),
563 };
564
565 let app = Router::new()
566 .route("/", get(index_handler))
567 .route("/projects/{name}", get(project_handler))
568 .route("/projects/{name}/tasks/{id}", get(task_handler))
569 .route("/static/oat.min.css", get(static_oat_css))
570 .route("/static/td.css", get(static_td_css))
571 .route("/static/oat.min.js", get(static_js))
572 .with_state(state);
573
574 let addr = format!("{host}:{port}");
575 let root_url = format!("http://{addr}");
576
577 // Resolve current project for a convenience URL.
578 let project_url = match explicit_project {
579 Some(p) => Some(format!("{root_url}/projects/{p}")),
580 None => db::try_open(cwd)
581 .ok()
582 .flatten()
583 .map(|s| format!("{root_url}/projects/{}", s.project_name())),
584 };
585
586 eprintln!("listening on {root_url}");
587 if let Some(ref url) = project_url {
588 eprintln!("project: {url}");
589 }
590
591 tokio::runtime::Builder::new_multi_thread()
592 .enable_all()
593 .build()?
594 .block_on(async {
595 let listener = tokio::net::TcpListener::bind(&addr).await?;
596 axum::serve(listener, app).await?;
597 Ok(())
598 })
599}