Detailed changes
@@ -59,6 +59,19 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
[[package]]
name = "anstream"
version = "0.6.21"
@@ -802,6 +815,29 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "ctr"
version = "0.9.2"
@@ -963,6 +999,21 @@ dependencies = [
"litrs",
]
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
[[package]]
name = "either"
version = "1.15.0"
@@ -1135,6 +1186,16 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
[[package]]
name = "futures"
version = "0.3.32"
@@ -1448,6 +1509,17 @@ dependencies = [
"digest",
]
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -1972,6 +2044,12 @@ dependencies = [
"twox-hash",
]
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
[[package]]
name = "magic-wormhole"
version = "0.7.6"
@@ -2013,6 +2091,34 @@ dependencies = [
"ws_stream_wasm",
]
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2057,6 +2163,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
[[package]]
name = "noise-protocol"
version = "0.2.0"
@@ -2287,6 +2399,58 @@ dependencies = [
"rustc_version",
]
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2390,6 +2554,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
[[package]]
name = "predicates"
version = "3.1.4"
@@ -2449,6 +2619,24 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
[[package]]
name = "quick_cache"
version = "0.6.18"
@@ -2838,6 +3026,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
[[package]]
name = "sized-chunks"
version = "0.6.5"
@@ -2910,6 +3104,31 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3000,6 +3219,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
[[package]]
name = "termtree"
version = "0.5.1"
@@ -3285,6 +3515,12 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -3514,6 +3750,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -3801,6 +4049,7 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
name = "yatd"
version = "0.1.3"
dependencies = [
+ "ammonia",
"anyhow",
"askama",
"assert_cmd",
@@ -3812,6 +4061,7 @@ dependencies = [
"loro",
"magic-wormhole",
"predicates",
+ "pulldown-cmark",
"serde",
"serde_json",
"shell-words",
@@ -23,6 +23,8 @@ tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
fs2 = "0.4"
axum = "0.8"
askama = "0.15"
+pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
+ammonia = "4"
[dev-dependencies]
assert_cmd = "2"
@@ -15,6 +15,33 @@ use crate::score;
const PAGE_SIZE: usize = 25;
+/// 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
// ---------------------------------------------------------------------------
@@ -59,6 +86,8 @@ struct TaskRow {
priority: String,
effort: String,
title: String,
+ created_at: String,
+ created_at_display: String,
}
/// View-model for the task detail page.
@@ -72,13 +101,16 @@ struct TaskView {
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,
}
@@ -268,8 +300,12 @@ struct ProjectQuery {
async fn project_handler(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
- Query(query): Query<ProjectQuery>,
+ 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);
@@ -407,6 +443,8 @@ async fn project_handler(
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();
@@ -484,6 +522,8 @@ async fn task_handler(
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();
@@ -491,20 +531,23 @@ async fn task_handler(
full_id: task.id.as_str().to_string(),
short_id: task.id.short(),
title: task.title.clone(),
- description: task.description.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: l.message.clone(),
+ message: render_markdown(&l.message),
})
.collect(),
};
@@ -7,6 +7,35 @@
<link rel="stylesheet" href="/static/oat.min.css">
<link rel="stylesheet" href="/static/td.css">
<script src="/static/oat.min.js" defer></script>
+ <script defer>
+ document.addEventListener("DOMContentLoaded", () => {
+ document.querySelectorAll("time[datetime]").forEach(el => {
+ const d = new Date(el.getAttribute("datetime"));
+ if (!isNaN(d)) {
+ el.textContent = d.toLocaleString(undefined, {
+ day: "numeric", month: "short", year: "numeric",
+ hour: "2-digit", minute: "2-digit"
+ });
+ }
+ });
+
+ // Reveal copy-to-clipboard buttons only when the API is available
+ // (requires a secure context: HTTPS or localhost).
+ if (navigator.clipboard) {
+ const copyIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
+ const checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
+ document.querySelectorAll(".js-copy-id").forEach(btn => {
+ btn.hidden = false;
+ btn.addEventListener("click", () => {
+ navigator.clipboard.writeText(btn.dataset.copy).then(() => {
+ btn.innerHTML = checkIcon;
+ setTimeout(() => { btn.innerHTML = copyIcon; }, 1500);
+ });
+ });
+ });
+ }
+ });
+ </script>
</head>
<body data-sidebar-layout>
<a href="#main-content" class="skip-link">Skip to main content</a>
@@ -30,7 +59,7 @@
</aside>
<main id="main-content" tabindex="-1">
- <div class="p-4">
+ <div class="container p-4">
{% block content %}{% endblock %}
</div>
</main>
@@ -0,0 +1,29 @@
+{% macro task_table(project_name, tasks, caption) %}
+<div class="table">
+ <table>
+ <caption class="sr-only">{{ caption }}</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>
+ <th scope="col">Created</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for t in 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>
+ <td><time datetime="{{ t.created_at }}">{{ t.created_at_display }}</time></td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+</div>
+{% endmacro %}
@@ -1,4 +1,5 @@
{% extends "base.html" %}
+{% import "macros.html" as macros %}
{% block title %}td โ {{ project_name }}{% endblock %}
@@ -13,26 +14,24 @@
<h1>{{ project_name }}</h1>
-<div class="container">
- <div class="row">
+<div class="row">
<article class="card col-4">
- <h2>Open</h2>
+ <p class="text-light">Open</p>
<p><strong>{{ stats_open }}</strong></p>
</article>
<article class="card col-4">
- <h2>In progress</h2>
+ <p class="text-light">In progress</p>
<p><strong>{{ stats_in_progress }}</strong></p>
</article>
<article class="card col-4">
- <h2>Closed</h2>
+ <p class="text-light">Closed</p>
<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>
+ <summary>Next up</summary>
<div class="table">
<table>
<caption class="sr-only">Top scored tasks recommended to work on next</caption>
@@ -59,8 +58,8 @@
</details>
{% endif %}
-<details open class="mt-4">
- <summary><h2>Tasks</h2></summary>
+<details{% if next_up.is_empty() || filter_priority.is_some() || filter_effort.is_some() || filter_label.is_some() || !filter_search.is_empty() || filter_status.as_deref() != Some("open") %} open{% endif %} class="mt-4">
+ <summary>Tasks</summary>
<form method="get" action="/projects/{{ project_name }}" class="hstack gap-2 mt-2 mb-4">
<div data-field>
@@ -109,31 +108,7 @@
{% 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>
+ {% call macros::task_table(project_name, page_tasks, "Task list for project") %}{% endcall %}
{% if total_pages > 1 %}
<nav aria-label="Task list pagination" class="mt-4">
@@ -1,4 +1,5 @@
{% extends "base.html" %}
+{% import "macros.html" as macros %}
{% block title %}td โ {{ task.title }}{% endblock %}
@@ -10,50 +11,26 @@
<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>
+ <li>
+ <button class="outline small js-copy-id" hidden aria-label="Copy task ID {{ task.short_id }}" data-copy="{{ task.short_id }}"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button>
+ </li>
</ol>
</nav>
<article class="card mt-4">
- <header>
+ <header class="hstack items-center gap-2">
<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>
+ <div>{{ task.description|safe }}</div>
{% 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>
+ <hr>
+ <footer>
+ <p class="text-light">{{ task.task_type }} ยท {{ task.priority }} priority ยท {{ task.effort }} effort<br>Created <time datetime="{{ task.created_at }}">{{ task.created_at_display }}</time> ยท Updated <time datetime="{{ task.updated_at }}">{{ task.updated_at_display }}</time></p>
+ </footer>
{% if !task.labels.is_empty() %}
<section class="mt-4" aria-label="Labels">
@@ -66,57 +43,34 @@
</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>
+ <details class="mt-4">
+ <summary>Work log</summary>
{% for log in task.logs %}
- <details>
- <summary><time>{{ log.timestamp }}</time></summary>
- <p>{{ log.message }}</p>
- </details>
+ <div><time datetime="{{ log.timestamp }}">{{ log.timestamp_display }}</time> โ {{ log.message|safe }}</div>
{% endfor %}
- </section>
+ </details>
{% endif %}
</article>
+
+{% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %}
+<article class="card mt-4">
+ <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>
+</article>
+{% endif %}
+
+{% if !subtasks.is_empty() %}
+<article class="card mt-4">
+ <h2>Subtasks</h2>
+ {% call macros::task_table(project_name, subtasks, "Subtasks") %}{% endcall %}
+</article>
+{% endif %}
{% endblock %}