Skip to main content

max / goingson

25.9 KB · 767 lines History Blame Raw
1 //! Task CRUD and lifecycle commands.
2 //!
3 //! Provides core CRUD operations for tasks, the primary work unit in GoingsOn.
4 //! Snooze/waiting commands live in [`super::task_state`], and annotation/subtask
5 //! commands live in [`super::task_subtasks`].
6 //!
7 //! # Task Features
8 //!
9 //! - Priority levels (High, Medium, Low)
10 //! - Recurrence (Daily, Weekly, Monthly)
11 //! - Urgency calculation based on priority, due date, age, and tags
12 //! - Time blocking (scheduled_start + scheduled_duration)
13
14 use chrono::{DateTime, Utc};
15 use serde::{Deserialize, Serialize};
16 use std::sync::Arc;
17 use tauri::State;
18 use tracing::instrument;
19
20 use goingson_core::{
21 Annotation, MilestoneStatus, NewTask, ParseableEnum, Priority, Recurrence, RecurrenceRule,
22 Subtask, Task, TaskStatus, UpdateTask, Validate, calculate_next_due, calculate_next_due_rich,
23 calculate_urgency, parse_quick_add, TaskId, ProjectId, MilestoneId, ContactId, EmailId,
24 date_utils::{format_relative_date, format_relative_future},
25 };
26
27 use crate::state::{AppState, DESKTOP_USER_ID};
28 use super::{ApiError, OptionNotFound};
29
30 // ============ Types ============
31
32 /// Frontend input for creating or updating a task.
33 ///
34 /// String-typed fields like `status`, `priority`, and `recurrence` are parsed
35 /// into their enum equivalents in the command handler using `from_str_or_default`.
36 #[derive(Debug, Deserialize)]
37 #[serde(rename_all = "camelCase")]
38 pub struct TaskInput {
39 /// Associated project, if any.
40 pub project_id: Option<ProjectId>,
41 /// Task description/title (required, validated non-empty by the command handler).
42 pub description: String,
43 /// Lifecycle status as a string ("Pending", "Started", "Completed"), parsed to `TaskStatus`.
44 pub status: Option<String>,
45 /// Priority level as a string ("H", "M", "L" or full names), parsed to `Priority`.
46 pub priority: String,
47 /// Due date, if set.
48 pub due: Option<DateTime<Utc>>,
49 /// User-defined tags for categorization (defaults to empty vec if omitted).
50 pub tags: Option<Vec<String>>,
51 /// Recurrence pattern as a string ("Daily", "Weekly", "Monthly"), parsed to `Recurrence`.
52 pub recurrence: Option<String>,
53 /// Associated contact, if any.
54 pub contact_id: Option<ContactId>,
55 /// Target milestone within the project, if any.
56 pub milestone_id: Option<MilestoneId>,
57 /// Estimated duration in minutes.
58 pub estimated_minutes: Option<i32>,
59 /// Rich recurrence configuration (JSON).
60 pub recurrence_rule: Option<RecurrenceRule>,
61 }
62
63 /// Task response with pre-computed fields for UI.
64 /// Uses domain Task fields + computed fields for efficiency.
65 #[derive(Debug, Serialize)]
66 #[serde(rename_all = "camelCase")]
67 pub struct TaskResponse {
68 pub id: TaskId,
69 pub project_id: Option<ProjectId>,
70 pub project_name: Option<String>,
71 pub description: String,
72 pub description_html: String,
73 pub status: String,
74 pub priority: String,
75 pub due: Option<DateTime<Utc>>,
76 pub tags: Vec<String>,
77 pub urgency: f64,
78 pub recurrence: String,
79 pub recurrence_parent_id: Option<TaskId>,
80 pub source_email_id: Option<EmailId>,
81 pub snoozed_until: Option<DateTime<Utc>>,
82 pub waiting_for_response: bool,
83 pub waiting_since: Option<DateTime<Utc>>,
84 pub expected_response_date: Option<DateTime<Utc>>,
85 pub scheduled_start: Option<DateTime<Utc>>,
86 pub scheduled_duration: Option<i32>,
87 pub contact_id: Option<ContactId>,
88 pub contact_name: Option<String>,
89 pub milestone_id: Option<MilestoneId>,
90 pub annotations: Vec<Annotation>,
91 pub subtasks: Vec<Subtask>,
92 pub created_at: DateTime<Utc>,
93 /// Whether this task is marked as focus for the week
94 pub is_focus: bool,
95 /// When the focus was set
96 pub focus_set_at: Option<DateTime<Utc>>,
97 /// Estimated duration in minutes
98 pub estimated_minutes: Option<i32>,
99 /// Total tracked time in minutes
100 pub actual_minutes: i32,
101 /// Time progress as percentage (0-100+), None if no estimate
102 pub time_progress: Option<u8>,
103 /// Whether actual exceeds estimate
104 pub is_over_estimate: bool,
105 /// Whether a timer is currently running
106 pub timer_active: bool,
107 /// When the active timer started (for frontend elapsed display)
108 pub timer_started_at: Option<DateTime<Utc>>,
109 // Pre-computed fields
110 /// True if snoozed_until > now
111 pub is_snoozed: bool,
112 /// True if due date is in the past
113 pub is_overdue: bool,
114 /// Total number of subtasks
115 pub subtask_count: usize,
116 /// Number of completed subtasks
117 pub subtask_completed: usize,
118 /// Urgency classification: "overdue", "high", "medium", or "low"
119 pub urgency_class: String,
120 /// Subtask progress as percentage (0-100), None if no subtasks
121 pub subtask_progress: Option<u8>,
122 /// Human-readable due date: "today", "tomorrow", "+3d", "2d ago", etc.
123 pub due_formatted: Option<String>,
124 /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15"
125 pub snoozed_until_formatted: Option<String>,
126 }
127
128 impl From<Task> for TaskResponse {
129 fn from(t: Task) -> Self {
130 // Pre-compute fields
131 let is_snoozed = t.is_snoozed();
132 let is_overdue = t.is_overdue();
133 let subtask_count = t.subtask_count();
134 let subtask_completed = t.subtasks_completed();
135 // Strip the "urgency-" CSS prefix so the frontend gets bare class names
136 // ("overdue", "high", "medium", "low") for flexible styling.
137 let urgency_class = t.urgency_class().trim_start_matches("urgency-").to_string();
138
139 let subtask_progress = if subtask_count > 0 {
140 Some(((subtask_completed as f64 / subtask_count as f64) * 100.0).round() as u8)
141 } else {
142 None
143 };
144
145 let now = Utc::now();
146 let due_formatted = t.due.map(|due| format_relative_date(due, now));
147 let snoozed_until_formatted = t.snoozed_until.map(|s| format_relative_future(s, now));
148
149 let time_progress = t.time_progress();
150 let is_over_estimate = t.is_over_estimate();
151 let timer_active = t.has_active_timer();
152 let timer_started_at = t.active_session.as_ref().map(|s| s.started_at);
153
154 TaskResponse {
155 id: t.id,
156 project_id: t.project_id,
157 project_name: t.project_name,
158 description_html: docengine::render_standard(&t.description),
159 description: t.description,
160 status: t.status.as_str().to_string(),
161 priority: t.priority.as_str().to_string(),
162 due: t.due,
163 tags: t.tags,
164 urgency: t.urgency,
165 recurrence: t.recurrence.as_str().to_string(),
166 recurrence_parent_id: t.recurrence_parent_id,
167 source_email_id: t.source_email_id,
168 snoozed_until: t.snoozed_until,
169 waiting_for_response: t.waiting_for_response,
170 waiting_since: t.waiting_since,
171 expected_response_date: t.expected_response_date,
172 scheduled_start: t.scheduled_start,
173 scheduled_duration: t.scheduled_duration,
174 contact_id: t.contact_id,
175 contact_name: t.contact_name,
176 milestone_id: t.milestone_id,
177 annotations: t.annotations,
178 subtasks: t.subtasks,
179 created_at: t.created_at,
180 is_focus: t.is_focus,
181 focus_set_at: t.focus_set_at,
182 estimated_minutes: t.estimated_minutes,
183 actual_minutes: t.actual_minutes,
184 time_progress,
185 is_over_estimate,
186 timer_active,
187 timer_started_at,
188 is_snoozed,
189 is_overdue,
190 subtask_count,
191 subtask_completed,
192 urgency_class,
193 subtask_progress,
194 due_formatted,
195 snoozed_until_formatted,
196 }
197 }
198 }
199
200 #[derive(Debug, Deserialize)]
201 #[serde(rename_all = "camelCase")]
202 pub struct QuickAddInput {
203 pub text: String,
204 }
205
206 #[derive(Debug, Serialize)]
207 #[serde(rename_all = "camelCase")]
208 pub struct CompleteTaskResponse {
209 pub completed: bool,
210 pub next_recurring_task: Option<TaskResponse>,
211 }
212
213 /// Filter criteria for listing tasks.
214 /// All fields are optional - omitted fields don't filter.
215 #[derive(Debug, Default, Deserialize)]
216 #[serde(rename_all = "camelCase")]
217 pub struct TaskFilterInput {
218 /// Filter by status (Pending, Started, Completed)
219 pub status: Option<String>,
220 /// Filter by project ID
221 pub project_id: Option<ProjectId>,
222 /// Filter by milestone ID
223 pub milestone_id: Option<MilestoneId>,
224 /// Filter by priority (High, Medium, Low)
225 pub priority: Option<String>,
226 /// Include snoozed tasks (default: false = hide snoozed)
227 #[serde(default)]
228 pub show_snoozed: bool,
229 /// Show only tasks marked as waiting for response
230 #[serde(default)]
231 pub waiting_only: bool,
232 /// Pagination: number of items to skip
233 pub offset: Option<i64>,
234 /// Pagination: maximum items to return
235 pub limit: Option<i64>,
236 /// Column to sort by: description, project, priority, due, urgency (default: urgency)
237 pub sort_column: Option<String>,
238 /// Sort direction: asc or desc (default: desc for urgency, asc for others)
239 pub sort_direction: Option<String>,
240 }
241
242 /// Paginated response with total count for UI pagination.
243 #[derive(Debug, Serialize)]
244 #[serde(rename_all = "camelCase")]
245 pub struct PaginatedTasksResponse {
246 pub tasks: Vec<TaskResponse>,
247 pub total: i64,
248 }
249
250 // ============ Task Commands ============
251
252 /// Lists all non-deleted tasks for the current user.
253 ///
254 /// Returns tasks sorted by urgency (descending) then creation date.
255 ///
256 /// # Errors
257 ///
258 /// Returns `DATABASE_ERROR` if the query fails.
259 #[tauri::command]
260 #[instrument(skip_all)]
261 pub async fn list_tasks(state: State<'_, Arc<AppState>>) -> Result<Vec<TaskResponse>, ApiError> {
262 let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
263 Ok(tasks.into_iter().map(TaskResponse::from).collect())
264 }
265
266 /// Lists tasks with server-side filtering and pagination.
267 ///
268 /// Returns paginated results with total count for UI pagination controls.
269 ///
270 /// # Arguments
271 ///
272 /// * `filters` - Filter criteria (all optional):
273 /// - `status`: Filter by task status
274 /// - `project_id`: Filter by project
275 /// - `priority`: Filter by priority level
276 /// - `show_snoozed`: Include snoozed tasks (default: false)
277 /// - `waiting_only`: Show only tasks awaiting response
278 /// - `offset`/`limit`: Pagination parameters
279 ///
280 /// # Errors
281 ///
282 /// Returns `DATABASE_ERROR` if the query fails.
283 #[tauri::command]
284 #[instrument(skip_all)]
285 pub async fn list_tasks_filtered(
286 state: State<'_, Arc<AppState>>,
287 filters: TaskFilterInput,
288 ) -> Result<PaginatedTasksResponse, ApiError> {
289 use goingson_core::{TaskFilterQuery, TaskStatus, Priority, TaskSortColumn, SortDirection};
290
291 let query = TaskFilterQuery {
292 status: filters.status.map(|s| TaskStatus::from_str_or_default(&s)),
293 project_id: filters.project_id,
294 milestone_id: filters.milestone_id,
295 priority: filters.priority.map(|p| Priority::from_str_or_default(&p)),
296 show_snoozed: filters.show_snoozed,
297 waiting_only: filters.waiting_only,
298 offset: filters.offset,
299 limit: filters.limit,
300 sort_column: filters.sort_column.map(|s| TaskSortColumn::from_str_or_default(&s)),
301 sort_direction: filters.sort_direction.map(|s| SortDirection::from_str_or_default(&s)),
302 };
303
304 let (tasks, total) = state.tasks.list_filtered(DESKTOP_USER_ID, query).await?;
305
306 Ok(PaginatedTasksResponse {
307 tasks: tasks.into_iter().map(TaskResponse::from).collect(),
308 total,
309 })
310 }
311
312 /// Retrieves a single task by ID.
313 ///
314 /// # Errors
315 ///
316 /// Returns `DATABASE_ERROR` if the query fails.
317 /// Returns `None` (not an error) if the task doesn't exist.
318 #[tauri::command]
319 #[instrument(skip_all)]
320 pub async fn get_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<Option<TaskResponse>, ApiError> {
321 let task = state.tasks.get_by_id(id, DESKTOP_USER_ID).await?;
322 Ok(task.map(TaskResponse::from))
323 }
324
325 /// Creates a new task.
326 ///
327 /// # Arguments
328 ///
329 /// * `input` - Task data:
330 /// - `description` (required): Task description
331 /// - `priority`: Priority level (defaults to Medium)
332 /// - `due`: Optional due date
333 /// - `project_id`: Optional project association
334 /// - `tags`: Optional tags array
335 /// - `recurrence`: Optional recurrence pattern
336 ///
337 /// # Errors
338 ///
339 /// Returns `VALIDATION_ERROR` if description is empty.
340 /// Returns `DATABASE_ERROR` if the insert fails.
341 #[tauri::command]
342 #[instrument(skip_all)]
343 pub async fn create_task(state: State<'_, Arc<AppState>>, input: TaskInput) -> Result<TaskResponse, ApiError> {
344 if input.description.trim().is_empty() {
345 return Err(ApiError::validation("description", "Description is required"));
346 }
347
348 let priority = Priority::from_str_or_default(&input.priority);
349 let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None);
350 let tags = input.tags.unwrap_or_default();
351 let created_at = Utc::now();
352
353 let urgency = calculate_urgency(
354 &priority,
355 &TaskStatus::Pending,
356 input.due.as_ref(),
357 &created_at,
358 &tags,
359 );
360
361 let new_task = NewTask {
362 project_id: input.project_id,
363 description: input.description,
364 priority,
365 due: input.due,
366 tags,
367 recurrence,
368 urgency,
369 source_email_id: None,
370 scheduled_start: None,
371 scheduled_duration: None,
372 estimated_minutes: input.estimated_minutes,
373 contact_id: input.contact_id,
374 milestone_id: input.milestone_id,
375 recurrence_rule: input.recurrence_rule.clone(),
376 recurrence_parent_id: None,
377 };
378
379 new_task.validate()?;
380
381 let task = state.tasks.create(DESKTOP_USER_ID, new_task).await?;
382 Ok(TaskResponse::from(task))
383 }
384
385 /// Creates a task from natural language input.
386 ///
387 /// Parses quick-add syntax like:
388 /// - `Fix bug +work @today !high`
389 /// - `Call mom @tomorrow #family`
390 ///
391 /// # Errors
392 ///
393 /// Returns `VALIDATION_ERROR` if the parsed description is empty.
394 /// Returns `DATABASE_ERROR` if the insert fails.
395 #[tauri::command]
396 #[instrument(skip_all)]
397 pub async fn quick_add_task(state: State<'_, Arc<AppState>>, input: QuickAddInput) -> Result<TaskResponse, ApiError> {
398 let parsed = parse_quick_add(&input.text);
399
400 if parsed.description.trim().is_empty() {
401 return Err(ApiError::validation("text", "Task description is required"));
402 }
403
404 // Look up project by name if specified
405 let project_id = if let Some(project_name) = &parsed.project_name {
406 state.projects
407 .find_by_name(DESKTOP_USER_ID, project_name)
408 .await?
409 .map(|p| p.id)
410 } else {
411 None
412 };
413
414 let priority = parsed.priority.unwrap_or(Priority::Medium);
415 let recurrence = parsed.recurrence.unwrap_or(Recurrence::None);
416 let created_at = Utc::now();
417
418 let urgency = calculate_urgency(
419 &priority,
420 &TaskStatus::Pending,
421 parsed.due.as_ref(),
422 &created_at,
423 &parsed.tags,
424 );
425
426 let new_task = NewTask {
427 project_id,
428 description: parsed.description,
429 priority,
430 due: parsed.due,
431 tags: parsed.tags,
432 recurrence,
433 urgency,
434 source_email_id: None,
435 scheduled_start: None,
436 scheduled_duration: None,
437 estimated_minutes: None,
438 contact_id: None,
439 milestone_id: None,
440 recurrence_rule: None,
441 recurrence_parent_id: None,
442 };
443
444 new_task.validate()?;
445
446 let task = state.tasks.create(DESKTOP_USER_ID, new_task).await?;
447 Ok(TaskResponse::from(task))
448 }
449
450 /// Updates an existing task.
451 ///
452 /// # Errors
453 ///
454 /// Returns `VALIDATION_ERROR` if description is empty.
455 /// Returns `NOT_FOUND` if the task doesn't exist.
456 /// Returns `DATABASE_ERROR` if the update fails.
457 #[tauri::command]
458 #[instrument(skip_all)]
459 pub async fn update_task(state: State<'_, Arc<AppState>>, id: TaskId, input: TaskInput) -> Result<TaskResponse, ApiError> {
460 if input.description.trim().is_empty() {
461 return Err(ApiError::validation("description", "Description is required"));
462 }
463
464 let status = input.status.as_deref().map(TaskStatus::from_str_or_default).unwrap_or(TaskStatus::Pending);
465 let priority = Priority::from_str_or_default(&input.priority);
466 let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None);
467 let tags = input.tags.unwrap_or_default();
468
469 // Lightweight fetch for created_at + scheduling (avoids annotation/subtask/session sub-queries)
470 let ctx = state.tasks
471 .get_update_context(id, DESKTOP_USER_ID)
472 .await?
473 .or_not_found("task", id)?;
474
475 let urgency = calculate_urgency(
476 &priority,
477 &status,
478 input.due.as_ref(),
479 &ctx.created_at,
480 &tags,
481 );
482
483 let update_task = UpdateTask {
484 project_id: input.project_id,
485 description: input.description,
486 status,
487 priority,
488 due: input.due,
489 tags,
490 recurrence,
491 urgency,
492 scheduled_start: ctx.scheduled_start,
493 scheduled_duration: ctx.scheduled_duration,
494 estimated_minutes: input.estimated_minutes,
495 contact_id: input.contact_id,
496 milestone_id: input.milestone_id,
497 };
498
499 update_task.validate()?;
500
501 let task = state.tasks
502 .update(id, DESKTOP_USER_ID, update_task)
503 .await?
504 .or_not_found("task", id)?;
505
506 Ok(TaskResponse::from(task))
507 }
508
509 /// Soft-deletes a task by setting its status to Deleted.
510 ///
511 /// # Errors
512 ///
513 /// Returns `DATABASE_ERROR` if the update fails.
514 #[tauri::command]
515 #[instrument(skip_all)]
516 pub async fn delete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<bool, ApiError> {
517 Ok(state.tasks.delete(id, DESKTOP_USER_ID).await?)
518 }
519
520 /// Marks a task as started (in progress).
521 ///
522 /// Only works for tasks in Pending status.
523 ///
524 /// # Errors
525 ///
526 /// Returns `DATABASE_ERROR` if the update fails.
527 #[tauri::command]
528 #[instrument(skip_all)]
529 pub async fn start_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<bool, ApiError> {
530 Ok(state.tasks.start(id, DESKTOP_USER_ID).await?)
531 }
532
533 /// Marks a task as completed.
534 ///
535 /// For recurring tasks, this creates the next instance with an updated due date.
536 ///
537 /// # Returns
538 ///
539 /// - `completed`: Whether the task was marked complete
540 /// - `next_recurring_task`: The newly created next instance (for recurring tasks)
541 ///
542 /// # Errors
543 ///
544 /// Returns `DATABASE_ERROR` if the update or insert fails.
545 #[tauri::command]
546 #[instrument(skip_all)]
547 pub async fn complete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<CompleteTaskResponse, ApiError> {
548 // Auto-stop any running timer before completing
549 let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID).await;
550
551 // Read the task first to determine recurrence before completing
552 let task = match state.tasks.get_by_id(id, DESKTOP_USER_ID).await? {
553 Some(t) if t.status != TaskStatus::Completed => t,
554 _ => return Ok(CompleteTaskResponse { completed: false, next_recurring_task: None }),
555 };
556
557 // Build next recurring instance if needed
558 let next_new_task = if task.has_recurrence() {
559 let next_due = if let Some(ref rule) = task.recurrence_rule {
560 calculate_next_due_rich(task.due.as_ref(), rule)
561 } else {
562 calculate_next_due(task.due.as_ref(), &task.recurrence)
563 };
564 let created_at = Utc::now();
565 let fresh_urgency = calculate_urgency(
566 &task.priority,
567 &TaskStatus::Pending,
568 next_due.as_ref(),
569 &created_at,
570 &task.tags,
571 );
572 Some(NewTask {
573 project_id: task.project_id,
574 description: task.description.clone(),
575 priority: task.priority.clone(),
576 due: next_due,
577 tags: task.tags.clone(),
578 recurrence: task.recurrence.clone(),
579 urgency: fresh_urgency,
580 source_email_id: None,
581 scheduled_start: None,
582 scheduled_duration: None,
583 estimated_minutes: task.estimated_minutes,
584 contact_id: task.contact_id,
585 milestone_id: task.milestone_id,
586 recurrence_rule: task.recurrence_rule.clone(),
587 recurrence_parent_id: Some(task.recurrence_parent_id.unwrap_or(task.id)),
588 })
589 } else {
590 None
591 };
592
593 // Atomically complete + create next instance in a single transaction
594 let (_completed, next_task) = state.tasks.complete_recurring(id, DESKTOP_USER_ID, next_new_task).await?;
595
596 // Auto-complete milestone if all tasks in it are done.
597 // Note: recurring tasks create a new Pending task above, so the count
598 // check naturally prevents auto-complete when a task recurs.
599 if let Some(milestone_id) = task.milestone_id {
600 let remaining = state.tasks.count_incomplete_by_milestone(milestone_id, DESKTOP_USER_ID).await?;
601 if remaining == 0 {
602 if let Some(ms) = state.milestones.get_by_id(milestone_id, DESKTOP_USER_ID).await? {
603 state.milestones.update(
604 milestone_id, DESKTOP_USER_ID,
605 &ms.name, &ms.description, ms.target_date,
606 &MilestoneStatus::Completed,
607 ).await?;
608 }
609 }
610 }
611
612 Ok(CompleteTaskResponse {
613 completed: true,
614 next_recurring_task: next_task.map(TaskResponse::from),
615 })
616 }
617
618 // ============ Task Overview ============
619
620 /// A completed instance in a recurrence chain (lightweight).
621 #[derive(Debug, Serialize)]
622 #[serde(rename_all = "camelCase")]
623 pub struct RecurrenceInstance {
624 pub id: TaskId,
625 pub status: String,
626 pub completed_at: Option<DateTime<Utc>>,
627 pub due: Option<DateTime<Utc>>,
628 pub actual_minutes: i32,
629 pub created_at: DateTime<Utc>,
630 }
631
632 /// Streak and completion rate stats for a recurring task.
633 #[derive(Debug, Serialize)]
634 #[serde(rename_all = "camelCase")]
635 pub struct StreakInfo {
636 pub current_streak: u32,
637 pub best_streak: u32,
638 pub total_completed: u32,
639 pub total_instances: u32,
640 pub completion_rate_30d: f64,
641 }
642
643 /// Full task overview response.
644 #[derive(Debug, Serialize)]
645 #[serde(rename_all = "camelCase")]
646 pub struct TaskOverviewResponse {
647 pub task: TaskResponse,
648 pub time_sessions: Vec<goingson_core::TimeSession>,
649 pub recurrence_chain: Vec<RecurrenceInstance>,
650 pub streak: Option<StreakInfo>,
651 }
652
653 /// Gets comprehensive task overview data.
654 #[tauri::command]
655 #[instrument(skip_all)]
656 pub async fn get_task_overview(
657 state: State<'_, Arc<AppState>>,
658 id: TaskId,
659 ) -> Result<TaskOverviewResponse, ApiError> {
660 let (task, sessions) = tokio::join!(
661 state.tasks.get_by_id(id, DESKTOP_USER_ID),
662 state.tasks.list_time_sessions(id, DESKTOP_USER_ID),
663 );
664 let task = task?.or_not_found("task", id)?;
665 let sessions = sessions?;
666
667 let (chain, streak) = if task.has_recurrence() || task.recurrence_parent_id.is_some() {
668 let root_id = task.recurrence_parent_id.unwrap_or(task.id);
669 let chain_tasks = state.tasks.list_recurrence_chain(root_id, DESKTOP_USER_ID).await?;
670
671 let instances: Vec<RecurrenceInstance> = chain_tasks.iter().map(|t| RecurrenceInstance {
672 id: t.id,
673 status: t.status.as_str().to_string(),
674 completed_at: t.completed_at,
675 due: t.due,
676 actual_minutes: t.actual_minutes,
677 created_at: t.created_at,
678 }).collect();
679
680 let streak = compute_streak(&chain_tasks);
681 (instances, Some(streak))
682 } else {
683 (Vec::new(), None)
684 };
685
686 Ok(TaskOverviewResponse {
687 task: TaskResponse::from(task),
688 time_sessions: sessions,
689 recurrence_chain: chain,
690 streak,
691 })
692 }
693
694 /// Compute streak stats from a recurrence chain (sorted by created_at DESC).
695 fn compute_streak(chain: &[Task]) -> StreakInfo {
696 let total_instances = chain.len() as u32;
697 let total_completed = chain.iter().filter(|t| t.status == TaskStatus::Completed).count() as u32;
698
699 // Sort by due date (or created_at) ascending for streak calculation
700 let mut sorted: Vec<&Task> = chain.iter().collect();
701 sorted.sort_by_key(|t| t.due.unwrap_or(t.created_at));
702
703 let mut current_streak: u32 = 0;
704 let mut best_streak: u32 = 0;
705 let mut running: u32 = 0;
706
707 for t in &sorted {
708 if t.status == TaskStatus::Completed {
709 running += 1;
710 if running > best_streak {
711 best_streak = running;
712 }
713 } else {
714 running = 0;
715 }
716 }
717
718 // Current streak: count from the end backwards
719 for t in sorted.iter().rev() {
720 if t.status == TaskStatus::Completed {
721 current_streak += 1;
722 } else {
723 // Skip the current pending instance (the active one)
724 if t.status == TaskStatus::Pending || t.status == TaskStatus::Started {
725 continue;
726 }
727 break;
728 }
729 }
730
731 // 30-day completion rate
732 let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
733 let recent: Vec<&&Task> = sorted.iter()
734 .filter(|t| t.created_at >= thirty_days_ago)
735 .collect();
736 let recent_completed = recent.iter().filter(|t| t.status == TaskStatus::Completed).count();
737 let completion_rate_30d = if recent.is_empty() {
738 0.0
739 } else {
740 (recent_completed as f64 / recent.len() as f64) * 100.0
741 };
742
743 StreakInfo {
744 current_streak,
745 best_streak,
746 total_completed,
747 total_instances,
748 completion_rate_30d,
749 }
750 }
751
752 // ============ Project Dashboard Commands ============
753
754 /// Lists all tasks for a specific project.
755 ///
756 /// # Errors
757 ///
758 /// Returns `DATABASE_ERROR` if the query fails.
759 #[tauri::command]
760 #[instrument(skip_all)]
761 pub async fn list_tasks_for_project(state: State<'_, Arc<AppState>>, project_id: ProjectId) -> Result<Vec<TaskResponse>, ApiError> {
762 let mut tasks = state.tasks.list_by_project(DESKTOP_USER_ID, project_id).await?;
763 // Pre-sort by urgency DESC so JS doesn't need to sort
764 tasks.sort_by(|a, b| b.urgency.partial_cmp(&a.urgency).unwrap_or(std::cmp::Ordering::Equal));
765 Ok(tasks.into_iter().map(TaskResponse::from).collect())
766 }
767