1use anyhow::Result;
2use axum::extract::State;
3use axum::response::Response;
4
5use crate::db::Store;
6use crate::model::Status;
7
8use super::helpers::{error_response, list_projects_safe, render};
9use super::AppState;
10
11mod views;
12use views::{IndexTemplate, ProjectCard};
13
14/// Activity tier for sorting project cards on the index page.
15/// Lower values sort first (most active projects at the top).
16fn project_card_activity_tier(card: &ProjectCard) -> u32 {
17 match card {
18 ProjectCard::Ok {
19 in_progress, open, ..
20 } => {
21 if *in_progress > 0 {
22 0 // Has in-progress tasks
23 } else if *open > 0 {
24 1 // Has open tasks only
25 } else {
26 2 // Only closed or empty
27 }
28 }
29 ProjectCard::Err { .. } => 3, // Error state
30 }
31}
32
33pub(in crate::cmd::webui) async fn index_handler(State(state): State<AppState>) -> Response {
34 let root = state.data_root.clone();
35 let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
36 let projects = list_projects_safe(&root);
37 let mut cards = Vec::with_capacity(projects.len());
38
39 for name in &projects {
40 match Store::open(&root, name) {
41 Ok(store) => {
42 let tasks = store.list_tasks()?;
43 let open = tasks.iter().filter(|t| t.status == Status::Open).count();
44 let in_progress = tasks
45 .iter()
46 .filter(|t| t.status == Status::InProgress)
47 .count();
48 let closed = tasks.iter().filter(|t| t.status == Status::Closed).count();
49 cards.push(ProjectCard::Ok {
50 name: name.clone(),
51 open,
52 in_progress,
53 closed,
54 total: tasks.len(),
55 });
56 }
57 Err(e) => {
58 cards.push(ProjectCard::Err {
59 name: name.clone(),
60 error: format!("{e}"),
61 });
62 }
63 }
64 }
65
66 // Sort cards by activity tier, then by name alphabetically.
67 cards.sort_by(|a, b| {
68 let tier_a = project_card_activity_tier(a);
69 let tier_b = project_card_activity_tier(b);
70 match tier_a.cmp(&tier_b) {
71 std::cmp::Ordering::Equal => {
72 let name_a = match a {
73 ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
74 };
75 let name_b = match b {
76 ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
77 };
78 name_a.cmp(name_b)
79 }
80 other => other,
81 }
82 });
83
84 Ok(IndexTemplate {
85 all_projects: projects,
86 active_project: None,
87 projects: cards,
88 })
89 })
90 .await;
91
92 match result {
93 Ok(Ok(tmpl)) => render(tmpl),
94 Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
95 Err(e) => error_response(500, &format!("join error: {e}"), &[]),
96 }
97}