Skip to main content

max / goingson

Mobile UX improvements, monthly review, maintainability splits Phase 2 Calendar Views: Day/Week/Month pills under Time tab. Heat-map calendar with day tap summary sheet, monthly goals (form modal), reflection auto-save, project pulse, completion streaks, pattern detection. Migration 034, 5 Tauri commands, 5 core unit tests. Maintainability splits: task commands, sync service, day planning, updater, and navigation all split into focused submodules. Mobile touch: auto-grow textareas, pull-to-refresh on reviews, swipe nav between Day/Week/Month, nav dial sub-view expansion via long-press. Fix navigateToDay bug (setDate → state.set). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-07 21:10 UTC
Commit: 7fd322177aeabaa9bcc5f9ce01af0b899c168c5b
Parent: 4e918b4
108 files changed, +12622 insertions, -5237 deletions
A CHANGELOG.md +30
@@ -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
M Cargo.lock +2 -25
@@ -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",
M Cargo.toml +2 -2
@@ -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 + }
M docs/styleguide.md +124 -140
M docs/todo/todo.md +152 -49