remove subtasks (parent_id)
Drop parent_id column from tasks via migration 008. Disable foreign
keys during migration to preserve task_tags associations. Remove all
parent-child logic: ordering constraints, incomplete-subtasks check
on done, --parent CLI flag, parent display in show, subtask display
in web UI. Removes 10 tests, 590+ lines. 100% coverage maintained.
change uksvxvoomysvmwztwrxuqpmylnrmqqom
commit ffe3d5a992d984ce00f0b1c10a8dfa6d5603d9cc
author Alpha Chen <alpha@kejadlen.dev>
date
parent nrouumwk
diff --git a/migrations/008_drop_parent_id.sql b/migrations/008_drop_parent_id.sql
new file mode 100644
index 0000000..2bb77ad
--- /dev/null
+++ b/migrations/008_drop_parent_id.sql
@@ -0,0 +1,25 @@
+-- Drop parent_id from tasks (SQLite requires table recreation)
+-- Disable foreign keys during migration to prevent CASCADE deleting task_tags
+PRAGMA foreign_keys = OFF;
+
+CREATE TABLE tasks_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    key TEXT NOT NULL UNIQUE,
+    backlog_id INTEGER NOT NULL REFERENCES backlogs(id) ON DELETE CASCADE,
+    title TEXT NOT NULL,
+    description TEXT,
+    state TEXT NOT NULL DEFAULT 'icebox',
+    position TEXT NOT NULL,
+    archived INTEGER NOT NULL DEFAULT 0,
+    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+
+INSERT INTO tasks_new (id, key, backlog_id, title, description, state, position, archived, created_at, updated_at)
+SELECT id, key, backlog_id, title, description, state, position, archived, created_at, updated_at
+FROM tasks;
+
+DROP TABLE tasks;
+ALTER TABLE tasks_new RENAME TO tasks;
+
+PRAGMA foreign_keys = ON;
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 6134b28..703a481 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -90,9 +90,6 @@ struct TaskView {
     title: String,
     description: Option<String>,
     tags: Vec<String>,
-    has_subtasks: bool,
-    subtask_count: usize,
-    done_subtask_count: usize,
 }
 
 async fn render_board(state: &AppState, backlog_name: &str) -> color_eyre::Result<Markup> {
@@ -235,7 +232,7 @@ fn render_column_panel(label: &str, state_class: &str, tasks: &[TaskView]) -> Ma
 }
 
 fn render_task(task: &TaskView) -> Markup {
-    let has_details = task.description.is_some() || task.has_subtasks;
+    let has_details = task.description.is_some();
     html! {
         @if has_details {
             details.task {
@@ -258,11 +255,6 @@ fn render_task(task: &TaskView) -> Markup {
                     @if let Some(desc) = &task.description {
                         div.desc { (desc) }
                     }
-                    @if task.has_subtasks {
-                        div.subtask-indicator {
-                            "◆ " (task.done_subtask_count) "/" (task.subtask_count) " subtasks"
-                        }
-                    }
                 }
             }
         } @else {
@@ -349,31 +341,12 @@ async fn to_task_views(
             .map(|t| t.name)
             .collect();
 
-        // Check for subtasks
-        let subtasks: Vec<Task> = sqlx::query_as(
-            "SELECT id, key, backlog_id, parent_id, title, description, state, position, archived, created_at, updated_at \
-             FROM tasks WHERE parent_id = ? AND archived = 0 ORDER BY position",
-        )
-        .bind(task.id)
-        .fetch_all(&mut **conn)
-        .await?;
-
-        let has_subtasks = !subtasks.is_empty();
-        let subtask_count = subtasks.len();
-        let done_subtask_count = subtasks
-            .iter()
-            .filter(|t| t.state == ranger::models::State::Done)
-            .count();
-
         views.push(TaskView {
             key_prefix,
             key_rest,
             title: task.title.clone(),
             description: task.description.clone(),
             tags,
-            has_subtasks,
-            subtask_count,
-            done_subtask_count,
         });
     }
     Ok(views)
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index bd28e17..d34deca 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -80,9 +80,6 @@ pub enum TaskCommands {
         /// Initial state (icebox, queued, in_progress, done)
         #[arg(long)]
         state: Option<String>,
-        /// Parent task key or prefix (makes this a subtask)
-        #[arg(long, add = ArgValueCompleter::new(completions::complete_task_keys))]
-        parent: Option<String>,
         #[command(flatten)]
         position: PositionArgs,
     },
@@ -180,21 +177,11 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             backlog,
             description,
             state,
-            parent,
             position,
         } => {
             let mut tx = pool.begin().await?;
 
             let bl = ops::backlog::get_by_name(&mut tx, &backlog).await?;
-            let parent_id = if let Some(parent_key) = &parent {
-                Some(
-                    ops::task::get_by_key_prefix(&mut tx, parent_key, Some(bl.id))
-                        .await?
-                        .id,
-                )
-            } else {
-                None
-            };
             let anchors = position.resolve(&mut tx, Some(bl.id)).await?;
             let state = state.map(|s| s.parse::<State>()).transpose()?;
 
@@ -204,7 +191,6 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                     title: &title,
                     backlog_id: bl.id,
                     state,
-                    parent_id,
                     description: description.as_deref(),
                 },
             )
@@ -399,9 +385,6 @@ fn print_task_detail(t: &Task, prefixes: &HashMap<String, usize>) {
     if let Some(desc) = &t.description {
         println!("Desc:    {}", desc);
     }
-    if let Some(pid) = t.parent_id {
-        println!("Parent:  {}", pid);
-    }
     println!("Created: {}", t.created_at);
     println!("Updated: {}", t.updated_at);
 }
diff --git a/src/error.rs b/src/error.rs
index 342d88c..17ad532 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -9,12 +9,6 @@ pub enum RangerError {
         task_state: String,
         anchor_state: String,
     },
-    #[error("can't mark parent task done: {count} subtask(s) still incomplete")]
-    IncompleteSubtasks { count: usize },
-    #[error("can't move parent task before its subtask")]
-    ParentBeforeChild,
-    #[error("can't move subtask after its parent")]
-    ChildAfterParent,
     #[error("database error: {0}")]
     Db(#[from] sqlx::Error),
     #[error("migration error: {0}")]
diff --git a/src/models.rs b/src/models.rs
index 9f5bef5..b09b601 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -91,7 +91,6 @@ pub struct Task {
     pub id: i64,
     pub key: String,
     pub backlog_id: i64,
-    pub parent_id: Option<i64>,
     pub title: String,
     pub description: Option<String>,
     pub state: State,
diff --git a/src/ops/comment.rs b/src/ops/comment.rs
index 3f9285c..a55028e 100644
--- a/src/ops/comment.rs
+++ b/src/ops/comment.rs
@@ -52,7 +52,6 @@ mod tests {
                 title: "Task",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
diff --git a/src/ops/tag.rs b/src/ops/tag.rs
index fe19d31..0ab1537 100644
--- a/src/ops/tag.rs
+++ b/src/ops/tag.rs
@@ -102,7 +102,6 @@ mod tests {
                 title: "Test task",
                 backlog_id: backlog.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -183,7 +182,6 @@ mod tests {
                 title: "Second task",
                 backlog_id: backlog.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
diff --git a/src/ops/task.rs b/src/ops/task.rs
index b1ac632..969f1da 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -4,13 +4,12 @@ use crate::models::{State, Task};
 use crate::position;
 use sqlx::sqlite::SqliteConnection;
 
-const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.parent_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at";
+const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at";
 
 pub struct CreateTask<'a> {
     pub title: &'a str,
     pub backlog_id: i64,
     pub state: Option<State>,
-    pub parent_id: Option<i64>,
     pub description: Option<&'a str>,
 }
 
@@ -33,15 +32,14 @@ pub async fn create(
     let new_pos = position::between(last_pos.as_deref().unwrap_or(""), "");
 
     let query = format!(
-        "INSERT INTO tasks (key, backlog_id, parent_id, title, description, state, position) \
-         VALUES (?, ?, ?, ?, ?, ?, ?) \
+        "INSERT INTO tasks (key, backlog_id, title, description, state, position) \
+         VALUES (?, ?, ?, ?, ?, ?) \
          RETURNING {TASK_COLUMNS}"
     );
 
     let task = sqlx::query_as::<_, Task>(&query)
         .bind(&key)
         .bind(params.backlog_id)
-        .bind(params.parent_id)
         .bind(params.title)
         .bind(params.description)
         .bind(state.as_str())
@@ -183,22 +181,6 @@ pub async fn edit(
             .await?;
     }
     if let Some(new_state) = &state {
-        // Prevent marking a parent done while subtasks are incomplete
-        if *new_state == State::Done {
-            let incomplete: i64 = sqlx::query_scalar(
-                "SELECT COUNT(*) FROM tasks WHERE parent_id = ? AND state != 'done'",
-            )
-            .bind(task_id)
-            .fetch_one(&mut *conn)
-            .await?;
-
-            if incomplete > 0 {
-                return Err(RangerError::IncompleteSubtasks {
-                    count: incomplete as usize,
-                });
-            }
-        }
-
         // Fetch the current state to determine direction
         let old_state: State = sqlx::query_scalar("SELECT state FROM tasks WHERE id = ?")
             .bind(task_id)
@@ -271,34 +253,6 @@ pub async fn move_task(
         }
     }
 
-    // Parent-child ordering: parent must come after children.
-    // Check if task is being moved before one of its own children,
-    // or if a child is being moved after its parent.
-    for anchor in placement.anchors() {
-        // Is anchor a child of task? (task is the parent being moved)
-        if anchor.parent_id == Some(task.id) {
-            // task is parent, anchor is child — parent can't go before child
-            match &placement {
-                Placement::Before(_) => return Err(RangerError::ParentBeforeChild),
-                Placement::Between { before, .. } if before.id == anchor.id => {
-                    return Err(RangerError::ParentBeforeChild);
-                }
-                _ => {}
-            }
-        }
-        // Is task a child of anchor? (anchor is the parent)
-        if task.parent_id == Some(anchor.id) {
-            // task is child, anchor is parent — child can't go after parent
-            match &placement {
-                Placement::After(_) => return Err(RangerError::ChildAfterParent),
-                Placement::Between { after, .. } if after.id == anchor.id => {
-                    return Err(RangerError::ChildAfterParent);
-                }
-                _ => {}
-            }
-        }
-    }
-
     let new_pos = match placement {
         Placement::After(anchor) => {
             let next =
@@ -539,7 +493,6 @@ mod tests {
                 title: "My Task",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -564,7 +517,6 @@ mod tests {
                 title: "First",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -576,7 +528,6 @@ mod tests {
                 title: "Second",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -588,7 +539,6 @@ mod tests {
                 title: "Third",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -615,7 +565,6 @@ mod tests {
                 title: "Icebox task",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -627,7 +576,6 @@ mod tests {
                 title: "Queued task",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -672,7 +620,6 @@ mod tests {
                 title: "Find me",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -696,7 +643,6 @@ mod tests {
                 title: "Original",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -729,7 +675,6 @@ mod tests {
                 title: "Delete me",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -758,7 +703,6 @@ mod tests {
                 title: "Find by id",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -838,7 +782,6 @@ mod tests {
                 title: "Original",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -863,7 +806,6 @@ mod tests {
                 title: "First",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -875,7 +817,6 @@ mod tests {
                 title: "Second",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -905,7 +846,6 @@ mod tests {
                 title: "A",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -917,7 +857,6 @@ mod tests {
                 title: "B",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -929,7 +868,6 @@ mod tests {
                 title: "C",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -959,7 +897,6 @@ mod tests {
                 title: "A",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -971,7 +908,6 @@ mod tests {
                 title: "B",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -983,7 +919,6 @@ mod tests {
                 title: "C",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1013,7 +948,6 @@ mod tests {
                 title: "A",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1025,7 +959,6 @@ mod tests {
                 title: "B",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1037,7 +970,6 @@ mod tests {
                 title: "C",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1074,7 +1006,6 @@ mod tests {
                 title: "Q",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1086,7 +1017,6 @@ mod tests {
                 title: "D",
                 backlog_id: bl.id,
                 state: Some(State::Done),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1113,7 +1043,6 @@ mod tests {
                 title: "Done 1",
                 backlog_id: bl.id,
                 state: Some(State::Done),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1125,7 +1054,6 @@ mod tests {
                 title: "Done 2",
                 backlog_id: bl.id,
                 state: Some(State::Done),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1137,7 +1065,6 @@ mod tests {
                 title: "Queued 1",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1182,7 +1109,6 @@ mod tests {
                 title: "Queued 1",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1194,7 +1120,6 @@ mod tests {
                 title: "Queued 2",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1206,7 +1131,6 @@ mod tests {
                 title: "In Progress",
                 backlog_id: bl.id,
                 state: Some(State::InProgress),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1250,7 +1174,6 @@ mod tests {
                 title: "First",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1262,7 +1185,6 @@ mod tests {
                 title: "Second",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1303,7 +1225,6 @@ mod tests {
                 title: "Queued task",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1342,7 +1263,6 @@ mod tests {
                 title: "In progress task",
                 backlog_id: bl.id,
                 state: Some(State::InProgress),
-                parent_id: None,
                 description: None,
             },
         )
@@ -1383,7 +1303,6 @@ mod tests {
                     title,
                     backlog_id: bl.id,
                     state: None,
-                    parent_id: None,
                     description: None,
                 },
             )
@@ -1442,7 +1361,6 @@ mod tests {
                 title: "Task in Alpha",
                 backlog_id: bl1.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1455,7 +1373,6 @@ mod tests {
                 title: "Task in Beta",
                 backlog_id: bl2.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1484,7 +1401,6 @@ mod tests {
                 title: "Keep",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1496,7 +1412,6 @@ mod tests {
                 title: "Archive me",
                 backlog_id: bl.id,
                 state: None,
-                parent_id: None,
                 description: None,
             },
         )
@@ -1537,471 +1452,6 @@ mod tests {
         assert_eq!(visible.len(), 2);
     }
 
-    // -- Parent-child constraint tests --
-
-    #[tokio::test]
-    async fn parent_cannot_be_marked_done_with_incomplete_subtasks() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: Some(State::Queued),
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: Some(State::Queued),
-                parent_id: Some(parent.id),
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        let err = edit(&mut conn, parent.id, None, None, Some(State::Done))
-            .await
-            .unwrap_err();
-        assert!(
-            err.to_string().contains("incomplete"),
-            "expected incomplete subtasks error, got: {err}"
-        );
-    }
-
-    #[tokio::test]
-    async fn parent_can_be_marked_done_when_all_subtasks_done() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: Some(State::Queued),
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: Some(State::Done),
-                parent_id: Some(parent.id),
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        let updated = edit(&mut conn, parent.id, None, None, Some(State::Done))
-            .await
-            .unwrap();
-        assert_eq!(updated.state, State::Done);
-    }
-
-    #[tokio::test]
-    async fn parent_can_change_to_non_done_state_with_incomplete_subtasks() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: Some(State::Queued),
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: Some(State::Queued),
-                parent_id: Some(parent.id),
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        // Moving to in_progress is fine even with incomplete subtasks
-        let updated = edit(&mut conn, parent.id, None, None, Some(State::InProgress))
-            .await
-            .unwrap();
-        assert_eq!(updated.state, State::InProgress);
-    }
-
-    #[tokio::test]
-    async fn task_without_subtasks_can_be_marked_done_freely() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        let task = create(
-            &mut conn,
-            CreateTask {
-                title: "Leaf task",
-                backlog_id: bl.id,
-                state: Some(State::Queued),
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-
-        let updated = edit(&mut conn, task.id, None, None, Some(State::Done))
-            .await
-            .unwrap();
-        assert_eq!(updated.state, State::Done);
-    }
-
-    #[tokio::test]
-    async fn move_parent_before_subtask_rejected() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        // child first, parent second (correct ordering)
-        let child = create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        // Set parent_id after creation to avoid ordering chicken-and-egg
-        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
-            .bind(parent.id)
-            .bind(child.id)
-            .execute(&mut *conn)
-            .await
-            .unwrap();
-        let child = get_by_id(&mut conn, child.id).await.unwrap();
-
-        // Try to move parent before its child — should fail
-        let err = move_task(&mut conn, &parent, Placement::Before(&child))
-            .await
-            .unwrap_err();
-        assert!(
-            err.to_string().contains("parent"),
-            "expected parent-before-child error, got: {err}"
-        );
-    }
-
-    #[tokio::test]
-    async fn move_subtask_after_parent_rejected() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        // child first, parent second (correct ordering)
-        let child = create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
-            .bind(parent.id)
-            .bind(child.id)
-            .execute(&mut *conn)
-            .await
-            .unwrap();
-        let child = get_by_id(&mut conn, child.id).await.unwrap();
-
-        // Try to move child after its parent — should fail
-        let err = move_task(&mut conn, &child, Placement::After(&parent))
-            .await
-            .unwrap_err();
-        assert!(
-            err.to_string().contains("subtask"),
-            "expected child-after-parent error, got: {err}"
-        );
-    }
-
-    #[tokio::test]
-    async fn move_parent_between_with_child_as_before_rejected() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        // order: other, child, parent
-        let other = create(
-            &mut conn,
-            CreateTask {
-                title: "Other",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let child = create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
-            .bind(parent.id)
-            .bind(child.id)
-            .execute(&mut *conn)
-            .await
-            .unwrap();
-        let child = get_by_id(&mut conn, child.id).await.unwrap();
-
-        // Try to place parent between other and child (before=child) — should fail
-        let err = move_task(
-            &mut conn,
-            &parent,
-            Placement::Between {
-                after: &other,
-                before: &child,
-            },
-        )
-        .await
-        .unwrap_err();
-        assert!(
-            err.to_string().contains("parent"),
-            "expected parent-before-child error, got: {err}"
-        );
-    }
-
-    #[tokio::test]
-    async fn move_subtask_between_with_parent_as_after_rejected() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        // order: child, parent, other
-        let child = create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let other = create(
-            &mut conn,
-            CreateTask {
-                title: "Other",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
-            .bind(parent.id)
-            .bind(child.id)
-            .execute(&mut *conn)
-            .await
-            .unwrap();
-        let child = get_by_id(&mut conn, child.id).await.unwrap();
-
-        // Try to place child between parent and other (after=parent) — should fail
-        let err = move_task(
-            &mut conn,
-            &child,
-            Placement::Between {
-                after: &parent,
-                before: &other,
-            },
-        )
-        .await
-        .unwrap_err();
-        assert!(
-            err.to_string().contains("subtask"),
-            "expected child-after-parent error, got: {err}"
-        );
-    }
-
-    #[tokio::test]
-    async fn move_parent_after_child_is_fine() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        let child = create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
-            .bind(parent.id)
-            .bind(child.id)
-            .execute(&mut *conn)
-            .await
-            .unwrap();
-        let child = get_by_id(&mut conn, child.id).await.unwrap();
-
-        // Moving parent after its child is fine (preserves child-before-parent order)
-        move_task(&mut conn, &parent, Placement::After(&child))
-            .await
-            .unwrap();
-    }
-
-    #[tokio::test]
-    async fn move_subtask_before_parent_is_fine() {
-        let pool = test_pool().await;
-        let mut conn = pool.acquire().await.unwrap();
-        let bl = backlog::create(&mut conn, "Test").await.unwrap();
-
-        let child = create(
-            &mut conn,
-            CreateTask {
-                title: "Child",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        let parent = create(
-            &mut conn,
-            CreateTask {
-                title: "Parent",
-                backlog_id: bl.id,
-                state: None,
-                parent_id: None,
-                description: None,
-            },
-        )
-        .await
-        .unwrap();
-        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
-            .bind(parent.id)
-            .bind(child.id)
-            .execute(&mut *conn)
-            .await
-            .unwrap();
-        let child = get_by_id(&mut conn, child.id).await.unwrap();
-
-        // Moving child before its parent is fine (preserves child-before-parent order)
-        move_task(&mut conn, &child, Placement::Before(&parent))
-            .await
-            .unwrap();
-    }
-
     #[tokio::test]
     async fn list_tasks_with_tag_and_state_filter() {
         let pool = test_pool().await;
@@ -2013,7 +1463,6 @@ mod tests {
                 title: "Tagged queued",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
@@ -2025,7 +1474,6 @@ mod tests {
                 title: "Untagged queued",
                 backlog_id: bl.id,
                 state: Some(State::Queued),
-                parent_id: None,
                 description: None,
             },
         )
diff --git a/static/style.css b/static/style.css
index 4bd7aec..53d432b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -344,12 +344,6 @@ details.task[open] > summary .expand-icon {
   word-break: break-word;
 }
 
-.task .subtask-indicator {
-  font-size: var(--step-mono);
-  color: #7a7868;
-  margin-top: 4px;
-}
-
 /* === Tags === */
 .task .tags {
   display: inline-flex;