feat(tasks): persist list_id/effort/notes + update_task_cmd — close Task 2 metadata gap

This commit is contained in:
2026-04-19 16:23:25 +01:00
parent db5c739f22
commit 9378980639
6 changed files with 272 additions and 26 deletions

View File

@@ -271,30 +271,43 @@ pub async fn delete_dictionary_entry(pool: &SqlitePool, id: i64) -> Result<()> {
// --- Task CRUD ---
/// Insert a task. `list_id` and `effort` are nullable (schema predates their
/// UI surfacing); `notes` defaults to '' at the column level. Callers that
/// want to set metadata at creation time pass `Some(...)`; omit for defaults.
pub async fn insert_task(
pool: &SqlitePool,
id: &str,
text: &str,
bucket: &str,
source_transcript_id: Option<&str>,
list_id: Option<&str>,
effort: Option<&str>,
) -> Result<()> {
sqlx::query("INSERT INTO tasks (id, text, bucket, source_transcript_id) VALUES (?, ?, ?, ?)")
.bind(id)
.bind(text)
.bind(bucket)
.bind(source_transcript_id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
sqlx::query(
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(text)
.bind(bucket)
.bind(source_transcript_id)
.bind(list_id)
.bind(effort)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
Ok(())
}
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows =
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, parent_task_id FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC")
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id \
FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC",
)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
Ok(rows
.into_iter()
@@ -304,8 +317,8 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
let row = sqlx::query(
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
source_transcript_id, parent_task_id FROM tasks WHERE id = ?"
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id FROM tasks WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
@@ -315,6 +328,46 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRo
Ok(row.map(task_row_from))
}
/// Patch-style update. Each `Option` that is `Some` overwrites the column;
/// `None` preserves the existing value via COALESCE. Returns the refreshed
/// row, or an error if `id` does not exist after the UPDATE.
///
/// Intentionally does not touch `done` / `done_at` — those go through the
/// dedicated `complete_task` / `uncomplete_task` commands because they also
/// set the server-side timestamp.
pub async fn update_task(
pool: &SqlitePool,
id: &str,
text: Option<&str>,
bucket: Option<&str>,
list_id: Option<&str>,
effort: Option<&str>,
notes: Option<&str>,
) -> Result<TaskRow> {
sqlx::query(
"UPDATE tasks SET \
text = COALESCE(?, text), \
bucket = COALESCE(?, bucket), \
list_id = COALESCE(?, list_id), \
effort = COALESCE(?, effort), \
notes = COALESCE(?, notes) \
WHERE id = ?",
)
.bind(text)
.bind(bucket)
.bind(list_id)
.bind(effort)
.bind(notes)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Update task failed: {e}")))?;
get_task_by_id(pool, id)
.await?
.ok_or_else(|| KonError::StorageError(format!("update_task: task {id} not found after update")))
}
pub async fn insert_subtask(
pool: &SqlitePool,
id: &str,
@@ -335,9 +388,9 @@ pub async fn insert_subtask(
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id \
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC"
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC",
)
.bind(parent_id)
.fetch_all(pool)
@@ -468,6 +521,7 @@ pub struct TaskRow {
pub bucket: String,
pub list_id: Option<String>,
pub effort: Option<String>,
pub notes: String,
pub done: bool,
pub done_at: Option<String>,
pub created_at: String,
@@ -503,6 +557,7 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
notes: r.get("notes"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
@@ -643,7 +698,7 @@ mod tests {
async fn task_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "task1", "Buy groceries", "today", None)
insert_task(&pool, "task1", "Buy groceries", "today", None, None, None)
.await
.unwrap();
@@ -664,7 +719,7 @@ mod tests {
#[tokio::test]
async fn subtask_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "p1", "Write report", "inbox", None).await.unwrap();
insert_task(&pool, "p1", "Write report", "inbox", None, None, None).await.unwrap();
insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap();
insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap();
@@ -688,6 +743,55 @@ mod tests {
assert!(parent.done, "parent should auto-complete when all children done");
}
#[tokio::test]
async fn update_task_overwrites_provided_fields() {
// Task 2.6 — happy path: insert, update bucket + effort, read back.
let pool = test_pool().await;
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None)
.await
.unwrap();
let row = update_task(
&pool,
"u1",
None,
Some("today"),
None,
Some("15m"),
None,
)
.await
.unwrap();
assert_eq!(row.bucket, "today");
assert_eq!(row.effort.as_deref(), Some("15m"));
assert_eq!(row.text, "Draft post", "text must be unchanged");
assert_eq!(row.notes, "", "notes default must survive");
}
#[tokio::test]
async fn update_task_partial_leaves_others_unchanged() {
// Task 2.6 — partial update: only notes changes; text/bucket intact.
let pool = test_pool().await;
insert_task(&pool, "u2", "Prep slides", "today", None, None, Some("30m"))
.await
.unwrap();
let row = update_task(&pool, "u2", None, None, None, None, Some("remember venue wifi"))
.await
.unwrap();
assert_eq!(row.text, "Prep slides");
assert_eq!(row.bucket, "today");
assert_eq!(row.effort.as_deref(), Some("30m"));
assert_eq!(row.notes, "remember venue wifi");
}
#[tokio::test]
async fn update_task_missing_id_errors() {
let pool = test_pool().await;
let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await;
assert!(res.is_err(), "update_task must error when id does not exist");
}
#[tokio::test]
async fn settings_crud_roundtrip() {
let pool = test_pool().await;