feat(tasks): add Tauri commands for full task CRUD with WIP limit enforcement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 12:53:06 +00:00
parent 13456e1fb6
commit 7a9a034e3a
3 changed files with 270 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<String>,
pub status: String,
pub bucket: String,
pub effort: Option<String>,
pub done: bool,
pub done_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub sort_order: i64,
pub notes: String,
pub source_transcript_id: Option<String>,
}
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<String>,
project: Option<String>,
bucket: Option<String>,
effort: Option<String>,
source_transcript_id: Option<String>,
status: Option<String>,
) -> Result<TaskResponse, String> {
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::<String, _>("created_at"), r.get::<String, _>("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<String>,
status: String,
bucket: String,
effort: Option<String>,
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(),
&notes,
)
.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<String>,
limit: Option<i64>,
) -> Result<Vec<TaskResponse>, 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<String>,
) -> 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(|_| ())
}

View File

@@ -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,