Detailed changes
@@ -142,6 +142,58 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679"
+[[package]]
+name = "askama"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57"
+dependencies = [
+ "askama_macros",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "askama_macros"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b"
+dependencies = [
+ "askama_derive",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c"
+dependencies = [
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "unicode-ident",
+ "winnow",
+]
+
[[package]]
name = "assert_cmd"
version = "2.1.2"
@@ -378,12 +430,73 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "axum"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -1345,12 +1458,77 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "pin-utils",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -1824,7 +2002,7 @@ dependencies = [
"serde_json",
"sha-1",
"sha2",
- "socket2",
+ "socket2 0.5.10",
"spake2",
"stun_codec",
"tar",
@@ -1844,6 +2022,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "md5"
version = "0.7.0"
@@ -1856,6 +2040,23 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "noise-protocol"
version = "0.2.0"
@@ -2443,6 +2644,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "salsa20"
version = "0.10.2"
@@ -2544,6 +2751,29 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "sha-1"
version = "0.10.1"
@@ -2643,6 +2873,16 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "spake2"
version = "0.4.0"
@@ -2719,6 +2959,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -2856,8 +3102,12 @@ version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
+ "libc",
+ "mio",
"pin-project-lite",
+ "socket2 0.6.3",
"tokio-macros",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2871,6 +3121,34 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -3364,6 +3642,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -3512,10 +3799,12 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yatd"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
+ "askama",
"assert_cmd",
+ "axum",
"chrono",
"clap",
"comfy-table",
@@ -21,6 +21,8 @@ ulid = "1"
magic-wormhole = "0.7.6"
tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
fs2 = "0.4"
+axum = "0.8"
+askama = "0.15"
[dev-dependencies]
assert_cmd = "2"
@@ -208,6 +208,18 @@ pub enum Command {
code: Option<String>,
},
+ /// Launch a read-only web UI
+ #[command(name = "webui")]
+ WebUi {
+ /// Listen address
+ #[arg(long, default_value = "127.0.0.1")]
+ host: String,
+
+ /// Listen port
+ #[arg(long, default_value_t = 8080)]
+ port: u16,
+ },
+
/// Install the agent skill file (SKILL.md)
Skill {
/// Skills directory (writes managing-tasks-with-td/SKILL.md inside)
@@ -19,6 +19,7 @@ mod stats;
pub mod sync;
mod tidy;
mod update;
+mod webui;
use crate::cli::{Cli, Command};
use crate::db;
@@ -173,6 +174,10 @@ pub fn dispatch(cli: &Cli) -> Result<()> {
let root = require_root()?;
sync::run(&root, code.as_deref(), cli.json)
}
+ Command::WebUi { host, port } => {
+ let root = require_root()?;
+ webui::run(&root, host, *port, cli.project.as_deref())
+ }
Command::Skill { dir } => skill::run(dir.as_deref()),
}
}
@@ -0,0 +1,599 @@
+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::StatusCode;
+use axum::response::{Html, IntoResponse, Response};
+use axum::routing::get;
+use axum::Router;
+
+use crate::db::{self, Store, TaskId};
+use crate::score;
+
+const PAGE_SIZE: usize = 25;
+
+// ---------------------------------------------------------------------------
+// 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,
+}
+
+/// Minimal view-model for a task row in the project task table.
+struct TaskRow {
+ full_id: String,
+ short_id: String,
+ status: String,
+ priority: String,
+ effort: String,
+ title: 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,
+ updated_at: String,
+ labels: Vec<String>,
+ logs: Vec<LogView>,
+}
+
+struct LogView {
+ timestamp: String,
+ message: String,
+}
+
+/// A blocker reference for the task detail page.
+struct BlockerRef {
+ full_id: String,
+ short_id: String,
+}
+
+// ---------------------------------------------------------------------------
+// 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>,
+}
+
+impl ProjectTemplate {
+ /// Build a pagination link preserving current filter query params.
+ fn pagination_href(&self, target_page: &usize) -> String {
+ let target_page = *target_page;
+ 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.push(format!("page={target_page}"));
+ format!("/projects/{}?{}", self.project_name, parts.join("&"))
+ }
+}
+
+#[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>,
+}
+
+async fn project_handler(
+ State(state): State<AppState>,
+ AxumPath(name): AxumPath<String>,
+ Query(query): Query<ProjectQuery>,
+) -> Response {
+ 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),
+ })
+ .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: priority score ascending, then created_at.
+ filtered.sort_by_key(|t| (t.priority.score(), t.created_at.clone()));
+
+ // 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| TaskRow {
+ full_id: t.id.as_str().to_string(),
+ short_id: t.id.short(),
+ status: db::status_label(t.status).to_string(),
+ priority: db::priority_label(t.priority).to_string(),
+ effort: db::effort_label(t.effort).to_string(),
+ title: t.title.clone(),
+ })
+ .collect();
+
+ Ok(ProjectTemplate {
+ all_projects,
+ active_project: Some(name),
+ project_name: store.project_name().to_string(),
+ 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(),
+ })
+ })
+ .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| TaskRow {
+ full_id: t.id.as_str().to_string(),
+ short_id: t.id.short(),
+ status: db::status_label(t.status).to_string(),
+ priority: db::priority_label(t.priority).to_string(),
+ effort: db::effort_label(t.effort).to_string(),
+ title: t.title.clone(),
+ })
+ .collect();
+
+ let task_view = TaskView {
+ full_id: task.id.as_str().to_string(),
+ short_id: task.id.short(),
+ title: task.title.clone(),
+ description: task.description.clone(),
+ 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: task.created_at.clone(),
+ updated_at: task.updated_at.clone(),
+ labels: task.labels.clone(),
+ logs: task
+ .logs
+ .iter()
+ .map(|l| LogView {
+ timestamp: l.timestamp.clone(),
+ message: l.message.clone(),
+ })
+ .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}"), &[]),
+ }
+}
+
+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(),
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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/{name}", get(project_handler))
+ .route("/projects/{name}/tasks/{id}", get(task_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))
+ .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 @@
@@ -0,0 +1 @@
@@ -0,0 +1,51 @@
+@font-face {
+ font-family: "Atkinson Hyperlegible Next";
+ src: url("/static/fonts/AtkinsonHyperlegibleNext-Regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "Atkinson Hyperlegible Next";
+ src: url("/static/fonts/AtkinsonHyperlegibleNext-Bold.woff2") format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "0xProto";
+ src: url("/static/fonts/0xProto-Regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+:root {
+ --font-sans: "Atkinson Hyperlegible Next", system-ui, sans-serif;
+ --font-mono: "0xProto", ui-monospace, monospace;
+}
+body { font-family: var(--font-sans); }
+code, pre, .mono { font-family: var(--font-mono); }
+.skip-link {
+ position: absolute;
+ left: -9999px;
+ z-index: 999;
+ padding: 1em;
+ background: #000;
+ color: #fff;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.skip-link:focus {
+ left: 50%;
+ transform: translateX(-50%);
+ top: 0;
+}
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{% block title %}td{% endblock %}</title>
+ <link rel="stylesheet" href="/static/oat.min.css">
+ <link rel="stylesheet" href="/static/td.css">
+ <script src="/static/oat.min.js" defer></script>
+</head>
+<body data-sidebar-layout>
+ <a href="#main-content" class="skip-link">Skip to main content</a>
+
+ <nav data-topnav>
+ <button data-sidebar-toggle aria-label="Toggle sidebar" class="outline">β°</button>
+ <strong>td</strong>
+ </nav>
+
+ <aside data-sidebar>
+ <nav aria-label="Project navigation">
+ <ul>
+ <li><a href="/"{% if active_project.is_none() %} aria-current="page"{% endif %}>All projects</a></li>
+ {% for p in all_projects %}
+ <li>
+ <a href="/projects/{{ p }}"{% if active_project.as_deref() == Some(p.as_str()) %} aria-current="page"{% endif %}>{{ p }}</a>
+ </li>
+ {% endfor %}
+ </ul>
+ </nav>
+ </aside>
+
+ <main id="main-content" tabindex="-1">
+ <div class="p-4">
+ {% block content %}{% endblock %}
+ </div>
+ </main>
+</body>
+</html>
@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+
+{% block title %}td β error{% endblock %}
+
+{% block content %}
+<div role="alert" data-variant="error">
+ <strong>{{ status_code }}</strong> β {{ message }}
+</div>
+<p class="mt-4"><a href="/">β Back to projects</a></p>
+{% endblock %}
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+
+{% block title %}td β Projects{% endblock %}
+
+{% block content %}
+<h1>Projects</h1>
+
+{% if projects.is_empty() %}
+<article class="card align-center">
+ <h2>No projects yet</h2>
+ <p class="text-light">Run <code>td project init <name></code> to create one.</p>
+</article>
+{% else %}
+<div class="container">
+ <div class="row">
+ {% for p in projects %}
+ <article class="card col-4">
+ {% match p %}
+ {% when ProjectCard::Ok { name, open, in_progress, closed, total } %}
+ <header>
+ <h2><a href="/projects/{{ name }}">{{ name }}</a></h2>
+ </header>
+ <div class="hstack gap-2">
+ <span class="badge">{{ open }} open</span>
+ <span class="badge secondary">{{ in_progress }} in progress</span>
+ <span class="badge success">{{ closed }} closed</span>
+ </div>
+ {% if *total > 0 %}
+ <progress value="{{ total - open - in_progress }}" max="{{ total }}" class="mt-2" aria-label="{{ closed }} of {{ total }} tasks closed in {{ name }}"></progress>
+ {% endif %}
+ {% when ProjectCard::Err { name, error } %}
+ <header class="hstack justify-between items-center">
+ <h2>{{ name }}</h2>
+ <span class="badge danger">error</span>
+ </header>
+ <p class="text-light">{{ error }}</p>
+ {% endmatch %}
+ </article>
+ {% endfor %}
+ </div>
+</div>
+{% endif %}
+{% endblock %}
@@ -0,0 +1,163 @@
+{% extends "base.html" %}
+
+{% block title %}td β {{ project_name }}{% endblock %}
+
+{% block content %}
+<nav aria-label="Breadcrumb">
+ <ol class="unstyled hstack">
+ <li><a href="/" class="unstyled">Projects</a></li>
+ <li aria-hidden="true">/</li>
+ <li><a href="/projects/{{ project_name }}" class="unstyled" aria-current="page"><strong>{{ project_name }}</strong></a></li>
+ </ol>
+</nav>
+
+<h1>{{ project_name }}</h1>
+
+<div class="container">
+ <div class="row">
+ <article class="card col-4">
+ <h2>Open</h2>
+ <p><strong>{{ stats_open }}</strong></p>
+ </article>
+ <article class="card col-4">
+ <h2>In progress</h2>
+ <p><strong>{{ stats_in_progress }}</strong></p>
+ </article>
+ <article class="card col-4">
+ <h2>Closed</h2>
+ <p><strong>{{ stats_closed }}</strong></p>
+ </article>
+ </div>
+</div>
+
+{% if !next_up.is_empty() %}
+<details open class="mt-4">
+ <summary><h2>Next up</h2></summary>
+ <div class="table">
+ <table>
+ <caption class="sr-only">Top scored tasks recommended to work on next</caption>
+ <thead>
+ <tr>
+ <th scope="col">#</th>
+ <th scope="col">ID</th>
+ <th scope="col">Score</th>
+ <th scope="col">Title</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for (i, s) in next_up.iter().enumerate() %}
+ <tr>
+ <td>{{ i + 1 }}</td>
+ <td><a href="/projects/{{ project_name }}/tasks/{{ s.id }}"><code>{{ s.short_id }}</code></a></td>
+ <td>{{ s.score }}</td>
+ <td>{{ s.title }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+</details>
+{% endif %}
+
+<details open class="mt-4">
+ <summary><h2>Tasks</h2></summary>
+
+ <form method="get" action="/projects/{{ project_name }}" class="hstack gap-2 mt-2 mb-4">
+ <div data-field>
+ <label for="filter-status">Status</label>
+ <select id="filter-status" name="status" aria-label="Filter by status">
+ <option value="">All</option>
+ <option value="open"{% if filter_status.as_deref() == Some("open") %} selected{% endif %}>Open</option>
+ <option value="in_progress"{% if filter_status.as_deref() == Some("in_progress") %} selected{% endif %}>In progress</option>
+ <option value="closed"{% if filter_status.as_deref() == Some("closed") %} selected{% endif %}>Closed</option>
+ </select>
+ </div>
+ <div data-field>
+ <label for="filter-priority">Priority</label>
+ <select id="filter-priority" name="priority" aria-label="Filter by priority">
+ <option value="">All</option>
+ <option value="high"{% if filter_priority.as_deref() == Some("high") %} selected{% endif %}>High</option>
+ <option value="medium"{% if filter_priority.as_deref() == Some("medium") %} selected{% endif %}>Medium</option>
+ <option value="low"{% if filter_priority.as_deref() == Some("low") %} selected{% endif %}>Low</option>
+ </select>
+ </div>
+ <div data-field>
+ <label for="filter-effort">Effort</label>
+ <select id="filter-effort" name="effort" aria-label="Filter by effort">
+ <option value="">All</option>
+ <option value="low"{% if filter_effort.as_deref() == Some("low") %} selected{% endif %}>Low</option>
+ <option value="medium"{% if filter_effort.as_deref() == Some("medium") %} selected{% endif %}>Medium</option>
+ <option value="high"{% if filter_effort.as_deref() == Some("high") %} selected{% endif %}>High</option>
+ </select>
+ </div>
+ <div data-field>
+ <label for="filter-label">Label</label>
+ <select id="filter-label" name="label" aria-label="Filter by label">
+ <option value="">All</option>
+ {% for l in all_labels %}
+ <option value="{{ l }}"{% if filter_label.as_deref() == Some(l.as_str()) %} selected{% endif %}>{{ l }}</option>
+ {% endfor %}
+ </select>
+ </div>
+ <div data-field>
+ <label for="filter-search">Search</label>
+ <input type="text" id="filter-search" name="q" value="{{ filter_search }}" placeholder="Search titlesβ¦" aria-label="Search tasks by title">
+ </div>
+ <button type="submit" class="outline">Filter</button>
+ </form>
+
+ {% if page_tasks.is_empty() %}
+ <p class="text-light">No tasks match the current filters.</p>
+ {% else %}
+ <div class="table">
+ <table>
+ <caption class="sr-only">Task list for project {{ project_name }}</caption>
+ <thead>
+ <tr>
+ <th scope="col">ID</th>
+ <th scope="col">Status</th>
+ <th scope="col">Priority</th>
+ <th scope="col">Effort</th>
+ <th scope="col">Title</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for t in page_tasks %}
+ <tr>
+ <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></td>
+ <td><span class="badge{% if t.status == "closed" %} success{% elif t.status == "in_progress" %} secondary{% endif %}">{{ t.status }}</span></td>
+ <td>{{ t.priority }}</td>
+ <td>{{ t.effort }}</td>
+ <td>{{ t.title }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+
+ {% if total_pages > 1 %}
+ <nav aria-label="Task list pagination" class="mt-4">
+ <menu class="buttons">
+ {% if page > 1 %}
+ {% let prev = page - 1 %}
+ <li><a href="{{ self.pagination_href(prev) }}" class="button outline small">β Previous</a></li>
+ {% endif %}
+ {% for p in pagination_pages %}
+ <li>
+ {% if *p == page %}
+ <a href="{{ self.pagination_href(p) }}" class="button small" aria-current="page">{{ p }}</a>
+ {% else %}
+ <a href="{{ self.pagination_href(p) }}" class="button outline small">{{ p }}</a>
+ {% endif %}
+ </li>
+ {% endfor %}
+ {% if page < total_pages %}
+ {% let next = page + 1 %}
+ <li><a href="{{ self.pagination_href(next) }}" class="button outline small">Next β</a></li>
+ {% endif %}
+ </menu>
+ </nav>
+ {% endif %}
+ {% endif %}
+</details>
+{% endblock %}
@@ -0,0 +1,122 @@
+{% extends "base.html" %}
+
+{% block title %}td β {{ task.title }}{% endblock %}
+
+{% block content %}
+<nav aria-label="Breadcrumb">
+ <ol class="unstyled hstack">
+ <li><a href="/" class="unstyled">Projects</a></li>
+ <li aria-hidden="true">/</li>
+ <li><a href="/projects/{{ project_name }}" class="unstyled">{{ project_name }}</a></li>
+ <li aria-hidden="true">/</li>
+ <li><a href="/projects/{{ project_name }}/tasks/{{ task.full_id }}" class="unstyled" aria-current="page"><strong>{{ task.short_id }}</strong></a></li>
+ </ol>
+</nav>
+
+<article class="card mt-4">
+ <header>
+ <h1>{{ task.title }}</h1>
+ <span class="badge{% if task.status == "closed" %} success{% elif task.status == "in_progress" %} secondary{% endif %}">{{ task.status }}</span>
+ </header>
+
+ {% if !task.description.is_empty() %}
+ <p>{{ task.description }}</p>
+ {% endif %}
+
+ <div class="table mt-4">
+ <table>
+ <caption class="sr-only">Task metadata</caption>
+ <tbody>
+ <tr>
+ <th scope="row">ID</th>
+ <td><code>{{ task.short_id }}</code></td>
+ </tr>
+ <tr>
+ <th scope="row">Type</th>
+ <td>{{ task.task_type }}</td>
+ </tr>
+ <tr>
+ <th scope="row">Priority</th>
+ <td>{{ task.priority }}</td>
+ </tr>
+ <tr>
+ <th scope="row">Effort</th>
+ <td>{{ task.effort }}</td>
+ </tr>
+ <tr>
+ <th scope="row">Created</th>
+ <td><time>{{ task.created_at }}</time></td>
+ </tr>
+ <tr>
+ <th scope="row">Updated</th>
+ <td><time>{{ task.updated_at }}</time></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ {% if !task.labels.is_empty() %}
+ <section class="mt-4" aria-label="Labels">
+ <h2>Labels</h2>
+ <div class="hstack gap-2">
+ {% for l in task.labels %}
+ <span class="badge">{{ l }}</span>
+ {% endfor %}
+ </div>
+ </section>
+ {% endif %}
+
+ {% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %}
+ <section class="mt-4" aria-label="Blockers">
+ <h2>Blockers</h2>
+ <ul>
+ {% for b in blockers_open %}
+ <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge warning">open</span></li>
+ {% endfor %}
+ {% for b in blockers_resolved %}
+ <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge success">resolved</span></li>
+ {% endfor %}
+ </ul>
+ </section>
+ {% endif %}
+
+ {% if !subtasks.is_empty() %}
+ <section class="mt-4" aria-label="Subtasks">
+ <h2>Subtasks</h2>
+ <div class="table">
+ <table>
+ <caption class="sr-only">Subtasks of {{ task.title }}</caption>
+ <thead>
+ <tr>
+ <th scope="col">ID</th>
+ <th scope="col">Status</th>
+ <th scope="col">Title</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for s in subtasks %}
+ <tr>
+ <td><a href="/projects/{{ project_name }}/tasks/{{ s.full_id }}"><code>{{ s.short_id }}</code></a></td>
+ <td><span class="badge{% if s.status == "closed" %} success{% elif s.status == "in_progress" %} secondary{% endif %}">{{ s.status }}</span></td>
+ <td>{{ s.title }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ </section>
+ {% endif %}
+
+ {% if !task.logs.is_empty() %}
+ <section class="mt-4" aria-label="Work log">
+ <h2>Work log</h2>
+ {% for log in task.logs %}
+ <details>
+ <summary><time>{{ log.timestamp }}</time></summary>
+ <p>{{ log.message }}</p>
+ </details>
+ {% endfor %}
+ </section>
+ {% endif %}
+</article>
+{% endblock %}