mod.rs

 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}