webui.rs

  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}