max / goingson
108 files changed,
+12622 insertions,
-5237 deletions
| @@ -0,0 +1,30 @@ | |||
| 1 | + | # Changelog | |
| 2 | + | ||
| 3 | + | All notable changes to GoingsOn will be documented in this file. | |
| 4 | + | ||
| 5 | + | Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | |
| 6 | + | ||
| 7 | + | ## [0.3.0] — 2026-03-28 | |
| 8 | + | ||
| 9 | + | First beta-ready release. | |
| 10 | + | ||
| 11 | + | ### Added | |
| 12 | + | - Cloud sync via SyncKit SDK (tasks, projects, events, contacts, emails) | |
| 13 | + | - OTA updates via tauri-plugin-updater | |
| 14 | + | - MCP server integration for Claude Desktop | |
| 15 | + | - Rhai plugin system for extensibility | |
| 16 | + | - Day planner with drag-and-drop scheduling | |
| 17 | + | - Email (IMAP/SMTP) with multi-account support | |
| 18 | + | - Calendar with recurring events | |
| 19 | + | - Contact management with vCard import/export | |
| 20 | + | - Snooze and subtask linking | |
| 21 | + | - 16 bundled color themes | |
| 22 | + | - macOS, Windows, and Linux builds (signed and notarized on macOS) | |
| 23 | + | ||
| 24 | + | ### Fixed | |
| 25 | + | - All issues identified in audit runs 1–12 | |
| 26 | + | - Concurrency and type safety patterns (Rust patterns audit) | |
| 27 | + | ||
| 28 | + | ### Security | |
| 29 | + | - Full observability instrumentation (206 traced functions) | |
| 30 | + | - No production unsafe code |
| @@ -154,8 +154,8 @@ checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" | |||
| 154 | 154 | dependencies = [ | |
| 155 | 155 | "compression-codecs", | |
| 156 | 156 | "compression-core", | |
| 157 | - | "futures-io", | |
| 158 | 157 | "pin-project-lite", | |
| 158 | + | "tokio", | |
| 159 | 159 | ] | |
| 160 | 160 | ||
| 161 | 161 | [[package]] | |
| @@ -180,7 +180,6 @@ checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" | |||
| 180 | 180 | dependencies = [ | |
| 181 | 181 | "async-channel 2.5.0", | |
| 182 | 182 | "async-compression", | |
| 183 | - | "async-std", | |
| 184 | 183 | "base64 0.22.1", | |
| 185 | 184 | "bytes", | |
| 186 | 185 | "chrono", | |
| @@ -193,6 +192,7 @@ dependencies = [ | |||
| 193 | 192 | "self_cell", | |
| 194 | 193 | "stop-token", | |
| 195 | 194 | "thiserror 1.0.69", | |
| 195 | + | "tokio", | |
| 196 | 196 | ] | |
| 197 | 197 | ||
| 198 | 198 | [[package]] | |
| @@ -272,28 +272,6 @@ dependencies = [ | |||
| 272 | 272 | ] | |
| 273 | 273 | ||
| 274 | 274 | [[package]] | |
| 275 | - | name = "async-std" | |
| 276 | - | version = "1.13.2" | |
| 277 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 278 | - | checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" | |
| 279 | - | dependencies = [ | |
| 280 | - | "async-channel 1.9.0", | |
| 281 | - | "async-io", | |
| 282 | - | "async-lock", | |
| 283 | - | "async-process", | |
| 284 | - | "crossbeam-utils", | |
| 285 | - | "futures-channel", | |
| 286 | - | "futures-core", | |
| 287 | - | "futures-io", | |
| 288 | - | "memchr", | |
| 289 | - | "once_cell", | |
| 290 | - | "pin-project-lite", | |
| 291 | - | "pin-utils", | |
| 292 | - | "slab", | |
| 293 | - | "wasm-bindgen-futures", | |
| 294 | - | ] | |
| 295 | - | ||
| 296 | - | [[package]] | |
| 297 | 275 | name = "async-task" | |
| 298 | 276 | version = "4.7.1" | |
| 299 | 277 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -6337,7 +6315,6 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" | |||
| 6337 | 6315 | dependencies = [ | |
| 6338 | 6316 | "bytes", | |
| 6339 | 6317 | "futures-core", | |
| 6340 | - | "futures-io", | |
| 6341 | 6318 | "futures-sink", | |
| 6342 | 6319 | "pin-project-lite", | |
| 6343 | 6320 | "tokio", |
| @@ -33,9 +33,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio"] } | |||
| 33 | 33 | argon2 = "0.5" | |
| 34 | 34 | ||
| 35 | 35 | # Email integration | |
| 36 | - | async-imap = "0.11" | |
| 36 | + | async-imap = { version = "0.11", default-features = false, features = ["runtime-tokio"] } | |
| 37 | 37 | tokio-native-tls = "0.3" | |
| 38 | - | tokio-util = { version = "0.7", features = ["compat"] } | |
| 38 | + | tokio-util = "0.7" | |
| 39 | 39 | futures = "0.3" | |
| 40 | 40 | mailparse = "0.16" | |
| 41 | 41 | lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-native-tls", "smtp-transport", "builder"] } |
| @@ -116,6 +116,8 @@ define_uuid_id!( | |||
| 116 | 116 | ContactId, | |
| 117 | 117 | MilestoneId, | |
| 118 | 118 | WeeklyReviewId, | |
| 119 | + | MonthlyGoalId, | |
| 120 | + | MonthlyReflectionId, | |
| 119 | 121 | SavedViewId, | |
| 120 | 122 | EmailAccountId, | |
| 121 | 123 | LlmSettingsId, |
| @@ -44,6 +44,7 @@ pub mod plugin; | |||
| 44 | 44 | pub mod recurrence; | |
| 45 | 45 | pub mod repository; | |
| 46 | 46 | pub mod search_parser; | |
| 47 | + | pub mod monthly_review; | |
| 47 | 48 | pub mod urgency; | |
| 48 | 49 | pub mod validation; | |
| 49 | 50 | pub mod weekly_review; | |
| @@ -55,15 +56,17 @@ pub use contact::{ | |||
| 55 | 56 | pub use error::CoreError; | |
| 56 | 57 | pub use id_types::{ | |
| 57 | 58 | AnnotationId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, EmailAccountId, | |
| 58 | - | EmailId, EventId, LlmSettingsId, MilestoneId, ProjectId, SavedViewId, SocialHandleId, | |
| 59 | + | EmailId, EventId, LlmSettingsId, MilestoneId, MonthlyGoalId, MonthlyReflectionId, | |
| 60 | + | ProjectId, SavedViewId, SocialHandleId, | |
| 59 | 61 | SubtaskId, TaskId, UserId, WeeklyReviewId, | |
| 60 | 62 | }; | |
| 61 | 63 | pub use models::{ | |
| 62 | 64 | Annotation, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount, EmailAuthType, | |
| 63 | 65 | EmailThread, Event, LlmContext, LlmProviderType, LlmSettings, Milestone, MilestoneStatus, | |
| 66 | + | MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, | |
| 64 | 67 | NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, NewLlmSettings, | |
| 65 | 68 | NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, Project, | |
| 66 | - | ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection, SortField, Subtask, Task, | |
| 69 | + | ParseableEnum, ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection, SortField, Subtask, Task, | |
| 67 | 70 | TaskFilterQuery, TaskSortColumn, TaskStatus, UpdateEvent, UpdateProject, UpdateTask, User, | |
| 68 | 71 | ViewFilters, ViewType, WeeklyReview, | |
| 69 | 72 | }; |
| @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; | |||
| 4 | 4 | use serde::{Deserialize, Serialize}; | |
| 5 | 5 | use strum_macros::EnumString; | |
| 6 | 6 | use crate::id_types::{MilestoneId, UserId, ProjectId}; | |
| 7 | - | use super::shared::{CssClass, DbValue}; | |
| 7 | + | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 8 | 8 | ||
| 9 | 9 | // ============ Milestones ============ | |
| 10 | 10 | ||
| @@ -29,13 +29,10 @@ impl MilestoneStatus { | |||
| 29 | 29 | } | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | - | /// Parses a string into a MilestoneStatus, falling back to `Open` on invalid input. | |
| 33 | - | #[allow(clippy::should_implement_trait)] | |
| 34 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 35 | - | s.parse().unwrap_or_default() | |
| 36 | - | } | |
| 37 | 32 | } | |
| 38 | 33 | ||
| 34 | + | impl ParseableEnum for MilestoneStatus {} | |
| 35 | + | ||
| 39 | 36 | impl DbValue for MilestoneStatus { | |
| 40 | 37 | fn db_value(&self) -> &'static str { | |
| 41 | 38 | match self { |
| @@ -11,6 +11,7 @@ mod saved_view; | |||
| 11 | 11 | mod shared; | |
| 12 | 12 | mod task; | |
| 13 | 13 | mod user; | |
| 14 | + | mod monthly_review; | |
| 14 | 15 | mod weekly_review; | |
| 15 | 16 | ||
| 16 | 17 | pub use backup::*; | |
| @@ -19,6 +20,7 @@ pub use email_account::*; | |||
| 19 | 20 | pub use event::*; | |
| 20 | 21 | pub use llm::*; | |
| 21 | 22 | pub use milestone::*; | |
| 23 | + | pub use monthly_review::*; | |
| 22 | 24 | pub use project::*; | |
| 23 | 25 | pub use saved_view::*; | |
| 24 | 26 | pub use shared::*; |
| @@ -0,0 +1,70 @@ | |||
| 1 | + | //! Monthly review domain types. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | use crate::id_types::{MonthlyGoalId, MonthlyReflectionId, UserId}; | |
| 6 | + | ||
| 7 | + | // ============ Monthly Goal ============ | |
| 8 | + | ||
| 9 | + | /// Status of a monthly goal. | |
| 10 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 11 | + | #[serde(rename_all = "lowercase")] | |
| 12 | + | pub enum MonthlyGoalStatus { | |
| 13 | + | Active, | |
| 14 | + | Done, | |
| 15 | + | Abandoned, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl MonthlyGoalStatus { | |
| 19 | + | pub fn as_str(&self) -> &str { | |
| 20 | + | match self { | |
| 21 | + | Self::Active => "active", | |
| 22 | + | Self::Done => "done", | |
| 23 | + | Self::Abandoned => "abandoned", | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl std::str::FromStr for MonthlyGoalStatus { | |
| 29 | + | type Err = crate::CoreError; | |
| 30 | + | ||
| 31 | + | fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { | |
| 32 | + | match s { | |
| 33 | + | "active" => Ok(Self::Active), | |
| 34 | + | "done" => Ok(Self::Done), | |
| 35 | + | "abandoned" => Ok(Self::Abandoned), | |
| 36 | + | _ => Err(crate::CoreError::parse(&format!("Invalid goal status: {s}"))), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A monthly goal — free-text item set at the start of each month. | |
| 42 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 43 | + | #[serde(rename_all = "camelCase")] | |
| 44 | + | pub struct MonthlyGoal { | |
| 45 | + | pub id: MonthlyGoalId, | |
| 46 | + | pub user_id: UserId, | |
| 47 | + | /// Month in YYYY-MM format. | |
| 48 | + | pub month: String, | |
| 49 | + | pub text: String, | |
| 50 | + | pub status: MonthlyGoalStatus, | |
| 51 | + | /// Position 1-3. | |
| 52 | + | pub position: i32, | |
| 53 | + | pub created_at: DateTime<Utc>, | |
| 54 | + | pub updated_at: DateTime<Utc>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // ============ Monthly Reflection ============ | |
| 58 | + | ||
| 59 | + | /// A monthly reflection capturing highlights and areas for improvement. | |
| 60 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 61 | + | #[serde(rename_all = "camelCase")] | |
| 62 | + | pub struct MonthlyReflection { | |
| 63 | + | pub id: MonthlyReflectionId, | |
| 64 | + | pub user_id: UserId, | |
| 65 | + | /// Month in YYYY-MM format. | |
| 66 | + | pub month: String, | |
| 67 | + | pub highlight_text: String, | |
| 68 | + | pub change_text: String, | |
| 69 | + | pub completed_at: DateTime<Utc>, | |
| 70 | + | } |
| @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; | |||
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | use strum_macros::EnumString; | |
| 11 | 11 | use crate::id_types::ProjectId; | |
| 12 | - | use super::shared::{CssClass, DbValue}; | |
| 12 | + | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 13 | 13 | ||
| 14 | 14 | // ============ Project Types ============ | |
| 15 | 15 | ||
| @@ -54,17 +54,10 @@ impl ProjectType { | |||
| 54 | 54 | } | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | /// Parses a string into a ProjectType, falling back to `Other` on invalid input. | |
| 58 | - | /// | |
| 59 | - | /// This intentional fallback ensures database reads and frontend input never fail, | |
| 60 | - | /// even if the value is from a newer schema version or corrupted. | |
| 61 | - | /// Use `str.parse::<ProjectType>()` if you need error handling. | |
| 62 | - | #[allow(clippy::should_implement_trait)] | |
| 63 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 64 | - | s.parse().unwrap_or_default() | |
| 65 | - | } | |
| 66 | 57 | } | |
| 67 | 58 | ||
| 59 | + | impl ParseableEnum for ProjectType {} | |
| 60 | + | ||
| 68 | 61 | impl DbValue for ProjectType { | |
| 69 | 62 | fn db_value(&self) -> &'static str { | |
| 70 | 63 | match self { | |
| @@ -122,17 +115,10 @@ impl ProjectStatus { | |||
| 122 | 115 | } | |
| 123 | 116 | } | |
| 124 | 117 | ||
| 125 | - | /// Parses a string into a ProjectStatus, falling back to `Active` on invalid input. | |
| 126 | - | /// | |
| 127 | - | /// This intentional fallback ensures database reads and frontend input never fail, | |
| 128 | - | /// even if the value is from a newer schema version or corrupted. | |
| 129 | - | /// Use `str.parse::<ProjectStatus>()` if you need error handling. | |
| 130 | - | #[allow(clippy::should_implement_trait)] | |
| 131 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 132 | - | s.parse().unwrap_or_default() | |
| 133 | - | } | |
| 134 | 118 | } | |
| 135 | 119 | ||
| 120 | + | impl ParseableEnum for ProjectStatus {} | |
| 121 | + | ||
| 136 | 122 | impl DbValue for ProjectStatus { | |
| 137 | 123 | fn db_value(&self) -> &'static str { | |
| 138 | 124 | match self { |
| @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; | |||
| 9 | 9 | use strum_macros::EnumString; | |
| 10 | 10 | use crate::id_types::{SavedViewId, UserId, ProjectId}; | |
| 11 | 11 | ||
| 12 | - | use super::shared::{DbValue, SortDirection}; | |
| 12 | + | use super::shared::{DbValue, ParseableEnum, SortDirection}; | |
| 13 | 13 | ||
| 14 | 14 | // ============ Saved Views ============ | |
| 15 | 15 | ||
| @@ -39,17 +39,10 @@ impl ViewType { | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | /// Parses a string into a ViewType, falling back to `Tasks` on invalid input. | |
| 43 | - | /// | |
| 44 | - | /// This intentional fallback ensures database reads and frontend input never fail, | |
| 45 | - | /// even if the value is from a newer schema version or corrupted. | |
| 46 | - | /// Use `str.parse::<ViewType>()` if you need error handling. | |
| 47 | - | #[allow(clippy::should_implement_trait)] | |
| 48 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 49 | - | s.parse().unwrap_or_default() | |
| 50 | - | } | |
| 51 | 42 | } | |
| 52 | 43 | ||
| 44 | + | impl ParseableEnum for ViewType {} | |
| 45 | + | ||
| 53 | 46 | impl DbValue for ViewType { | |
| 54 | 47 | fn db_value(&self) -> &'static str { | |
| 55 | 48 | match self { |
| @@ -17,6 +17,25 @@ pub trait CssClass { | |||
| 17 | 17 | fn css_class(&self) -> &'static str; | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | /// Trait for enums that can be parsed from a string with a fallback to `Default`. | |
| 21 | + | /// | |
| 22 | + | /// Provides a default `from_str_or_default()` method for enums that derive | |
| 23 | + | /// both `EnumString` and `Default`. This replaces identical boilerplate | |
| 24 | + | /// across 6+ enums. | |
| 25 | + | /// | |
| 26 | + | /// Enums with custom parsing logic (Priority, LlmProviderType, TaskSortColumn, | |
| 27 | + | /// SortDirection, EmailAuthType) keep their manual implementations. | |
| 28 | + | pub trait ParseableEnum: std::str::FromStr + Default { | |
| 29 | + | /// Parses a string into this enum, falling back to `Default` on invalid input. | |
| 30 | + | /// | |
| 31 | + | /// This intentional fallback ensures database reads and frontend input never fail, | |
| 32 | + | /// even if the value is from a newer schema version or corrupted. | |
| 33 | + | #[allow(clippy::should_implement_trait)] | |
| 34 | + | fn from_str_or_default(s: &str) -> Self { | |
| 35 | + | s.parse().unwrap_or_default() | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 20 | 39 | // ============ Shared Enums ============ | |
| 21 | 40 | ||
| 22 | 41 | /// Type of time block for protecting intentional time on the calendar. | |
| @@ -103,17 +122,10 @@ impl Recurrence { | |||
| 103 | 122 | } | |
| 104 | 123 | } | |
| 105 | 124 | ||
| 106 | - | /// Parses a string into a Recurrence, falling back to `None` on invalid input. | |
| 107 | - | /// | |
| 108 | - | /// This intentional fallback ensures database reads and frontend input never fail, | |
| 109 | - | /// even if the value is from a newer schema version or corrupted. | |
| 110 | - | /// Use `str.parse::<Recurrence>()` if you need error handling. | |
| 111 | - | #[allow(clippy::should_implement_trait)] | |
| 112 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 113 | - | s.parse().unwrap_or_default() | |
| 114 | - | } | |
| 115 | 125 | } | |
| 116 | 126 | ||
| 127 | + | impl ParseableEnum for Recurrence {} | |
| 128 | + | ||
| 117 | 129 | impl DbValue for Recurrence { | |
| 118 | 130 | fn db_value(&self) -> &'static str { | |
| 119 | 131 | match self { |
| @@ -14,7 +14,7 @@ use crate::constants::{ | |||
| 14 | 14 | DAYS_THRESHOLD_SHORT_FORMAT, URGENCY_HIGH_THRESHOLD, URGENCY_MEDIUM_THRESHOLD, | |
| 15 | 15 | }; | |
| 16 | 16 | use crate::id_types::{TaskId, ProjectId, MilestoneId, ContactId, EmailId, AnnotationId, SubtaskId}; | |
| 17 | - | use super::shared::{CssClass, DbValue, Recurrence, SortDirection}; | |
| 17 | + | use super::shared::{CssClass, DbValue, ParseableEnum, Recurrence, SortDirection}; | |
| 18 | 18 | ||
| 19 | 19 | // ============ Task Types ============ | |
| 20 | 20 | ||
| @@ -47,25 +47,13 @@ impl TaskStatus { | |||
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | - | /// Parses a string into a TaskStatus, falling back to `Pending` on invalid input. | |
| 51 | - | /// | |
| 52 | - | /// This intentional fallback ensures database reads and frontend input never fail, | |
| 53 | - | /// even if the value is from a newer schema version or corrupted. | |
| 54 | - | /// Use `str.parse::<TaskStatus>()` if you need error handling. | |
| 55 | - | #[allow(clippy::should_implement_trait)] | |
| 56 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 57 | - | s.parse().unwrap_or_default() | |
| 58 | - | } | |
| 59 | 50 | } | |
| 60 | 51 | ||
| 52 | + | impl ParseableEnum for TaskStatus {} | |
| 53 | + | ||
| 61 | 54 | impl DbValue for TaskStatus { | |
| 62 | 55 | fn db_value(&self) -> &'static str { | |
| 63 | - | match self { | |
| 64 | - | TaskStatus::Pending => "Pending", | |
| 65 | - | TaskStatus::Started => "Started", | |
| 66 | - | TaskStatus::Completed => "Completed", | |
| 67 | - | TaskStatus::Deleted => "Deleted", | |
| 68 | - | } | |
| 56 | + | self.as_str() | |
| 69 | 57 | } | |
| 70 | 58 | } | |
| 71 | 59 |
| @@ -0,0 +1,456 @@ | |||
| 1 | + | //! Monthly review aggregation logic. | |
| 2 | + | //! | |
| 3 | + | //! Contains pure functions for computing monthly review data. | |
| 4 | + | //! All I/O is done by the command layer; this module only transforms | |
| 5 | + | //! pre-fetched data into the final response shape. | |
| 6 | + | ||
| 7 | + | use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc}; | |
| 8 | + | use serde::Serialize; | |
| 9 | + | use std::collections::HashMap; | |
| 10 | + | ||
| 11 | + | use crate::id_types::ProjectId; | |
| 12 | + | use crate::models::{Event, MonthlyGoal, MonthlyReflection, Task, TaskStatus}; | |
| 13 | + | use crate::weekly_review::{compute_project_health, ProjectHealth}; | |
| 14 | + | ||
| 15 | + | // ============ Types ============ | |
| 16 | + | ||
| 17 | + | /// Pre-computed monthly review data for the frontend. | |
| 18 | + | #[derive(Debug, Serialize)] | |
| 19 | + | #[serde(rename_all = "camelCase")] | |
| 20 | + | pub struct MonthlyReviewData { | |
| 21 | + | /// Month in YYYY-MM format. | |
| 22 | + | pub month: String, | |
| 23 | + | /// Human-readable display (e.g., "April 2026"). | |
| 24 | + | pub month_display: String, | |
| 25 | + | /// First day of the month. | |
| 26 | + | pub month_start_date: String, | |
| 27 | + | /// Last day of the month. | |
| 28 | + | pub month_end_date: String, | |
| 29 | + | ||
| 30 | + | // ===== Heat Map ===== | |
| 31 | + | /// Per-day data for the calendar heat map. | |
| 32 | + | pub days: Vec<MonthDayData>, | |
| 33 | + | /// Number of weeks (rows) the calendar grid needs. | |
| 34 | + | pub week_count: u32, | |
| 35 | + | /// Day-of-week offset for the 1st (0=Mon, 6=Sun). | |
| 36 | + | pub first_day_offset: u32, | |
| 37 | + | ||
| 38 | + | // ===== Stats ===== | |
| 39 | + | pub tasks_completed_count: usize, | |
| 40 | + | pub tasks_created_count: usize, | |
| 41 | + | pub events_count: usize, | |
| 42 | + | /// Busiest day (most completed tasks). | |
| 43 | + | pub busiest_day: Option<String>, | |
| 44 | + | /// Quietest day (fewest completed tasks, at least 1 day in past). | |
| 45 | + | pub quietest_day: Option<String>, | |
| 46 | + | /// Longest streak of consecutive days with completed tasks. | |
| 47 | + | pub completion_streak: u32, | |
| 48 | + | ||
| 49 | + | // ===== Project Pulse ===== | |
| 50 | + | pub project_pulse: Vec<ProjectPulse>, | |
| 51 | + | ||
| 52 | + | // ===== Project Health ===== | |
| 53 | + | pub project_health: Vec<ProjectHealth>, | |
| 54 | + | ||
| 55 | + | // ===== Goals & Reflection ===== | |
| 56 | + | pub goals: Vec<MonthlyGoal>, | |
| 57 | + | pub reflection: Option<MonthlyReflection>, | |
| 58 | + | ||
| 59 | + | // ===== Patterns ===== | |
| 60 | + | pub patterns: Vec<String>, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Per-day data for the calendar heat map grid. | |
| 64 | + | #[derive(Debug, Clone, Serialize)] | |
| 65 | + | #[serde(rename_all = "camelCase")] | |
| 66 | + | pub struct MonthDayData { | |
| 67 | + | /// Date in YYYY-MM-DD format. | |
| 68 | + | pub date: String, | |
| 69 | + | /// Day of month (1-31). | |
| 70 | + | pub day_number: u32, | |
| 71 | + | /// Whether this is today. | |
| 72 | + | pub is_today: bool, | |
| 73 | + | /// Whether this day is in the past. | |
| 74 | + | pub is_past: bool, | |
| 75 | + | /// Whether this day is a vacation day. | |
| 76 | + | pub is_vacation: bool, | |
| 77 | + | /// Number of tasks completed on this day. | |
| 78 | + | pub completed_count: i32, | |
| 79 | + | /// Number of events on this day. | |
| 80 | + | pub event_count: i32, | |
| 81 | + | /// Activity intensity level: 0 (none), 1 (low), 2 (medium), 3 (high). | |
| 82 | + | pub intensity: u8, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Per-project pulse showing net progress direction. | |
| 86 | + | #[derive(Debug, Clone, Serialize)] | |
| 87 | + | #[serde(rename_all = "camelCase")] | |
| 88 | + | pub struct ProjectPulse { | |
| 89 | + | pub id: ProjectId, | |
| 90 | + | pub name: String, | |
| 91 | + | /// Tasks completed this month in this project. | |
| 92 | + | pub completed: i32, | |
| 93 | + | /// Tasks created this month in this project. | |
| 94 | + | pub created: i32, | |
| 95 | + | /// "growing" if created > completed, "shrinking" if completed > created, "stable" otherwise. | |
| 96 | + | pub direction: String, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// All data needed to compute the monthly review, pre-fetched by the command layer. | |
| 100 | + | pub struct MonthlyReviewInput { | |
| 101 | + | pub month_start: NaiveDate, | |
| 102 | + | pub month_end: NaiveDate, | |
| 103 | + | pub tasks_completed: Vec<Task>, | |
| 104 | + | pub tasks_created: Vec<Task>, | |
| 105 | + | pub events: Vec<Event>, | |
| 106 | + | pub all_tasks: Vec<Task>, | |
| 107 | + | pub projects: Vec<crate::models::Project>, | |
| 108 | + | pub goals: Vec<MonthlyGoal>, | |
| 109 | + | pub reflection: Option<MonthlyReflection>, | |
| 110 | + | pub vacation_days: Vec<NaiveDate>, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // ============ Date Helpers ============ | |
| 114 | + | ||
| 115 | + | /// Gets the first day of the current month. | |
| 116 | + | pub fn current_month_start() -> NaiveDate { | |
| 117 | + | let today = Utc::now().date_naive(); | |
| 118 | + | NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("day 1 is always valid") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Gets the last day of the month. | |
| 122 | + | pub fn month_end(month_start: NaiveDate) -> NaiveDate { | |
| 123 | + | // Move to next month day 1, then subtract 1 day | |
| 124 | + | let (next_year, next_month) = if month_start.month() == 12 { | |
| 125 | + | (month_start.year() + 1, 1) | |
| 126 | + | } else { | |
| 127 | + | (month_start.year(), month_start.month() + 1) | |
| 128 | + | }; | |
| 129 | + | NaiveDate::from_ymd_opt(next_year, next_month, 1) | |
| 130 | + | .expect("next month day 1 is valid") | |
| 131 | + | - Duration::days(1) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// Formats a month for display (e.g., "April 2026"). | |
| 135 | + | pub fn format_month_display(month_start: NaiveDate) -> String { | |
| 136 | + | month_start.format("%B %Y").to_string() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// Parses a YYYY-MM string into the first day of that month. | |
| 140 | + | pub fn parse_month(month_str: &str) -> Option<NaiveDate> { | |
| 141 | + | let parts: Vec<&str> = month_str.split('-').collect(); | |
| 142 | + | if parts.len() != 2 { | |
| 143 | + | return None; | |
| 144 | + | } | |
| 145 | + | let year: i32 = parts[0].parse().ok()?; | |
| 146 | + | let month: u32 = parts[1].parse().ok()?; | |
| 147 | + | NaiveDate::from_ymd_opt(year, month, 1) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // ============ Pure Aggregation ============ | |
| 151 | + | ||
| 152 | + | /// Computes the full monthly review from pre-fetched data. | |
| 153 | + | pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData { | |
| 154 | + | let today = Utc::now().date_naive(); | |
| 155 | + | let month_start = input.month_start; | |
| 156 | + | let month_end_date = input.month_end; | |
| 157 | + | let days_in_month = (month_end_date - month_start).num_days() as u32 + 1; | |
| 158 | + | ||
| 159 | + | // Calendar grid layout | |
| 160 | + | let first_day_offset = month_start.weekday().num_days_from_monday(); | |
| 161 | + | let week_count = (first_day_offset + days_in_month + 6) / 7; | |
| 162 | + | ||
| 163 | + | // Build per-day data | |
| 164 | + | let days: Vec<MonthDayData> = (0..days_in_month) | |
| 165 | + | .map(|day_offset| { | |
| 166 | + | let date = month_start + Duration::days(day_offset as i64); | |
| 167 | + | let day_start = date.and_hms_opt(0, 0, 0) | |
| 168 | + | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 169 | + | .unwrap_or_else(Utc::now); | |
| 170 | + | let day_end = date.and_hms_opt(23, 59, 59) | |
| 171 | + | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 172 | + | .unwrap_or_else(Utc::now); | |
| 173 | + | ||
| 174 | + | let completed_count = input.tasks_completed.iter() | |
| 175 | + | .filter(|t| { | |
| 176 | + | t.completed_at | |
| 177 | + | .map(|ca| ca >= day_start && ca <= day_end) | |
| 178 | + | .unwrap_or(false) | |
| 179 | + | }) | |
| 180 | + | .count() as i32; | |
| 181 | + | ||
| 182 | + | let event_count = input.events.iter() | |
| 183 | + | .filter(|e| e.start_time >= day_start && e.start_time <= day_end) | |
| 184 | + | .count() as i32; | |
| 185 | + | ||
| 186 | + | let activity = completed_count + event_count; | |
| 187 | + | let intensity = match activity { | |
| 188 | + | 0 => 0, | |
| 189 | + | 1..=2 => 1, | |
| 190 | + | 3..=5 => 2, | |
| 191 | + | _ => 3, | |
| 192 | + | }; | |
| 193 | + | ||
| 194 | + | MonthDayData { | |
| 195 | + | date: date.format("%Y-%m-%d").to_string(), | |
| 196 | + | day_number: date.day(), | |
| 197 | + | is_today: date == today, | |
| 198 | + | is_past: date < today, | |
| 199 | + | is_vacation: input.vacation_days.contains(&date), | |
| 200 | + | completed_count, | |
| 201 | + | event_count, | |
| 202 | + | intensity, | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | .collect(); | |
| 206 | + | ||
| 207 | + | // Stats | |
| 208 | + | let tasks_completed_count = input.tasks_completed.len(); | |
| 209 | + | let tasks_created_count = input.tasks_created.len(); | |
| 210 | + | let events_count = input.events.len(); | |
| 211 | + | ||
| 212 | + | // Busiest/quietest day (only past days) | |
| 213 | + | let past_days: Vec<_> = days.iter().filter(|d| d.is_past || d.is_today).collect(); | |
| 214 | + | let busiest_day = past_days.iter() | |
| 215 | + | .max_by_key(|d| d.completed_count) | |
| 216 | + | .filter(|d| d.completed_count > 0) | |
| 217 | + | .map(|d| d.date.clone()); | |
| 218 | + | let quietest_day = past_days.iter() | |
| 219 | + | .filter(|d| d.completed_count == 0 && !d.is_vacation) | |
| 220 | + | .next() | |
| 221 | + | .or_else(|| past_days.iter().min_by_key(|d| d.completed_count)) | |
| 222 | + | .map(|d| d.date.clone()); | |
| 223 | + | ||
| 224 | + | // Completion streak | |
| 225 | + | let completion_streak = compute_streak(&days); | |
| 226 | + | ||
| 227 | + | // Project pulse | |
| 228 | + | let project_pulse = compute_project_pulse(&input.tasks_completed, &input.tasks_created, &input.projects); | |
| 229 | + | ||
| 230 | + | // Project health (reuse weekly review logic) | |
| 231 | + | let project_health = compute_project_health(&input.projects, &input.all_tasks); | |
| 232 | + | ||
| 233 | + | // Patterns | |
| 234 | + | let patterns = compute_patterns(&days, &input.all_tasks, &input.projects, &input.tasks_completed); | |
| 235 | + | ||
| 236 | + | MonthlyReviewData { | |
| 237 | + | month: month_start.format("%Y-%m").to_string(), | |
| 238 | + | month_display: format_month_display(month_start), | |
| 239 | + | month_start_date: month_start.format("%Y-%m-%d").to_string(), | |
| 240 | + | month_end_date: month_end_date.format("%Y-%m-%d").to_string(), | |
| 241 | + | ||
| 242 | + | days, | |
| 243 | + | week_count, | |
| 244 | + | first_day_offset, | |
| 245 | + | ||
| 246 | + | tasks_completed_count, | |
| 247 | + | tasks_created_count, | |
| 248 | + | events_count, | |
| 249 | + | busiest_day, | |
| 250 | + | quietest_day, | |
| 251 | + | completion_streak, | |
| 252 | + | ||
| 253 | + | project_pulse, | |
| 254 | + | project_health, | |
| 255 | + | ||
| 256 | + | goals: input.goals, | |
| 257 | + | reflection: input.reflection, | |
| 258 | + | ||
| 259 | + | patterns, | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// Computes the longest streak of consecutive days with completed tasks. | |
| 264 | + | fn compute_streak(days: &[MonthDayData]) -> u32 { | |
| 265 | + | let mut max_streak = 0u32; | |
| 266 | + | let mut current_streak = 0u32; | |
| 267 | + | ||
| 268 | + | for day in days { | |
| 269 | + | if !day.is_past && !day.is_today { | |
| 270 | + | break; | |
| 271 | + | } | |
| 272 | + | if day.completed_count > 0 { | |
| 273 | + | current_streak += 1; | |
| 274 | + | max_streak = max_streak.max(current_streak); | |
| 275 | + | } else if !day.is_vacation { | |
| 276 | + | current_streak = 0; | |
| 277 | + | } | |
| 278 | + | // Vacation days don't break the streak | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | max_streak | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// Computes per-project pulse data. | |
| 285 | + | fn compute_project_pulse( | |
| 286 | + | tasks_completed: &[Task], | |
| 287 | + | tasks_created: &[Task], | |
| 288 | + | projects: &[crate::models::Project], | |
| 289 | + | ) -> Vec<ProjectPulse> { | |
| 290 | + | let mut completed_by_project: HashMap<ProjectId, i32> = HashMap::new(); | |
| 291 | + | let mut created_by_project: HashMap<ProjectId, i32> = HashMap::new(); | |
| 292 | + | ||
| 293 | + | for task in tasks_completed { | |
| 294 | + | if let Some(pid) = task.project_id { | |
| 295 | + | *completed_by_project.entry(pid).or_default() += 1; | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | for task in tasks_created { | |
| 299 | + | if let Some(pid) = task.project_id { | |
| 300 | + | *created_by_project.entry(pid).or_default() += 1; | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | projects.iter() | |
| 305 | + | .filter_map(|p| { | |
| 306 | + | let completed = completed_by_project.get(&p.id).copied().unwrap_or(0); | |
| 307 | + | let created = created_by_project.get(&p.id).copied().unwrap_or(0); | |
| 308 | + | ||
| 309 | + | if completed == 0 && created == 0 { | |
| 310 | + | return None; | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | let direction = if created > completed { | |
| 314 | + | "growing" | |
| 315 | + | } else if completed > created { | |
| 316 | + | "shrinking" | |
| 317 | + | } else { | |
| 318 | + | "stable" | |
| 319 | + | }.to_string(); | |
| 320 | + | ||
| 321 | + | Some(ProjectPulse { | |
| 322 | + | id: p.id, | |
| 323 | + | name: p.name.clone(), | |
| 324 | + | completed, | |
| 325 | + | created, | |
| 326 | + | direction, | |
| 327 | + | }) | |
| 328 | + | }) | |
| 329 | + | .collect() | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Computes simple pattern observations. | |
| 333 | + | fn compute_patterns( | |
| 334 | + | days: &[MonthDayData], | |
| 335 | + | all_tasks: &[Task], | |
| 336 | + | projects: &[crate::models::Project], | |
| 337 | + | tasks_completed: &[Task], | |
| 338 | + | ) -> Vec<String> { | |
| 339 | + | let mut patterns = Vec::new(); | |
| 340 | + | ||
| 341 | + | // Day-of-week productivity pattern | |
| 342 | + | let day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; | |
| 343 | + | let mut completions_by_dow = [0i32; 7]; | |
| 344 | + | let mut days_counted_by_dow = [0i32; 7]; | |
| 345 | + | ||
| 346 | + | for day in days.iter().filter(|d| d.is_past || d.is_today) { | |
| 347 | + | if let Some(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d").ok() { | |
| 348 | + | let dow = date.weekday().num_days_from_monday() as usize; | |
| 349 | + | completions_by_dow[dow] += day.completed_count; | |
| 350 | + | days_counted_by_dow[dow] += 1; | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // Find most productive day(s) of week | |
| 355 | + | let max_completions = completions_by_dow.iter().max().copied().unwrap_or(0); | |
| 356 | + | if max_completions > 0 { | |
| 357 | + | let best_days: Vec<&str> = completions_by_dow.iter() | |
| 358 | + | .enumerate() | |
| 359 | + | .filter(|(_, &c)| c == max_completions && c > 0) | |
| 360 | + | .map(|(i, _)| day_names[i]) | |
| 361 | + | .collect(); | |
| 362 | + | ||
| 363 | + | if best_days.len() <= 2 && max_completions >= 3 { | |
| 364 | + | patterns.push(format!( | |
| 365 | + | "You completed the most tasks on {}", | |
| 366 | + | best_days.join(" and ") | |
| 367 | + | )); | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // Chronic overdue tasks (3+ weeks overdue) | |
| 372 | + | let now = Utc::now(); | |
| 373 | + | let chronic_overdue: Vec<_> = all_tasks.iter() | |
| 374 | + | .filter(|t| { | |
| 375 | + | t.is_overdue() && t.due.map(|d| (now - d).num_weeks() >= 3).unwrap_or(false) | |
| 376 | + | }) | |
| 377 | + | .collect(); | |
| 378 | + | if !chronic_overdue.is_empty() { | |
| 379 | + | patterns.push(format!( | |
| 380 | + | "{} task{} been overdue for 3+ weeks", | |
| 381 | + | chronic_overdue.len(), | |
| 382 | + | if chronic_overdue.len() == 1 { " has" } else { "s have" } | |
| 383 | + | )); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // Inactive projects | |
| 387 | + | let active_project_ids: std::collections::HashSet<_> = tasks_completed.iter() | |
| 388 | + | .filter_map(|t| t.project_id) | |
| 389 | + | .collect(); | |
| 390 | + | let inactive_projects: Vec<_> = projects.iter() | |
| 391 | + | .filter(|p| { | |
| 392 | + | !active_project_ids.contains(&p.id) && | |
| 393 | + | all_tasks.iter().any(|t| t.project_id == Some(p.id) && (t.status == TaskStatus::Pending || t.status == TaskStatus::Started)) | |
| 394 | + | }) | |
| 395 | + | .collect(); | |
| 396 | + | for p in inactive_projects.iter().take(2) { | |
| 397 | + | patterns.push(format!("{} had no activity this month", p.name)); | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | patterns | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | #[cfg(test)] | |
| 404 | + | mod tests { | |
| 405 | + | use super::*; | |
| 406 | + | ||
| 407 | + | #[test] | |
| 408 | + | fn test_month_end() { | |
| 409 | + | let jan = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); | |
| 410 | + | assert_eq!(month_end(jan), NaiveDate::from_ymd_opt(2026, 1, 31).unwrap()); | |
| 411 | + | ||
| 412 | + | let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(); | |
| 413 | + | assert_eq!(month_end(feb), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap()); | |
| 414 | + | ||
| 415 | + | let dec = NaiveDate::from_ymd_opt(2025, 12, 1).unwrap(); | |
| 416 | + | assert_eq!(month_end(dec), NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()); | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | #[test] | |
| 420 | + | fn test_format_month_display() { | |
| 421 | + | let april = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(); | |
| 422 | + | assert_eq!(format_month_display(april), "April 2026"); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn test_parse_month() { | |
| 427 | + | assert_eq!( | |
| 428 | + | parse_month("2026-04"), | |
| 429 | + | Some(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()) | |
| 430 | + | ); | |
| 431 | + | assert_eq!(parse_month("invalid"), None); | |
| 432 | + | assert_eq!(parse_month("2026-13"), None); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn test_compute_streak() { | |
| 437 | + | let days = vec![ | |
| 438 | + | MonthDayData { date: "2026-04-01".into(), day_number: 1, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, | |
| 439 | + | MonthDayData { date: "2026-04-02".into(), day_number: 2, is_today: false, is_past: true, is_vacation: false, completed_count: 2, event_count: 0, intensity: 2 }, | |
| 440 | + | MonthDayData { date: "2026-04-03".into(), day_number: 3, is_today: false, is_past: true, is_vacation: false, completed_count: 0, event_count: 0, intensity: 0 }, | |
| 441 | + | MonthDayData { date: "2026-04-04".into(), day_number: 4, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, | |
| 442 | + | MonthDayData { date: "2026-04-05".into(), day_number: 5, is_today: true, is_past: false, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, | |
| 443 | + | ]; | |
| 444 | + | assert_eq!(compute_streak(&days), 2); // days 1-2, then broken by day 3 | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | #[test] | |
| 448 | + | fn test_streak_vacation_doesnt_break() { | |
| 449 | + | let days = vec![ | |
| 450 | + | MonthDayData { date: "2026-04-01".into(), day_number: 1, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, | |
| 451 | + | MonthDayData { date: "2026-04-02".into(), day_number: 2, is_today: false, is_past: true, is_vacation: true, completed_count: 0, event_count: 0, intensity: 0 }, | |
| 452 | + | MonthDayData { date: "2026-04-03".into(), day_number: 3, is_today: true, is_past: false, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, | |
| 453 | + | ]; | |
| 454 | + | assert_eq!(compute_streak(&days), 2); // vacation doesn't break streak | |
| 455 | + | } | |
| 456 | + | } |
| @@ -170,6 +170,9 @@ pub trait TaskRepository: Send + Sync { | |||
| 170 | 170 | /// Lists tasks due within a date range (for weekly review). | |
| 171 | 171 | async fn list_due_between(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Task>>; | |
| 172 | 172 | ||
| 173 | + | /// Lists tasks created within a date range (for monthly review). | |
| 174 | + | async fn list_created_between(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Task>>; | |
| 175 | + | ||
| 173 | 176 | /// Lists high-priority pending tasks for focus selection. | |
| 174 | 177 | async fn list_available_for_focus(&self, user_id: UserId, limit: i64) -> Result<Vec<Task>>; | |
| 175 | 178 | } | |
| @@ -707,6 +710,28 @@ pub trait WeeklyReviewRepository: Send + Sync { | |||
| 707 | 710 | async fn set_vacation_days(&self, user_id: UserId, week_start: NaiveDate, days: &[u8]) -> Result<()>; | |
| 708 | 711 | } | |
| 709 | 712 | ||
| 713 | + | /// Repository for monthly review goals and reflections. | |
| 714 | + | #[async_trait] | |
| 715 | + | pub trait MonthlyReviewRepository: Send + Sync { | |
| 716 | + | /// Gets all goals for a specific month. | |
| 717 | + | async fn list_goals(&self, user_id: UserId, month: &str) -> Result<Vec<crate::models::MonthlyGoal>>; | |
| 718 | + | ||
| 719 | + | /// Creates or updates a goal for a month at a given position (1-3). | |
| 720 | + | async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<crate::models::MonthlyGoal>; | |
| 721 | + | ||
| 722 | + | /// Updates the status of a goal. | |
| 723 | + | async fn update_goal_status(&self, id: crate::MonthlyGoalId, user_id: UserId, status: &crate::models::MonthlyGoalStatus) -> Result<Option<crate::models::MonthlyGoal>>; | |
| 724 | + | ||
| 725 | + | /// Deletes a goal. | |
| 726 | + | async fn delete_goal(&self, id: crate::MonthlyGoalId, user_id: UserId) -> Result<bool>; | |
| 727 | + | ||
| 728 | + | /// Gets the reflection for a specific month. | |
| 729 | + | async fn get_reflection(&self, user_id: UserId, month: &str) -> Result<Option<crate::models::MonthlyReflection>>; | |
| 730 | + | ||
| 731 | + | /// Creates or updates the reflection for a month. | |
| 732 | + | async fn upsert_reflection(&self, user_id: UserId, month: &str, highlight: &str, change: &str) -> Result<crate::models::MonthlyReflection>; | |
| 733 | + | } | |
| 734 | + | ||
| 710 | 735 | /// Repository for milestone management within projects. | |
| 711 | 736 | #[async_trait] | |
| 712 | 737 | pub trait MilestoneRepository: Send + Sync { |
| @@ -24,6 +24,41 @@ fn validate_tag(tag: &str) -> Result<(), CoreError> { | |||
| 24 | 24 | .map_err(|e| CoreError::validation("tags", e.0)) | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | /// Validates that a required string field is non-empty and within a max length. | |
| 28 | + | fn validate_required_string(field: &'static str, value: &str, max_len: usize) -> Result<(), CoreError> { | |
| 29 | + | if value.trim().is_empty() { | |
| 30 | + | return Err(CoreError::validation(field, "cannot be empty")); | |
| 31 | + | } | |
| 32 | + | if value.len() > max_len { | |
| 33 | + | return Err(CoreError::validation( | |
| 34 | + | field, | |
| 35 | + | format!("must be {} characters or less", max_len), | |
| 36 | + | )); | |
| 37 | + | } | |
| 38 | + | Ok(()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Validates an optional duration is positive and within a max. | |
| 42 | + | fn validate_optional_duration(field: &'static str, value: Option<i32>, max: i32) -> Result<(), CoreError> { | |
| 43 | + | if let Some(duration) = value { | |
| 44 | + | if duration <= 0 { | |
| 45 | + | return Err(CoreError::validation(field, "must be positive")); | |
| 46 | + | } | |
| 47 | + | if duration > max { | |
| 48 | + | return Err(CoreError::validation(field, "cannot exceed 24 hours")); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | Ok(()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Validates a slice of tags against the GoingsOn tag config. | |
| 55 | + | fn validate_tags(tags: &[String]) -> Result<(), CoreError> { | |
| 56 | + | for tag in tags { | |
| 57 | + | validate_tag(tag)?; | |
| 58 | + | } | |
| 59 | + | Ok(()) | |
| 60 | + | } | |
| 61 | + | ||
| 27 | 62 | /// A trait for types that can validate their own data before persistence. | |
| 28 | 63 | /// | |
| 29 | 64 | /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`, | |
| @@ -50,32 +85,14 @@ pub trait Validate { | |||
| 50 | 85 | // Project validation: non-empty name within MAX_PROJECT_NAME_LENGTH. | |
| 51 | 86 | impl Validate for NewProject { | |
| 52 | 87 | fn validate(&self) -> Result<(), CoreError> { | |
| 53 | - | if self.name.trim().is_empty() { | |
| 54 | - | return Err(CoreError::validation("name", "cannot be empty")); | |
| 55 | - | } | |
| 56 | - | if self.name.len() > MAX_PROJECT_NAME_LENGTH { | |
| 57 | - | return Err(CoreError::validation( | |
| 58 | - | "name", | |
| 59 | - | format!("must be {} characters or less", MAX_PROJECT_NAME_LENGTH), | |
| 60 | - | )); | |
| 61 | - | } | |
| 62 | - | Ok(()) | |
| 88 | + | validate_required_string("name", &self.name, MAX_PROJECT_NAME_LENGTH) | |
| 63 | 89 | } | |
| 64 | 90 | } | |
| 65 | 91 | ||
| 66 | 92 | // Same rules as NewProject — updates must also pass validation. | |
| 67 | 93 | impl Validate for UpdateProject { | |
| 68 | 94 | fn validate(&self) -> Result<(), CoreError> { | |
| 69 | - | if self.name.trim().is_empty() { | |
| 70 | - | return Err(CoreError::validation("name", "cannot be empty")); | |
| 71 | - | } | |
| 72 | - | if self.name.len() > MAX_PROJECT_NAME_LENGTH { | |
| 73 | - | return Err(CoreError::validation( | |
| 74 | - | "name", | |
| 75 | - | format!("must be {} characters or less", MAX_PROJECT_NAME_LENGTH), | |
| 76 | - | )); | |
| 77 | - | } | |
| 78 | - | Ok(()) | |
| 95 | + | validate_required_string("name", &self.name, MAX_PROJECT_NAME_LENGTH) | |
| 79 | 96 | } | |
| 80 | 97 | } | |
| 81 | 98 | ||
| @@ -83,69 +100,25 @@ impl Validate for UpdateProject { | |||
| 83 | 100 | // tags validated via tagtree (lowercase, dot-separated, max 3 levels, 60 chars). | |
| 84 | 101 | impl Validate for NewTask { | |
| 85 | 102 | fn validate(&self) -> Result<(), CoreError> { | |
| 86 | - | if self.description.trim().is_empty() { | |
| 87 | - | return Err(CoreError::validation("description", "cannot be empty")); | |
| 88 | - | } | |
| 89 | - | if self.description.len() > MAX_TASK_DESCRIPTION_LENGTH { | |
| 90 | - | return Err(CoreError::validation( | |
| 91 | - | "description", | |
| 92 | - | format!("must be {} characters or less", MAX_TASK_DESCRIPTION_LENGTH), | |
| 93 | - | )); | |
| 94 | - | } | |
| 95 | - | if let Some(duration) = self.scheduled_duration { | |
| 96 | - | if duration <= 0 { | |
| 97 | - | return Err(CoreError::validation("scheduled_duration", "must be positive")); | |
| 98 | - | } | |
| 99 | - | if duration > MAX_SCHEDULED_DURATION_MINUTES { | |
| 100 | - | return Err(CoreError::validation("scheduled_duration", "cannot exceed 24 hours")); | |
| 101 | - | } | |
| 102 | - | } | |
| 103 | - | for tag in &self.tags { | |
| 104 | - | validate_tag(tag)?; | |
| 105 | - | } | |
| 106 | - | Ok(()) | |
| 103 | + | validate_required_string("description", &self.description, MAX_TASK_DESCRIPTION_LENGTH)?; | |
| 104 | + | validate_optional_duration("scheduled_duration", self.scheduled_duration, MAX_SCHEDULED_DURATION_MINUTES)?; | |
| 105 | + | validate_tags(&self.tags) | |
| 107 | 106 | } | |
| 108 | 107 | } | |
| 109 | 108 | ||
| 110 | 109 | // Same rules as NewTask — updates must also pass validation. | |
| 111 | 110 | impl Validate for UpdateTask { | |
| 112 | 111 | fn validate(&self) -> Result<(), CoreError> { | |
| 113 | - | if self.description.trim().is_empty() { | |
| 114 | - | return Err(CoreError::validation("description", "cannot be empty")); | |
| 115 | - | } | |
| 116 | - | if self.description.len() > MAX_TASK_DESCRIPTION_LENGTH { | |
| 117 | - | return Err(CoreError::validation( | |
| 118 | - | "description", | |
| 119 | - | format!("must be {} characters or less", MAX_TASK_DESCRIPTION_LENGTH), | |
| 120 | - | )); | |
| 121 | - | } | |
| 122 | - | if let Some(duration) = self.scheduled_duration { | |
| 123 | - | if duration <= 0 { | |
| 124 | - | return Err(CoreError::validation("scheduled_duration", "must be positive")); | |
| 125 | - | } | |
| 126 | - | if duration > MAX_SCHEDULED_DURATION_MINUTES { | |
| 127 | - | return Err(CoreError::validation("scheduled_duration", "cannot exceed 24 hours")); | |
| 128 | - | } | |
| 129 | - | } | |
| 130 | - | for tag in &self.tags { | |
| 131 | - | validate_tag(tag)?; | |
| 132 | - | } | |
| 133 | - | Ok(()) | |
| 112 | + | validate_required_string("description", &self.description, MAX_TASK_DESCRIPTION_LENGTH)?; | |
| 113 | + | validate_optional_duration("scheduled_duration", self.scheduled_duration, MAX_SCHEDULED_DURATION_MINUTES)?; | |
| 114 | + | validate_tags(&self.tags) | |
| 134 | 115 | } | |
| 135 | 116 | } | |
| 136 | 117 | ||
| 137 | 118 | // Event validation: non-empty title, end_time must be after start_time. | |
| 138 | 119 | impl Validate for NewEvent { | |
| 139 | 120 | fn validate(&self) -> Result<(), CoreError> { | |
| 140 | - | if self.title.trim().is_empty() { | |
| 141 | - | return Err(CoreError::validation("title", "cannot be empty")); | |
| 142 | - | } | |
| 143 | - | if self.title.len() > MAX_EVENT_TITLE_LENGTH { | |
| 144 | - | return Err(CoreError::validation( | |
| 145 | - | "title", | |
| 146 | - | format!("must be {} characters or less", MAX_EVENT_TITLE_LENGTH), | |
| 147 | - | )); | |
| 148 | - | } | |
| 121 | + | validate_required_string("title", &self.title, MAX_EVENT_TITLE_LENGTH)?; | |
| 149 | 122 | if let Some(end) = self.end_time { | |
| 150 | 123 | if end <= self.start_time { | |
| 151 | 124 | return Err(CoreError::validation("end_time", "must be after start_time")); | |
| @@ -158,15 +131,7 @@ impl Validate for NewEvent { | |||
| 158 | 131 | // Same rules as NewEvent — updates must also pass validation. | |
| 159 | 132 | impl Validate for UpdateEvent { | |
| 160 | 133 | fn validate(&self) -> Result<(), CoreError> { | |
| 161 | - | if self.title.trim().is_empty() { | |
| 162 | - | return Err(CoreError::validation("title", "cannot be empty")); | |
| 163 | - | } | |
| 164 | - | if self.title.len() > MAX_EVENT_TITLE_LENGTH { | |
| 165 | - | return Err(CoreError::validation( | |
| 166 | - | "title", | |
| 167 | - | format!("must be {} characters or less", MAX_EVENT_TITLE_LENGTH), | |
| 168 | - | )); | |
| 169 | - | } | |
| 134 | + | validate_required_string("title", &self.title, MAX_EVENT_TITLE_LENGTH)?; | |
| 170 | 135 | if let Some(end) = self.end_time { | |
| 171 | 136 | if end <= self.start_time { | |
| 172 | 137 | return Err(CoreError::validation("end_time", "must be after start_time")); | |
| @@ -178,22 +143,8 @@ impl Validate for UpdateEvent { | |||
| 178 | 143 | ||
| 179 | 144 | // Contact validation: non-empty display_name, valid tags. | |
| 180 | 145 | fn validate_contact_fields(display_name: &str, tags: &[String]) -> Result<(), CoreError> { | |
| 181 | - | if display_name.trim().is_empty() { | |
| 182 | - | return Err(CoreError::validation("display_name", "cannot be empty")); | |
| 183 | - | } | |
| 184 | - | if display_name.len() > MAX_CONTACT_DISPLAY_NAME_LENGTH { | |
| 185 | - | return Err(CoreError::validation( | |
| 186 | - | "display_name", | |
| 187 | - | format!( | |
| 188 | - | "must be {} characters or less", | |
| 189 | - | MAX_CONTACT_DISPLAY_NAME_LENGTH | |
| 190 | - | ), | |
| 191 | - | )); | |
| 192 | - | } | |
| 193 | - | for tag in tags { | |
| 194 | - | validate_tag(tag)?; | |
| 195 | - | } | |
| 196 | - | Ok(()) | |
| 146 | + | validate_required_string("display_name", display_name, MAX_CONTACT_DISPLAY_NAME_LENGTH)?; | |
| 147 | + | validate_tags(tags) | |
| 197 | 148 | } | |
| 198 | 149 | ||
| 199 | 150 | impl Validate for NewContact { |
| @@ -58,6 +58,7 @@ pub use repository::{ | |||
| 58 | 58 | SqliteLlmSettingsRepository, | |
| 59 | 59 | SqliteLlmCacheRepository, | |
| 60 | 60 | SqliteMilestoneRepository, | |
| 61 | + | SqliteMonthlyReviewRepository, | |
| 61 | 62 | SqliteSavedViewRepository, | |
| 62 | 63 | SqliteWeeklyReviewRepository, | |
| 63 | 64 | }; |
| @@ -10,8 +10,8 @@ use async_trait::async_trait; | |||
| 10 | 10 | use chrono::NaiveDate; | |
| 11 | 11 | use sqlx::SqlitePool; | |
| 12 | 12 | use goingson_core::{ | |
| 13 | - | BlockType, CoreError, DbValue, Event, EventId, EventRepository, NewEvent, ProjectId, | |
| 14 | - | Recurrence, Result, TaskId, UpdateEvent, UserId, | |
| 13 | + | BlockType, CoreError, DbValue, Event, EventId, EventRepository, NewEvent, ParseableEnum, | |
| 14 | + | ProjectId, Recurrence, Result, TaskId, UpdateEvent, UserId, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| 17 | 17 | use crate::utils::{format_datetime, format_datetime_opt, parse_datetime, parse_uuid, parse_uuid_opt}; |
| @@ -10,7 +10,7 @@ use chrono::NaiveDate; | |||
| 10 | 10 | use sqlx::SqlitePool; | |
| 11 | 11 | use goingson_core::{ | |
| 12 | 12 | CoreError, DbValue, Milestone, MilestoneId, MilestoneRepository, MilestoneStatus, | |
| 13 | - | NewMilestone, ProjectId, Result, UserId, | |
| 13 | + | NewMilestone, ParseableEnum, ProjectId, Result, UserId, | |
| 14 | 14 | }; | |
| 15 | 15 | ||
| 16 | 16 | use crate::utils::{format_datetime_now, parse_datetime, parse_uuid}; |
| @@ -9,6 +9,7 @@ mod project_repo; | |||
| 9 | 9 | mod annotation_repo; | |
| 10 | 10 | mod subtask_repo; | |
| 11 | 11 | mod task_repo; | |
| 12 | + | mod task_repo_state; | |
| 12 | 13 | mod event_repo; | |
| 13 | 14 | mod email_repo; | |
| 14 | 15 | mod user_repo; | |
| @@ -18,6 +19,7 @@ mod search_repo; | |||
| 18 | 19 | mod llm_repo; | |
| 19 | 20 | mod saved_view_repo; | |
| 20 | 21 | mod milestone_repo; | |
| 22 | + | mod monthly_review_repo; | |
| 21 | 23 | mod weekly_review_repo; | |
| 22 | 24 | ||
| 23 | 25 | pub use backup_settings_repo::SqliteBackupSettingsRepository; | |
| @@ -33,4 +35,5 @@ pub use search_repo::SqliteSearchRepository; | |||
| 33 | 35 | pub use llm_repo::{SqliteLlmSettingsRepository, SqliteLlmCacheRepository}; | |
| 34 | 36 | pub use saved_view_repo::SqliteSavedViewRepository; | |
| 35 | 37 | pub use milestone_repo::SqliteMilestoneRepository; | |
| 38 | + | pub use monthly_review_repo::SqliteMonthlyReviewRepository; | |
| 36 | 39 | pub use weekly_review_repo::SqliteWeeklyReviewRepository; |
| @@ -0,0 +1,279 @@ | |||
| 1 | + | //! SQLite implementation of the MonthlyReviewRepository. | |
| 2 | + | //! | |
| 3 | + | //! Provides monthly goal and reflection persistence. | |
| 4 | + | ||
| 5 | + | use async_trait::async_trait; | |
| 6 | + | use chrono::Utc; | |
| 7 | + | use sqlx::SqlitePool; | |
| 8 | + | use goingson_core::{ | |
| 9 | + | CoreError, MonthlyGoal, MonthlyGoalId, MonthlyGoalStatus, MonthlyReflection, | |
| 10 | + | MonthlyReflectionId, MonthlyReviewRepository, Result, UserId, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | use crate::utils::{format_datetime, parse_datetime, parse_uuid}; | |
| 14 | + | ||
| 15 | + | /// SQLite-backed implementation of [`MonthlyReviewRepository`]. | |
| 16 | + | pub struct SqliteMonthlyReviewRepository { | |
| 17 | + | pool: SqlitePool, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | impl SqliteMonthlyReviewRepository { | |
| 21 | + | pub fn new(pool: SqlitePool) -> Self { | |
| 22 | + | Self { pool } | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // ============ Row Types ============ | |
| 27 | + | ||
| 28 | + | #[derive(sqlx::FromRow)] | |
| 29 | + | struct MonthlyGoalRow { | |
| 30 | + | id: String, | |
| 31 | + | user_id: String, | |
| 32 | + | month: String, | |
| 33 | + | text: String, | |
| 34 | + | status: String, | |
| 35 | + | position: i32, | |
| 36 | + | created_at: String, | |
| 37 | + | updated_at: String, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[derive(sqlx::FromRow)] | |
| 41 | + | struct MonthlyReflectionRow { | |
| 42 | + | id: String, | |
| 43 | + | user_id: String, | |
| 44 | + | month: String, | |
| 45 | + | highlight_text: String, | |
| 46 | + | change_text: String, | |
| 47 | + | completed_at: String, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ============ Conversions ============ | |
| 51 | + | ||
| 52 | + | impl TryFrom<MonthlyGoalRow> for MonthlyGoal { | |
| 53 | + | type Error = CoreError; | |
| 54 | + | ||
| 55 | + | fn try_from(row: MonthlyGoalRow) -> Result<Self> { | |
| 56 | + | Ok(MonthlyGoal { | |
| 57 | + | id: parse_uuid(&row.id)?.into(), | |
| 58 | + | user_id: parse_uuid(&row.user_id)?.into(), | |
| 59 | + | month: row.month, | |
| 60 | + | text: row.text, | |
| 61 | + | status: row.status.parse()?, | |
| 62 | + | position: row.position, | |
| 63 | + | created_at: parse_datetime(&row.created_at)?, | |
| 64 | + | updated_at: parse_datetime(&row.updated_at)?, | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | impl TryFrom<MonthlyReflectionRow> for MonthlyReflection { | |
| 70 | + | type Error = CoreError; | |
| 71 | + | ||
| 72 | + | fn try_from(row: MonthlyReflectionRow) -> Result<Self> { | |
| 73 | + | Ok(MonthlyReflection { | |
| 74 | + | id: parse_uuid(&row.id)?.into(), | |
| 75 | + | user_id: parse_uuid(&row.user_id)?.into(), | |
| 76 | + | month: row.month, | |
| 77 | + | highlight_text: row.highlight_text, | |
| 78 | + | change_text: row.change_text, | |
| 79 | + | completed_at: parse_datetime(&row.completed_at)?, | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // ============ Repository Implementation ============ | |
| 85 | + | ||
| 86 | + | #[async_trait] | |
| 87 | + | impl MonthlyReviewRepository for SqliteMonthlyReviewRepository { | |
| 88 | + | async fn list_goals(&self, user_id: UserId, month: &str) -> Result<Vec<MonthlyGoal>> { | |
| 89 | + | let user_id_str = user_id.to_string(); | |
| 90 | + | ||
| 91 | + | let rows: Vec<MonthlyGoalRow> = sqlx::query_as( | |
| 92 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 93 | + | FROM monthly_goals | |
| 94 | + | WHERE user_id = ? AND month = ? | |
| 95 | + | ORDER BY position" | |
| 96 | + | ) | |
| 97 | + | .bind(&user_id_str) | |
| 98 | + | .bind(month) | |
| 99 | + | .fetch_all(&self.pool) | |
| 100 | + | .await | |
| 101 | + | .map_err(CoreError::database)?; | |
| 102 | + | ||
| 103 | + | rows.into_iter().map(MonthlyGoal::try_from).collect() | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<MonthlyGoal> { | |
| 107 | + | let user_id_str = user_id.to_string(); | |
| 108 | + | let now = format_datetime(&Utc::now()); | |
| 109 | + | ||
| 110 | + | // Check if a goal exists at this position for this month | |
| 111 | + | let existing: Option<MonthlyGoalRow> = sqlx::query_as( | |
| 112 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 113 | + | FROM monthly_goals | |
| 114 | + | WHERE user_id = ? AND month = ? AND position = ?" | |
| 115 | + | ) | |
| 116 | + | .bind(&user_id_str) | |
| 117 | + | .bind(month) | |
| 118 | + | .bind(position) | |
| 119 | + | .fetch_optional(&self.pool) | |
| 120 | + | .await | |
| 121 | + | .map_err(CoreError::database)?; | |
| 122 | + | ||
| 123 | + | if let Some(existing) = existing { | |
| 124 | + | let id = existing.id.clone(); | |
| 125 | + | sqlx::query( | |
| 126 | + | "UPDATE monthly_goals SET text = ?, updated_at = ? WHERE id = ?" | |
| 127 | + | ) | |
| 128 | + | .bind(text) | |
| 129 | + | .bind(&now) | |
| 130 | + | .bind(&id) | |
| 131 | + | .execute(&self.pool) | |
| 132 | + | .await | |
| 133 | + | .map_err(CoreError::database)?; | |
| 134 | + | ||
| 135 | + | let mut goal = MonthlyGoal::try_from(existing)?; | |
| 136 | + | goal.text = text.to_string(); | |
| 137 | + | goal.updated_at = Utc::now(); | |
| 138 | + | Ok(goal) | |
| 139 | + | } else { | |
| 140 | + | let id = MonthlyGoalId::new(); | |
| 141 | + | sqlx::query( | |
| 142 | + | "INSERT INTO monthly_goals (id, user_id, month, text, status, position, created_at, updated_at) | |
| 143 | + | VALUES (?, ?, ?, ?, 'active', ?, ?, ?)" | |
| 144 | + | ) | |
| 145 | + | .bind(id.to_string()) | |
| 146 | + | .bind(&user_id_str) | |
| 147 | + | .bind(month) | |
| 148 | + | .bind(text) | |
| 149 | + | .bind(position) | |
| 150 | + | .bind(&now) | |
| 151 | + | .bind(&now) | |
| 152 | + | .execute(&self.pool) | |
| 153 | + | .await | |
| 154 | + | .map_err(CoreError::database)?; | |
| 155 | + | ||
| 156 | + | let now_dt = Utc::now(); | |
| 157 | + | Ok(MonthlyGoal { | |
| 158 | + | id, | |
| 159 | + | user_id, | |
| 160 | + | month: month.to_string(), | |
| 161 | + | text: text.to_string(), | |
| 162 | + | status: MonthlyGoalStatus::Active, | |
| 163 | + | position, | |
| 164 | + | created_at: now_dt, | |
| 165 | + | updated_at: now_dt, | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | async fn update_goal_status(&self, id: MonthlyGoalId, user_id: UserId, status: &MonthlyGoalStatus) -> Result<Option<MonthlyGoal>> { | |
| 171 | + | let user_id_str = user_id.to_string(); | |
| 172 | + | let id_str = id.to_string(); | |
| 173 | + | let now = format_datetime(&Utc::now()); | |
| 174 | + | ||
| 175 | + | let result = sqlx::query( | |
| 176 | + | "UPDATE monthly_goals SET status = ?, updated_at = ? WHERE id = ? AND user_id = ?" | |
| 177 | + | ) | |
| 178 | + | .bind(status.as_str()) | |
| 179 | + | .bind(&now) | |
| 180 | + | .bind(&id_str) | |
| 181 | + | .bind(&user_id_str) | |
| 182 | + | .execute(&self.pool) | |
| 183 | + | .await | |
| 184 | + | .map_err(CoreError::database)?; | |
| 185 | + | ||
| 186 | + | if result.rows_affected() == 0 { | |
| 187 | + | return Ok(None); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | let row: MonthlyGoalRow = sqlx::query_as( | |
| 191 | + | "SELECT id, user_id, month, text, status, position, created_at, updated_at | |
| 192 | + | FROM monthly_goals WHERE id = ?" | |
| 193 | + | ) | |
| 194 | + | .bind(&id_str) | |
| 195 | + | .fetch_one(&self.pool) | |
| 196 | + | .await | |
| 197 | + | .map_err(CoreError::database)?; | |
| 198 | + | ||
| 199 | + | Ok(Some(MonthlyGoal::try_from(row)?)) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | async fn delete_goal(&self, id: MonthlyGoalId, user_id: UserId) -> Result<bool> { | |
| 203 | + | let result = sqlx::query( | |
| 204 | + | "DELETE FROM monthly_goals WHERE id = ? AND user_id = ?" | |
| 205 | + | ) | |
| 206 | + | .bind(id.to_string()) | |
| 207 | + | .bind(user_id.to_string()) | |
| 208 | + | .execute(&self.pool) | |
| 209 | + | .await | |
| 210 | + | .map_err(CoreError::database)?; | |
| 211 | + | ||
| 212 | + | Ok(result.rows_affected() > 0) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | async fn get_reflection(&self, user_id: UserId, month: &str) -> Result<Option<MonthlyReflection>> { | |
| 216 | + | let row: Option<MonthlyReflectionRow> = sqlx::query_as( | |
| 217 | + | "SELECT id, user_id, month, highlight_text, change_text, completed_at | |
| 218 | + | FROM monthly_reflections | |
| 219 | + | WHERE user_id = ? AND month = ?" | |
| 220 | + | ) | |
| 221 | + | .bind(user_id.to_string()) | |
| 222 | + | .bind(month) | |
| 223 | + | .fetch_optional(&self.pool) | |
| 224 | + | .await | |
| 225 | + | .map_err(CoreError::database)?; | |
| 226 | + | ||
| 227 | + | row.map(MonthlyReflection::try_from).transpose() | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | async fn upsert_reflection(&self, user_id: UserId, month: &str, highlight: &str, change: &str) -> Result<MonthlyReflection> { | |
| 231 | + | let user_id_str = user_id.to_string(); | |
| 232 | + | let now = Utc::now(); | |
| 233 | + | let now_str = format_datetime(&now); | |
| 234 | + | ||
| 235 | + | let existing = self.get_reflection(user_id, month).await?; | |
| 236 | + | ||
| 237 | + | let id = if let Some(existing) = existing { | |
| 238 | + | sqlx::query( | |
| 239 | + | "UPDATE monthly_reflections SET highlight_text = ?, change_text = ?, completed_at = ? | |
| 240 | + | WHERE id = ?" | |
| 241 | + | ) | |
| 242 | + | .bind(highlight) | |
| 243 | + | .bind(change) | |
| 244 | + | .bind(&now_str) | |
| 245 | + | .bind(existing.id.to_string()) | |
| 246 | + | .execute(&self.pool) | |
| 247 | + | .await | |
| 248 | + | .map_err(CoreError::database)?; | |
| 249 | + | ||
| 250 | + | existing.id | |
| 251 | + | } else { | |
| 252 | + | let id = MonthlyReflectionId::new(); | |
| 253 | + | sqlx::query( | |
| 254 | + | "INSERT INTO monthly_reflections (id, user_id, month, highlight_text, change_text, completed_at) | |
| 255 | + | VALUES (?, ?, ?, ?, ?, ?)" | |
| 256 | + | ) | |
| 257 | + | .bind(id.to_string()) | |
| 258 | + | .bind(&user_id_str) | |
| 259 | + | .bind(month) | |
| 260 | + | .bind(highlight) | |
| 261 | + | .bind(change) | |
| 262 | + | .bind(&now_str) | |
| 263 | + | .execute(&self.pool) | |
| 264 | + | .await | |
| 265 | + | .map_err(CoreError::database)?; | |
| 266 | + | ||
| 267 | + | id | |
| 268 | + | }; | |
| 269 | + | ||
| 270 | + | Ok(MonthlyReflection { | |
| 271 | + | id, | |
| 272 | + | user_id, | |
| 273 | + | month: month.to_string(), | |
| 274 | + | highlight_text: highlight.to_string(), | |
| 275 | + | change_text: change.to_string(), | |
| 276 | + | completed_at: now, | |
| 277 | + | }) | |
| 278 | + | } | |
| 279 | + | } |