From 7a9a034e3a8e260a95e4b8d143f47f328bb1cb13 Mon Sep 17 00:00:00 2001 From: jake Date: Sat, 21 Mar 2026 12:53:06 +0000 Subject: [PATCH] feat(tasks): add Tauri commands for full task CRUD with WIP limit enforcement Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/tasks.rs | 261 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 8 + 3 files changed, 270 insertions(+) create mode 100644 src-tauri/src/commands/tasks.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 22ee2db..d592fdd 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,5 +3,6 @@ pub mod clipboard; pub mod hardware; pub mod history; pub mod models; +pub mod tasks; pub mod transcription; pub mod windows; diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs new file mode 100644 index 0000000..857a170 --- /dev/null +++ b/src-tauri/src/commands/tasks.rs @@ -0,0 +1,261 @@ +use serde::Serialize; +use sqlx::Row; + +use crate::AppState; +use kon_storage::{ + delete_task, insert_task_v2, list_tasks, list_tasks_by_status, reorder_tasks, update_task_v2, + TaskRow, +}; + +/// Maximum number of tasks with status "active" — a core design constraint +/// for neurodivergent users to prevent the freeze response. +const WIP_LIMIT: i64 = 3; + +/// Serialisable task response for the frontend. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskResponse { + pub id: String, + pub text: String, + pub priority: String, + pub project: Option, + pub status: String, + pub bucket: String, + pub effort: Option, + pub done: bool, + pub done_at: Option, + pub created_at: String, + pub updated_at: String, + pub sort_order: i64, + pub notes: String, + pub source_transcript_id: Option, +} + +fn task_response_from(row: TaskRow) -> TaskResponse { + TaskResponse { + id: row.id, + text: row.text, + priority: row.priority, + project: row.project, + status: row.status, + bucket: row.bucket, + effort: row.effort, + done: row.done, + done_at: row.done_at, + created_at: row.created_at, + updated_at: row.updated_at, + sort_order: row.sort_order, + notes: row.notes, + source_transcript_id: row.source_transcript_id, + } +} + +/// Create a new task. Returns the created task. +/// +/// If `status` is "active" and the WIP limit is already reached, the task +/// is created with status "pending" instead — focus on your current tasks first. +#[tauri::command] +pub async fn create_task( + state: tauri::State<'_, AppState>, + text: String, + priority: Option, + project: Option, + bucket: Option, + effort: Option, + source_transcript_id: Option, + status: Option, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let priority = priority.unwrap_or_else(|| "medium".to_string()); + let bucket = bucket.unwrap_or_else(|| "later".to_string()); + let mut status = status.unwrap_or_else(|| "pending".to_string()); + + // Enforce WIP limit: if requesting "active" status, check current count + if status == "active" { + let active = list_tasks_by_status(&state.db, "active", WIP_LIMIT + 1) + .await + .map_err(|e| e.to_string())?; + if active.len() as i64 >= WIP_LIMIT { + // Downgrade to pending — don't reject the task entirely + status = "pending".to_string(); + } + } + + // Determine sort_order: place at end of current tasks + let existing = list_tasks(&state.db).await.map_err(|e| e.to_string())?; + let sort_order = existing + .iter() + .map(|t| t.sort_order) + .max() + .unwrap_or(-1) + + 1; + + insert_task_v2( + &state.db, + &id, + &text, + &priority, + project.as_deref(), + &status, + &bucket, + effort.as_deref(), + source_transcript_id.as_deref(), + sort_order, + ) + .await + .map_err(|e| e.to_string())?; + + // Re-fetch the created task to get accurate SQLite timestamps + let row = sqlx::query( + "SELECT created_at, updated_at FROM tasks WHERE id = ?", + ) + .bind(&id) + .fetch_optional(&state.db) + .await + .map_err(|e| format!("Failed to fetch created task: {e}"))?; + + let (created_at, updated_at) = row + .map(|r| (r.get::("created_at"), r.get::("updated_at"))) + .unwrap_or_default(); + + Ok(TaskResponse { + id, + text, + priority, + project, + status, + bucket, + effort, + done: false, + done_at: None, + created_at, + updated_at, + sort_order, + notes: String::new(), + source_transcript_id, + }) +} + +/// Update a task's mutable fields. +#[tauri::command] +pub async fn update_task_cmd( + state: tauri::State<'_, AppState>, + id: String, + text: String, + priority: String, + project: Option, + status: String, + bucket: String, + effort: Option, + notes: String, +) -> Result<(), String> { + // Enforce WIP limit when promoting to "active" + if status == "active" { + let active = list_tasks_by_status(&state.db, "active", WIP_LIMIT + 1) + .await + .map_err(|e| e.to_string())?; + // Allow if this task is already active (just updating other fields) + let already_active = active.iter().any(|t| t.id == id); + if !already_active && active.len() as i64 >= WIP_LIMIT { + return Err("Focus on your current 3 tasks first — finish or park one before adding another.".to_string()); + } + } + + update_task_v2( + &state.db, + &id, + &text, + &priority, + project.as_deref(), + &status, + &bucket, + effort.as_deref(), + ¬es, + ) + .await + .map_err(|e| e.to_string()) +} + +/// Delete a task by ID. +#[tauri::command] +pub async fn delete_task_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + delete_task(&state.db, &id) + .await + .map_err(|e| e.to_string()) +} + +/// List tasks, optionally filtered by status. +/// +/// WIP limit enforcement: if status is "active", at most 3 results are returned +/// regardless of the requested limit. This is a product constraint. +#[tauri::command] +pub async fn list_tasks_cmd( + state: tauri::State<'_, AppState>, + status: Option, + limit: Option, +) -> Result, String> { + let rows = if let Some(ref s) = status { + let effective_limit = if s == "active" { + WIP_LIMIT + } else { + limit.unwrap_or(100) + }; + list_tasks_by_status(&state.db, s, effective_limit) + .await + .map_err(|e| e.to_string())? + } else { + // No filter — return all tasks up to limit + let all = list_tasks(&state.db).await.map_err(|e| e.to_string())?; + let lim = limit.unwrap_or(100) as usize; + all.into_iter().take(lim).collect() + }; + + Ok(rows.into_iter().map(task_response_from).collect()) +} + +/// Reorder tasks by setting sort_order based on position in the ID list. +#[tauri::command] +pub async fn reorder_tasks_cmd( + state: tauri::State<'_, AppState>, + task_ids: Vec, +) -> Result<(), String> { + reorder_tasks(&state.db, &task_ids) + .await + .map_err(|e| e.to_string()) +} + +/// Mark a task as complete — sets status to "done" and done flag. +#[tauri::command] +pub async fn complete_task_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + // Use a direct query that sets both status and done in one operation + sqlx::query( + "UPDATE tasks SET status = 'done', done = 1, done_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + ) + .bind(&id) + .execute(&state.db) + .await + .map_err(|e| format!("Complete task failed: {e}")) + .map(|_| ()) +} + +/// Uncomplete a task — revert to pending status. +#[tauri::command] +pub async fn uncomplete_task_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + sqlx::query( + "UPDATE tasks SET status = 'pending', done = 0, done_at = NULL, updated_at = datetime('now') WHERE id = ?", + ) + .bind(&id) + .execute(&state.db) + .await + .map_err(|e| format!("Uncomplete task failed: {e}")) + .map(|_| ()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96493ea..0d1c80d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,6 +146,14 @@ pub fn run() { commands::history::search_all_transcripts, commands::history::save_segments, commands::history::get_transcript_segments, + // Tasks + commands::tasks::create_task, + commands::tasks::update_task_cmd, + commands::tasks::delete_task_cmd, + commands::tasks::list_tasks_cmd, + commands::tasks::reorder_tasks_cmd, + commands::tasks::complete_task_cmd, + commands::tasks::uncomplete_task_cmd, // Transcription commands::transcription::transcribe_pcm, commands::transcription::transcribe_file,