Skip to main content

max / goingson

Audit remediation: observability, adversarial fixes, JS tests, doc comments Multi-round audit improvements: - Observability A: 195 #[instrument(skip_all)] annotations across all commands, schedulers, email clients, OAuth, JMAP, state, db_watcher, notifications - Adversarial fixes: Validate trait wired into commands, email HTML sanitized, LLM typed errors, export path traversal guard, IMAP timeout hardening - JS test infrastructure: 48 automated tests covering AppStateManager, utility functions (escapeHtml, escapeAttr, validateEmail, parseEmailAddress, debounce), PaginationManager, and SelectionManager - Code documentation: module-level docs, public function docs, README - Concurrency: graceful shutdown with CancellationToken, explicit HTTP timeouts on all clients (JMAP 30s, OAuth 15s, IMAP 30s) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:35 UTC
Commit: 7efdd78d199229a2c786d17ca4e4b98c0a960d83
Parent: 90f959e
57 files changed, +1693 insertions, -122 deletions
M Cargo.lock +9 -6
@@ -1771,7 +1771,7 @@ dependencies = [
1771 1771
1772 1772 [[package]]
1773 1773 name = "goingson-core"
1774 - version = "0.1.0"
1774 + version = "0.2.1"
1775 1775 dependencies = [
1776 1776 "async-trait",
1777 1777 "chrono",
@@ -1786,7 +1786,7 @@ dependencies = [
1786 1786
1787 1787 [[package]]
1788 1788 name = "goingson-db-sqlite"
1789 - version = "0.1.0"
1789 + version = "0.2.1"
1790 1790 dependencies = [
1791 1791 "argon2",
1792 1792 "async-trait",
@@ -1801,7 +1801,7 @@ dependencies = [
1801 1801
1802 1802 [[package]]
1803 1803 name = "goingson-desktop"
1804 - version = "0.1.0"
1804 + version = "0.2.1"
1805 1805 dependencies = [
1806 1806 "async-imap",
1807 1807 "async-trait",
@@ -1850,7 +1850,7 @@ dependencies = [
1850 1850
1851 1851 [[package]]
1852 1852 name = "goingson-mcp"
1853 - version = "0.1.0"
1853 + version = "0.2.1"
1854 1854 dependencies = [
1855 1855 "chrono",
1856 1856 "dirs",
@@ -1869,7 +1869,7 @@ dependencies = [
1869 1869
1870 1870 [[package]]
1871 1871 name = "goingson-plugin-runtime"
1872 - version = "0.1.0"
1872 + version = "0.2.1"
1873 1873 dependencies = [
1874 1874 "async-trait",
1875 1875 "chrono",
@@ -5386,13 +5386,15 @@ dependencies = [
5386 5386
5387 5387 [[package]]
5388 5388 name = "synckit-client"
5389 - version = "0.2.0"
5389 + version = "0.2.1"
5390 5390 dependencies = [
5391 5391 "argon2",
5392 5392 "base64 0.22.1",
5393 + "bytes",
5393 5394 "chacha20poly1305",
5394 5395 "chrono",
5395 5396 "keyring",
5397 + "parking_lot",
5396 5398 "rand 0.8.5",
5397 5399 "reqwest 0.12.28",
5398 5400 "serde",
@@ -5401,6 +5403,7 @@ dependencies = [
5401 5403 "thiserror 1.0.69",
5402 5404 "tokio",
5403 5405 "tracing",
5406 + "unicode-normalization",
5404 5407 "urlencoding",
5405 5408 "uuid",
5406 5409 ]
A README.md +65
@@ -0,0 +1,65 @@
1 + # GoingsOn
2 +
3 + A desktop productivity app -- tasks, email, calendar, contacts, and project management in one place. Built with Tauri 2, Rust, and vanilla JS.
4 +
5 + ## Prerequisites
6 +
7 + - **Rust** (stable toolchain, 2021 edition)
8 + - **Tauri 2 CLI** (`cargo install tauri-cli --version '^2'`)
9 + - **Linux only:** system dependencies for WebKitGTK
10 + ```
11 + # Debian/Ubuntu
12 + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
13 + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
14 +
15 + # Arch
16 + sudo pacman -S webkit2gtk-4.1 base-devel curl wget file openssl \
17 + appmenu-gtk-module libappindicator-gtk3 librsvg2-dev
18 + ```
19 + - **macOS / Windows:** no extra system dependencies beyond Rust and the Tauri CLI.
20 +
21 + ## Build and Run
22 +
23 + ```sh
24 + # Development (hot-reload frontend, debug backend)
25 + cargo tauri dev
26 +
27 + # Production build (macOS DMG, Windows installer, Linux AppImage)
28 + cargo tauri build
29 +
30 + # Run all workspace tests
31 + cargo test --workspace
32 + ```
33 +
34 + ## Workspace Architecture
35 +
36 + The project is a Cargo workspace with four library crates and one application crate:
37 +
38 + | Crate | Path | Role |
39 + |-------|------|------|
40 + | `goingson-core` | `crates/core/` | Domain models, repository traits, error types, business logic. No database dependency (optional sqlx feature for type derives). |
41 + | `goingson-db-sqlite` | `crates/db-sqlite/` | SQLite persistence via sqlx. Repository implementations, FTS5 full-text search, migrations. |
42 + | `goingson-plugin-runtime` | `crates/plugin-runtime/` | Rhai scripting engine for import plugins (CSV, custom formats). File watching for hot-reload. |
43 + | `goingson-mcp` | `crates/goingson-mcp/` | MCP server binary for Claude Desktop integration. Task management tools over stdio transport (rmcp). |
44 + | `goingson-desktop` | `src-tauri/` | Tauri 2 desktop shell. Commands (thin wrappers over library crates), frontend (vanilla HTML/CSS/JS), OAuth flows, email sync, SyncKit integration. |
45 +
46 + Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `core` -> `mcp` depends on `core` + `db-sqlite` -> `src-tauri` depends on all four plus `synckit-client`.
47 +
48 + ## Features
49 +
50 + - **Tasks** -- urgency scoring, recurrence, snooze, subtasks, day planning (time blocking), weekly review
51 + - **Email** -- IMAP/SMTP and Fastmail JMAP, OAuth (Google, Microsoft, Fastmail), threaded display, compose/reply
52 + - **Calendar** -- events with recurrence, project/contact linking, timeline view
53 + - **Contacts** -- multi-field (emails, phones, social handles), tags, search
54 + - **Projects** -- per-project dashboards (tasks + events + emails), milestones
55 + - **Search** -- FTS5 full-text search across all entity types
56 + - **Cloud sync** -- SyncKit integration with E2E encryption
57 + - **Plugins** -- Rhai scripting for CSV/data import
58 + - **MCP server** -- manage tasks from Claude Desktop
59 + - **Themes** -- 5 built-in themes (light and dark), system auto-detection
60 + - **Keyboard shortcuts** -- vim-style navigation throughout
61 + - **Platforms** -- macOS (primary), Windows, Linux; iOS in development
62 +
63 + ## License
64 +
65 + PolyForm Noncommercial 1.0.0
@@ -1,4 +1,10 @@
1 1 //! Email domain types and DTOs.
2 + //!
3 + //! Emails are synced from IMAP accounts and threaded using RFC 2822 Message-ID
4 + //! and In-Reply-To headers. The thread model groups related messages under a
5 + //! shared `thread_id` derived from the originating Message-ID. Emails support
6 + //! project linking, snoozing, and waiting-for-response tracking. JMAP-style
7 + //! thread aggregation is available via `EmailThread` for efficient list rendering.
2 8
3 9 use chrono::{DateTime, Utc};
4 10 use serde::{Deserialize, Serialize};
@@ -95,18 +101,22 @@ impl Email {
95 101 }
96 102 }
97 103
104 + /// Returns true if the email is associated with a project.
98 105 pub fn has_project(&self) -> bool {
99 106 self.project_name.is_some()
100 107 }
101 108
109 + /// Returns the project name, or an empty string if unset.
102 110 pub fn project_name_or_empty(&self) -> &str {
103 111 self.project_name.as_deref().unwrap_or("")
104 112 }
105 113
114 + /// Returns the read status as a string literal ("true" or "false") for HTML data attributes.
106 115 pub fn is_read_str(&self) -> &'static str {
107 116 if self.is_read { "true" } else { "false" }
108 117 }
109 118
119 + /// Returns the archived status as a string literal ("true" or "false") for HTML data attributes.
110 120 pub fn is_archived_str(&self) -> &'static str {
111 121 if self.is_archived { "true" } else { "false" }
112 122 }
@@ -1,4 +1,9 @@
1 1 //! Event domain types and DTOs.
2 + //!
3 + //! Events represent calendar entries that can be standalone or linked to a task
4 + //! for time-blocking. They support recurrence patterns (Daily, Weekly, Monthly),
5 + //! optional project and contact associations, and block-type classification
6 + //! (focus, meeting, break, etc.).
2 7
3 8 use chrono::{DateTime, Utc};
4 9 use serde::{Deserialize, Serialize};
@@ -77,38 +82,47 @@ impl Event {
77 82 }
78 83 }
79 84
85 + /// Returns the zero-padded day of the month (e.g., "07", "15").
80 86 pub fn day_number(&self) -> String {
81 87 self.start_time.format("%d").to_string()
82 88 }
83 89
90 + /// Returns the start time as a Unix timestamp (seconds since epoch).
84 91 pub fn timestamp(&self) -> i64 {
85 92 self.start_time.timestamp()
86 93 }
87 94
95 + /// Returns true if the event has a location set.
88 96 pub fn has_location(&self) -> bool {
89 97 self.location.is_some()
90 98 }
91 99
100 + /// Returns the location string, or an empty string if unset.
92 101 pub fn location_or_empty(&self) -> &str {
93 102 self.location.as_deref().unwrap_or("")
94 103 }
95 104
105 + /// Returns true if the event is associated with a project.
96 106 pub fn has_project(&self) -> bool {
97 107 self.project_name.is_some()
98 108 }
99 109
110 + /// Returns the project name, or an empty string if unset.
100 111 pub fn project_name_or_empty(&self) -> &str {
101 112 self.project_name.as_deref().unwrap_or("")
102 113 }
103 114
115 + /// Returns true if the event has a non-empty description.
104 116 pub fn has_description(&self) -> bool {
105 117 !self.description.is_empty()
106 118 }
107 119
120 + /// Returns true if the event has a recurrence pattern set (not `None`).
108 121 pub fn has_recurrence(&self) -> bool {
109 122 self.recurrence != Recurrence::None
110 123 }
111 124
125 + /// Returns true if this event is a time-block linked to a task.
112 126 pub fn is_linked_to_task(&self) -> bool {
113 127 self.linked_task_id.is_some()
114 128 }
@@ -119,31 +133,52 @@ impl Event {
119 133 /// Data for creating a new event.
120 134 #[derive(Debug, Clone, Serialize, Deserialize)]
121 135 pub struct NewEvent {
136 + /// Owner user ID (set by the command layer for desktop).
122 137 pub user_id: Option<UserId>,
138 + /// Associated project, if any.
123 139 pub project_id: Option<ProjectId>,
140 + /// Associated contact, if any.
124 141 pub contact_id: Option<ContactId>,
142 + /// Event title (required, validated non-empty).
125 143 pub title: String,
144 + /// Event description or notes.
126 145 pub description: String,
146 + /// When the event starts.
127 147 pub start_time: DateTime<Utc>,
148 + /// When the event ends (optional for all-day or open-ended events).
128 149 pub end_time: Option<DateTime<Utc>>,
150 + /// Location (physical address or video link).
129 151 pub location: Option<String>,
152 + /// Linked task ID for time-blocking (set programmatically, not via form).
130 153 pub linked_task_id: Option<TaskId>,
154 + /// Recurrence pattern (None, Daily, Weekly, Monthly).
131 155 pub recurrence: Recurrence,
156 + /// Block type classification (focus, meeting, break, etc.).
132 157 pub block_type: Option<BlockType>,
133 158 }
134 159
135 160 /// Data for updating an existing event.
136 161 #[derive(Debug, Clone, Serialize, Deserialize)]
137 162 pub struct UpdateEvent {
163 + /// Associated project, if any.
138 164 pub project_id: Option<ProjectId>,
165 + /// Associated contact, if any.
139 166 pub contact_id: Option<ContactId>,
167 + /// Event title (required, validated non-empty).
140 168 pub title: String,
169 + /// Event description or notes.
141 170 pub description: String,
171 + /// When the event starts.
142 172 pub start_time: DateTime<Utc>,
173 + /// When the event ends (optional for all-day or open-ended events).
143 174 pub end_time: Option<DateTime<Utc>>,
175 + /// Location (physical address or video link).
144 176 pub location: Option<String>,
177 + /// Linked task ID for time-blocking (preserved from the existing event on update).
145 178 pub linked_task_id: Option<TaskId>,
179 + /// Recurrence pattern (None, Daily, Weekly, Monthly).
146 180 pub recurrence: Recurrence,
181 + /// Block type classification (focus, meeting, break, etc.).
147 182 pub block_type: Option<BlockType>,
148 183 }
149 184
@@ -1,4 +1,9 @@
1 1 //! Project domain types and DTOs.
2 + //!
3 + //! Projects group related tasks, events, and emails under a single umbrella.
4 + //! Each project has a type classification (Job, SideProject, Company, Essay,
5 + //! Article, Painting, Other) and a lifecycle status (Active, OnHold, Completed,
6 + //! Archived). Projects can contain milestones for phased tracking.
2 7
3 8 use chrono::{DateTime, Utc};
4 9 use serde::{Deserialize, Serialize};
@@ -1,4 +1,8 @@
1 1 //! Saved view types and DTOs.
2 + //!
3 + //! Saved views are user-defined filter/sort configurations that can be pinned
4 + //! to the sidebar for quick access. Each view targets a specific domain
5 + //! (tasks, emails, or events) and stores its filter criteria as JSON.
2 6
3 7 use chrono::{DateTime, Utc};
4 8 use serde::{Deserialize, Serialize};
@@ -60,11 +64,17 @@ impl DbValue for ViewType {
60 64 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61 65 #[serde(rename_all = "snake_case")]
62 66 pub enum SortField {
67 + /// Sort by calculated urgency score (tasks only).
63 68 Urgency,
69 + /// Sort by due date (tasks) or received date (emails). Nulls sort last.
64 70 Due,
71 + /// Sort by creation timestamp.
65 72 CreatedAt,
73 + /// Sort alphabetically by description (tasks) or subject (emails) or title (events).
66 74 Description,
75 + /// Sort by priority level (High > Medium > Low, tasks only).
67 76 Priority,
77 + /// Sort alphabetically by associated project name. Nulls sort last.
68 78 Project,
69 79 }
70 80
@@ -1,4 +1,11 @@
1 1 //! Task domain types and DTOs.
2 + //!
3 + //! Tasks are the primary work unit in GoingsOn. Each task carries a priority,
4 + //! an urgency score computed from priority/due date/age/tags, and optional
5 + //! recurrence (Daily, Weekly, Monthly). Tasks can be snoozed to temporarily
6 + //! hide them, marked as waiting-for-response, and scheduled into time blocks.
7 + //! Subtasks provide checklist items and can link to other tasks for multi-phase
8 + //! workflows.
2 9
3 10 use chrono::{DateTime, Utc};
4 11 use serde::{Deserialize, Serialize};
@@ -274,22 +281,30 @@ impl Task {
274 281 self.annotations.len()
275 282 }
276 283
284 + /// Returns true if the task has any annotations attached.
277 285 pub fn has_annotations(&self) -> bool {
278 286 !self.annotations.is_empty()
279 287 }
280 288
289 + /// Returns true if the task has a recurrence pattern set (not `None`).
281 290 pub fn has_recurrence(&self) -> bool {
282 291 self.recurrence != Recurrence::None
283 292 }
284 293
294 + /// Returns the project name, or `"-"` if unset. The dash fallback is used
295 + /// for display in table views where an empty cell would look broken.
285 296 pub fn project_name_or_dash(&self) -> &str {
286 297 self.project_name.as_deref().unwrap_or("-")
287 298 }
288 299
300 + /// Returns the project name, or an empty string if unset.
289 301 pub fn project_name_or_empty(&self) -> &str {
290 302 self.project_name.as_deref().unwrap_or("")
291 303 }
292 304
305 + /// Returns the due date as a Unix timestamp (seconds since epoch).
306 + /// Returns 0 (Unix epoch) when no due date is set, which sorts
307 + /// undated tasks to the beginning in timestamp-based ordering.
293 308 pub fn due_timestamp(&self) -> i64 {
294 309 self.due.map(|d| d.timestamp()).unwrap_or(0)
295 310 }
@@ -434,18 +449,29 @@ pub struct TaskFilterQuery {
434 449 /// Data for creating a new task.
435 450 #[derive(Debug, Clone, Serialize, Deserialize)]
436 451 pub struct NewTask {
452 + /// Associated project, if any.
437 453 pub project_id: Option<ProjectId>,
454 + /// Target milestone within the project, if any.
438 455 pub milestone_id: Option<MilestoneId>,
456 + /// Associated contact, if any.
439 457 pub contact_id: Option<ContactId>,
458 + /// Task description/title (required, validated non-empty).
440 459 pub description: String,
460 + /// Priority level, affects urgency calculation.
441 461 pub priority: Priority,
462 + /// Due date, if set. Used in urgency scoring.
442 463 pub due: Option<DateTime<Utc>>,
464 + /// User-defined tags for categorization and urgency modifiers.
443 465 pub tags: Vec<String>,
466 + /// Recurrence pattern (None, Daily, Weekly, Monthly).
444 467 pub recurrence: Recurrence,
468 + /// Pre-calculated urgency score based on priority, due date, age, and tags.
445 469 pub urgency: f64,
470 + /// Email this task was created from, if any (set by email-to-task flow).
446 471 pub source_email_id: Option<EmailId>,
472 + /// Scheduled start time for time-blocking.
447 473 pub scheduled_start: Option<DateTime<Utc>>,
448 - /// Duration in minutes.
474 + /// Scheduled duration in minutes for time-blocking.
449 475 pub scheduled_duration: Option<i32>,
450 476 }
451 477
@@ -599,17 +625,28 @@ impl NewTaskBuilder {
599 625 /// Data for updating an existing task.
600 626 #[derive(Debug, Clone, Serialize, Deserialize)]
601 627 pub struct UpdateTask {
628 + /// Associated project, if any.
602 629 pub project_id: Option<ProjectId>,
630 + /// Target milestone within the project, if any.
603 631 pub milestone_id: Option<MilestoneId>,
632 + /// Associated contact, if any.
604 633 pub contact_id: Option<ContactId>,
634 + /// Task description/title (required, validated non-empty).
605 635 pub description: String,
636 + /// Updated lifecycle status.
606 637 pub status: TaskStatus,
638 + /// Priority level, affects urgency calculation.
607 639 pub priority: Priority,
640 + /// Due date, if set. Used in urgency scoring.
608 641 pub due: Option<DateTime<Utc>>,
642 + /// User-defined tags for categorization and urgency modifiers.
609 643 pub tags: Vec<String>,
644 + /// Recurrence pattern (None, Daily, Weekly, Monthly).
610 645 pub recurrence: Recurrence,
646 + /// Re-calculated urgency score based on priority, due date, age, and tags.
611 647 pub urgency: f64,
648 + /// Scheduled start time for time-blocking.
612 649 pub scheduled_start: Option<DateTime<Utc>>,
613 - /// Duration in minutes.
650 + /// Scheduled duration in minutes for time-blocking.
614 651 pub scheduled_duration: Option<i32>,
615 652 }
@@ -221,4 +221,54 @@ mod tests {
221 221 assert_eq!(days_in_month(2026, 4), 30); // April
222 222 assert_eq!(days_in_month(2026, 12), 31); // December
223 223 }
224 +
225 + #[test]
226 + fn test_recurring_task_fresh_urgency_after_completion() {
227 + use crate::models::{Priority, TaskStatus};
228 + use crate::urgency::calculate_urgency;
229 +
230 + // Simulate an overdue recurring weekly task:
231 + // Original due date was 3 days ago, so it had high urgency from the overdue penalty.
232 + let overdue_due = Utc::now() - Duration::days(3);
233 + let old_created = Utc::now() - Duration::days(10);
234 + let tags: Vec<String> = vec![];
235 +
236 + let old_urgency = calculate_urgency(
237 + &Priority::Medium,
238 + &TaskStatus::Pending,
239 + Some(&overdue_due),
240 + &old_created,
241 + &tags,
242 + );
243 +
244 + // Old task should have overdue urgency (12.0 from overdue + priority + age)
245 + assert!(old_urgency > 15.0, "Overdue task should have high urgency, got: {}", old_urgency);
246 +
247 + // When completing and creating the next instance, we calculate next_due
248 + let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap();
249 + let new_created = Utc::now();
250 +
251 + let new_urgency = calculate_urgency(
252 + &Priority::Medium,
253 + &TaskStatus::Pending,
254 + Some(&next_due),
255 + &new_created,
256 + &tags,
257 + );
258 +
259 + // The new instance should NOT be overdue (due date is in the future)
260 + // and should have much lower urgency than the old overdue one
261 + assert!(
262 + new_urgency < old_urgency,
263 + "New recurring instance should have lower urgency ({}) than the completed overdue one ({})",
264 + new_urgency, old_urgency
265 + );
266 +
267 + // Specifically, it should NOT have the overdue penalty
268 + assert!(
269 + new_urgency < 12.0,
270 + "New recurring instance should not have overdue penalty, got urgency: {}",
271 + new_urgency
272 + );
273 + }
224 274 }
@@ -621,7 +621,8 @@ impl SearchQuery {
621 621 #[async_trait]
622 622 pub trait SearchRepository: Send + Sync {
623 623 /// Searches across all indexed content using FTS.
624 - async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<Vec<SearchResultItem>>;
624 + /// Returns (results, total_count) where total_count is the pre-pagination count.
625 + async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<(Vec<SearchResultItem>, usize)>;
625 626 }
626 627
627 628 /// Repository for LLM provider settings.
@@ -298,7 +298,11 @@ pub fn build_timeline_days(
298 298 .unwrap_or_else(Utc::now);
299 299
300 300 let completed_count = tasks_completed.iter()
301 - .filter(|t| t.created_at >= day_start && t.created_at <= day_end)
301 + .filter(|t| {
302 + t.completed_at
303 + .map(|ca| ca >= day_start && ca <= day_end)
304 + .unwrap_or(false)
305 + })
302 306 .count() as i32;
303 307
304 308 let event_count = events_occurred.iter()
@@ -423,9 +427,46 @@ fn event_to_summary(event: &Event) -> EventSummary {
423 427 #[cfg(test)]
424 428 mod tests {
425 429 use super::*;
426 - use crate::id_types::{UserId, WeeklyReviewId};
430 + use crate::id_types::{TaskId, UserId, WeeklyReviewId};
431 + use crate::models::{Priority, Recurrence};
427 432 use chrono::NaiveDate;
428 433
434 + /// Creates a minimal completed task with specified created_at and completed_at.
435 + fn make_completed_task(
436 + created_at: DateTime<Utc>,
437 + completed_at: DateTime<Utc>,
438 + ) -> Task {
439 + Task {
440 + id: TaskId::new(),
441 + project_id: None,
442 + project_name: None,
443 + milestone_id: None,
444 + contact_id: None,
445 + contact_name: None,
446 + description: "test".to_string(),
447 + status: TaskStatus::Completed,
448 + priority: Priority::Medium,
449 + due: None,
450 + tags: vec![],
451 + urgency: 0.0,
452 + recurrence: Recurrence::None,
453 + recurrence_parent_id: None,
454 + source_email_id: None,
455 + snoozed_until: None,
456 + waiting_for_response: false,
457 + waiting_since: None,
458 + expected_response_date: None,
459 + scheduled_start: None,
460 + scheduled_duration: None,
461 + annotations: vec![],
462 + subtasks: vec![],
463 + created_at,
464 + completed_at: Some(completed_at),
465 + is_focus: false,
466 + focus_set_at: None,
467 + }
468 + }
469 +
429 470 #[test]
430 471 fn test_week_end() {
431 472 let monday = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
@@ -476,4 +517,81 @@ mod tests {
476 517 assert!(days[1].is_past);
477 518 assert!(!days[2].is_past); // today is not past
478 519 }
520 +
521 + #[test]
522 + fn test_timeline_uses_completed_at_not_created_at() {
523 + // Task created on Monday (Feb 9) but completed on Friday (Feb 13).
524 + // The timeline should show it as completed on Friday, not Monday.
525 + let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday
526 + let today = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap(); // Saturday
527 +
528 + let monday = week_start
529 + .and_hms_opt(10, 0, 0)
530 + .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
531 + .unwrap();
532 + let friday = NaiveDate::from_ymd_opt(2026, 2, 13)
533 + .unwrap()
534 + .and_hms_opt(15, 0, 0)
535 + .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
536 + .unwrap();
537 +
538 + let task = make_completed_task(monday, friday);
539 + let tasks_completed = vec![task];
540 +
541 + let days = build_timeline_days(
542 + week_start,
543 + today,
544 + &[],
545 + &tasks_completed,
546 + &[],
547 + &[],
548 + &[],
549 + );
550 +
551 + // Monday (index 0) should have 0 completions (task was created Monday but not completed then)
552 + assert_eq!(
553 + days[0].completed_count, 0,
554 + "Monday should have 0 completions (task was created Monday but completed Friday)"
555 + );
556 + // Friday (index 4) should have 1 completion
557 + assert_eq!(
558 + days[4].completed_count, 1,
559 + "Friday should have 1 completion (task was completed on Friday)"
560 + );
561 + }
562 +
563 + #[test]
564 + fn test_timeline_task_without_completed_at_not_counted() {
565 + // Edge case: a completed task with no completed_at timestamp should not count.
566 + let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
567 + let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap();
568 +
569 + let monday = week_start
570 + .and_hms_opt(10, 0, 0)
571 + .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
572 + .unwrap();
573 +
574 + let mut task = make_completed_task(monday, monday);
575 + task.completed_at = None; // No completed_at timestamp
576 +
577 + let tasks_completed = vec![task];
578 + let days = build_timeline_days(
579 + week_start,
580 + today,
581 + &[],
582 + &tasks_completed,
583 + &[],
584 + &[],
585 + &[],
586 + );
587 +
588 + // No day should count this task
589 + for day in &days {
590 + assert_eq!(
591 + day.completed_count, 0,
592 + "Day {} should have 0 completions for task without completed_at",
593 + day.day_name
594 + );
595 + }
596 + }
479 597 }
@@ -126,16 +126,19 @@ impl EmailRepository for SqliteEmailRepository {
126 126
127 127 let has_unread = emails.iter().any(|e| !e.is_read);
128 128 let thread_count = emails.len();
129 - let most_recent_email = emails.into_iter().next().expect("thread has at least one email");
129 + let most_recent_email = emails
130 + .into_iter()
131 + .next()
132 + .ok_or_else(|| CoreError::Internal("thread with no emails".into()))?;
130 133
131 - EmailThread {
134 + Ok(EmailThread {
132 135 thread_id,
133 136 most_recent_email,
134 137 thread_count,
135 138 has_unread,
136 - }
139 + })
137 140 })
138 - .collect();
141 + .collect::<Result<Vec<_>>>()?;
139 142
140 143 // Sort threads by most recent email (newest thread first)
141 144 threads.sort_by(|a, b| b.most_recent_email.received_at.cmp(&a.most_recent_email.received_at));
@@ -33,7 +33,7 @@ impl SqliteSearchRepository {
33 33
34 34 #[async_trait]
35 35 impl SearchRepository for SqliteSearchRepository {
36 - async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<Vec<SearchResultItem>> {
36 + async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<(Vec<SearchResultItem>, usize)> {
37 37 // If no text and no filters, return empty
38 38 let has_text = !query.query.trim().is_empty();
39 39 let has_filters = !query.is_filters.is_empty()
@@ -46,7 +46,7 @@ impl SearchRepository for SqliteSearchRepository {
46 46 || query.date_to.is_some();
47 47
48 48 if !has_text && !has_filters {
49 - return Ok(vec![]);
49 + return Ok((vec![], 0));
50 50 }
51 51
52 52 let user_id_str = user_id.to_string();
@@ -121,6 +121,9 @@ impl SearchRepository for SqliteSearchRepository {
121 121 .unwrap_or(std::cmp::Ordering::Equal)
122 122 });
123 123
124 + // Capture total before pagination
125 + let total = results.len();
126 +
124 127 // Apply pagination
125 128 let results: Vec<_> = results
126 129 .into_iter()
@@ -128,7 +131,7 @@ impl SearchRepository for SqliteSearchRepository {
128 131 .take(limit as usize)
129 132 .collect();
130 133
131 - Ok(results)
134 + Ok((results, total))
132 135 }
133 136 }
134 137
@@ -347,7 +350,7 @@ async fn search_tasks_fts(
347 350 params.push(dt.to_rfc3339());
348 351 }
349 352
350 - sql.push_str(" ORDER BY rank LIMIT 100");
353 + sql.push_str(" ORDER BY rank LIMIT 500");
351 354
352 355 // Build and execute query
353 356 let mut db_query = sqlx::query_as::<_, Row>(&sql);
@@ -461,7 +464,7 @@ async fn search_emails_fts(
461 464 params.push(dt.to_rfc3339());
462 465 }
463 466
464 - sql.push_str(" ORDER BY rank LIMIT 100");
467 + sql.push_str(" ORDER BY rank LIMIT 500");
465 468
466 469 let mut db_query = sqlx::query_as::<_, Row>(&sql)
467 470 .bind(search_term)
@@ -549,7 +552,7 @@ async fn search_projects_fts(
549 552 params.push(dt.to_rfc3339());
550 553 }
551 554
552 - sql.push_str(" ORDER BY rank LIMIT 100");
555 + sql.push_str(" ORDER BY rank LIMIT 500");
553 556
554 557 let mut db_query = sqlx::query_as::<_, Row>(&sql)
555 558 .bind(search_term)
@@ -697,7 +700,7 @@ async fn search_events_fts(
697 700 params.push(dt.to_rfc3339());
698 701 }
699 702
700 - sql.push_str(" ORDER BY rank LIMIT 100");
703 + sql.push_str(" ORDER BY rank LIMIT 500");
701 704
702 705 let mut db_query = sqlx::query_as::<_, Row>(&sql);
703 706
@@ -780,7 +783,7 @@ async fn search_contacts_fts(
780 783 WHERE contacts_fts MATCH $1
781 784 AND contacts_fts.user_id = $2
782 785 ORDER BY rank
783 - LIMIT 100
786 + LIMIT 500
784 787 "#;
785 788
786 789 let rows: Vec<Row> = sqlx::query_as::<_, Row>(sql)
@@ -260,10 +260,18 @@ impl TaskRepository for SqliteTaskRepository {
260 260 return Ok((vec![], 0));
261 261 }
262 262
263 - // Build paginated query
263 + // Build paginated query with parameterized LIMIT/OFFSET
264 + let mut pagination_binds: Vec<i64> = Vec::new();
264 265 let pagination = match (query.limit, query.offset) {
265 - (Some(limit), Some(offset)) => format!(" LIMIT {} OFFSET {}", limit, offset),
266 - (Some(limit), None) => format!(" LIMIT {}", limit),
266 + (Some(limit), Some(offset)) => {
267 + pagination_binds.push(limit);
268 + pagination_binds.push(offset);
269 + " LIMIT ? OFFSET ?".to_string()
270 + }
271 + (Some(limit), None) => {
272 + pagination_binds.push(limit);
273 + " LIMIT ?".to_string()
274 + }
267 275 _ => String::new(),
268 276 };
269 277
@@ -316,6 +324,11 @@ impl TaskRepository for SqliteTaskRepository {
316 324 sqlx_query = sqlx_query.bind(value);
317 325 }
318 326
327 + // Bind LIMIT/OFFSET values
328 + for value in pagination_binds {
329 + sqlx_query = sqlx_query.bind(value);
330 + }
331 +
319 332 let rows = sqlx_query
320 333 .fetch_all(&self.pool)
321 334 .await
@@ -566,6 +579,18 @@ impl TaskRepository for SqliteTaskRepository {
566 579 // ---- Snooze ----
567 580
568 581 async fn snooze(&self, id: TaskId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Task>> {
582 + // Check task status before snoozing — completed/deleted tasks cannot be snoozed
583 + if let Some(task) = self.get_by_id(id, user_id).await? {
584 + if task.status == goingson_core::TaskStatus::Completed {
585 + return Err(CoreError::validation("status", "cannot snooze a completed task"));
586 + }
587 + if task.status == goingson_core::TaskStatus::Deleted {
588 + return Err(CoreError::validation("status", "cannot snooze a deleted task"));
589 + }
590 + } else {
591 + return Ok(None);
592 + }
593 +
569 594 let until_str = format_datetime(&until);
570 595
571 596 let result = sqlx::query(
@@ -85,7 +85,7 @@ async fn search_empty_query_returns_empty() {
85 85 query: String::new(),
86 86 ..Default::default()
87 87 };
88 - let results = repo.search(user_id, query).await.unwrap();
88 + let (results, _total) = repo.search(user_id, query).await.unwrap();
89 89 assert!(results.is_empty());
90 90 }
91 91
@@ -99,7 +99,7 @@ async fn search_tasks_by_text() {
99 99
100 100 let repo = SqliteSearchRepository::new(pool);
101 101 let query = SearchQuery::new("login");
102 - let results = repo.search(user_id, query).await.unwrap();
102 + let (results, _total) = repo.search(user_id, query).await.unwrap();
103 103
104 104 assert_eq!(results.len(), 1);
105 105 assert_eq!(results[0].result_type, SearchResultType::Task);
@@ -116,7 +116,7 @@ async fn search_projects_by_name() {
116 116
117 117 let repo = SqliteSearchRepository::new(pool);
118 118 let query = SearchQuery::new("redesign");
119 - let results = repo.search(user_id, query).await.unwrap();
119 + let (results, _total) = repo.search(user_id, query).await.unwrap();
120 120
121 121 assert!(results.iter().any(|r| r.result_type == SearchResultType::Project));
122 122 assert!(results.iter().any(|r| r.title.contains("Redesign")));
@@ -132,7 +132,7 @@ async fn search_returns_multiple_types() {
132 132
133 133 let repo = SqliteSearchRepository::new(pool);
134 134 let query = SearchQuery::new("authentication");
135 - let results = repo.search(user_id, query).await.unwrap();
135 + let (results, _total) = repo.search(user_id, query).await.unwrap();
136 136
137 137 let types: Vec<_> = results.iter().map(|r| &r.result_type).collect();
138 138 assert!(types.contains(&&SearchResultType::Task));
@@ -152,7 +152,7 @@ async fn search_type_filter() {
152 152 let repo = SqliteSearchRepository::new(pool);
153 153 let query = SearchQuery::new("infrastructure")
154 154 .with_types(vec![SearchResultType::Task]);
155 - let results = repo.search(user_id, query).await.unwrap();
155 + let (results, _total) = repo.search(user_id, query).await.unwrap();
156 156
157 157 assert!(results.iter().all(|r| r.result_type == SearchResultType::Task));
158 158 }
@@ -168,7 +168,7 @@ async fn search_priority_filter() {
168 168 let repo = SqliteSearchRepository::new(pool);
169 169 let mut query = SearchQuery::new("deploy");
170 170 query.priority = Some(Priority::High);
171 - let results = repo.search(user_id, query).await.unwrap();
171 + let (results, _total) = repo.search(user_id, query).await.unwrap();
172 172
173 173 assert_eq!(results.len(), 1);
174 174 assert!(results[0].title.contains("High priority"));
@@ -208,7 +208,7 @@ async fn search_tag_include() {
208 208 let repo = SqliteSearchRepository::new(pool);
209 209 let mut query = SearchQuery::new("search testing");
210 210 query.tags_include = vec!["backend".to_string()];
211 - let results = repo.search(user_id, query).await.unwrap();
211 + let (results, _total) = repo.search(user_id, query).await.unwrap();
212 212
213 213 assert_eq!(results.len(), 1);
214 214 assert_eq!(results[0].id, *task.id);
@@ -246,7 +246,7 @@ async fn search_tag_exclude() {
246 246 let repo = SqliteSearchRepository::new(pool);
247 247 let mut query = SearchQuery::new("tag filtering");
248 248 query.tags_exclude = vec!["wontfix".to_string()];
249 - let results = repo.search(user_id, query).await.unwrap();
249 + let (results, _total) = repo.search(user_id, query).await.unwrap();
250 250
251 251 assert_eq!(results.len(), 1);
252 252 assert!(results[0].title.contains("Included"));
@@ -265,7 +265,7 @@ async fn search_with_limit() {
265 265
266 266 let repo = SqliteSearchRepository::new(pool);
267 267 let query = SearchQuery::new("pagination").with_limit(2);
268 - let results = repo.search(user_id, query).await.unwrap();
268 + let (results, _total) = repo.search(user_id, query).await.unwrap();
269 269
270 270 assert_eq!(results.len(), 2);
271 271 }
@@ -283,11 +283,11 @@ async fn search_with_offset() {
283 283
284 284 // Get all results first
285 285 let all_query = SearchQuery::new("offset task");
286 - let all = repo.search(user_id, all_query).await.unwrap();
286 + let (all, _total) = repo.search(user_id, all_query).await.unwrap();
287 287
288 288 // Get with offset
289 289 let offset_query = SearchQuery::new("offset task").with_offset(2).with_limit(50);
290 - let offset = repo.search(user_id, offset_query).await.unwrap();
290 + let (offset, _total) = repo.search(user_id, offset_query).await.unwrap();
291 291
292 292 assert_eq!(offset.len(), all.len() - 2);
293 293 }
@@ -317,7 +317,7 @@ async fn search_no_results() {
317 317
318 318 let repo = SqliteSearchRepository::new(pool);
319 319 let query = SearchQuery::new("xyznonexistentquery");
320 - let results = repo.search(user_id, query).await.unwrap();
320 + let (results, _total) = repo.search(user_id, query).await.unwrap();
321 321
322 322 assert!(results.is_empty());
323 323 }
@@ -318,3 +318,94 @@ async fn test_update_from_completed_clears_completed_at() {
318 318 assert_eq!(reverted.status, TaskStatus::Pending);
319 319 assert!(reverted.completed_at.is_none(), "completed_at should be cleared when moving away from Completed");
320 320 }
321 +
322 + #[tokio::test]
323 + async fn test_snooze_completed_task_fails() {
324 + let pool = common::setup_test_db().await;
325 + let user_id = common::create_test_user(&pool).await;
326 + let repo = SqliteTaskRepository::new(pool);
327 +
328 + let new_task = NewTask::builder("Completed task")
329 + .priority(Priority::Medium)
330 + .build();
331 +
332 + let task = repo.create(user_id, new_task).await.expect("Failed to create");
333 +
334 + // Complete the task
335 + repo.complete(task.id, user_id).await.expect("Failed to complete");
336 +
337 + // Attempt to snooze the completed task — should fail
338 + let until = Utc::now() + Duration::hours(2);
339 + let result = repo.snooze(task.id, user_id, until).await;
340 + assert!(result.is_err(), "Snoozing a completed task should return an error");
341 + let err = result.unwrap_err();
342 + assert!(err.is_validation(), "Error should be a validation error, got: {}", err);
343 + }
344 +
345 + #[tokio::test]
346 + async fn test_snooze_deleted_task_fails() {
347 + let pool = common::setup_test_db().await;
348 + let user_id = common::create_test_user(&pool).await;
349 + let repo = SqliteTaskRepository::new(pool);
350 +
351 + let new_task = NewTask::builder("Deleted task")
352 + .priority(Priority::Medium)
353 + .build();
354 +
355 + let task = repo.create(user_id, new_task).await.expect("Failed to create");
356 +
357 + // Delete the task
358 + repo.delete(task.id, user_id).await.expect("Failed to delete");
359 +
360 + // Attempt to snooze the deleted task — should fail
361 + let until = Utc::now() + Duration::hours(2);
362 + let result = repo.snooze(task.id, user_id, until).await;
363 + assert!(result.is_err(), "Snoozing a deleted task should return an error");
364 + let err = result.unwrap_err();
365 + assert!(err.is_validation(), "Error should be a validation error, got: {}", err);
366 + }
367 +
368 + #[tokio::test]
369 + async fn test_snooze_pending_task_succeeds() {
370 + let pool = common::setup_test_db().await;
371 + let user_id = common::create_test_user(&pool).await;
372 + let repo = SqliteTaskRepository::new(pool);
373 +
374 + let new_task = NewTask::builder("Pending task")
375 + .priority(Priority::Medium)
376 + .build();
377 +
378 + let task = repo.create(user_id, new_task).await.expect("Failed to create");
379 +
380 + // Snooze the pending task — should succeed
381 + let until = Utc::now() + Duration::hours(2);
382 + let result = repo.snooze(task.id, user_id, until).await;
383 + assert!(result.is_ok(), "Snoozing a pending task should succeed");
384 + let snoozed = result.unwrap();
385 + assert!(snoozed.is_some());
386 + assert!(snoozed.unwrap().snoozed_until.is_some());
387 + }
388 +
389 + #[tokio::test]
390 + async fn test_snooze_started_task_succeeds() {
391 + let pool = common::setup_test_db().await;
392 + let user_id = common::create_test_user(&pool).await;
393 + let repo = SqliteTaskRepository::new(pool);
394 +
395 + let new_task = NewTask::builder("Started task")
396 + .priority(Priority::Medium)
397 + .build();
398 +
399 + let task = repo.create(user_id, new_task).await.expect("Failed to create");
400 +
401 + // Start the task first
402 + repo.start(task.id, user_id).await.expect("Failed to start");
403 +
404 + // Snooze the started task — should succeed
405 + let until = Utc::now() + Duration::hours(2);
406 + let result = repo.snooze(task.id, user_id, until).await;
407 + assert!(result.is_ok(), "Snoozing a started task should succeed");
408 + let snoozed = result.unwrap();
409 + assert!(snoozed.is_some());
410 + assert!(snoozed.unwrap().snoozed_until.is_some());
411 + }
@@ -1,6 +1,7 @@
1 1 //! Event implementation methods for GoingsOnServer.
2 2
3 3 use chrono::{DateTime, Utc};
4 + use tracing::instrument;
4 5
5 6 use goingson_core::{EventId, NewEvent, Recurrence, UpdateEvent};
6 7
@@ -24,6 +25,7 @@ impl GoingsOnServer {
24 25 }
25 26 }
26 27
28 + #[instrument(skip_all)]
27 29 pub(crate) async fn create_event_impl(
28 30 &self,
29 31 params: CreateEventParams,
@@ -73,6 +75,7 @@ impl GoingsOnServer {
73 75 Ok(format!("Event created:\n{}", serde_json::to_string_pretty(&result)?))
74 76 }
75 77
78 + #[instrument(skip_all)]
76 79 pub(crate) async fn list_events_impl(
77 80 &self,
78 81 params: ListEventsParams,
@@ -101,6 +104,7 @@ impl GoingsOnServer {
101 104 Ok(serde_json::to_string_pretty(&results)?)
102 105 }
103 106
107 + #[instrument(skip_all)]
104 108 pub(crate) async fn list_upcoming_events_impl(
105 109 &self,
106 110 params: ListUpcomingEventsParams,
@@ -114,6 +118,7 @@ impl GoingsOnServer {
114 118 Ok(serde_json::to_string_pretty(&results)?)
115 119 }
116 120
121 + #[instrument(skip_all)]
117 122 pub(crate) async fn get_event_impl(
118 123 &self,
119 124 params: GetEventParams,
@@ -125,6 +130,7 @@ impl GoingsOnServer {
125 130 Ok(serde_json::to_string_pretty(&result)?)
126 131 }
127 132
133 + #[instrument(skip_all)]
128 134 pub(crate) async fn update_event_impl(
129 135 &self,
130 136 params: UpdateEventParams,
@@ -184,6 +190,7 @@ impl GoingsOnServer {
184 190 Ok(format!("Event updated:\n{}", serde_json::to_string_pretty(&result)?))
185 191 }
186 192
193 + #[instrument(skip_all)]
187 194 pub(crate) async fn delete_event_impl(
188 195 &self,
189 196 params: DeleteEventParams,
@@ -1,6 +1,7 @@
1 1 //! Milestone implementation methods for GoingsOnServer.
2 2
3 3 use chrono::NaiveDate;
4 + use tracing::instrument;
4 5
5 6 use goingson_core::{MilestoneId, MilestoneStatus, NewMilestone, ProjectId, TaskStatus};
6 7
@@ -11,6 +12,7 @@ use super::results::MilestoneResult;
11 12 use super::milestone_params::*;
12 13
13 14 impl GoingsOnServer {
15 + #[instrument(skip_all)]
14 16 pub(crate) async fn list_milestones_impl(
15 17 &self,
16 18 params: ListMilestonesParams,
@@ -56,6 +58,7 @@ impl GoingsOnServer {
56 58 Ok(serde_json::to_string_pretty(&results)?)
57 59 }
58 60
61 + #[instrument(skip_all)]
59 62 pub(crate) async fn create_milestone_impl(
60 63 &self,
61 64 params: CreateMilestoneParams,
@@ -101,6 +104,7 @@ impl GoingsOnServer {
101 104 Ok(format!("Milestone created:\n{}", serde_json::to_string_pretty(&result)?))
102 105 }
103 106
107 + #[instrument(skip_all)]
104 108 pub(crate) async fn update_milestone_impl(
105 109 &self,
106 110 params: UpdateMilestoneParams,
@@ -141,6 +145,7 @@ impl GoingsOnServer {
141 145 Ok(format!("Milestone updated:\n{}", serde_json::to_string_pretty(&result)?))
142 146 }
143 147
148 + #[instrument(skip_all)]
144 149 pub(crate) async fn delete_milestone_impl(
145 150 &self,
146 151 params: DeleteMilestoneParams,
@@ -1,6 +1,7 @@
1 1 //! Project implementation methods for GoingsOnServer.
2 2
3 3 use goingson_core::{NewProject, ProjectId, ProjectStatus, ProjectType, UpdateProject};
4 + use tracing::instrument;
4 5
5 6 use crate::state::DESKTOP_USER_ID;
6 7
@@ -9,6 +10,7 @@ use super::results::ProjectResult;
9 10 use super::project_params::*;
10 11
11 12 impl GoingsOnServer {
13 + #[instrument(skip_all)]
12 14 pub(crate) async fn create_project_impl(
13 15 &self,
14 16 params: CreateProjectParams,
@@ -51,6 +53,7 @@ impl GoingsOnServer {
51 53 ))
52 54 }
53 55
56 + #[instrument(skip_all)]
54 57 pub(crate) async fn list_projects_impl(
55 58 &self,
56 59 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -73,6 +76,7 @@ impl GoingsOnServer {
73 76 Ok(serde_json::to_string_pretty(&results)?)
74 77 }
75 78
79 + #[instrument(skip_all)]
76 80 pub(crate) async fn get_project_impl(
77 81 &self,
78 82 params: GetProjectParams,
@@ -90,6 +94,7 @@ impl GoingsOnServer {
90 94 Ok(serde_json::to_string_pretty(&result)?)
91 95 }
92 96
97 + #[instrument(skip_all)]
93 98 pub(crate) async fn update_project_impl(
94 99 &self,
95 100 params: UpdateProjectParams,
@@ -122,6 +127,7 @@ impl GoingsOnServer {
122 127 Ok(format!("Project updated:\n{}", serde_json::to_string_pretty(&result)?))
123 128 }
124 129
130 + #[instrument(skip_all)]
125 131 pub(crate) async fn delete_project_impl(
126 132 &self,
127 133 params: DeleteProjectParams,
@@ -1,10 +1,11 @@
1 1 //! Task, annotation, and subtask implementation methods for GoingsOnServer.
2 2
3 3 use chrono::{DateTime, Duration, Local, NaiveTime, Utc, Weekday, Datelike, TimeZone};
4 + use tracing::instrument;
4 5
5 6 use goingson_core::{
6 7 AnnotationId, MilestoneStatus, NewTask, Priority, Recurrence, SubtaskId, TaskId, TaskStatus,
7 - UpdateTask, calculate_next_due, calculate_urgency, should_recur,
8 + UpdateTask, Validate, calculate_next_due, calculate_urgency, should_recur,
8 9 };
9 10
10 11 use crate::state::DESKTOP_USER_ID;
@@ -30,6 +31,7 @@ impl GoingsOnServer {
30 31 }
31 32 }
32 33
34 + #[instrument(skip_all)]
33 35 pub(crate) async fn list_tasks_impl(
34 36 &self,
35 37 params: ListTasksParams,
@@ -82,6 +84,7 @@ impl GoingsOnServer {
82 84 }
83 85 }
84 86
87 + #[instrument(skip_all)]
85 88 pub(crate) async fn create_task_impl(
86 89 &self,
87 90 params: CreateTaskParams,
@@ -153,6 +156,8 @@ impl GoingsOnServer {
153 156 milestone_id: None,
154 157 };
155 158
159 + new_task.validate()?;
160 +
156 161 let task = self.state.tasks.create(DESKTOP_USER_ID, new_task).await?;
157 162 let result = Self::task_to_result(&task);
158 163
@@ -162,6 +167,7 @@ impl GoingsOnServer {
162 167 ))
163 168 }
164 169
170 + #[instrument(skip_all)]
165 171 pub(crate) async fn update_task_impl(
166 172 &self,
167 173 params: UpdateTaskParams,
@@ -234,6 +240,7 @@ impl GoingsOnServer {
234 240 Ok(format!("Task updated:\n{}", serde_json::to_string_pretty(&result)?))
235 241 }
236 242
243 + #[instrument(skip_all)]
237 244 pub(crate) async fn delete_task_impl(
238 245 &self,
239 246 params: DeleteTaskParams,
@@ -248,6 +255,7 @@ impl GoingsOnServer {
248 255 }
249 256 }
250 257
258 + #[instrument(skip_all)]
251 259 pub(crate) async fn complete_task_impl(
252 260 &self,
253 261 params: CompleteTaskParams,
@@ -263,6 +271,14 @@ impl GoingsOnServer {
263 271 // Create next recurring instance if needed
264 272 if should_recur(&task.recurrence) {
265 273 let next_due = calculate_next_due(task.due.as_ref(), &task.recurrence);
274 + let created_at = Utc::now();
275 + let fresh_urgency = calculate_urgency(
276 + &task.priority,
277 + &TaskStatus::Pending,
278 + next_due.as_ref(),
279 + &created_at,
280 + &task.tags,
281 + );
266 282 let new_task = NewTask {
267 283 project_id: task.project_id,
268 284 description: task.description.clone(),
@@ -270,7 +286,7 @@ impl GoingsOnServer {
270 286 due: next_due,
271 287 tags: task.tags.clone(),
272 288 recurrence: task.recurrence.clone(),
273 - urgency: task.urgency,
289 + urgency: fresh_urgency,
274 290 source_email_id: None,
275 291 scheduled_start: None,
276 292 scheduled_duration: None,
@@ -303,6 +319,7 @@ impl GoingsOnServer {
303 319 Ok(msg)
304 320 }
305 321
322 + #[instrument(skip_all)]
306 323 pub(crate) async fn snooze_task_impl(
307 324 &self,
308 325 params: SnoozeTaskParams,
@@ -358,6 +375,7 @@ impl GoingsOnServer {
358 375 ))
359 376 }
360 377
378 + #[instrument(skip_all)]
361 379 pub(crate) async fn get_task_impl(
362 380 &self,
363 381 params: GetTaskParams,
@@ -369,6 +387,7 @@ impl GoingsOnServer {
369 387 Ok(serde_json::to_string_pretty(&result)?)
370 388 }
371 389
390 + #[instrument(skip_all)]
372 391 pub(crate) async fn start_task_impl(
373 392 &self,
374 393 params: StartTaskParams,
@@ -382,6 +401,7 @@ impl GoingsOnServer {
382 401 }
383 402 }
384 403
404 + #[instrument(skip_all)]
385 405 pub(crate) async fn list_snoozed_tasks_impl(
386 406 &self,
387 407 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -393,6 +413,7 @@ impl GoingsOnServer {
393 413 Ok(serde_json::to_string_pretty(&results)?)
394 414 }
395 415
416 + #[instrument(skip_all)]
396 417 pub(crate) async fn unsnooze_task_impl(
397 418 &self,
398 419 params: UnsnoozeTaskParams,
@@ -403,6 +424,7 @@ impl GoingsOnServer {
403 424 Ok(format!("Task {} unsnoozed. Status: {}", task.id, task.status.as_str()))
404 425 }
405 426
427 + #[instrument(skip_all)]
406 428 pub(crate) async fn list_waiting_tasks_impl(
407 429 &self,
408 430 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -414,6 +436,7 @@ impl GoingsOnServer {
414 436 Ok(serde_json::to_string_pretty(&results)?)
415 437 }
416 438
439 + #[instrument(skip_all)]
417 440 pub(crate) async fn mark_task_waiting_impl(
418 441 &self,
419 442 params: MarkTaskWaitingParams,
@@ -432,6 +455,7 @@ impl GoingsOnServer {
432 455 Ok(msg)
433 456 }
434 457
458 + #[instrument(skip_all)]
435 459 pub(crate) async fn clear_task_waiting_impl(
436 460 &self,
437 461 params: ClearTaskWaitingParams,
@@ -442,6 +466,7 @@ impl GoingsOnServer {
442 466 Ok(format!("Task {} waiting status cleared.", id))
443 467 }
444 468
469 + #[instrument(skip_all)]
445 470 pub(crate) async fn add_annotation_impl(
446 471 &self,
447 472 params: AddAnnotationParams,
@@ -461,6 +486,7 @@ impl GoingsOnServer {
461 486 Ok(format!("Annotation added:\n{}", serde_json::to_string_pretty(&result)?))
462 487 }
463 488
489 + #[instrument(skip_all)]
464 490 pub(crate) async fn list_annotations_impl(
465 491 &self,
466 492 params: ListAnnotationsParams,
@@ -479,6 +505,7 @@ impl GoingsOnServer {
479 505 Ok(serde_json::to_string_pretty(&results)?)
480 506 }
481 507
508 + #[instrument(skip_all)]
482 509 pub(crate) async fn delete_annotation_impl(
483 510 &self,
484 511 params: DeleteAnnotationParams,
@@ -492,6 +519,7 @@ impl GoingsOnServer {
492 519 }
493 520 }
494 521
522 + #[instrument(skip_all)]
495 523 pub(crate) async fn add_subtask_impl(
496 524 &self,
497 525 params: AddSubtaskParams,
@@ -512,6 +540,7 @@ impl GoingsOnServer {
512 540 Ok(format!("Subtask added:\n{}", serde_json::to_string_pretty(&result)?))
513 541 }
514 542
543 + #[instrument(skip_all)]
515 544 pub(crate) async fn add_subtask_link_impl(
516 545 &self,
517 546 params: AddSubtaskLinkParams,
@@ -532,6 +561,7 @@ impl GoingsOnServer {
532 561 ))
533 562 }
534 563
564 + #[instrument(skip_all)]
535 565 pub(crate) async fn list_subtasks_impl(
536 566 &self,
537 567 params: ListSubtasksParams,
@@ -551,6 +581,7 @@ impl GoingsOnServer {
551 581 Ok(serde_json::to_string_pretty(&results)?)
552 582 }
553 583
584 + #[instrument(skip_all)]
554 585 pub(crate) async fn toggle_subtask_impl(
555 586 &self,
556 587 params: ToggleSubtaskParams,
@@ -562,6 +593,7 @@ impl GoingsOnServer {
562 593 Ok(format!("Subtask {} toggled to {}.", id, status))
563 594 }
564 595
596 + #[instrument(skip_all)]
565 597 pub(crate) async fn delete_subtask_impl(
566 598 &self,
567 599 params: DeleteSubtaskParams,
@@ -2,6 +2,7 @@
2 2
3 3 use chrono::Utc;
4 4 use serde::Serialize;
5 + use tracing::instrument;
5 6
6 7 use goingson_core::TaskStatus;
7 8
@@ -12,6 +13,7 @@ use super::results::{ContextResult, DashboardStatsResult, HighUrgencyTaskResult,
12 13 use super::utility_params::*;
13 14
14 15 impl GoingsOnServer {
16 + #[instrument(skip_all)]
15 17 pub(crate) async fn search_impl(
16 18 &self,
17 19 params: SearchParams,
@@ -43,6 +45,7 @@ impl GoingsOnServer {
43 45 }
44 46 }
45 47
48 + #[instrument(skip_all)]
46 49 pub(crate) async fn get_context_impl(
47 50 &self,
48 51 params: GetContextParams,
@@ -109,6 +112,7 @@ impl GoingsOnServer {
109 112 Ok(serde_json::to_string_pretty(&result)?)
110 113 }
111 114
115 + #[instrument(skip_all)]
112 116 pub(crate) async fn export_roadmap_impl(
113 117 &self,
114 118 params: ExportRoadmapParams,
@@ -218,6 +222,7 @@ impl GoingsOnServer {
218 222 }
219 223 }
220 224
225 + #[instrument(skip_all)]
221 226 pub(crate) async fn get_dashboard_stats_impl(
222 227 &self,
223 228 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {