Detailed changes
@@ -4,111 +4,9 @@ use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Json;
use crate::db;
-use crate::model::{Status, Task};
use super::views::ErrorTemplate;
-/// Columns the task table can be sorted by.
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub(super) enum SortField {
- Id,
- Status,
- Priority,
- Effort,
- Title,
- Created,
-}
-
-impl SortField {
- pub(super) fn parse(s: &str) -> Option<Self> {
- match s {
- "id" => Some(Self::Id),
- "status" => Some(Self::Status),
- "priority" => Some(Self::Priority),
- "effort" => Some(Self::Effort),
- "title" => Some(Self::Title),
- "created" => Some(Self::Created),
- _ => None,
- }
- }
-
- pub(super) fn as_str(self) -> &'static str {
- match self {
- Self::Id => "id",
- Self::Status => "status",
- Self::Priority => "priority",
- Self::Effort => "effort",
- Self::Title => "title",
- Self::Created => "created",
- }
- }
-
- /// Sensible default direction when the user first clicks a column.
- pub(super) fn default_order(self) -> SortOrder {
- match self {
- // Newest first, alphabetical ascending for text fields.
- Self::Created => SortOrder::Desc,
- Self::Title | Self::Id => SortOrder::Asc,
- // Highest priority/effort first; open before closed.
- Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
- }
- }
-}
-
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub(super) enum SortOrder {
- Asc,
- Desc,
-}
-
-impl SortOrder {
- pub(super) fn parse(s: &str) -> Option<Self> {
- match s {
- "asc" => Some(Self::Asc),
- "desc" => Some(Self::Desc),
- _ => None,
- }
- }
-
- pub(super) fn as_str(self) -> &'static str {
- match self {
- Self::Asc => "asc",
- Self::Desc => "desc",
- }
- }
-}
-
-/// Map a `Status` to a numeric value for semantic sorting.
-/// Lower values sort first in ascending order: open → in_progress → closed.
-fn status_sort_key(s: Status) -> i32 {
- match s {
- Status::Open => 1,
- Status::InProgress => 2,
- Status::Closed => 3,
- }
-}
-
-/// Apply the chosen sort field and direction to a filtered task list.
-pub(super) fn sort_tasks(tasks: &mut [&Task], field: SortField, order: SortOrder) {
- tasks.sort_by(|a, b| {
- let cmp = match field {
- SortField::Id => a.id.as_str().cmp(b.id.as_str()),
- SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
- SortField::Priority => a.priority.score().cmp(&b.priority.score()),
- SortField::Effort => a.effort.score().cmp(&b.effort.score()),
- SortField::Title => a
- .title
- .to_ascii_lowercase()
- .cmp(&b.title.to_ascii_lowercase()),
- SortField::Created => a.created_at.cmp(&b.created_at),
- };
- match order {
- SortOrder::Asc => cmp,
- SortOrder::Desc => cmp.reverse(),
- }
- });
-}
-
/// Return a human-friendly status label (e.g. "In progress" instead of
/// "in_progress").
pub(super) fn friendly_status(raw: &str) -> &'static str {
@@ -0,0 +1,97 @@
+use anyhow::Result;
+use axum::extract::State;
+use axum::response::Response;
+
+use crate::db::Store;
+use crate::model::Status;
+
+use super::helpers::{error_response, list_projects_safe, render};
+use super::AppState;
+
+mod views;
+use views::{IndexTemplate, ProjectCard};
+
+/// Activity tier for sorting project cards on the index page.
+/// Lower values sort first (most active projects at the top).
+fn project_card_activity_tier(card: &ProjectCard) -> u32 {
+ match card {
+ ProjectCard::Ok {
+ in_progress, open, ..
+ } => {
+ if *in_progress > 0 {
+ 0 // Has in-progress tasks
+ } else if *open > 0 {
+ 1 // Has open tasks only
+ } else {
+ 2 // Only closed or empty
+ }
+ }
+ ProjectCard::Err { .. } => 3, // Error state
+ }
+}
+
+pub(in crate::cmd::webui) 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 == Status::Open).count();
+ let in_progress = tasks
+ .iter()
+ .filter(|t| t.status == Status::InProgress)
+ .count();
+ let closed = tasks.iter().filter(|t| t.status == 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}"),
+ });
+ }
+ }
+ }
+
+ // Sort cards by activity tier, then by name alphabetically.
+ cards.sort_by(|a, b| {
+ let tier_a = project_card_activity_tier(a);
+ let tier_b = project_card_activity_tier(b);
+ match tier_a.cmp(&tier_b) {
+ std::cmp::Ordering::Equal => {
+ let name_a = match a {
+ ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
+ };
+ let name_b = match b {
+ ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
+ };
+ name_a.cmp(name_b)
+ }
+ other => other,
+ }
+ });
+
+ 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}"), &[]),
+ }
+}
@@ -0,0 +1,24 @@
+use askama::Template;
+
+/// A project card on the root page — either healthy or failed.
+pub(super) enum ProjectCard {
+ Ok {
+ name: String,
+ open: usize,
+ in_progress: usize,
+ closed: usize,
+ total: usize,
+ },
+ Err {
+ name: String,
+ error: String,
+ },
+}
+
+#[derive(Template)]
+#[template(path = "index.html")]
+pub(super) struct IndexTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) projects: Vec<ProjectCard>,
+}
@@ -1,6 +1,7 @@
-mod handlers;
mod helpers;
-mod mutations;
+mod index;
+mod project;
+mod task;
mod views;
use std::path::Path;
@@ -59,37 +60,43 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) ->
};
let app = Router::new()
- .route("/", get(handlers::index_handler))
- .route("/projects", post(mutations::create_project_handler))
- .route("/projects/{name}", get(handlers::project_handler))
- .route("/projects/{name}/tasks", post(mutations::create_handler))
+ .route("/", get(index::index_handler))
+ .route(
+ "/projects",
+ post(project::mutations::create_project_handler),
+ )
+ .route("/projects/{name}", get(project::project_handler))
+ .route(
+ "/projects/{name}/tasks",
+ post(project::mutations::create_handler),
+ )
.route(
"/projects/{name}/tasks/{id}",
- get(handlers::task_handler).post(mutations::update_handler),
+ get(task::task_handler).post(project::mutations::update_handler),
)
.route(
"/projects/{name}/tasks/{id}/log",
- post(mutations::log_handler),
+ post(project::mutations::log_handler),
)
.route(
"/projects/{name}/tasks/{id}/done",
- post(mutations::done_handler),
+ post(project::mutations::done_handler),
)
.route(
"/projects/{name}/tasks/{id}/reopen",
- post(mutations::reopen_handler),
+ post(project::mutations::reopen_handler),
)
.route(
"/projects/{name}/tasks/{id}/labels",
- post(mutations::label_handler),
+ post(project::mutations::label_handler),
)
.route(
"/projects/{name}/tasks/{id}/deps",
- post(mutations::dep_handler),
+ post(project::mutations::dep_handler),
)
.route(
"/projects/{name}/tasks/{id}/delete",
- post(mutations::delete_handler),
+ post(project::mutations::delete_handler),
)
.route("/static/oat.min.css", get(static_oat_css))
.route("/static/td.css", get(static_td_css))
@@ -100,7 +107,6 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) ->
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)
@@ -4,110 +4,21 @@ use anyhow::Result;
use axum::extract::{Path as AxumPath, Query, State};
use axum::response::Response;
-use crate::db::{self, Store};
-use crate::model::{Effort, Priority, Status, Task, TaskId};
+use crate::db::Store;
+use crate::model::{Effort, Priority, Status, TaskId};
use crate::score;
-use super::helpers::{
- error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown,
- sort_tasks, SortField, SortOrder,
-};
-use super::views::{
- BlockerRef, IndexTemplate, LogView, ProjectCard, ProjectTemplate, ScoredEntry, SortContext,
- TaskRow, TaskTemplate, TaskView,
-};
+use super::helpers::{error_response, friendly_date, friendly_status, list_projects_safe, render};
use super::AppState;
-const PAGE_SIZE: usize = 25;
-
-/// Activity tier for sorting project cards on the index page.
-/// Lower values sort first (most active projects at the top).
-fn project_card_activity_tier(card: &ProjectCard) -> u32 {
- match card {
- ProjectCard::Ok {
- in_progress, open, ..
- } => {
- if *in_progress > 0 {
- 0 // Has in-progress tasks
- } else if *open > 0 {
- 1 // Has open tasks only
- } else {
- 2 // Only closed or empty
- }
- }
- ProjectCard::Err { .. } => 3, // Error state
- }
-}
-
-pub(super) async fn index_handler(State(state): State<AppState>) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
- let projects = list_projects_safe(&root);
- let mut cards = Vec::with_capacity(projects.len());
-
- for name in &projects {
- match Store::open(&root, name) {
- Ok(store) => {
- let tasks = store.list_tasks()?;
- let open = tasks.iter().filter(|t| t.status == Status::Open).count();
- let in_progress = tasks
- .iter()
- .filter(|t| t.status == Status::InProgress)
- .count();
- let closed = tasks.iter().filter(|t| t.status == 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}"),
- });
- }
- }
- }
+pub(super) mod mutations;
+mod sorting;
+pub(super) mod views;
- // Sort cards by activity tier, then by name alphabetically
- // Tier 0: in-progress tasks (most active)
- // Tier 1: open tasks only
- // Tier 2: closed/empty only
- // Tier 3: errors
- cards.sort_by(|a, b| {
- let tier_a = project_card_activity_tier(a);
- let tier_b = project_card_activity_tier(b);
- match tier_a.cmp(&tier_b) {
- std::cmp::Ordering::Equal => {
- let name_a = match a {
- ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
- };
- let name_b = match b {
- ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
- };
- name_a.cmp(name_b)
- }
- other => other,
- }
- });
-
- Ok(IndexTemplate {
- all_projects: projects,
- active_project: None,
- projects: cards,
- })
- })
- .await;
+use sorting::{SortField, SortOrder};
+use views::{ProjectTemplate, ScoredEntry, SortContext, TaskRow};
- match result {
- Ok(Ok(tmpl)) => render(tmpl),
- Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
- Err(e) => error_response(500, &format!("join error: {e}"), &[]),
- }
-}
+const PAGE_SIZE: usize = 25;
#[derive(serde::Deserialize)]
pub(super) struct ProjectQuery {
@@ -121,7 +32,7 @@ pub(super) struct ProjectQuery {
order: Option<String>,
}
-pub(super) async fn project_handler(
+pub(in crate::cmd::webui) async fn project_handler(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
Query(mut query): Query<ProjectQuery>,
@@ -206,7 +117,7 @@ pub(super) async fn project_handler(
.collect();
// Apply filters.
- let mut filtered: Vec<&Task> = tasks.iter().collect();
+ let mut filtered: Vec<&crate::model::Task> = tasks.iter().collect();
if let Some(ref s) = query.status {
if !s.is_empty() {
@@ -251,7 +162,7 @@ pub(super) async fn project_handler(
.as_deref()
.and_then(SortOrder::parse)
.unwrap_or_else(|| sort_field.default_order());
- sort_tasks(&mut filtered, sort_field, sort_order);
+ sorting::sort_tasks(&mut filtered, sort_field, sort_order);
// Pagination.
let total = filtered.len();
@@ -340,101 +251,3 @@ pub(super) async fn project_handler(
Err(e) => error_response(500, &format!("join error: {e}"), &[]),
}
}
-
-pub(super) async fn task_handler(
- State(state): State<AppState>,
- AxumPath((name, id)): AxumPath<(String, String)>,
-) -> Response {
- let root = state.data_root.clone();
- let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
- let all_projects = list_projects_safe(&root);
- let store = Store::open(&root, &name)?;
-
- let task_id = db::resolve_task_id(&store, &id, false)?;
- let task = store
- .get_task(&task_id, false)?
- .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
-
- // Partition blockers.
- let partition = db::partition_blockers(&store, &task.blockers)?;
- let blockers_open: Vec<BlockerRef> = partition
- .open
- .iter()
- .map(|b| BlockerRef {
- full_id: b.as_str().to_string(),
- short_id: b.short(),
- })
- .collect();
- let blockers_resolved: Vec<BlockerRef> = partition
- .resolved
- .iter()
- .map(|b| BlockerRef {
- full_id: b.as_str().to_string(),
- short_id: b.short(),
- })
- .collect();
-
- // Find subtasks.
- let all_tasks = store.list_tasks()?;
- let subtasks: Vec<TaskRow> = all_tasks
- .iter()
- .filter(|t| t.parent.as_ref() == Some(&task_id))
- .map(|t| {
- let status = t.status.as_str().to_string();
- TaskRow {
- full_id: t.id.as_str().to_string(),
- short_id: t.id.short(),
- status_display: friendly_status(&status),
- status,
- priority: t.priority.as_str().to_string(),
- effort: t.effort.as_str().to_string(),
- title: t.title.clone(),
- created_at_display: friendly_date(&t.created_at),
- created_at: t.created_at.clone(),
- }
- })
- .collect();
-
- let task_view = TaskView {
- full_id: task.id.as_str().to_string(),
- short_id: task.id.short(),
- title: task.title.clone(),
- description: render_markdown(&task.description),
- task_type: task.task_type.clone(),
- status: task.status.as_str().to_string(),
- priority: task.priority.as_str().to_string(),
- effort: task.effort.as_str().to_string(),
- created_at_display: friendly_date(&task.created_at),
- created_at: task.created_at.clone(),
- updated_at_display: friendly_date(&task.updated_at),
- updated_at: task.updated_at.clone(),
- labels: task.labels.clone(),
- logs: task
- .logs
- .iter()
- .map(|l| LogView {
- timestamp_display: friendly_date(&l.timestamp),
- timestamp: l.timestamp.clone(),
- message: render_markdown(&l.message),
- })
- .collect(),
- };
-
- Ok(TaskTemplate {
- all_projects,
- active_project: Some(name),
- project_name: store.project_name().to_string(),
- task: task_view,
- blockers_open,
- blockers_resolved,
- subtasks,
- })
- })
- .await;
-
- match result {
- Ok(Ok(tmpl)) => render(tmpl),
- Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
- Err(e) => error_response(500, &format!("join error: {e}"), &[]),
- }
-}
@@ -8,18 +8,18 @@ use crate::db::{self, Store};
use crate::model::{Effort, LogEntry, Priority, Status, Task, TaskId};
use crate::ops;
-use super::helpers::{mutation_error, mutation_response};
-use super::AppState;
+use super::super::helpers::{mutation_error, mutation_response};
+use super::super::AppState;
#[derive(serde::Deserialize)]
-pub(super) struct ProjectForm {
+pub(in crate::cmd::webui) struct ProjectForm {
name: String,
#[serde(default)]
bind_path: String,
}
#[derive(serde::Deserialize)]
-pub(super) struct CreateForm {
+pub(in crate::cmd::webui) struct CreateForm {
title: String,
#[serde(default)]
description: String,
@@ -48,7 +48,7 @@ fn default_effort() -> String {
}
#[derive(serde::Deserialize)]
-pub(super) struct UpdateForm {
+pub(in crate::cmd::webui) struct UpdateForm {
#[serde(default)]
title: Option<String>,
#[serde(default)]
@@ -64,25 +64,25 @@ pub(super) struct UpdateForm {
}
#[derive(serde::Deserialize)]
-pub(super) struct LogForm {
+pub(in crate::cmd::webui) struct LogForm {
message: String,
}
#[derive(serde::Deserialize)]
-pub(super) struct LabelForm {
+pub(in crate::cmd::webui) struct LabelForm {
/// "add" or "rm"
action: String,
label: String,
}
#[derive(serde::Deserialize)]
-pub(super) struct DepForm {
+pub(in crate::cmd::webui) struct DepForm {
/// "add" or "rm"
action: String,
blocker: String,
}
-pub(super) async fn create_project_handler(
+pub(in crate::cmd::webui) async fn create_project_handler(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<ProjectForm>,
@@ -109,7 +109,7 @@ pub(super) async fn create_project_handler(
}
}
-pub(super) async fn create_handler(
+pub(in crate::cmd::webui) async fn create_handler(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
headers: HeaderMap,
@@ -162,7 +162,7 @@ pub(super) async fn create_handler(
}
}
-pub(super) async fn update_handler(
+pub(in crate::cmd::webui) async fn update_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -219,7 +219,7 @@ pub(super) async fn update_handler(
}
}
-pub(super) async fn log_handler(
+pub(in crate::cmd::webui) async fn log_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -246,7 +246,7 @@ pub(super) async fn log_handler(
}
}
-pub(super) async fn done_handler(
+pub(in crate::cmd::webui) async fn done_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -272,7 +272,7 @@ pub(super) async fn done_handler(
}
}
-pub(super) async fn reopen_handler(
+pub(in crate::cmd::webui) async fn reopen_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -298,7 +298,7 @@ pub(super) async fn reopen_handler(
}
}
-pub(super) async fn label_handler(
+pub(in crate::cmd::webui) async fn label_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -324,7 +324,7 @@ pub(super) async fn label_handler(
}
}
-pub(super) async fn dep_handler(
+pub(in crate::cmd::webui) async fn dep_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -351,7 +351,7 @@ pub(super) async fn dep_handler(
}
}
-pub(super) async fn delete_handler(
+pub(in crate::cmd::webui) async fn delete_handler(
State(state): State<AppState>,
AxumPath((name, id)): AxumPath<(String, String)>,
headers: HeaderMap,
@@ -0,0 +1,102 @@
+use crate::model::Task;
+
+/// Columns the task table can be sorted by.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(in crate::cmd::webui) enum SortField {
+ Id,
+ Status,
+ Priority,
+ Effort,
+ Title,
+ Created,
+}
+
+impl SortField {
+ pub(in crate::cmd::webui) fn parse(s: &str) -> Option<Self> {
+ match s {
+ "id" => Some(Self::Id),
+ "status" => Some(Self::Status),
+ "priority" => Some(Self::Priority),
+ "effort" => Some(Self::Effort),
+ "title" => Some(Self::Title),
+ "created" => Some(Self::Created),
+ _ => None,
+ }
+ }
+
+ pub(in crate::cmd::webui) fn as_str(self) -> &'static str {
+ match self {
+ Self::Id => "id",
+ Self::Status => "status",
+ Self::Priority => "priority",
+ Self::Effort => "effort",
+ Self::Title => "title",
+ Self::Created => "created",
+ }
+ }
+
+ /// Sensible default direction when the user first clicks a column.
+ pub(in crate::cmd::webui) fn default_order(self) -> SortOrder {
+ match self {
+ // Newest first, alphabetical ascending for text fields.
+ Self::Created => SortOrder::Desc,
+ Self::Title | Self::Id => SortOrder::Asc,
+ // Highest priority/effort first; open before closed.
+ Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
+ }
+ }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(in crate::cmd::webui) enum SortOrder {
+ Asc,
+ Desc,
+}
+
+impl SortOrder {
+ pub(in crate::cmd::webui) fn parse(s: &str) -> Option<Self> {
+ match s {
+ "asc" => Some(Self::Asc),
+ "desc" => Some(Self::Desc),
+ _ => None,
+ }
+ }
+
+ pub(in crate::cmd::webui) fn as_str(self) -> &'static str {
+ match self {
+ Self::Asc => "asc",
+ Self::Desc => "desc",
+ }
+ }
+}
+
+/// Map a `Status` to a numeric value for semantic sorting.
+/// Lower values sort first in ascending order: open → in_progress → closed.
+fn status_sort_key(s: crate::model::Status) -> i32 {
+ match s {
+ crate::model::Status::Open => 1,
+ crate::model::Status::InProgress => 2,
+ crate::model::Status::Closed => 3,
+ }
+}
+
+/// Apply the chosen sort field and direction to a filtered task list.
+pub(in crate::cmd::webui) fn sort_tasks(tasks: &mut [&Task], field: SortField, order: SortOrder) {
+ tasks.sort_by(|a, b| {
+ let cmp = match field {
+ SortField::Id => a.id.as_str().cmp(b.id.as_str()),
+ SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
+ SortField::Priority => a.priority.score().cmp(&b.priority.score()),
+ SortField::Effort => a.effort.score().cmp(&b.effort.score()),
+ SortField::Title => a
+ .title
+ .to_ascii_lowercase()
+ .cmp(&b.title.to_ascii_lowercase()),
+ SortField::Created => a.created_at.cmp(&b.created_at),
+ };
+ match order {
+ SortOrder::Asc => cmp,
+ SortOrder::Desc => cmp.reverse(),
+ }
+ });
+}
@@ -0,0 +1,140 @@
+use askama::Template;
+
+use super::sorting::SortField;
+
+/// Minimal view-model for a scored task in the "Next Up" table.
+pub(in crate::cmd::webui) struct ScoredEntry {
+ pub(in crate::cmd::webui) id: String,
+ pub(in crate::cmd::webui) short_id: String,
+ pub(in crate::cmd::webui) title: String,
+ pub(in crate::cmd::webui) score: String,
+ pub(in crate::cmd::webui) status: String,
+ pub(in crate::cmd::webui) status_display: &'static str,
+}
+
+/// Minimal view-model for a task row in the project task table.
+pub(in crate::cmd::webui) struct TaskRow {
+ pub(in crate::cmd::webui) full_id: String,
+ pub(in crate::cmd::webui) short_id: String,
+ pub(in crate::cmd::webui) status: String,
+ pub(in crate::cmd::webui) status_display: &'static str,
+ pub(in crate::cmd::webui) priority: String,
+ pub(in crate::cmd::webui) effort: String,
+ pub(in crate::cmd::webui) title: String,
+ pub(in crate::cmd::webui) created_at: String,
+ pub(in crate::cmd::webui) created_at_display: String,
+}
+
+/// Sort context passed to the task_table macro. When present, column headers
+/// become clickable links that set sort/order query params.
+pub(in crate::cmd::webui) struct SortContext {
+ /// Base URL for sort links (e.g. `/projects/myproj`).
+ pub(in crate::cmd::webui) base_href: String,
+ /// Current sort field.
+ pub(in crate::cmd::webui) field: String,
+ /// Current sort order ("asc" or "desc").
+ pub(in crate::cmd::webui) order: String,
+ /// Query string fragment for the current filters (without sort/order/page),
+ /// suitable for appending to hrefs.
+ pub(in crate::cmd::webui) filter_qs: String,
+}
+
+impl SortContext {
+ /// Build the href for a column header link. Clicking the currently-active
+ /// column toggles direction; clicking a different column uses its default.
+ fn column_href(&self, col: &str) -> String {
+ let order = if col == self.field {
+ // Toggle current direction.
+ match self.order.as_str() {
+ "asc" => "desc",
+ _ => "asc",
+ }
+ } else {
+ // Use the column's sensible default.
+ SortField::parse(col)
+ .map(|f| f.default_order().as_str())
+ .unwrap_or("asc")
+ };
+ let mut qs = self.filter_qs.clone();
+ if !qs.is_empty() {
+ qs.push('&');
+ }
+ qs.push_str(&format!("sort={col}&order={order}"));
+ format!("{}?{qs}", self.base_href)
+ }
+
+ /// Return the arrow indicator for the active column, or empty string.
+ fn arrow(&self, col: &str) -> &str {
+ if col == self.field {
+ match self.order.as_str() {
+ "asc" => " ↑",
+ "desc" => " ↓",
+ _ => "",
+ }
+ } else {
+ ""
+ }
+ }
+}
+
+#[derive(Template)]
+#[template(path = "project.html")]
+pub(super) struct ProjectTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) project_name: String,
+ pub(super) stats_open: usize,
+ pub(super) stats_in_progress: usize,
+ pub(super) stats_closed: usize,
+ pub(super) next_up: Vec<ScoredEntry>,
+ pub(super) page_tasks: Vec<TaskRow>,
+ pub(super) all_labels: Vec<String>,
+ pub(super) filter_status: Option<String>,
+ pub(super) filter_priority: Option<String>,
+ pub(super) filter_effort: Option<String>,
+ pub(super) filter_label: Option<String>,
+ pub(super) filter_search: String,
+ pub(super) page: usize,
+ pub(super) total_pages: usize,
+ pub(super) pagination_pages: Vec<usize>,
+ pub(super) sort_ctx: SortContext,
+}
+
+impl ProjectTemplate {
+ /// Build a query-string fragment containing the current filters (no sort,
+ /// no page). Reused by both pagination and sort helpers.
+ fn filter_qs(&self) -> String {
+ let mut parts = Vec::new();
+ if let Some(ref s) = self.filter_status {
+ parts.push(format!("status={s}"));
+ }
+ if let Some(ref p) = self.filter_priority {
+ parts.push(format!("priority={p}"));
+ }
+ if let Some(ref e) = self.filter_effort {
+ parts.push(format!("effort={e}"));
+ }
+ if let Some(ref l) = self.filter_label {
+ parts.push(format!("label={l}"));
+ }
+ if !self.filter_search.is_empty() {
+ parts.push(format!("q={}", self.filter_search));
+ }
+ parts.join("&")
+ }
+
+ /// Build a pagination link preserving current filter and sort params.
+ fn pagination_href(&self, target_page: &usize) -> String {
+ let target_page = *target_page;
+ let mut qs = self.filter_qs();
+ if !qs.is_empty() {
+ qs.push('&');
+ }
+ qs.push_str(&format!(
+ "sort={}&order={}",
+ self.sort_ctx.field, self.sort_ctx.order
+ ));
+ qs.push_str(&format!("&page={target_page}"));
+ format!("/projects/{}?{qs}", self.project_name)
+ }
+}
@@ -0,0 +1,112 @@
+use anyhow::Result;
+use axum::extract::{Path as AxumPath, State};
+use axum::response::Response;
+
+use crate::db::{self, Store};
+
+use super::helpers::{
+ error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown,
+};
+use super::project::views::TaskRow;
+use super::AppState;
+
+mod views;
+use views::{BlockerRef, LogView, TaskTemplate, TaskView};
+
+pub(in crate::cmd::webui) async fn task_handler(
+ State(state): State<AppState>,
+ AxumPath((name, id)): AxumPath<(String, String)>,
+) -> Response {
+ let root = state.data_root.clone();
+ let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
+ let all_projects = list_projects_safe(&root);
+ let store = Store::open(&root, &name)?;
+
+ let task_id = db::resolve_task_id(&store, &id, false)?;
+ let task = store
+ .get_task(&task_id, false)?
+ .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
+
+ // Partition blockers.
+ let partition = db::partition_blockers(&store, &task.blockers)?;
+ let blockers_open: Vec<BlockerRef> = partition
+ .open
+ .iter()
+ .map(|b| BlockerRef {
+ full_id: b.as_str().to_string(),
+ short_id: b.short(),
+ })
+ .collect();
+ let blockers_resolved: Vec<BlockerRef> = partition
+ .resolved
+ .iter()
+ .map(|b| BlockerRef {
+ full_id: b.as_str().to_string(),
+ short_id: b.short(),
+ })
+ .collect();
+
+ // Find subtasks.
+ let all_tasks = store.list_tasks()?;
+ let subtasks: Vec<TaskRow> = all_tasks
+ .iter()
+ .filter(|t| t.parent.as_ref() == Some(&task_id))
+ .map(|t| {
+ let status = t.status.as_str().to_string();
+ TaskRow {
+ full_id: t.id.as_str().to_string(),
+ short_id: t.id.short(),
+ status_display: friendly_status(&status),
+ status,
+ priority: t.priority.as_str().to_string(),
+ effort: t.effort.as_str().to_string(),
+ title: t.title.clone(),
+ created_at_display: friendly_date(&t.created_at),
+ created_at: t.created_at.clone(),
+ }
+ })
+ .collect();
+
+ let task_view = TaskView {
+ full_id: task.id.as_str().to_string(),
+ short_id: task.id.short(),
+ title: task.title.clone(),
+ description: render_markdown(&task.description),
+ task_type: task.task_type.clone(),
+ status: task.status.as_str().to_string(),
+ priority: task.priority.as_str().to_string(),
+ effort: task.effort.as_str().to_string(),
+ created_at_display: friendly_date(&task.created_at),
+ created_at: task.created_at.clone(),
+ updated_at_display: friendly_date(&task.updated_at),
+ updated_at: task.updated_at.clone(),
+ labels: task.labels.clone(),
+ logs: task
+ .logs
+ .iter()
+ .map(|l| LogView {
+ timestamp_display: friendly_date(&l.timestamp),
+ timestamp: l.timestamp.clone(),
+ message: render_markdown(&l.message),
+ })
+ .collect(),
+ };
+
+ Ok(TaskTemplate {
+ all_projects,
+ active_project: Some(name),
+ project_name: store.project_name().to_string(),
+ task: task_view,
+ blockers_open,
+ blockers_resolved,
+ subtasks,
+ })
+ })
+ .await;
+
+ match result {
+ Ok(Ok(tmpl)) => render(tmpl),
+ Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+ Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+ }
+}
@@ -0,0 +1,45 @@
+use askama::Template;
+
+use crate::cmd::webui::project::views::TaskRow;
+
+/// View-model for the task detail page.
+pub(in crate::cmd::webui) struct TaskView {
+ pub(in crate::cmd::webui) full_id: String,
+ pub(in crate::cmd::webui) short_id: String,
+ pub(in crate::cmd::webui) title: String,
+ pub(in crate::cmd::webui) description: String,
+ pub(in crate::cmd::webui) task_type: String,
+ pub(in crate::cmd::webui) status: String,
+ pub(in crate::cmd::webui) priority: String,
+ pub(in crate::cmd::webui) effort: String,
+ pub(in crate::cmd::webui) created_at: String,
+ pub(in crate::cmd::webui) created_at_display: String,
+ pub(in crate::cmd::webui) updated_at: String,
+ pub(in crate::cmd::webui) updated_at_display: String,
+ pub(in crate::cmd::webui) labels: Vec<String>,
+ pub(in crate::cmd::webui) logs: Vec<LogView>,
+}
+
+pub(in crate::cmd::webui) struct LogView {
+ pub(in crate::cmd::webui) timestamp: String,
+ pub(in crate::cmd::webui) timestamp_display: String,
+ pub(in crate::cmd::webui) message: String,
+}
+
+/// A blocker reference for the task detail page.
+pub(in crate::cmd::webui) struct BlockerRef {
+ pub(in crate::cmd::webui) full_id: String,
+ pub(in crate::cmd::webui) short_id: String,
+}
+
+#[derive(Template)]
+#[template(path = "task.html")]
+pub(super) struct TaskTemplate {
+ pub(super) all_projects: Vec<String>,
+ pub(super) active_project: Option<String>,
+ pub(super) project_name: String,
+ pub(super) task: TaskView,
+ pub(super) blockers_open: Vec<BlockerRef>,
+ pub(super) blockers_resolved: Vec<BlockerRef>,
+ pub(super) subtasks: Vec<TaskRow>,
+}
@@ -1,209 +1,5 @@
use askama::Template;
-use super::helpers::SortField;
-
-/// A project card on the root page — either healthy or failed.
-pub(super) enum ProjectCard {
- Ok {
- name: String,
- open: usize,
- in_progress: usize,
- closed: usize,
- total: usize,
- },
- Err {
- name: String,
- error: String,
- },
-}
-
-/// Minimal view-model for a scored task in the "Next Up" table.
-pub(super) struct ScoredEntry {
- pub(super) id: String,
- pub(super) short_id: String,
- pub(super) title: String,
- pub(super) score: String,
- pub(super) status: String,
- pub(super) status_display: &'static str,
-}
-
-/// Minimal view-model for a task row in the project task table.
-pub(super) struct TaskRow {
- pub(super) full_id: String,
- pub(super) short_id: String,
- pub(super) status: String,
- pub(super) status_display: &'static str,
- pub(super) priority: String,
- pub(super) effort: String,
- pub(super) title: String,
- pub(super) created_at: String,
- pub(super) created_at_display: String,
-}
-
-/// View-model for the task detail page.
-pub(super) struct TaskView {
- pub(super) full_id: String,
- pub(super) short_id: String,
- pub(super) title: String,
- pub(super) description: String,
- pub(super) task_type: String,
- pub(super) status: String,
- pub(super) priority: String,
- pub(super) effort: String,
- pub(super) created_at: String,
- pub(super) created_at_display: String,
- pub(super) updated_at: String,
- pub(super) updated_at_display: String,
- pub(super) labels: Vec<String>,
- pub(super) logs: Vec<LogView>,
-}
-
-pub(super) struct LogView {
- pub(super) timestamp: String,
- pub(super) timestamp_display: String,
- pub(super) message: String,
-}
-
-/// A blocker reference for the task detail page.
-pub(super) struct BlockerRef {
- pub(super) full_id: String,
- pub(super) short_id: String,
-}
-
-/// Sort context passed to the task_table macro. When present, column headers
-/// become clickable links that set sort/order query params.
-pub(super) struct SortContext {
- /// Base URL for sort links (e.g. `/projects/myproj`).
- pub(super) base_href: String,
- /// Current sort field.
- pub(super) field: String,
- /// Current sort order ("asc" or "desc").
- pub(super) order: String,
- /// Query string fragment for the current filters (without sort/order/page),
- /// suitable for appending to hrefs.
- pub(super) filter_qs: String,
-}
-
-impl SortContext {
- /// Build the href for a column header link. Clicking the currently-active
- /// column toggles direction; clicking a different column uses its default.
- fn column_href(&self, col: &str) -> String {
- let order = if col == self.field {
- // Toggle current direction.
- match self.order.as_str() {
- "asc" => "desc",
- _ => "asc",
- }
- } else {
- // Use the column's sensible default.
- SortField::parse(col)
- .map(|f| f.default_order().as_str())
- .unwrap_or("asc")
- };
- let mut qs = self.filter_qs.clone();
- if !qs.is_empty() {
- qs.push('&');
- }
- qs.push_str(&format!("sort={col}&order={order}"));
- format!("{}?{qs}", self.base_href)
- }
-
- /// Return the arrow indicator for the active column, or empty string.
- fn arrow(&self, col: &str) -> &str {
- if col == self.field {
- match self.order.as_str() {
- "asc" => " ↑",
- "desc" => " ↓",
- _ => "",
- }
- } else {
- ""
- }
- }
-}
-
-#[derive(Template)]
-#[template(path = "index.html")]
-pub(super) struct IndexTemplate {
- pub(super) all_projects: Vec<String>,
- pub(super) active_project: Option<String>,
- pub(super) projects: Vec<ProjectCard>,
-}
-
-#[derive(Template)]
-#[template(path = "project.html")]
-pub(super) struct ProjectTemplate {
- pub(super) all_projects: Vec<String>,
- pub(super) active_project: Option<String>,
- pub(super) project_name: String,
- pub(super) stats_open: usize,
- pub(super) stats_in_progress: usize,
- pub(super) stats_closed: usize,
- pub(super) next_up: Vec<ScoredEntry>,
- pub(super) page_tasks: Vec<TaskRow>,
- pub(super) all_labels: Vec<String>,
- pub(super) filter_status: Option<String>,
- pub(super) filter_priority: Option<String>,
- pub(super) filter_effort: Option<String>,
- pub(super) filter_label: Option<String>,
- pub(super) filter_search: String,
- pub(super) page: usize,
- pub(super) total_pages: usize,
- pub(super) pagination_pages: Vec<usize>,
- pub(super) sort_ctx: SortContext,
-}
-
-impl ProjectTemplate {
- /// Build a query-string fragment containing the current filters (no sort,
- /// no page). Reused by both pagination and sort helpers.
- fn filter_qs(&self) -> String {
- let mut parts = Vec::new();
- if let Some(ref s) = self.filter_status {
- parts.push(format!("status={s}"));
- }
- if let Some(ref p) = self.filter_priority {
- parts.push(format!("priority={p}"));
- }
- if let Some(ref e) = self.filter_effort {
- parts.push(format!("effort={e}"));
- }
- if let Some(ref l) = self.filter_label {
- parts.push(format!("label={l}"));
- }
- if !self.filter_search.is_empty() {
- parts.push(format!("q={}", self.filter_search));
- }
- parts.join("&")
- }
-
- /// Build a pagination link preserving current filter and sort params.
- fn pagination_href(&self, target_page: &usize) -> String {
- let target_page = *target_page;
- let mut qs = self.filter_qs();
- if !qs.is_empty() {
- qs.push('&');
- }
- qs.push_str(&format!(
- "sort={}&order={}",
- self.sort_ctx.field, self.sort_ctx.order
- ));
- qs.push_str(&format!("&page={target_page}"));
- format!("/projects/{}?{qs}", self.project_name)
- }
-}
-
-#[derive(Template)]
-#[template(path = "task.html")]
-pub(super) struct TaskTemplate {
- pub(super) all_projects: Vec<String>,
- pub(super) active_project: Option<String>,
- pub(super) project_name: String,
- pub(super) task: TaskView,
- pub(super) blockers_open: Vec<BlockerRef>,
- pub(super) blockers_resolved: Vec<BlockerRef>,
- pub(super) subtasks: Vec<TaskRow>,
-}
-
#[derive(Template)]
#[template(path = "error.html")]
pub(super) struct ErrorTemplate {
@@ -1,5 +1,4 @@
{% extends "base.html" %}
-{% import "macros.html" as macros %}
{% block title %}{{ task.title }} — td{% endblock %}
@@ -152,7 +151,33 @@
{% if !subtasks.is_empty() %}
<article class="card mt-4">
<h2>Subtasks</h2>
- {% call macros::task_table(project_name, subtasks, "Subtasks") %}{% endcall %}
+ <div class="table">
+ <table>
+ <caption class="sr-only">Subtasks</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 subtasks %}
+ <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 == "open" %} success{% elif t.status == "in_progress" %} warning{% 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>
</article>
{% endif %}
{% endblock %}