feat(tasks): persist list_id/effort/notes + update_task_cmd — close Task 2 metadata gap
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user