Skip to main content

max / goingson

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