Detailed changes
@@ -1,1313 +0,0 @@
-use std::collections::HashSet;
-use std::path::Path;
-use std::sync::Arc;
-
-use anyhow::Result;
-use askama::Template;
-use axum::extract::{Path as AxumPath, Query, State};
-use axum::http::{HeaderMap, StatusCode};
-use axum::response::{Html, IntoResponse, Redirect, Response};
-use axum::routing::{get, post};
-use axum::{Form, Json, Router};
-
-use crate::db::{self, Store, TaskId};
-use crate::ops;
-use crate::score;
-
-const PAGE_SIZE: usize = 25;
-
-// ---------------------------------------------------------------------------
-// Sort helpers
-// ---------------------------------------------------------------------------
-
-/// Columns the task table can be sorted by.
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum SortField {
- Id,
- Status,
- Priority,
- Effort,
- Title,
- Created,
-}
-
-impl SortField {
- fn parse(s: &str) -> Option<Self> {
- match s {
- "id" => Some(Self::Id),
- "status" => Some(Self::Status),
- "priority" => Some(Self::Priority),
- "effort" => Some(Self::Effort),
- "title" => Some(Self::Title),
- "created" => Some(Self::Created),
- _ => None,
- }
- }
-
- fn as_str(self) -> &'static str {
- match self {
- Self::Id => "id",
- Self::Status => "status",
- Self::Priority => "priority",
- Self::Effort => "effort",
- Self::Title => "title",
- Self::Created => "created",
- }
- }
-
- /// Sensible default direction when the user first clicks a column.
- fn default_order(self) -> SortOrder {
- match self {
- // Newest first, alphabetical ascending for text fields.
- Self::Created => SortOrder::Desc,
- Self::Title | Self::Id => SortOrder::Asc,
- // Highest priority/effort first; open before closed.
- Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
- }
- }
-}
-
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum SortOrder {
- Asc,
- Desc,
-}
-
-impl SortOrder {
- fn parse(s: &str) -> Option<Self> {
- match s {
- "asc" => Some(Self::Asc),
- "desc" => Some(Self::Desc),
- _ => None,
- }
- }
-
- fn as_str(self) -> &'static str {
- match self {
- Self::Asc => "asc",
- Self::Desc => "desc",
- }
- }
-}
-
-/// Map a `Status` to a numeric value for semantic sorting.
-/// Lower values sort first in ascending order: open → in_progress → closed.
-fn status_sort_key(s: db::Status) -> i32 {
- match s {
- db::Status::Open => 1,
- db::Status::InProgress => 2,
- db::Status::Closed => 3,
- }
-}
-
-/// Apply the chosen sort field and direction to a filtered task list.
-fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
- tasks.sort_by(|a, b| {
- let cmp = match field {
- SortField::Id => a.id.as_str().cmp(b.id.as_str()),
- SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
- SortField::Priority => a.priority.score().cmp(&b.priority.score()),
- SortField::Effort => a.effort.score().cmp(&b.effort.score()),
- SortField::Title => a
- .title
- .to_ascii_lowercase()
- .cmp(&b.title.to_ascii_lowercase()),
- SortField::Created => a.created_at.cmp(&b.created_at),
- };
- match order {
- SortOrder::Asc => cmp,
- SortOrder::Desc => cmp.reverse(),
- }
- });
-}
-
-/// Return a human-friendly status label (e.g. "In progress" instead of
-/// "in_progress").
-fn friendly_status(raw: &str) -> &'static str {
- match raw {
- "open" => "Open",
- "in_progress" => "In progress",
- "closed" => "Closed",
- _ => "Open",
- }
-}
-
-/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
-/// for the noscript fallback. Returns the original string unchanged on parse
-/// failure so the page still renders something sensible.
-fn friendly_date(iso: &str) -> String {
- chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
- .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
- .unwrap_or_else(|_| iso.to_string())
-}
-
-/// Render a markdown string to sanitised HTML.
-///
-/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
-/// untrusted user input can be displayed safely. Images are stripped
-/// entirely — only structural/inline markup is allowed through.
-fn render_markdown(src: &str) -> String {
- use pulldown_cmark::{html::push_html, Parser};
-
- let parser = Parser::new(src);
- let mut raw_html = String::new();
- push_html(&mut raw_html, parser);
-
- ammonia::Builder::default()
- .rm_tags(&["img"])
- .clean(&raw_html)
- .to_string()
-}
-
-// ---------------------------------------------------------------------------
-// Shared state
-// ---------------------------------------------------------------------------
-
-#[derive(Clone)]
-struct AppState {
- data_root: Arc<std::path::PathBuf>,
-}
-
-// ---------------------------------------------------------------------------
-// Template view-models
-// ---------------------------------------------------------------------------
-
-/// A project card on the root page — either healthy or failed.
-enum ProjectCard {
- Ok {
- name: String,
- open: usize,
- in_progress: usize,
- closed: usize,
- total: usize,
- },
- Err {
- name: String,
- error: String,
- },
-}
-
-/// Minimal view-model for a scored task in the "Next Up" table.
-struct ScoredEntry {
- id: String,
- short_id: String,
- title: String,
- score: String,
- status: String,
- status_display: &'static str,
-}
-
-/// Minimal view-model for a task row in the project task table.
-struct TaskRow {
- full_id: String,
- short_id: String,
- status: String,
- status_display: &'static str,
- priority: String,
- effort: String,
- title: String,
- created_at: String,
- created_at_display: String,
-}
-
-/// View-model for the task detail page.
-struct TaskView {
- full_id: String,
- short_id: String,
- title: String,
- description: String,
- task_type: String,
- status: String,
- priority: String,
- effort: String,
- created_at: String,
- created_at_display: String,
- updated_at: String,
- updated_at_display: String,
- labels: Vec<String>,
- logs: Vec<LogView>,
-}
-
-struct LogView {
- timestamp: String,
- timestamp_display: String,
- message: String,
-}
-
-/// A blocker reference for the task detail page.
-struct BlockerRef {
- full_id: String,
- short_id: String,
-}
-
-/// Sort context passed to the task_table macro. When present, column headers
-/// become clickable links that set sort/order query params.
-struct SortContext {
- /// Base URL for sort links (e.g. `/projects/myproj`).
- base_href: String,
- /// Current sort field.
- field: String,
- /// Current sort order ("asc" or "desc").
- order: String,
- /// Query string fragment for the current filters (without sort/order/page),
- /// suitable for appending to hrefs.
- filter_qs: String,
-}
-
-impl SortContext {
- /// Build the href for a column header link. Clicking the currently-active
- /// column toggles direction; clicking a different column uses its default.
- fn column_href(&self, col: &str) -> String {
- let order = if col == self.field {
- // Toggle current direction.
- match self.order.as_str() {
- "asc" => "desc",
- _ => "asc",
- }
- } else {
- // Use the column's sensible default.
- SortField::parse(col)
- .map(|f| f.default_order().as_str())
- .unwrap_or("asc")
- };
- let mut qs = self.filter_qs.clone();
- if !qs.is_empty() {
- qs.push('&');
- }
- qs.push_str(&format!("sort={col}&order={order}"));
- format!("{}?{qs}", self.base_href)
- }
-
- /// Return the arrow indicator for the active column, or empty string.
- fn arrow(&self, col: &str) -> &str {
- if col == self.field {
- match self.order.as_str() {
- "asc" => " ↑",
- "desc" => " ↓",
- _ => "",
- }
- } else {
- ""
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Askama templates
-// ---------------------------------------------------------------------------
-
-#[derive(Template)]
-#[template(path = "index.html")]
-struct IndexTemplate {
- all_projects: Vec<String>,
- active_project: Option<String>,
- projects: Vec<ProjectCard>,
-}
-
-#[derive(Template)]
-#[template(path = "project.html")]
-struct ProjectTemplate {
- all_projects: Vec<String>,
- active_project: Option<String>,
- project_name: String,
- stats_open: usize,
- stats_in_progress: usize,
- stats_closed: usize,
- next_up: Vec<ScoredEntry>,
- page_tasks: Vec<TaskRow>,
- all_labels: Vec<String>,
- filter_status: Option<String>,
- filter_priority: Option<String>,
- filter_effort: Option<String>,
- filter_label: Option<String>,
- filter_search: String,
- page: usize,
- total_pages: usize,
- pagination_pages: Vec<usize>,
- sort_ctx: SortContext,
-}
-
-impl ProjectTemplate {
- /// Build a query-string fragment containing the current filters (no sort,
- /// no page). Reused by both pagination and sort helpers.
- fn filter_qs(&self) -> String {
- let mut parts = Vec::new();
- if let Some(ref s) = self.filter_status {
- parts.push(format!("status={s}"));
- }
- if let Some(ref p) = self.filter_priority {
- parts.push(format!("priority={p}"));
- }
- if let Some(ref e) = self.filter_effort {
- parts.push(format!("effort={e}"));
- }
- if let Some(ref l) = self.filter_label {
- parts.push(format!("label={l}"));
- }
- if !self.filter_search.is_empty() {
- parts.push(format!("q={}", self.filter_search));
- }
- parts.join("&")
- }
-
- /// Build a pagination link preserving current filter and sort params.
- fn pagination_href(&self, target_page: &usize) -> String {
- let target_page = *target_page;
- let mut qs = self.filter_qs();
- if !qs.is_empty() {
- qs.push('&');
- }
- qs.push_str(&format!(
- "sort={}&order={}",
- self.sort_ctx.field, self.sort_ctx.order
- ));
- qs.push_str(&format!("&page={target_page}"));
- format!("/projects/{}?{qs}", self.project_name)
- }
-}
-
-#[derive(Template)]
-#[template(path = "task.html")]
-struct TaskTemplate {
- all_projects: Vec<String>,
- active_project: Option<String>,
- project_name: String,
- task: TaskView,
- blockers_open: Vec<BlockerRef>,
- blockers_resolved: Vec<BlockerRef>,
- subtasks: Vec<TaskRow>,
-}
-
-#[derive(Template)]
-#[template(path = "error.html")]
-struct ErrorTemplate {
- all_projects: Vec<String>,
- active_project: Option<String>,
- status_code: u16,
- message: String,
-}
-
-// ---------------------------------------------------------------------------
-// Response helpers
-// ---------------------------------------------------------------------------
-
-fn render(tmpl: impl Template) -> Response {
- match tmpl.render() {
- Ok(html) => Html(html).into_response(),
- Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
- }
-}
-
-fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
- let body = ErrorTemplate {
- all_projects: all_projects.to_vec(),
- active_project: None,
- status_code: code,
- message: msg.to_string(),
- };
- let html = body
- .render()
- .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
- let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
- (status, Html(html)).into_response()
-}
-
-fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
- db::list_projects_in(root).unwrap_or_default()
-}
-
-// ---------------------------------------------------------------------------
-// Route handlers
-// ---------------------------------------------------------------------------
-
-async fn index_handler(State(state): State<AppState>) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
- let projects = list_projects_safe(&root);
- let mut cards = Vec::with_capacity(projects.len());
-
- for name in &projects {
- match Store::open(&root, name) {
- Ok(store) => {
- let tasks = store.list_tasks()?;
- let open = tasks
- .iter()
- .filter(|t| t.status == db::Status::Open)
- .count();
- let in_progress = tasks
- .iter()
- .filter(|t| t.status == db::Status::InProgress)
- .count();
- let closed = tasks
- .iter()
- .filter(|t| t.status == db::Status::Closed)
- .count();
- cards.push(ProjectCard::Ok {
- name: name.clone(),
- open,
- in_progress,
- closed,
- total: tasks.len(),
- });
- }
- Err(e) => {
- cards.push(ProjectCard::Err {
- name: name.clone(),
- error: format!("{e}"),
- });
- }
- }
- }
-
- Ok(IndexTemplate {
- all_projects: projects,
- active_project: None,
- projects: cards,
- })
- })
- .await;
-
- match result {
- Ok(Ok(tmpl)) => render(tmpl),
- Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
- Err(e) => error_response(500, &format!("join error: {e}"), &[]),
- }
-}
-
-#[derive(serde::Deserialize)]
-struct ProjectQuery {
- status: Option<String>,
- priority: Option<String>,
- effort: Option<String>,
- label: Option<String>,
- q: Option<String>,
- page: Option<usize>,
- sort: Option<String>,
- order: Option<String>,
-}
-
-async fn project_handler(
- State(state): State<AppState>,
- AxumPath(name): AxumPath<String>,
- Query(mut query): Query<ProjectQuery>,
-) -> Response {
- // Default to showing open tasks when no status filter is specified.
- if query.status.is_none() {
- query.status = Some("open".to_string());
- }
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
- let all_projects = list_projects_safe(&root);
- let store = Store::open(&root, &name)?;
- let tasks = store.list_tasks()?;
-
- // Stats from the full unfiltered set.
- let stats_open = tasks
- .iter()
- .filter(|t| t.status == db::Status::Open)
- .count();
- let stats_in_progress = tasks
- .iter()
- .filter(|t| t.status == db::Status::InProgress)
- .count();
- let stats_closed = tasks
- .iter()
- .filter(|t| t.status == db::Status::Closed)
- .count();
-
- // Collect distinct labels for the filter dropdown.
- let mut label_set: HashSet<String> = HashSet::new();
- for t in &tasks {
- for l in &t.labels {
- label_set.insert(l.clone());
- }
- }
- let mut all_labels: Vec<String> = label_set.into_iter().collect();
- all_labels.sort();
-
- // Next-up scoring (top 5 open tasks).
- let open_tasks: Vec<score::TaskInput> = tasks
- .iter()
- .filter(|t| t.status == db::Status::Open)
- .map(|t| score::TaskInput {
- id: t.id.as_str().to_string(),
- title: t.title.clone(),
- priority_score: t.priority.score(),
- effort_score: t.effort.score(),
- priority_label: db::priority_label(t.priority).to_string(),
- effort_label: db::effort_label(t.effort).to_string(),
- })
- .collect();
-
- let edges: Vec<(String, String)> = tasks
- .iter()
- .filter(|t| t.status == db::Status::Open)
- .flat_map(|t| {
- t.blockers
- .iter()
- .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
- .collect::<Vec<_>>()
- })
- .collect();
-
- let parents_with_open_children: HashSet<String> = tasks
- .iter()
- .filter(|t| t.status == db::Status::Open)
- .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
- .collect();
-
- let scored = score::rank(
- &open_tasks,
- &edges,
- &parents_with_open_children,
- score::Mode::Impact,
- 5,
- );
-
- let next_up: Vec<ScoredEntry> = scored
- .into_iter()
- .map(|s| ScoredEntry {
- short_id: TaskId::display_id(&s.id),
- id: s.id,
- title: s.title,
- score: format!("{:.2}", s.score),
- status: "open".to_string(),
- status_display: friendly_status("open"),
- })
- .collect();
-
- // Apply filters.
- let mut filtered: Vec<&db::Task> = tasks.iter().collect();
-
- if let Some(ref s) = query.status {
- if !s.is_empty() {
- if let Ok(parsed) = db::parse_status(s) {
- filtered.retain(|t| t.status == parsed);
- }
- }
- }
- if let Some(ref p) = query.priority {
- if !p.is_empty() {
- if let Ok(parsed) = db::parse_priority(p) {
- filtered.retain(|t| t.priority == parsed);
- }
- }
- }
- if let Some(ref e) = query.effort {
- if !e.is_empty() {
- if let Ok(parsed) = db::parse_effort(e) {
- filtered.retain(|t| t.effort == parsed);
- }
- }
- }
- if let Some(ref l) = query.label {
- if !l.is_empty() {
- filtered.retain(|t| t.labels.iter().any(|x| x == l));
- }
- }
- let search_term = query.q.clone().unwrap_or_default();
- if !search_term.is_empty() {
- let q = search_term.to_ascii_lowercase();
- filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
- }
-
- // Sort: user-selected column, or priority+created as default.
- let sort_field = query
- .sort
- .as_deref()
- .and_then(SortField::parse)
- .unwrap_or(SortField::Priority);
- let sort_order = query
- .order
- .as_deref()
- .and_then(SortOrder::parse)
- .unwrap_or_else(|| sort_field.default_order());
- sort_tasks(&mut filtered, sort_field, sort_order);
-
- // Pagination.
- let total = filtered.len();
- let total_pages = if total == 0 {
- 1
- } else {
- total.div_ceil(PAGE_SIZE)
- };
- let page = query.page.unwrap_or(1).clamp(1, total_pages);
- let start = (page - 1) * PAGE_SIZE;
- let end = (start + PAGE_SIZE).min(total);
-
- let page_tasks: Vec<TaskRow> = filtered[start..end]
- .iter()
- .map(|t| {
- let status = db::status_label(t.status).to_string();
- TaskRow {
- full_id: t.id.as_str().to_string(),
- short_id: t.id.short(),
- status_display: friendly_status(&status),
- status,
- priority: db::priority_label(t.priority).to_string(),
- effort: db::effort_label(t.effort).to_string(),
- title: t.title.clone(),
- created_at_display: friendly_date(&t.created_at),
- created_at: t.created_at.clone(),
- }
- })
- .collect();
-
- // Build filter query string for sort links (excludes sort/order/page).
- let filter_qs = {
- let mut parts = Vec::new();
- if let Some(ref s) = query.status {
- parts.push(format!("status={s}"));
- }
- if let Some(ref p) = query.priority {
- parts.push(format!("priority={p}"));
- }
- if let Some(ref e) = query.effort {
- parts.push(format!("effort={e}"));
- }
- if let Some(ref l) = query.label {
- parts.push(format!("label={l}"));
- }
- if !search_term.is_empty() {
- parts.push(format!("q={search_term}"));
- }
- parts.join("&")
- };
-
- let proj_name = store.project_name().to_string();
- let sort_ctx = SortContext {
- base_href: format!("/projects/{proj_name}"),
- field: sort_field.as_str().to_string(),
- order: sort_order.as_str().to_string(),
- filter_qs,
- };
-
- Ok(ProjectTemplate {
- all_projects,
- active_project: Some(name),
- project_name: proj_name,
- stats_open,
- stats_in_progress,
- stats_closed,
- next_up,
- page_tasks,
- all_labels,
- filter_status: query.status,
- filter_priority: query.priority,
- filter_effort: query.effort,
- filter_label: query.label,
- filter_search: search_term,
- page,
- total_pages,
- pagination_pages: (1..=total_pages).collect(),
- sort_ctx,
- })
- })
- .await;
-
- match result {
- Ok(Ok(tmpl)) => render(tmpl),
- Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
- Err(e) => error_response(500, &format!("join error: {e}"), &[]),
- }
-}
-
-async fn task_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
- let all_projects = list_projects_safe(&root);
- let store = Store::open(&root, &name)?;
-
- let task_id = db::resolve_task_id(&store, &id, false)?;
- let task = store
- .get_task(&task_id, false)?
- .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
-
- // Partition blockers.
- let partition = db::partition_blockers(&store, &task.blockers)?;
- let blockers_open: Vec<BlockerRef> = partition
- .open
- .iter()
- .map(|b| BlockerRef {
- full_id: b.as_str().to_string(),
- short_id: b.short(),
- })
- .collect();
- let blockers_resolved: Vec<BlockerRef> = partition
- .resolved
- .iter()
- .map(|b| BlockerRef {
- full_id: b.as_str().to_string(),
- short_id: b.short(),
- })
- .collect();
-
- // Find subtasks.
- let all_tasks = store.list_tasks()?;
- let subtasks: Vec<TaskRow> = all_tasks
- .iter()
- .filter(|t| t.parent.as_ref() == Some(&task_id))
- .map(|t| {
- let status = db::status_label(t.status).to_string();
- TaskRow {
- full_id: t.id.as_str().to_string(),
- short_id: t.id.short(),
- status_display: friendly_status(&status),
- status,
- priority: db::priority_label(t.priority).to_string(),
- effort: db::effort_label(t.effort).to_string(),
- title: t.title.clone(),
- created_at_display: friendly_date(&t.created_at),
- created_at: t.created_at.clone(),
- }
- })
- .collect();
-
- let task_view = TaskView {
- full_id: task.id.as_str().to_string(),
- short_id: task.id.short(),
- title: task.title.clone(),
- description: render_markdown(&task.description),
- task_type: task.task_type.clone(),
- status: db::status_label(task.status).to_string(),
- priority: db::priority_label(task.priority).to_string(),
- effort: db::effort_label(task.effort).to_string(),
- created_at_display: friendly_date(&task.created_at),
- created_at: task.created_at.clone(),
- updated_at_display: friendly_date(&task.updated_at),
- updated_at: task.updated_at.clone(),
- labels: task.labels.clone(),
- logs: task
- .logs
- .iter()
- .map(|l| LogView {
- timestamp_display: friendly_date(&l.timestamp),
- timestamp: l.timestamp.clone(),
- message: render_markdown(&l.message),
- })
- .collect(),
- };
-
- Ok(TaskTemplate {
- all_projects,
- active_project: Some(name),
- project_name: store.project_name().to_string(),
- task: task_view,
- blockers_open,
- blockers_resolved,
- subtasks,
- })
- })
- .await;
-
- match result {
- Ok(Ok(tmpl)) => render(tmpl),
- Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
- Err(e) => error_response(500, &format!("join error: {e}"), &[]),
- }
-}
-
-// ---------------------------------------------------------------------------
-// Content negotiation
-// ---------------------------------------------------------------------------
-
-/// Returns true when the client prefers a JSON response.
-fn wants_json(headers: &HeaderMap) -> bool {
- headers
- .get("accept")
- .and_then(|v| v.to_str().ok())
- .map(|v| v.contains("application/json"))
- .unwrap_or(false)
-}
-
-/// Build a redirect-or-JSON response after a successful mutation.
-fn mutation_response(
- headers: &HeaderMap,
- redirect_to: &str,
- json_body: serde_json::Value,
-) -> Response {
- if wants_json(headers) {
- Json(json_body).into_response()
- } else {
- Redirect::to(redirect_to).into_response()
- }
-}
-
-/// Build an error response appropriate for the caller (JSON or HTML).
-fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response {
- let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
- if wants_json(headers) {
- (status, Json(serde_json::json!({"error": err.to_string()}))).into_response()
- } else {
- error_response(code, &err.to_string(), &[])
- }
-}
-
-// ---------------------------------------------------------------------------
-// POST form/JSON input types
-// ---------------------------------------------------------------------------
-
-#[derive(serde::Deserialize)]
-struct CreateProjectForm {
- name: String,
- #[serde(default)]
- bind_path: String,
-}
-
-#[derive(serde::Deserialize)]
-struct CreateForm {
- title: String,
- #[serde(default)]
- description: String,
- #[serde(default = "default_task_type")]
- task_type: String,
- #[serde(default = "default_priority")]
- priority: String,
- #[serde(default = "default_effort")]
- effort: String,
- #[serde(default)]
- labels: String,
- #[serde(default)]
- parent: String,
-}
-
-fn default_task_type() -> String {
- "task".to_string()
-}
-fn default_priority() -> String {
- "medium".to_string()
-}
-fn default_effort() -> String {
- "medium".to_string()
-}
-
-#[derive(serde::Deserialize)]
-struct UpdateForm {
- #[serde(default)]
- title: Option<String>,
- #[serde(default)]
- description: Option<String>,
- #[serde(default)]
- status: Option<String>,
- #[serde(default)]
- priority: Option<String>,
- #[serde(default)]
- effort: Option<String>,
- #[serde(default)]
- redirect: Option<String>,
-}
-
-#[derive(serde::Deserialize)]
-struct LogForm {
- message: String,
-}
-
-#[derive(serde::Deserialize)]
-struct LabelForm {
- /// "add" or "rm"
- action: String,
- label: String,
-}
-
-#[derive(serde::Deserialize)]
-struct DepForm {
- /// "add" or "rm"
- action: String,
- blocker: String,
-}
-
-// ---------------------------------------------------------------------------
-// POST handlers
-// ---------------------------------------------------------------------------
-
-async fn create_project_handler(
- State(state): State<AppState>,
- headers: HeaderMap,
- Form(form): Form<CreateProjectForm>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<String> {
- let bind = if form.bind_path.is_empty() {
- None
- } else {
- Some(std::path::PathBuf::from(&form.bind_path))
- };
- ops::init_project(&root, &form.name, bind.as_deref())?;
- Ok(form.name)
- })
- .await;
-
- match result {
- Ok(Ok(name)) => {
- let redirect = format!("/projects/{name}");
- mutation_response(&headers, &redirect, serde_json::json!({"name": name}))
- }
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn create_handler(
- State(state): State<AppState>,
- AxumPath(name): AxumPath<String>,
- headers: HeaderMap,
- Form(form): Form<CreateForm>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
- let store = Store::open(&root, &name)?;
-
- let parent = if form.parent.is_empty() {
- None
- } else {
- Some(db::resolve_task_id(&store, &form.parent, false)?)
- };
-
- let labels: Vec<String> = form
- .labels
- .split(',')
- .map(str::trim)
- .filter(|l| !l.is_empty())
- .map(String::from)
- .collect();
-
- let task = ops::create_task(
- &store,
- ops::CreateOpts {
- title: form.title,
- description: form.description,
- task_type: form.task_type,
- priority: db::parse_priority(&form.priority)?,
- effort: db::parse_effort(&form.effort)?,
- parent,
- labels,
- },
- )?;
-
- let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
- Ok((task, redirect))
- })
- .await;
-
- match result {
- Ok(Ok((task, redirect))) => mutation_response(
- &headers,
- &redirect,
- serde_json::to_value(&task).unwrap_or_default(),
- ),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn update_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
- Form(form): Form<UpdateForm>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
- let store = Store::open(&root, &name)?;
- let task_id = db::resolve_task_id(&store, &id, false)?;
-
- let task = ops::update_task(
- &store,
- &task_id,
- ops::UpdateOpts {
- status: form
- .status
- .as_deref()
- .filter(|s| !s.is_empty())
- .map(db::parse_status)
- .transpose()?,
- priority: form
- .priority
- .as_deref()
- .filter(|s| !s.is_empty())
- .map(db::parse_priority)
- .transpose()?,
- effort: form
- .effort
- .as_deref()
- .filter(|s| !s.is_empty())
- .map(db::parse_effort)
- .transpose()?,
- title: form.title.filter(|s| !s.is_empty()),
- description: form.description,
- },
- )?;
-
- let redirect = form
- .redirect
- .filter(|r| r.starts_with('/'))
- .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str()));
- Ok((task, redirect))
- })
- .await;
-
- match result {
- Ok(Ok((task, redirect))) => mutation_response(
- &headers,
- &redirect,
- serde_json::to_value(&task).unwrap_or_default(),
- ),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn log_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
- Form(form): Form<LogForm>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> {
- let store = Store::open(&root, &name)?;
- let task_id = db::resolve_task_id(&store, &id, false)?;
- let entry = ops::add_log(&store, &task_id, &form.message)?;
- let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
- Ok((entry, redirect))
- })
- .await;
-
- match result {
- Ok(Ok((entry, redirect))) => mutation_response(
- &headers,
- &redirect,
- serde_json::to_value(&entry).unwrap_or_default(),
- ),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn done_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
- let store = Store::open(&root, &name)?;
- let task_id = db::resolve_task_id(&store, &id, false)?;
- ops::mark_done(&store, &task_id)?;
- let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
- Ok((task_id, redirect))
- })
- .await;
-
- match result {
- Ok(Ok((task_id, redirect))) => mutation_response(
- &headers,
- &redirect,
- serde_json::json!({"id": task_id, "status": "closed"}),
- ),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn reopen_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
- let store = Store::open(&root, &name)?;
- let task_id = db::resolve_task_id(&store, &id, false)?;
- ops::reopen_task(&store, &task_id)?;
- let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
- Ok((task_id, redirect))
- })
- .await;
-
- match result {
- Ok(Ok((task_id, redirect))) => mutation_response(
- &headers,
- &redirect,
- serde_json::json!({"id": task_id, "status": "open"}),
- ),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn label_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
- Form(form): Form<LabelForm>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<String> {
- let store = Store::open(&root, &name)?;
- let task_id = db::resolve_task_id(&store, &id, false)?;
- match form.action.as_str() {
- "add" => ops::add_label(&store, &task_id, &form.label)?,
- "rm" => ops::remove_label(&store, &task_id, &form.label)?,
- other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"),
- }
- Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str()))
- })
- .await;
-
- match result {
- Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn dep_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
- Form(form): Form<DepForm>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<String> {
- let store = Store::open(&root, &name)?;
- let child_id = db::resolve_task_id(&store, &id, false)?;
- let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?;
- match form.action.as_str() {
- "add" => ops::add_dep(&store, &child_id, &blocker_id)?,
- "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?,
- other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"),
- }
- Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str()))
- })
- .await;
-
- match result {
- Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn delete_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
- headers: HeaderMap,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> {
- let store = Store::open(&root, &name)?;
- let task_id = db::resolve_task_id(&store, &id, false)?;
- let dr = ops::soft_delete(&store, &[task_id], false)?;
- let redirect = format!("/projects/{}", name);
- Ok((dr, redirect))
- })
- .await;
-
- match result {
- Ok(Ok((dr, redirect))) => {
- let json_body = serde_json::json!({
- "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
- "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
- });
- mutation_response(&headers, &redirect, json_body)
- }
- Ok(Err(e)) => mutation_error(&headers, 400, &e),
- Err(e) => mutation_error(&headers, 500, &e.into()),
- }
-}
-
-async fn static_oat_css() -> impl IntoResponse {
- (
- [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
- include_bytes!("../../static/oat.min.css").as_slice(),
- )
-}
-
-async fn static_td_css() -> impl IntoResponse {
- (
- [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
- include_bytes!("../../static/td.css").as_slice(),
- )
-}
-
-async fn static_js() -> impl IntoResponse {
- (
- [(
- axum::http::header::CONTENT_TYPE,
- "application/javascript; charset=utf-8",
- )],
- include_bytes!("../../static/oat.min.js").as_slice(),
- )
-}
-
-async fn static_td_js() -> impl IntoResponse {
- (
- [(
- axum::http::header::CONTENT_TYPE,
- "application/javascript; charset=utf-8",
- )],
- include_bytes!("../../static/td.js").as_slice(),
- )
-}
-
-// ---------------------------------------------------------------------------
-// Entry point
-// ---------------------------------------------------------------------------
-
-pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> {
- let data_root = db::data_root()?;
- let state = AppState {
- data_root: Arc::new(data_root),
- };
-
- let app = Router::new()
- .route("/", get(index_handler))
- .route("/projects", post(create_project_handler))
- .route("/projects/{name}", get(project_handler))
- .route("/projects/{name}/tasks", post(create_handler))
- .route(
- "/projects/{name}/tasks/{id}",
- get(task_handler).post(update_handler),
- )
- .route("/projects/{name}/tasks/{id}/log", post(log_handler))
- .route("/projects/{name}/tasks/{id}/done", post(done_handler))
- .route("/projects/{name}/tasks/{id}/reopen", post(reopen_handler))
- .route("/projects/{name}/tasks/{id}/labels", post(label_handler))
- .route("/projects/{name}/tasks/{id}/deps", post(dep_handler))
- .route("/projects/{name}/tasks/{id}/delete", post(delete_handler))
- .route("/static/oat.min.css", get(static_oat_css))
- .route("/static/td.css", get(static_td_css))
- .route("/static/oat.min.js", get(static_js))
- .route("/static/td.js", get(static_td_js))
- .with_state(state);
-
- let addr = format!("{host}:{port}");
- let root_url = format!("http://{addr}");
-
- // Resolve current project for a convenience URL.
- let project_url = match explicit_project {
- Some(p) => Some(format!("{root_url}/projects/{p}")),
- None => db::try_open(cwd)
- .ok()
- .flatten()
- .map(|s| format!("{root_url}/projects/{}", s.project_name())),
- };
-
- eprintln!("listening on {root_url}");
- if let Some(ref url) = project_url {
- eprintln!("project: {url}");
- }
-
- tokio::runtime::Builder::new_multi_thread()
- .enable_all()
- .build()?
- .block_on(async {
- let listener = tokio::net::TcpListener::bind(&addr).await?;
- axum::serve(listener, app).await?;
- Ok(())
- })
-}
@@ -0,0 +1,410 @@
+use std::collections::HashSet;
+
+use anyhow::Result;
+use axum::extract::{Path as AxumPath, Query, State};
+use axum::response::Response;
+
+use crate::db::{self, Store, TaskId};
+use crate::score;
+
+use super::helpers::{
+ error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown,
+ sort_tasks, SortField, SortOrder,
+};
+use super::views::{
+ BlockerRef, IndexTemplate, LogView, ProjectCard, ProjectTemplate, ScoredEntry, SortContext,
+ TaskRow, TaskTemplate, TaskView,
+};
+use super::AppState;
+
+const PAGE_SIZE: usize = 25;
+
+pub(super) async fn index_handler(State(state): State<AppState>) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
+ let projects = list_projects_safe(&root);
+ let mut cards = Vec::with_capacity(projects.len());
+
+ for name in &projects {
+ match Store::open(&root, name) {
+ Ok(store) => {
+ let tasks = store.list_tasks()?;
+ let open = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Open)
+ .count();
+ let in_progress = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::InProgress)
+ .count();
+ let closed = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Closed)
+ .count();
+ cards.push(ProjectCard::Ok {
+ name: name.clone(),
+ open,
+ in_progress,
+ closed,
+ total: tasks.len(),
+ });
+ }
+ Err(e) => {
+ cards.push(ProjectCard::Err {
+ name: name.clone(),
+ error: format!("{e}"),
+ });
+ }
+ }
+ }
+
+ Ok(IndexTemplate {
+ all_projects: projects,
+ active_project: None,
+ projects: cards,
+ })
+ })
+ .await;
+
+ match result {
+ Ok(Ok(tmpl)) => render(tmpl),
+ Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+ Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+ }
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct ProjectQuery {
+ status: Option<String>,
+ priority: Option<String>,
+ effort: Option<String>,
+ label: Option<String>,
+ q: Option<String>,
+ page: Option<usize>,
+ sort: Option<String>,
+ order: Option<String>,
+}
+
+pub(super) async fn project_handler(
+ State(state): State<AppState>,
+ AxumPath(name): AxumPath<String>,
+ Query(mut query): Query<ProjectQuery>,
+) -> Response {
+ // Default to showing open tasks when no status filter is specified.
+ if query.status.is_none() {
+ query.status = Some("open".to_string());
+ }
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
+ let all_projects = list_projects_safe(&root);
+ let store = Store::open(&root, &name)?;
+ let tasks = store.list_tasks()?;
+
+ // Stats from the full unfiltered set.
+ let stats_open = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Open)
+ .count();
+ let stats_in_progress = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::InProgress)
+ .count();
+ let stats_closed = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Closed)
+ .count();
+
+ // Collect distinct labels for the filter dropdown.
+ let mut label_set: HashSet<String> = HashSet::new();
+ for t in &tasks {
+ for l in &t.labels {
+ label_set.insert(l.clone());
+ }
+ }
+ let mut all_labels: Vec<String> = label_set.into_iter().collect();
+ all_labels.sort();
+
+ // Next-up scoring (top 5 open tasks).
+ let open_tasks: Vec<score::TaskInput> = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Open)
+ .map(|t| score::TaskInput {
+ id: t.id.as_str().to_string(),
+ title: t.title.clone(),
+ priority_score: t.priority.score(),
+ effort_score: t.effort.score(),
+ priority_label: db::priority_label(t.priority).to_string(),
+ effort_label: db::effort_label(t.effort).to_string(),
+ })
+ .collect();
+
+ let edges: Vec<(String, String)> = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Open)
+ .flat_map(|t| {
+ t.blockers
+ .iter()
+ .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
+ .collect::<Vec<_>>()
+ })
+ .collect();
+
+ let parents_with_open_children: HashSet<String> = tasks
+ .iter()
+ .filter(|t| t.status == db::Status::Open)
+ .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
+ .collect();
+
+ let scored = score::rank(
+ &open_tasks,
+ &edges,
+ &parents_with_open_children,
+ score::Mode::Impact,
+ 5,
+ );
+
+ let next_up: Vec<ScoredEntry> = scored
+ .into_iter()
+ .map(|s| ScoredEntry {
+ short_id: TaskId::display_id(&s.id),
+ id: s.id,
+ title: s.title,
+ score: format!("{:.2}", s.score),
+ status: "open".to_string(),
+ status_display: friendly_status("open"),
+ })
+ .collect();
+
+ // Apply filters.
+ let mut filtered: Vec<&db::Task> = tasks.iter().collect();
+
+ if let Some(ref s) = query.status {
+ if !s.is_empty() {
+ if let Ok(parsed) = db::parse_status(s) {
+ filtered.retain(|t| t.status == parsed);
+ }
+ }
+ }
+ if let Some(ref p) = query.priority {
+ if !p.is_empty() {
+ if let Ok(parsed) = db::parse_priority(p) {
+ filtered.retain(|t| t.priority == parsed);
+ }
+ }
+ }
+ if let Some(ref e) = query.effort {
+ if !e.is_empty() {
+ if let Ok(parsed) = db::parse_effort(e) {
+ filtered.retain(|t| t.effort == parsed);
+ }
+ }
+ }
+ if let Some(ref l) = query.label {
+ if !l.is_empty() {
+ filtered.retain(|t| t.labels.iter().any(|x| x == l));
+ }
+ }
+ let search_term = query.q.clone().unwrap_or_default();
+ if !search_term.is_empty() {
+ let q = search_term.to_ascii_lowercase();
+ filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
+ }
+
+ // Sort: user-selected column, or priority+created as default.
+ let sort_field = query
+ .sort
+ .as_deref()
+ .and_then(SortField::parse)
+ .unwrap_or(SortField::Priority);
+ let sort_order = query
+ .order
+ .as_deref()
+ .and_then(SortOrder::parse)
+ .unwrap_or_else(|| sort_field.default_order());
+ sort_tasks(&mut filtered, sort_field, sort_order);
+
+ // Pagination.
+ let total = filtered.len();
+ let total_pages = if total == 0 {
+ 1
+ } else {
+ total.div_ceil(PAGE_SIZE)
+ };
+ let page = query.page.unwrap_or(1).clamp(1, total_pages);
+ let start = (page - 1) * PAGE_SIZE;
+ let end = (start + PAGE_SIZE).min(total);
+
+ let page_tasks: Vec<TaskRow> = filtered[start..end]
+ .iter()
+ .map(|t| {
+ let status = db::status_label(t.status).to_string();
+ TaskRow {
+ full_id: t.id.as_str().to_string(),
+ short_id: t.id.short(),
+ status_display: friendly_status(&status),
+ status,
+ priority: db::priority_label(t.priority).to_string(),
+ effort: db::effort_label(t.effort).to_string(),
+ title: t.title.clone(),
+ created_at_display: friendly_date(&t.created_at),
+ created_at: t.created_at.clone(),
+ }
+ })
+ .collect();
+
+ // Build filter query string for sort links (excludes sort/order/page).
+ let filter_qs = {
+ let mut parts = Vec::new();
+ if let Some(ref s) = query.status {
+ parts.push(format!("status={s}"));
+ }
+ if let Some(ref p) = query.priority {
+ parts.push(format!("priority={p}"));
+ }
+ if let Some(ref e) = query.effort {
+ parts.push(format!("effort={e}"));
+ }
+ if let Some(ref l) = query.label {
+ parts.push(format!("label={l}"));
+ }
+ if !search_term.is_empty() {
+ parts.push(format!("q={search_term}"));
+ }
+ parts.join("&")
+ };
+
+ let proj_name = store.project_name().to_string();
+ let sort_ctx = SortContext {
+ base_href: format!("/projects/{proj_name}"),
+ field: sort_field.as_str().to_string(),
+ order: sort_order.as_str().to_string(),
+ filter_qs,
+ };
+
+ Ok(ProjectTemplate {
+ all_projects,
+ active_project: Some(name),
+ project_name: proj_name,
+ stats_open,
+ stats_in_progress,
+ stats_closed,
+ next_up,
+ page_tasks,
+ all_labels,
+ filter_status: query.status,
+ filter_priority: query.priority,
+ filter_effort: query.effort,
+ filter_label: query.label,
+ filter_search: search_term,
+ page,
+ total_pages,
+ pagination_pages: (1..=total_pages).collect(),
+ sort_ctx,
+ })
+ })
+ .await;
+
+ match result {
+ Ok(Ok(tmpl)) => render(tmpl),
+ Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+ Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+ }
+}
+
+pub(super) async fn task_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
+ let all_projects = list_projects_safe(&root);
+ let store = Store::open(&root, &name)?;
+
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ let task = store
+ .get_task(&task_id, false)?
+ .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
+
+ // Partition blockers.
+ let partition = db::partition_blockers(&store, &task.blockers)?;
+ let blockers_open: Vec<BlockerRef> = partition
+ .open
+ .iter()
+ .map(|b| BlockerRef {
+ full_id: b.as_str().to_string(),
+ short_id: b.short(),
+ })
+ .collect();
+ let blockers_resolved: Vec<BlockerRef> = partition
+ .resolved
+ .iter()
+ .map(|b| BlockerRef {
+ full_id: b.as_str().to_string(),
+ short_id: b.short(),
+ })
+ .collect();
+
+ // Find subtasks.
+ let all_tasks = store.list_tasks()?;
+ let subtasks: Vec<TaskRow> = all_tasks
+ .iter()
+ .filter(|t| t.parent.as_ref() == Some(&task_id))
+ .map(|t| {
+ let status = db::status_label(t.status).to_string();
+ TaskRow {
+ full_id: t.id.as_str().to_string(),
+ short_id: t.id.short(),
+ status_display: friendly_status(&status),
+ status,
+ priority: db::priority_label(t.priority).to_string(),
+ effort: db::effort_label(t.effort).to_string(),
+ title: t.title.clone(),
+ created_at_display: friendly_date(&t.created_at),
+ created_at: t.created_at.clone(),
+ }
+ })
+ .collect();
+
+ let task_view = TaskView {
+ full_id: task.id.as_str().to_string(),
+ short_id: task.id.short(),
+ title: task.title.clone(),
+ description: render_markdown(&task.description),
+ task_type: task.task_type.clone(),
+ status: db::status_label(task.status).to_string(),
+ priority: db::priority_label(task.priority).to_string(),
+ effort: db::effort_label(task.effort).to_string(),
+ created_at_display: friendly_date(&task.created_at),
+ created_at: task.created_at.clone(),
+ updated_at_display: friendly_date(&task.updated_at),
+ updated_at: task.updated_at.clone(),
+ labels: task.labels.clone(),
+ logs: task
+ .logs
+ .iter()
+ .map(|l| LogView {
+ timestamp_display: friendly_date(&l.timestamp),
+ timestamp: l.timestamp.clone(),
+ message: render_markdown(&l.message),
+ })
+ .collect(),
+ };
+
+ Ok(TaskTemplate {
+ all_projects,
+ active_project: Some(name),
+ project_name: store.project_name().to_string(),
+ task: task_view,
+ blockers_open,
+ blockers_resolved,
+ subtasks,
+ })
+ })
+ .await;
+
+ match result {
+ Ok(Ok(tmpl)) => render(tmpl),
+ Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+ Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+ }
+}
@@ -0,0 +1,204 @@
+use askama::Template;
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{Html, IntoResponse, Redirect, Response};
+use axum::Json;
+
+use crate::db;
+
+use super::views::ErrorTemplate;
+
+/// Columns the task table can be sorted by.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum SortField {
+ Id,
+ Status,
+ Priority,
+ Effort,
+ Title,
+ Created,
+}
+
+impl SortField {
+ pub(super) fn parse(s: &str) -> Option<Self> {
+ match s {
+ "id" => Some(Self::Id),
+ "status" => Some(Self::Status),
+ "priority" => Some(Self::Priority),
+ "effort" => Some(Self::Effort),
+ "title" => Some(Self::Title),
+ "created" => Some(Self::Created),
+ _ => None,
+ }
+ }
+
+ pub(super) fn as_str(self) -> &'static str {
+ match self {
+ Self::Id => "id",
+ Self::Status => "status",
+ Self::Priority => "priority",
+ Self::Effort => "effort",
+ Self::Title => "title",
+ Self::Created => "created",
+ }
+ }
+
+ /// Sensible default direction when the user first clicks a column.
+ pub(super) fn default_order(self) -> SortOrder {
+ match self {
+ // Newest first, alphabetical ascending for text fields.
+ Self::Created => SortOrder::Desc,
+ Self::Title | Self::Id => SortOrder::Asc,
+ // Highest priority/effort first; open before closed.
+ Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
+ }
+ }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum SortOrder {
+ Asc,
+ Desc,
+}
+
+impl SortOrder {
+ pub(super) fn parse(s: &str) -> Option<Self> {
+ match s {
+ "asc" => Some(Self::Asc),
+ "desc" => Some(Self::Desc),
+ _ => None,
+ }
+ }
+
+ pub(super) fn as_str(self) -> &'static str {
+ match self {
+ Self::Asc => "asc",
+ Self::Desc => "desc",
+ }
+ }
+}
+
+/// Map a `Status` to a numeric value for semantic sorting.
+/// Lower values sort first in ascending order: open → in_progress → closed.
+fn status_sort_key(s: db::Status) -> i32 {
+ match s {
+ db::Status::Open => 1,
+ db::Status::InProgress => 2,
+ db::Status::Closed => 3,
+ }
+}
+
+/// Apply the chosen sort field and direction to a filtered task list.
+pub(super) fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
+ tasks.sort_by(|a, b| {
+ let cmp = match field {
+ SortField::Id => a.id.as_str().cmp(b.id.as_str()),
+ SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
+ SortField::Priority => a.priority.score().cmp(&b.priority.score()),
+ SortField::Effort => a.effort.score().cmp(&b.effort.score()),
+ SortField::Title => a
+ .title
+ .to_ascii_lowercase()
+ .cmp(&b.title.to_ascii_lowercase()),
+ SortField::Created => a.created_at.cmp(&b.created_at),
+ };
+ match order {
+ SortOrder::Asc => cmp,
+ SortOrder::Desc => cmp.reverse(),
+ }
+ });
+}
+
+/// Return a human-friendly status label (e.g. "In progress" instead of
+/// "in_progress").
+pub(super) fn friendly_status(raw: &str) -> &'static str {
+ match raw {
+ "open" => "Open",
+ "in_progress" => "In progress",
+ "closed" => "Closed",
+ _ => "Open",
+ }
+}
+
+/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
+/// for the noscript fallback. Returns the original string unchanged on parse
+/// failure so the page still renders something sensible.
+pub(super) fn friendly_date(iso: &str) -> String {
+ chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
+ .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
+ .unwrap_or_else(|_| iso.to_string())
+}
+
+/// Render a markdown string to sanitised HTML.
+///
+/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
+/// untrusted user input can be displayed safely. Images are stripped
+/// entirely — only structural/inline markup is allowed through.
+pub(super) fn render_markdown(src: &str) -> String {
+ use pulldown_cmark::{html::push_html, Parser};
+
+ let parser = Parser::new(src);
+ let mut raw_html = String::new();
+ push_html(&mut raw_html, parser);
+
+ ammonia::Builder::default()
+ .rm_tags(&["img"])
+ .clean(&raw_html)
+ .to_string()
+}
+
+pub(super) fn render(tmpl: impl Template) -> Response {
+ match tmpl.render() {
+ Ok(html) => Html(html).into_response(),
+ Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
+ }
+}
+
+pub(super) fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
+ let body = ErrorTemplate {
+ all_projects: all_projects.to_vec(),
+ active_project: None,
+ status_code: code,
+ message: msg.to_string(),
+ };
+ let html = body
+ .render()
+ .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
+ let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ (status, Html(html)).into_response()
+}
+
+pub(super) fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
+ db::list_projects_in(root).unwrap_or_default()
+}
+
+/// Returns true when the client prefers a JSON response.
+fn wants_json(headers: &HeaderMap) -> bool {
+ headers
+ .get("accept")
+ .and_then(|v| v.to_str().ok())
+ .map(|v| v.contains("application/json"))
+ .unwrap_or(false)
+}
+
+/// Build a redirect-or-JSON response after a successful mutation.
+pub(super) fn mutation_response(
+ headers: &HeaderMap,
+ redirect_to: &str,
+ json_body: serde_json::Value,
+) -> Response {
+ if wants_json(headers) {
+ Json(json_body).into_response()
+ } else {
+ Redirect::to(redirect_to).into_response()
+ }
+}
+
+/// Build an error response appropriate for the caller (JSON or HTML).
+pub(super) fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response {
+ let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ if wants_json(headers) {
+ (status, Json(serde_json::json!({"error": err.to_string()}))).into_response()
+ } else {
+ error_response(code, &err.to_string(), &[])
+ }
+}
@@ -0,0 +1,125 @@
+mod handlers;
+mod helpers;
+mod mutations;
+mod views;
+
+use std::path::Path;
+use std::sync::Arc;
+
+use anyhow::Result;
+use axum::response::IntoResponse;
+use axum::routing::{get, post};
+use axum::Router;
+
+use crate::db;
+
+#[derive(Clone)]
+struct AppState {
+ data_root: Arc<std::path::PathBuf>,
+}
+
+async fn static_oat_css() -> impl IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
+ include_bytes!("../../../static/oat.min.css").as_slice(),
+ )
+}
+
+async fn static_td_css() -> impl IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
+ include_bytes!("../../../static/td.css").as_slice(),
+ )
+}
+
+async fn static_js() -> impl IntoResponse {
+ (
+ [(
+ axum::http::header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )],
+ include_bytes!("../../../static/oat.min.js").as_slice(),
+ )
+}
+
+async fn static_td_js() -> impl IntoResponse {
+ (
+ [(
+ axum::http::header::CONTENT_TYPE,
+ "application/javascript; charset=utf-8",
+ )],
+ include_bytes!("../../../static/td.js").as_slice(),
+ )
+}
+
+pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> {
+ let data_root = db::data_root()?;
+ let state = AppState {
+ data_root: Arc::new(data_root),
+ };
+
+ let app = Router::new()
+ .route("/", get(handlers::index_handler))
+ .route("/projects", post(mutations::create_project_handler))
+ .route("/projects/{name}", get(handlers::project_handler))
+ .route("/projects/{name}/tasks", post(mutations::create_handler))
+ .route(
+ "/projects/{name}/tasks/{id}",
+ get(handlers::task_handler).post(mutations::update_handler),
+ )
+ .route(
+ "/projects/{name}/tasks/{id}/log",
+ post(mutations::log_handler),
+ )
+ .route(
+ "/projects/{name}/tasks/{id}/done",
+ post(mutations::done_handler),
+ )
+ .route(
+ "/projects/{name}/tasks/{id}/reopen",
+ post(mutations::reopen_handler),
+ )
+ .route(
+ "/projects/{name}/tasks/{id}/labels",
+ post(mutations::label_handler),
+ )
+ .route(
+ "/projects/{name}/tasks/{id}/deps",
+ post(mutations::dep_handler),
+ )
+ .route(
+ "/projects/{name}/tasks/{id}/delete",
+ post(mutations::delete_handler),
+ )
+ .route("/static/oat.min.css", get(static_oat_css))
+ .route("/static/td.css", get(static_td_css))
+ .route("/static/oat.min.js", get(static_js))
+ .route("/static/td.js", get(static_td_js))
+ .with_state(state);
+
+ let addr = format!("{host}:{port}");
+ let root_url = format!("http://{addr}");
+
+ // Resolve current project for a convenience URL.
+ let project_url = match explicit_project {
+ Some(p) => Some(format!("{root_url}/projects/{p}")),
+ None => db::try_open(cwd)
+ .ok()
+ .flatten()
+ .map(|s| format!("{root_url}/projects/{}", s.project_name())),
+ };
+
+ eprintln!("listening on {root_url}");
+ if let Some(ref url) = project_url {
+ eprintln!("project: {url}");
+ }
+
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()?
+ .block_on(async {
+ let listener = tokio::net::TcpListener::bind(&addr).await?;
+ axum::serve(listener, app).await?;
+ Ok(())
+ })
+}
@@ -0,0 +1,379 @@
+use anyhow::Result;
+use axum::extract::{Path as AxumPath, State};
+use axum::http::HeaderMap;
+use axum::response::Response;
+use axum::Form;
+
+use crate::db::{self, Store, TaskId};
+use crate::ops;
+
+use super::helpers::{mutation_error, mutation_response};
+use super::AppState;
+
+#[derive(serde::Deserialize)]
+pub(super) struct ProjectForm {
+ name: String,
+ #[serde(default)]
+ bind_path: String,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct CreateForm {
+ title: String,
+ #[serde(default)]
+ description: String,
+ #[serde(default = "default_task_type")]
+ task_type: String,
+ #[serde(default = "default_priority")]
+ priority: String,
+ #[serde(default = "default_effort")]
+ effort: String,
+ #[serde(default)]
+ labels: String,
+ #[serde(default)]
+ parent: String,
+}
+
+fn default_task_type() -> String {
+ "task".to_string()
+}
+
+fn default_priority() -> String {
+ "medium".to_string()
+}
+
+fn default_effort() -> String {
+ "medium".to_string()
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct UpdateForm {
+ #[serde(default)]
+ title: Option<String>,
+ #[serde(default)]
+ description: Option<String>,
+ #[serde(default)]
+ status: Option<String>,
+ #[serde(default)]
+ priority: Option<String>,
+ #[serde(default)]
+ effort: Option<String>,
+ #[serde(default)]
+ redirect: Option<String>,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct LogForm {
+ message: String,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct LabelForm {
+ /// "add" or "rm"
+ action: String,
+ label: String,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct DepForm {
+ /// "add" or "rm"
+ action: String,
+ blocker: String,
+}
+
+pub(super) async fn create_project_handler(
+ State(state): State<AppState>,
+ headers: HeaderMap,
+ Form(form): Form<ProjectForm>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<String> {
+ let bind = if form.bind_path.is_empty() {
+ None
+ } else {
+ Some(std::path::PathBuf::from(&form.bind_path))
+ };
+ ops::init_project(&root, &form.name, bind.as_deref())?;
+ Ok(form.name)
+ })
+ .await;
+
+ match result {
+ Ok(Ok(name)) => {
+ let redirect = format!("/projects/{name}");
+ mutation_response(&headers, &redirect, serde_json::json!({"name": name}))
+ }
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn create_handler(
+ State(state): State<AppState>,
+ AxumPath(name): AxumPath<String>,
+ headers: HeaderMap,
+ Form(form): Form<CreateForm>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
+ let store = Store::open(&root, &name)?;
+
+ let parent = if form.parent.is_empty() {
+ None
+ } else {
+ Some(db::resolve_task_id(&store, &form.parent, false)?)
+ };
+
+ let labels: Vec<String> = form
+ .labels
+ .split(',')
+ .map(str::trim)
+ .filter(|l| !l.is_empty())
+ .map(String::from)
+ .collect();
+
+ let task = ops::create_task(
+ &store,
+ ops::CreateOpts {
+ title: form.title,
+ description: form.description,
+ task_type: form.task_type,
+ priority: db::parse_priority(&form.priority)?,
+ effort: db::parse_effort(&form.effort)?,
+ parent,
+ labels,
+ },
+ )?;
+
+ let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
+ Ok((task, redirect))
+ })
+ .await;
+
+ match result {
+ Ok(Ok((task, redirect))) => mutation_response(
+ &headers,
+ &redirect,
+ serde_json::to_value(&task).unwrap_or_default(),
+ ),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn update_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+ Form(form): Form<UpdateForm>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
+ let store = Store::open(&root, &name)?;
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+
+ let task = ops::update_task(
+ &store,
+ &task_id,
+ ops::UpdateOpts {
+ status: form
+ .status
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .map(db::parse_status)
+ .transpose()?,
+ priority: form
+ .priority
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .map(db::parse_priority)
+ .transpose()?,
+ effort: form
+ .effort
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .map(db::parse_effort)
+ .transpose()?,
+ title: form.title.filter(|s| !s.is_empty()),
+ description: form.description,
+ },
+ )?;
+
+ let redirect = form
+ .redirect
+ .filter(|r| r.starts_with('/'))
+ .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str()));
+ Ok((task, redirect))
+ })
+ .await;
+
+ match result {
+ Ok(Ok((task, redirect))) => mutation_response(
+ &headers,
+ &redirect,
+ serde_json::to_value(&task).unwrap_or_default(),
+ ),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn log_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+ Form(form): Form<LogForm>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> {
+ let store = Store::open(&root, &name)?;
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ let entry = ops::add_log(&store, &task_id, &form.message)?;
+ let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
+ Ok((entry, redirect))
+ })
+ .await;
+
+ match result {
+ Ok(Ok((entry, redirect))) => mutation_response(
+ &headers,
+ &redirect,
+ serde_json::to_value(&entry).unwrap_or_default(),
+ ),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn done_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
+ let store = Store::open(&root, &name)?;
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ ops::mark_done(&store, &task_id)?;
+ let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
+ Ok((task_id, redirect))
+ })
+ .await;
+
+ match result {
+ Ok(Ok((task_id, redirect))) => mutation_response(
+ &headers,
+ &redirect,
+ serde_json::json!({"id": task_id, "status": "closed"}),
+ ),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn reopen_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
+ let store = Store::open(&root, &name)?;
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ ops::reopen_task(&store, &task_id)?;
+ let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
+ Ok((task_id, redirect))
+ })
+ .await;
+
+ match result {
+ Ok(Ok((task_id, redirect))) => mutation_response(
+ &headers,
+ &redirect,
+ serde_json::json!({"id": task_id, "status": "open"}),
+ ),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn label_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+ Form(form): Form<LabelForm>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<String> {
+ let store = Store::open(&root, &name)?;
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ match form.action.as_str() {
+ "add" => ops::add_label(&store, &task_id, &form.label)?,
+ "rm" => ops::remove_label(&store, &task_id, &form.label)?,
+ other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"),
+ }
+ Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str()))
+ })
+ .await;
+
+ match result {
+ Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn dep_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+ Form(form): Form<DepForm>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<String> {
+ let store = Store::open(&root, &name)?;
+ let child_id = db::resolve_task_id(&store, &id, false)?;
+ let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?;
+ match form.action.as_str() {
+ "add" => ops::add_dep(&store, &child_id, &blocker_id)?,
+ "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?,
+ other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"),
+ }
+ Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str()))
+ })
+ .await;
+
+ match result {
+ Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
+
+pub(super) async fn delete_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+ headers: HeaderMap,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> {
+ let store = Store::open(&root, &name)?;
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ let dr = ops::soft_delete(&store, &[task_id], false)?;
+ let redirect = format!("/projects/{}", name);
+ Ok((dr, redirect))
+ })
+ .await;
+
+ match result {
+ Ok(Ok((dr, redirect))) => {
+ let json_body = serde_json::json!({
+ "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
+ "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
+ });
+ mutation_response(&headers, &redirect, json_body)
+ }
+ Ok(Err(e)) => mutation_error(&headers, 400, &e),
+ Err(e) => mutation_error(&headers, 500, &e.into()),
+ }
+}
@@ -0,0 +1,214 @@
+use askama::Template;
+
+use super::helpers::SortField;
+
+/// A project card on the root page — either healthy or failed.
+pub(super) enum ProjectCard {
+ Ok {
+ name: String,
+ open: usize,
+ in_progress: usize,
+ closed: usize,
+ total: usize,
+ },
+ Err {
+ name: String,
+ error: String,
+ },
+}
+
+/// Minimal view-model for a scored task in the "Next Up" table.
+pub(super) struct ScoredEntry {
+ pub(super) id: String,
+ pub(super) short_id: String,
+ pub(super) title: String,
+ pub(super) score: String,
+ pub(super) status: String,
+ pub(super) status_display: &'static str,
+}
+
+/// Minimal view-model for a task row in the project task table.
+pub(super) struct TaskRow {
+ pub(super) full_id: String,
+ pub(super) short_id: String,
+ pub(super) status: String,
+ pub(super) status_display: &'static str,
+ pub(super) priority: String,
+ pub(super) effort: String,
+ pub(super) title: String,
+ pub(super) created_at: String,
+ pub(super) created_at_display: String,
+}
+
+/// View-model for the task detail page.
+pub(super) struct TaskView {
+ pub(super) full_id: String,
+ pub(super) short_id: String,
+ pub(super) title: String,
+ pub(super) description: String,
+ pub(super) task_type: String,
+ pub(super) status: String,
+ pub(super) priority: String,
+ pub(super) effort: String,
+ pub(super) created_at: String,
+ pub(super) created_at_display: String,
+ pub(super) updated_at: String,
+ pub(super) updated_at_display: String,
+ pub(super) labels: Vec<String>,
+ pub(super) logs: Vec<LogView>,
+}
+
+pub(super) struct LogView {
+ pub(super) timestamp: String,
+ pub(super) timestamp_display: String,
+ pub(super) message: String,
+}
+
+/// A blocker reference for the task detail page.
+pub(super) struct BlockerRef {
+ pub(super) full_id: String,
+ pub(super) short_id: String,
+}
+
+/// Sort context passed to the task_table macro. When present, column headers
+/// become clickable links that set sort/order query params.
+pub(super) struct SortContext {
+ /// Base URL for sort links (e.g. `/projects/myproj`).
+ pub(super) base_href: String,
+ /// Current sort field.
+ pub(super) field: String,
+ /// Current sort order ("asc" or "desc").
+ pub(super) order: String,
+ /// Query string fragment for the current filters (without sort/order/page),
+ /// suitable for appending to hrefs.
+ pub(super) filter_qs: String,
+}
+
+impl SortContext {
+ /// Build the href for a column header link. Clicking the currently-active
+ /// column toggles direction; clicking a different column uses its default.
+ fn column_href(&self, col: &str) -> String {
+ let order = if col == self.field {
+ // Toggle current direction.
+ match self.order.as_str() {
+ "asc" => "desc",
+ _ => "asc",
+ }
+ } else {
+ // Use the column's sensible default.
+ SortField::parse(col)
+ .map(|f| f.default_order().as_str())
+ .unwrap_or("asc")
+ };
+ let mut qs = self.filter_qs.clone();
+ if !qs.is_empty() {
+ qs.push('&');
+ }
+ qs.push_str(&format!("sort={col}&order={order}"));
+ format!("{}?{qs}", self.base_href)
+ }
+
+ /// Return the arrow indicator for the active column, or empty string.
+ fn arrow(&self, col: &str) -> &str {
+ if col == self.field {
+ match self.order.as_str() {
+ "asc" => " ↑",
+ "desc" => " ↓",
+ _ => "",
+ }
+ } else {
+ ""
+ }
+ }
+}
+
+#[derive(Template)]
+#[template(path = "index.html")]
+pub(super) struct IndexTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) projects: Vec<ProjectCard>,
+}
+
+#[derive(Template)]
+#[template(path = "project.html")]
+pub(super) struct ProjectTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) project_name: String,
+ pub(super) stats_open: usize,
+ pub(super) stats_in_progress: usize,
+ pub(super) stats_closed: usize,
+ pub(super) next_up: Vec<ScoredEntry>,
+ pub(super) page_tasks: Vec<TaskRow>,
+ pub(super) all_labels: Vec<String>,
+ pub(super) filter_status: Option<String>,
+ pub(super) filter_priority: Option<String>,
+ pub(super) filter_effort: Option<String>,
+ pub(super) filter_label: Option<String>,
+ pub(super) filter_search: String,
+ pub(super) page: usize,
+ pub(super) total_pages: usize,
+ pub(super) pagination_pages: Vec<usize>,
+ pub(super) sort_ctx: SortContext,
+}
+
+impl ProjectTemplate {
+ /// Build a query-string fragment containing the current filters (no sort,
+ /// no page). Reused by both pagination and sort helpers.
+ fn filter_qs(&self) -> String {
+ let mut parts = Vec::new();
+ if let Some(ref s) = self.filter_status {
+ parts.push(format!("status={s}"));
+ }
+ if let Some(ref p) = self.filter_priority {
+ parts.push(format!("priority={p}"));
+ }
+ if let Some(ref e) = self.filter_effort {
+ parts.push(format!("effort={e}"));
+ }
+ if let Some(ref l) = self.filter_label {
+ parts.push(format!("label={l}"));
+ }
+ if !self.filter_search.is_empty() {
+ parts.push(format!("q={}", self.filter_search));
+ }
+ parts.join("&")
+ }
+
+ /// Build a pagination link preserving current filter and sort params.
+ fn pagination_href(&self, target_page: &usize) -> String {
+ let target_page = *target_page;
+ let mut qs = self.filter_qs();
+ if !qs.is_empty() {
+ qs.push('&');
+ }
+ qs.push_str(&format!(
+ "sort={}&order={}",
+ self.sort_ctx.field, self.sort_ctx.order
+ ));
+ qs.push_str(&format!("&page={target_page}"));
+ format!("/projects/{}?{qs}", self.project_name)
+ }
+}
+
+#[derive(Template)]
+#[template(path = "task.html")]
+pub(super) struct TaskTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) project_name: String,
+ pub(super) task: TaskView,
+ pub(super) blockers_open: Vec<BlockerRef>,
+ pub(super) blockers_resolved: Vec<BlockerRef>,
+ pub(super) subtasks: Vec<TaskRow>,
+}
+
+#[derive(Template)]
+#[template(path = "error.html")]
+pub(super) struct ErrorTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) status_code: u16,
+ pub(super) message: String,
+}