Add read-only web UI subcommand (td webui)

Amolith created

Change summary

Cargo.lock             | 293 +++++++++++++++++++++
Cargo.toml             |   2 
src/cli.rs             |  12 
src/cmd/mod.rs         |   5 
src/cmd/webui.rs       | 599 ++++++++++++++++++++++++++++++++++++++++++++
static/oat.min.css     |   0 
static/oat.min.js      |   0 
static/td.css          |  51 +++
templates/base.html    |  38 ++
templates/error.html   |  10 
templates/index.html   |  43 +++
templates/project.html | 163 +++++++++++
templates/task.html    | 122 ++++++++
13 files changed, 1,336 insertions(+), 2 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -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",

Cargo.toml πŸ”—

@@ -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"

src/cli.rs πŸ”—

@@ -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)

src/cmd/mod.rs πŸ”—

@@ -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()),
     }
 }

src/cmd/webui.rs πŸ”—

@@ -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(())
+        })
+}

static/td.css πŸ”—

@@ -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;
+}

templates/base.html πŸ”—

@@ -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>

templates/error.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 %}

templates/index.html πŸ”—

@@ -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 &lt;name&gt;</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 %}

templates/project.html πŸ”—

@@ -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 %}

templates/task.html πŸ”—

@@ -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 %}