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;

View File

@@ -7,7 +7,7 @@ pub use database::{
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry, ErrorLogRow,
InsertTranscriptParams, TaskRow, TranscriptRow,
log_error, search_transcripts, set_setting, update_task, update_transcript, DictionaryEntry,
ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};

View File

@@ -112,6 +112,9 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)
"#),
(4, "tasks_meta: notes column for persisted free-text", r#"
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
"#),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -197,6 +200,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row;
#[tokio::test]
async fn test_migrations_run_on_empty_db() {
@@ -211,7 +215,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 3);
assert_eq!(count, 4);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -233,7 +237,35 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 3);
assert_eq!(count, 4);
}
#[tokio::test]
async fn migration_tasks_meta_adds_columns() {
// Task 2.6 — verify list_id / effort / notes are all present on the
// tasks table after migrations run. list_id and effort have been
// present since v1 (nullable); notes is added by v4.
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
run_migrations(&pool).await.expect("migrate");
let info = sqlx::query("PRAGMA table_info(tasks)")
.fetch_all(&pool)
.await
.unwrap();
let names: Vec<String> = info
.iter()
.map(|r| r.get::<String, _>("name"))
.collect();
for col in ["list_id", "effort", "notes"] {
assert!(
names.contains(&col.to_string()),
"tasks must have {col}; got {names:?}"
);
}
}
#[tokio::test]

View File

