max / goingson
57 files changed,
+1693 insertions,
-122 deletions
| @@ -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 | ] |
| @@ -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>> { |