| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 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 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
#[derive(Debug, Deserialize)] |
| 38 |
#[serde(rename_all = "camelCase")] |
| 39 |
pub struct TaskInput { |
| 40 |
|
| 41 |
pub project_id: Option<ProjectId>, |
| 42 |
|
| 43 |
pub description: String, |
| 44 |
|
| 45 |
pub status: Option<String>, |
| 46 |
|
| 47 |
pub priority: String, |
| 48 |
|
| 49 |
pub due: Option<DateTime<Utc>>, |
| 50 |
|
| 51 |
pub tags: Option<Vec<String>>, |
| 52 |
|
| 53 |
pub recurrence: Option<String>, |
| 54 |
|
| 55 |
pub contact_id: Option<ContactId>, |
| 56 |
|
| 57 |
pub milestone_id: Option<MilestoneId>, |
| 58 |
|
| 59 |
pub estimated_minutes: Option<i32>, |
| 60 |
|
| 61 |
pub recurrence_rule: Option<RecurrenceRule>, |
| 62 |
} |
| 63 |
|
| 64 |
|
| 65 |
|
| 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 |
|
| 94 |
pub status_tokens: Vec<StatusToken>, |
| 95 |
|
| 96 |
|
| 97 |
pub token_summary: String, |
| 98 |
pub created_at: DateTime<Utc>, |
| 99 |
|
| 100 |
pub is_focus: bool, |
| 101 |
|
| 102 |
pub focus_set_at: Option<DateTime<Utc>>, |
| 103 |
|
| 104 |
pub estimated_minutes: Option<i32>, |
| 105 |
|
| 106 |
pub actual_minutes: i32, |
| 107 |
|
| 108 |
pub time_progress: Option<u8>, |
| 109 |
|
| 110 |
pub is_over_estimate: bool, |
| 111 |
|
| 112 |
pub timer_active: bool, |
| 113 |
|
| 114 |
pub timer_started_at: Option<DateTime<Utc>>, |
| 115 |
|
| 116 |
|
| 117 |
pub is_snoozed: bool, |
| 118 |
|
| 119 |
pub is_overdue: bool, |
| 120 |
|
| 121 |
pub subtask_count: usize, |
| 122 |
|
| 123 |
pub subtask_completed: usize, |
| 124 |
|
| 125 |
pub urgency_class: String, |
| 126 |
|
| 127 |
pub subtask_progress: Option<u8>, |
| 128 |
|
| 129 |
pub due_formatted: Option<String>, |
| 130 |
|
| 131 |
pub snoozed_until_formatted: Option<String>, |
| 132 |
} |
| 133 |
|
| 134 |
impl From<Task> for TaskResponse { |
| 135 |
fn from(t: Task) -> Self { |
| 136 |
|
| 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 |
|
| 142 |
|
| 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 |
|
| 223 |
|
| 224 |
#[derive(Debug, Default, Deserialize)] |
| 225 |
#[serde(rename_all = "camelCase")] |
| 226 |
pub struct TaskFilterInput { |
| 227 |
|
| 228 |
pub status: Option<String>, |
| 229 |
|
| 230 |
pub project_id: Option<ProjectId>, |
| 231 |
|
| 232 |
pub milestone_id: Option<MilestoneId>, |
| 233 |
|
| 234 |
pub priority: Option<String>, |
| 235 |
|
| 236 |
#[serde(default)] |
| 237 |
pub show_snoozed: bool, |
| 238 |
|
| 239 |
#[serde(default)] |
| 240 |
pub waiting_only: bool, |
| 241 |
|
| 242 |
pub offset: Option<i64>, |
| 243 |
|
| 244 |
pub limit: Option<i64>, |
| 245 |
|
| 246 |
pub sort_column: Option<String>, |
| 247 |
|
| 248 |
pub sort_direction: Option<String>, |
| 249 |
} |
| 250 |
|
| 251 |
|
| 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 |
|
| 260 |
|
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
|
| 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 |
|
| 276 |
|
| 277 |
|
| 278 |
|
| 279 |
|
| 280 |
|
| 281 |
|
| 282 |
|
| 283 |
|
| 284 |
|
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
|
| 289 |
|
| 290 |
|
| 291 |
|
| 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 |
|
| 322 |
|
| 323 |
|
| 324 |
|
| 325 |
|
| 326 |
|
| 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 |
|
| 335 |
|
| 336 |
|
| 337 |
|
| 338 |
|
| 339 |
|
| 340 |
|
| 341 |
|
| 342 |
|
| 343 |
|
| 344 |
|
| 345 |
|
| 346 |
|
| 347 |
|
| 348 |
|
| 349 |
|
| 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 |
|
| 395 |
|
| 396 |
|
| 397 |
|
| 398 |
|
| 399 |
|
| 400 |
|
| 401 |
|
| 402 |
|
| 403 |
|
| 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 |
|
| 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 |
|
| 460 |
|
| 461 |
|
| 462 |
|
| 463 |
|
| 464 |
|
| 465 |
|
| 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 |
|
| 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 |
|
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
|
| 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 |
|
| 531 |
|
| 532 |
|
| 533 |
|
| 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 |
|
| 545 |
|
| 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 |
|
| 557 |
|
| 558 |
|
| 559 |
|
| 560 |
|
| 561 |
|
| 562 |
|
| 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 |
|
| 570 |
|
| 571 |
|
| 572 |
|
| 573 |
|
| 574 |
|
| 575 |
|
| 576 |
|
| 577 |
|
| 578 |
|
| 579 |
|
| 580 |
|
| 581 |
#[tauri::command] |
| 582 |
#[instrument(skip_all)] |
| 583 |
pub async fn complete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<CompleteTaskResponse, ApiError> { |
| 584 |
|
| 585 |
let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID).await; |
| 586 |
|
| 587 |
|
| 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 |
|
| 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 |
|
| 631 |
let (_completed, next_task) = state.tasks.complete_recurring(id, DESKTOP_USER_ID, next_new_task).await?; |
| 632 |
|
| 633 |
|
| 634 |
|
| 635 |
|
| 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 |
|
| 655 |
|
| 656 |
|
| 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 |
|
| 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 |
|
| 680 |
#[derive(Debug, Serialize)] |
| 681 |
#[serde(rename_all = "camelCase")] |
| 682 |
pub struct HeatmapBucket { |
| 683 |
|
| 684 |
pub date: String, |
| 685 |
|
| 686 |
pub count: u32, |
| 687 |
} |
| 688 |
|
| 689 |
|
| 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 |
|
| 698 |
|
| 699 |
pub completion_buckets: Vec<HeatmapBucket>, |
| 700 |
} |
| 701 |
|
| 702 |
|
| 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 |
|
| 747 |
|
| 748 |
|
| 749 |
|
| 750 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 788 |
for t in sorted.iter().rev() { |
| 789 |
if t.status == TaskStatus::Completed { |
| 790 |
current_streak += 1; |
| 791 |
} else { |
| 792 |
|
| 793 |
if t.status == TaskStatus::Pending || t.status == TaskStatus::Started { |
| 794 |
continue; |
| 795 |
} |
| 796 |
break; |
| 797 |
} |
| 798 |
} |
| 799 |
|
| 800 |
|
| 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 |
|
| 822 |
|
| 823 |
|
| 824 |
|
| 825 |
|
| 826 |
|
| 827 |
|
| 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 |
|
| 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 |
|
| 857 |
|
| 858 |
|
| 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 |
|
| 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 |
|