@@ -16,6 +16,7 @@ use kon_storage::{
list_subtasks as db_list_subtasks,
list_tasks as db_list_tasks,
uncomplete_task as db_uncomplete_task,
update_task as db_update_task,
TaskRow,
};
@@ -30,6 +31,7 @@ pub struct TaskDto {
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,
@@ -45,6 +47,7 @@ impl From<TaskRow> for TaskDto {
bucket: r.bucket,
list_id: r.list_id,
effort: r.effort,
notes: r.notes,
done: r.done,
done_at: r.done_at,
created_at: r.created_at,
@@ -60,7 +63,12 @@ pub struct CreateTaskRequest {
pub id: String,
pub text: String,
pub bucket: String,
#[serde(default)]
pub source_transcript_id: Option<String>,
#[serde(default)]
pub list_id: Option<String>,
#[serde(default)]
pub effort: Option<String>,
}
#[tauri::command]
@@ -74,6 +82,8 @@ pub async fn create_task_cmd(
&request.text,
&request.bucket,
request.source_transcript_id.as_deref(),
request.list_id.as_deref(),
request.effort.as_deref(),
)
.await
.map_err(|e| e.to_string())?;
@@ -87,6 +97,46 @@ pub async fn create_task_cmd(
.ok_or_else(|| format!("Task {} not found after insert", request.id))
}
/// Patch-shaped update. Any field omitted (or explicit `null`) leaves the
/// column untouched via `COALESCE` in the storage layer. Matches
/// `update_transcript`'s partial-update philosophy. `done` / `doneAt` are
/// intentionally absent — those flow through `complete_task_cmd` /
/// `uncomplete_task_cmd` to stamp the server-side timestamp.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTaskRequest {
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub bucket: Option<String>,
#[serde(default)]
pub list_id: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub notes: Option<String>,
}
#[tauri::command]
pub async fn update_task_cmd(
state: tauri::State<'_, AppState>,
id: String,
patch: UpdateTaskRequest,
) -> Result<TaskDto, String> {
let row = db_update_task(
&state.db,
&id,
patch.text.as_deref(),
patch.bucket.as_deref(),
patch.list_id.as_deref(),
patch.effort.as_deref(),
patch.notes.as_deref(),
)
.await
.map_err(|e| e.to_string())?;
Ok(TaskDto::from(row))
}
#[tauri::command]
pub async fn list_tasks_cmd(
state: tauri::State<'_, AppState>,

View File

@@ -258,6 +258,7 @@ pub fn run() {
// Tasks (canonical SQLite-backed task CRUD)
commands::tasks::create_task_cmd,
commands::tasks::list_tasks_cmd,
commands::tasks::update_task_cmd,
commands::tasks::complete_task_cmd,
commands::tasks::delete_task_cmd,
commands::tasks::uncomplete_task_cmd,

View File

@@ -244,6 +244,7 @@ function mapTaskRow(row) {
bucket: row.bucket ?? "inbox",
listId: row.listId ?? null,
effort: row.effort ?? "",
notes: row.notes ?? "",
done: !!row.done,
doneAt: row.doneAt ?? null,
createdAt: row.createdAt ?? new Date().toISOString(),
@@ -294,6 +295,11 @@ export async function addTask(task) {
text: task.text,
bucket: task.bucket || "inbox",
sourceTranscriptId: task.sourceTranscriptId || null,
// Task 2.6: forward list_id / effort so the SQLite row carries the
// metadata callers pass in. Callers that omit these get server
// defaults (NULL for list_id/effort, '' for notes at the column).
listId: task.listId ?? null,
effort: task.effort ?? null,
},
});
tasks.unshift(mapTaskRow(row));
@@ -303,7 +309,16 @@ export async function addTask(task) {
}
}
export function updateTask(id, updates) {
/**
* Apply a partial update to an in-memory task and broadcast the change.
* Used by `completeTask` / `uncompleteTask` after their dedicated server
* commands succeed. Not exported — external callers go through `updateTask`
* which persists via `update_task_cmd`.
*
* @param {string} id
* @param {Record<string, any>} updates
*/
function applyLocalTaskUpdate(id, updates) {
const idx = tasks.findIndex((t) => t.id === id);
if (idx >= 0) {
Object.assign(tasks[idx], updates);
@@ -311,6 +326,47 @@ export function updateTask(id, updates) {
}
}
/**
* Persist a patch to an existing task and refresh the in-memory copy with
* the server's canonical row. `updates` may contain any of
* `{ text, bucket, listId, effort, notes }`; omitted fields are preserved
* server-side via COALESCE. `done` / `doneAt` are ignored by the server's
* UpdateTaskRequest — use `completeTask` / `uncompleteTask` for those.
*
* Closes the Task 2.6 gap: before this, updateTask was a session-only
* mutation, so bucket / effort / listId edits vanished on restart.
*
* @param {string} id
* @param {Record<string, any>} updates
*/
export async function updateTask(id, updates) {
if (!hasTauriRuntime()) {
// Browser preview: fall back to the local-only path so the UI still
// reflects edits during Vite dev runs outside the Tauri shell.
applyLocalTaskUpdate(id, updates);
return;
}
try {
const row = await invoke("update_task_cmd", {
id,
patch: {
text: updates.text ?? null,
bucket: updates.bucket ?? null,
listId: updates.listId ?? null,
effort: updates.effort ?? null,
notes: updates.notes ?? null,
},
});
const idx = tasks.findIndex((t) => t.id === id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't update task", err?.message ?? String(err));
}
}
export async function deleteTask(id) {
if (!hasTauriRuntime()) return;
try {
@@ -329,7 +385,10 @@ export async function completeTask(id) {
if (!hasTauriRuntime()) return;
try {
await invoke("complete_task_cmd", { id });
updateTask(id, { done: true, doneAt: new Date().toISOString() });
// Local-only mirror: complete_task_cmd already stamped done/done_at
// server-side, so we just mirror the state here. Going through the
// persisting `updateTask` path would be a wasted round-trip.
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
} catch (err) {
toasts.error("Couldn't complete task", err?.message ?? String(err));
}
@@ -339,7 +398,7 @@ export async function uncompleteTask(id) {
if (!hasTauriRuntime()) return;
try {
await invoke("uncomplete_task_cmd", { id });
updateTask(id, { done: false, doneAt: null });
applyLocalTaskUpdate(id, { done: false, doneAt: null });
} catch (err) {
toasts.error("Couldn't uncomplete task", err?.message ?? String(err));
}