Skip to main content

max / goingson

Resolve a linked subtask's text from the task it links to The text was copied out of the linked task's description at link time and never updated, so editing a task left every subtask pointing at it showing the old text. Read it through a join instead. The stored copy stays as the COALESCE fallback, so a link whose task is gone still reads as the task it used to name rather than going blank. Both read paths drifted; the batch one backs task listing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-26 13:55 UTC
Signed with PGP, not checked
Commit: d39e0fdcd3ce1744877f76efda57aa97b131432d
Parent: 51bedef
2 files changed, +95 insertions, -9 deletions
@@ -2,7 +2,7 @@
2 2
3 3 mod common;
4 4
5 - use goingson_core::TaskAnnotations;
5 + use goingson_core::{TaskAnnotations, TaskCrud};
6 6 use goingson_db_sqlite::SqliteTaskRepository;
7 7
8 8 #[tokio::test]
@@ -314,3 +314,79 @@
314 314 .expect("Failed to get subtasks");
315 315 assert!(subtasks.is_empty(), "New task should have no subtasks");
316 316 }
317 +
318 + #[tokio::test]
319 + async fn test_linked_subtask_text_follows_the_linked_task_description() {
320 + let pool = common::setup_test_db().await;
321 + let user_id = common::create_test_user(&pool).await;
322 + let parent_task_id = common::create_test_task(&pool, user_id).await;
323 + let linked_task_id = common::create_test_task(&pool, user_id).await;
324 +
325 + let repo = SqliteTaskRepository::new(pool.clone());
326 + repo.add_subtask_link(parent_task_id, user_id, linked_task_id)
327 + .await
328 + .expect("Failed to add subtask link")
329 + .expect("Should return Some for valid link");
330 +
331 + // Edit the linked task after the link was made. The subtask stored a copy
332 + // of the old description; reads must not serve that copy.
333 + sqlx::query("UPDATE tasks SET description = ? WHERE id = ?")
334 + .bind("renamed after linking")
335 + .bind(linked_task_id.to_string())
336 + .execute(&pool)
337 + .await
338 + .unwrap();
339 +
340 + let subtasks = repo
341 + .get_subtasks_for_task(parent_task_id)
342 + .await
343 + .expect("Failed to get subtasks");
344 + assert_eq!(subtasks.len(), 1);
345 + assert_eq!(
346 + subtasks[0].text, "renamed after linking",
347 + "Linked subtask should read the linked task's current description"
348 + );
349 +
350 + // Listing uses a separate batch query that drifted the same way.
351 + let listed = repo.list_all(user_id).await.expect("Failed to list tasks");
352 + let parent = listed
353 + .iter()
354 + .find(|t| t.id == parent_task_id)
355 + .expect("parent present");
356 + assert_eq!(
357 + parent.subtasks[0].text, "renamed after linking",
358 + "Batch read should resolve the same as the single read"
359 + );
360 + }
361 +
362 + #[tokio::test]
363 + async fn test_linked_subtask_falls_back_to_stored_text_when_task_is_gone() {
364 + let pool = common::setup_test_db().await;
365 + let user_id = common::create_test_user(&pool).await;
366 + let parent_task_id = common::create_test_task(&pool, user_id).await;
367 + let linked_task_id = common::create_test_task(&pool, user_id).await;
368 +
369 + let repo = SqliteTaskRepository::new(pool.clone());
370 + let linked = repo
371 + .add_subtask_link(parent_task_id, user_id, linked_task_id)
372 + .await
373 + .expect("Failed to add subtask link")
374 + .expect("Should return Some for valid link");
375 + let text_at_link_time = linked.text.clone();
376 +
377 + sqlx::query("DELETE FROM tasks WHERE id = ?")
378 + .bind(linked_task_id.to_string())
379 + .execute(&pool)
380 + .await
381 + .unwrap();
382 +
383 + let subtasks = repo
384 + .get_subtasks_for_task(parent_task_id)
385 + .await
386 + .expect("Failed to get subtasks");
387 + assert_eq!(subtasks.len(), 1, "Dangling link should still be listed");
388 + assert_eq!(
389 + subtasks[0].text, text_at_link_time,
390 + "A dangling link should read as the task it used to name, not empty"
391 + );
392 + }
@@ -2,6 +2,12 @@
2 2 //!
3 3 //! Handles subtask CRUD: listing, adding, toggling, updating, deleting,
4 4 //! and linking tasks as subtasks.
5 + //!
6 + //! A linked subtask's `text` is resolved from the linked task on read, not
7 + //! from the copy stored at link time — otherwise editing a task's description
8 + //! leaves every subtask pointing at it showing the old text. The stored copy
9 + //! is still written, and is what `COALESCE` falls back to once the linked task
10 + //! is gone, so a dangling link keeps reading as the task it used to name.
5 11
6 12 use sqlx::SqlitePool;
7 13 use std::collections::HashMap;
@@ -47,10 +53,12 @@
47 53
48 54 let query = format!(
49 55 r"
50 - SELECT id, task_id, text, linked_task_id, is_completed, position
51 - FROM subtasks
52 - WHERE task_id IN ({})
53 - ORDER BY position ASC, created_at ASC
56 + SELECT s.id, s.task_id, COALESCE(t.description, s.text) AS text,
57 + s.linked_task_id, s.is_completed, s.position
58 + FROM subtasks s
59 + LEFT JOIN tasks t ON t.id = s.linked_task_id
60 + WHERE s.task_id IN ({})
61 + ORDER BY s.position ASC, s.created_at ASC
54 62 ",
55 63 bind_placeholders(task_ids.len())
56 64 );
@@ -78,10 +86,12 @@
78 86 ) -> Result<Vec<Subtask>> {
79 87 let rows = sqlx::query_as::<_, SubtaskRow>(
80 88 r"
81 - SELECT id, task_id, text, linked_task_id, is_completed, position
82 - FROM subtasks
83 - WHERE task_id = ?
84 - ORDER BY position ASC, created_at ASC
89 + SELECT s.id, s.task_id, COALESCE(t.description, s.text) AS text,
90 + s.linked_task_id, s.is_completed, s.position
91 + FROM subtasks s
92 + LEFT JOIN tasks t ON t.id = s.linked_task_id
93 + WHERE s.task_id = ?
94 + ORDER BY s.position ASC, s.created_at ASC
85 95 ",
86 96 )
87 97 .bind(task_id.to_string())