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 hardware;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod tasks;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod windows;
|
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::search_all_transcripts,
|
||||||
commands::history::save_segments,
|
commands::history::save_segments,
|
||||||
commands::history::get_transcript_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
|
// Transcription
|
||||||
commands::transcription::transcribe_pcm,
|
commands::transcription::transcribe_pcm,
|
||||||
commands::transcription::transcribe_file,
|
commands::transcription::transcribe_file,
|
||||||
|
|||||||
Reference in New Issue
Block a user