| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 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 |
|
| 31 |
|
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
#[derive(Debug, Deserialize)] |
| 37 |
#[serde(rename_all = "camelCase")] |
| 38 |
pub struct TaskInput { |
| 39 |
|
| 40 |
pub project_id: Option<ProjectId>, |
| 41 |
|
| 42 |
pub description: String, |
| 43 |
|
| 44 |
pub status: Option<String>, |
| 45 |
|
| 46 |
pub priority: String, |
| 47 |
|
| 48 |
pub due: Option<DateTime<Utc>>, |
| 49 |
|
| 50 |
pub tags: Option<Vec<String>>, |
| 51 |
|
| 52 |
pub recurrence: Option<String>, |
| 53 |
|
| 54 |
pub contact_id: Option<ContactId>, |
| 55 |
|
| 56 |
pub milestone_id: Option<MilestoneId>, |
| 57 |
|
| 58 |
pub estimated_minutes: Option<i32>, |
| 59 |
|
| 60 |
pub recurrence_rule: Option<RecurrenceRule>, |
| 61 |
} |
| 62 |
|
| 63 |
|
| 64 |
|
| 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 |
|
| 94 |
pub is_focus: bool, |
| 95 |
|
| 96 |
pub focus_set_at: Option<DateTime<Utc>>, |
| 97 |
|
| 98 |
pub estimated_minutes: Option<i32>, |
| 99 |
|
| 100 |
pub actual_minutes: i32, |
| 101 |
|
| 102 |
pub time_progress: Option<u8>, |
| 103 |
|
| 104 |
pub is_over_estimate: bool, |
| 105 |
|
| 106 |
pub timer_active: bool, |
| 107 |
|
| 108 |
pub timer_started_at: Option<DateTime<Utc>>, |
| 109 |
|
| 110 |
|
| 111 |
pub is_snoozed: bool, |
| 112 |
|
| 113 |
pub is_overdue: bool, |
| 114 |
|
| 115 |
pub subtask_count: usize, |
| 116 |
|
| 117 |
pub subtask_completed: usize, |
| 118 |
|
| 119 |
pub urgency_class: String, |
| 120 |
|
| 121 |
pub subtask_progress: Option<u8>, |
| 122 |
|
| 123 |
pub due_formatted: Option<String>, |
| 124 |
|
| 125 |
pub snoozed_until_formatted: Option<String>, |
| 126 |
} |
| 127 |
|
| 128 |
impl From<Task> for TaskResponse { |
| 129 |
fn from(t: Task) -> Self { |
| 130 |
|
| 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 |
|
| 136 |
|
| 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 |
|
| 214 |
|
| 215 |
#[derive(Debug, Default, Deserialize)] |
| 216 |
#[serde(rename_all = "camelCase")] |
| 217 |
pub struct TaskFilterInput { |
| 218 |
|
| 219 |
pub status: Option<String>, |
| 220 |
|
| 221 |
pub project_id: Option<ProjectId>, |
| 222 |
|
| 223 |
pub milestone_id: Option<MilestoneId>, |
| 224 |
|
| 225 |
pub priority: Option<String>, |
| 226 |
|
| 227 |
#[serde(default)] |
| 228 |
pub show_snoozed: bool, |
| 229 |
|
| 230 |
#[serde(default)] |
| 231 |
pub waiting_only: bool, |
| 232 |
|
| 233 |
pub offset: Option<i64>, |
| 234 |
|
| 235 |
pub limit: Option<i64>, |
| 236 |
|
| 237 |
pub sort_column: Option<String>, |
| 238 |
|
| 239 |
pub sort_direction: Option<String>, |
| 240 |
} |
| 241 |
|
| 242 |
|
| 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 |
|
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
|
| 256 |
|
| 257 |
|
| 258 |
|
| 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 |
|
| 267 |
|
| 268 |
|
| 269 |
|
| 270 |
|
| 271 |
|
| 272 |
|
| 273 |
|
| 274 |
|
| 275 |
|
| 276 |
|
| 277 |
|
| 278 |
|
| 279 |
|
| 280 |
|
| 281 |
|
| 282 |
|
| 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 |
|
| 313 |
|
| 314 |
|
| 315 |
|
| 316 |
|
| 317 |
|
| 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 |
|
| 326 |
|
| 327 |
|
| 328 |
|
| 329 |
|
| 330 |
|
| 331 |
|
| 332 |
|
| 333 |
|
| 334 |
|
| 335 |
|
| 336 |
|
| 337 |
|
| 338 |
|
| 339 |
|
| 340 |
|
| 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 |
|
| 386 |
|
| 387 |
|
| 388 |
|
| 389 |
|
| 390 |
|
| 391 |
|
| 392 |
|
| 393 |
|
| 394 |
|
| 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 |
|
| 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 |
|
| 451 |
|
| 452 |
|
| 453 |
|
| 454 |
|
| 455 |
|
| 456 |
|
| 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 |
|
| 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 |
|
| 510 |
|
| 511 |
|
| 512 |
|
| 513 |
|
| 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 |
|
| 521 |
|
| 522 |
|
| 523 |
|
| 524 |
|
| 525 |
|
| 526 |
|
| 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 |
|
| 534 |
|
| 535 |
|
| 536 |
|
| 537 |
|
| 538 |
|
| 539 |
|
| 540 |
|
| 541 |
|
| 542 |
|
| 543 |
|
| 544 |
|
| 545 |
#[tauri::command] |
| 546 |
#[instrument(skip_all)] |
| 547 |
pub async fn complete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result<CompleteTaskResponse, ApiError> { |
| 548 |
|
| 549 |
let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID).await; |
| 550 |
|
| 551 |
|
| 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 |
|
| 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 |
|
| 594 |
let (_completed, next_task) = state.tasks.complete_recurring(id, DESKTOP_USER_ID, next_new_task).await?; |
| 595 |
|
| 596 |
|
| 597 |
|
| 598 |
|
| 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 |
|
| 619 |
|
| 620 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 719 |
for t in sorted.iter().rev() { |
| 720 |
if t.status == TaskStatus::Completed { |
| 721 |
current_streak += 1; |
| 722 |
} else { |
| 723 |
|
| 724 |
if t.status == TaskStatus::Pending || t.status == TaskStatus::Started { |
| 725 |
continue; |
| 726 |
} |
| 727 |
break; |
| 728 |
} |
| 729 |
} |
| 730 |
|
| 731 |
|
| 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 |
|
| 753 |
|
| 754 |
|
| 755 |
|
| 756 |
|
| 757 |
|
| 758 |
|
| 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 |
|
| 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 |
|