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:
@@ -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;
|
||||
|
||||
261
src-tauri/src/commands/tasks.rs
Normal file
261
src-tauri/src/commands/tasks.rs
Normal 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(),
|
||||
¬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<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(|_| ())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user