max / goingson
26 files changed,
+1240 insertions,
-94 deletions
| @@ -68,7 +68,8 @@ pub use models::{ | |||
| 68 | 68 | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, | |
| 69 | 69 | AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, | |
| 70 | 70 | NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, | |
| 71 | - | Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection, | |
| 71 | + | Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, RecurrenceRule, MonthlySpec, | |
| 72 | + | SavedView, SortDirection, | |
| 72 | 73 | SyncAccount, | |
| 73 | 74 | SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 74 | 75 | TimeTrackingSummary, UpdateEvent, UpdateProject, UpdateTask, User, | |
| @@ -77,7 +78,7 @@ pub use models::{ | |||
| 77 | 78 | }; | |
| 78 | 79 | pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult}; | |
| 79 | 80 | pub use day_planning::{Conflict, TimelineItem, detect_conflicts}; | |
| 80 | - | pub use recurrence::{calculate_next_due, calculate_next_due_with_day, should_recur}; | |
| 81 | + | pub use recurrence::{calculate_next_due, calculate_next_due_with_day, calculate_next_due_rich, expand_recurrence, should_recur}; | |
| 81 | 82 | pub use repository::*; | |
| 82 | 83 | pub use urgency::calculate_urgency; | |
| 83 | 84 | pub use plugin::{ |
| @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; | |||
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | use crate::constants::DAYS_THRESHOLD_SHORT_FORMAT; | |
| 11 | 11 | use crate::id_types::{EventId, UserId, ProjectId, ContactId, TaskId}; | |
| 12 | - | use super::shared::{BlockType, Recurrence}; | |
| 12 | + | use super::shared::{BlockType, Recurrence, RecurrenceRule}; | |
| 13 | 13 | ||
| 14 | 14 | // ============ Event ============ | |
| 15 | 15 | ||
| @@ -45,10 +45,15 @@ pub struct Event { | |||
| 45 | 45 | pub location: Option<String>, | |
| 46 | 46 | /// If this is a time-block, the linked task ID. | |
| 47 | 47 | pub linked_task_id: Option<TaskId>, | |
| 48 | - | /// Recurrence pattern. | |
| 48 | + | /// Recurrence pattern (legacy, used when recurrence_rule is absent). | |
| 49 | 49 | pub recurrence: Recurrence, | |
| 50 | + | /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`. | |
| 51 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 50 | 52 | /// Original event ID if this is a recurrence instance. | |
| 51 | 53 | pub recurrence_parent_id: Option<EventId>, | |
| 54 | + | /// True if this is a virtual instance produced by recurrence expansion (not persisted). | |
| 55 | + | #[serde(default)] | |
| 56 | + | pub is_recurring_instance: bool, | |
| 52 | 57 | /// If this is a time block, the block type. | |
| 53 | 58 | pub block_type: Option<BlockType>, | |
| 54 | 59 | /// External sync source (e.g., "vcf", "ics", "google", "apple"). | |
| @@ -124,7 +129,13 @@ impl Event { | |||
| 124 | 129 | ||
| 125 | 130 | /// Returns true if the event has a recurrence pattern set (not `None`). | |
| 126 | 131 | pub fn has_recurrence(&self) -> bool { | |
| 127 | - | self.recurrence != Recurrence::None | |
| 132 | + | self.recurrence_rule.is_some() || self.recurrence != Recurrence::None | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Returns the effective recurrence rule, synthesizing from the legacy | |
| 136 | + | /// column if no explicit rule is set. | |
| 137 | + | pub fn effective_recurrence_rule(&self) -> Option<RecurrenceRule> { | |
| 138 | + | RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence) | |
| 128 | 139 | } | |
| 129 | 140 | ||
| 130 | 141 | /// Returns true if this event is a time-block linked to a task. | |
| @@ -158,6 +169,8 @@ pub struct NewEvent { | |||
| 158 | 169 | pub linked_task_id: Option<TaskId>, | |
| 159 | 170 | /// Recurrence pattern (None, Daily, Weekly, Monthly). | |
| 160 | 171 | pub recurrence: Recurrence, | |
| 172 | + | /// Rich recurrence configuration (JSON). | |
| 173 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 161 | 174 | /// Block type classification (focus, meeting, break, etc.). | |
| 162 | 175 | pub block_type: Option<BlockType>, | |
| 163 | 176 | } | |
| @@ -183,6 +196,8 @@ pub struct UpdateEvent { | |||
| 183 | 196 | pub linked_task_id: Option<TaskId>, | |
| 184 | 197 | /// Recurrence pattern (None, Daily, Weekly, Monthly). | |
| 185 | 198 | pub recurrence: Recurrence, | |
| 199 | + | /// Rich recurrence configuration (JSON). | |
| 200 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 186 | 201 | /// Block type classification (focus, meeting, break, etc.). | |
| 187 | 202 | pub block_type: Option<BlockType>, | |
| 188 | 203 | } | |
| @@ -220,6 +235,7 @@ pub struct NewEventBuilder { | |||
| 220 | 235 | location: Option<String>, | |
| 221 | 236 | linked_task_id: Option<TaskId>, | |
| 222 | 237 | recurrence: Recurrence, | |
| 238 | + | recurrence_rule: Option<RecurrenceRule>, | |
| 223 | 239 | block_type: Option<BlockType>, | |
| 224 | 240 | } | |
| 225 | 241 | ||
| @@ -237,6 +253,7 @@ impl NewEventBuilder { | |||
| 237 | 253 | location: None, | |
| 238 | 254 | linked_task_id: None, | |
| 239 | 255 | recurrence: Recurrence::default(), | |
| 256 | + | recurrence_rule: None, | |
| 240 | 257 | block_type: None, | |
| 241 | 258 | } | |
| 242 | 259 | } | |
| @@ -289,6 +306,12 @@ impl NewEventBuilder { | |||
| 289 | 306 | self | |
| 290 | 307 | } | |
| 291 | 308 | ||
| 309 | + | /// Sets the rich recurrence rule. | |
| 310 | + | pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self { | |
| 311 | + | self.recurrence_rule = Some(rule); | |
| 312 | + | self | |
| 313 | + | } | |
| 314 | + | ||
| 292 | 315 | /// Sets the block type. | |
| 293 | 316 | pub fn block_type(mut self, block_type: BlockType) -> Self { | |
| 294 | 317 | self.block_type = Some(block_type); | |
| @@ -308,6 +331,7 @@ impl NewEventBuilder { | |||
| 308 | 331 | location: self.location, | |
| 309 | 332 | linked_task_id: self.linked_task_id, | |
| 310 | 333 | recurrence: self.recurrence, | |
| 334 | + | recurrence_rule: self.recurrence_rule, | |
| 311 | 335 | block_type: self.block_type, | |
| 312 | 336 | } | |
| 313 | 337 | } |
| @@ -137,6 +137,124 @@ impl DbValue for Recurrence { | |||
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | + | // ============ Rich Recurrence Rules ============ | |
| 141 | + | ||
| 142 | + | /// Rich recurrence configuration stored as JSON. | |
| 143 | + | /// | |
| 144 | + | /// Extends the simple `Recurrence` enum with interval, weekday selection, | |
| 145 | + | /// and monthly specification. When absent in the database, a default rule | |
| 146 | + | /// is synthesized from the legacy `recurrence` column. | |
| 147 | + | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 148 | + | #[serde(rename_all = "camelCase")] | |
| 149 | + | pub struct RecurrenceRule { | |
| 150 | + | /// Base pattern: Daily, Weekly, Monthly. | |
| 151 | + | pub pattern: Recurrence, | |
| 152 | + | /// Repeat every N intervals (e.g., every 2 weeks). Default 1. | |
| 153 | + | #[serde(default = "default_interval")] | |
| 154 | + | pub interval: u32, | |
| 155 | + | /// For Weekly: which days (0=Mon, 1=Tue, ..., 6=Sun). | |
| 156 | + | /// Empty means same weekday as `start_time`. | |
| 157 | + | #[serde(default)] | |
| 158 | + | pub weekdays: Vec<u8>, | |
| 159 | + | /// For Monthly: day-of-month or Nth weekday specification. | |
| 160 | + | #[serde(default)] | |
| 161 | + | pub monthly_spec: Option<MonthlySpec>, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | fn default_interval() -> u32 { 1 } | |
| 165 | + | ||
| 166 | + | impl RecurrenceRule { | |
| 167 | + | /// Create a simple rule from a legacy `Recurrence` enum value. | |
| 168 | + | pub fn from_legacy(recurrence: &Recurrence) -> Option<Self> { | |
| 169 | + | if matches!(recurrence, Recurrence::None) { | |
| 170 | + | return None; | |
| 171 | + | } | |
| 172 | + | Some(Self { | |
| 173 | + | pattern: recurrence.clone(), | |
| 174 | + | interval: 1, | |
| 175 | + | weekdays: vec![], | |
| 176 | + | monthly_spec: None, | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Returns the effective rule: the explicit rule if set, or one | |
| 181 | + | /// synthesized from the legacy recurrence column. | |
| 182 | + | pub fn effective(rule: Option<&RecurrenceRule>, recurrence: &Recurrence) -> Option<RecurrenceRule> { | |
| 183 | + | rule.cloned().or_else(|| RecurrenceRule::from_legacy(recurrence)) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Human-readable display string (e.g., "Every 2 weeks on Mon, Wed, Fri"). | |
| 187 | + | pub fn display(&self) -> String { | |
| 188 | + | let freq = match self.pattern { | |
| 189 | + | Recurrence::Daily => "day", | |
| 190 | + | Recurrence::Weekly => "week", | |
| 191 | + | Recurrence::Monthly => "month", | |
| 192 | + | Recurrence::None => return String::new(), | |
| 193 | + | }; | |
| 194 | + | ||
| 195 | + | let mut parts = Vec::new(); | |
| 196 | + | ||
| 197 | + | if self.interval == 1 { | |
| 198 | + | parts.push(format!("Every {}", freq)); | |
| 199 | + | } else { | |
| 200 | + | parts.push(format!("Every {} {}s", self.interval, freq)); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | if !self.weekdays.is_empty() && matches!(self.pattern, Recurrence::Weekly) { | |
| 204 | + | let day_names: Vec<&str> = self.weekdays.iter().filter_map(|&d| { | |
| 205 | + | WEEKDAY_NAMES.get(d as usize).copied() | |
| 206 | + | }).collect(); | |
| 207 | + | if !day_names.is_empty() { | |
| 208 | + | parts.push(format!("on {}", day_names.join(", "))); | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | if let Some(ref spec) = self.monthly_spec { | |
| 213 | + | match spec { | |
| 214 | + | MonthlySpec::DayOfMonth { day } => { | |
| 215 | + | parts.push(format!("on day {}", day)); | |
| 216 | + | } | |
| 217 | + | MonthlySpec::NthWeekday { week, weekday } => { | |
| 218 | + | let week_label = match week { | |
| 219 | + | -1 => "last".to_string(), | |
| 220 | + | w => format!("{}{}", w, ordinal_suffix(*w as i32)), | |
| 221 | + | }; | |
| 222 | + | let day_name = WEEKDAY_NAMES.get(*weekday as usize).unwrap_or(&"?"); | |
| 223 | + | parts.push(format!("on the {} {}", week_label, day_name)); | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | parts.join(" ") | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | const WEEKDAY_NAMES: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; | |
| 233 | + | ||
| 234 | + | fn ordinal_suffix(n: i32) -> &'static str { | |
| 235 | + | match (n % 10, n % 100) { | |
| 236 | + | (1, 11) => "th", | |
| 237 | + | (2, 12) => "th", | |
| 238 | + | (3, 13) => "th", | |
| 239 | + | (1, _) => "st", | |
| 240 | + | (2, _) => "nd", | |
| 241 | + | (3, _) => "rd", | |
| 242 | + | _ => "th", | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Monthly recurrence specification. | |
| 247 | + | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 248 | + | #[serde(tag = "type", rename_all = "camelCase")] | |
| 249 | + | pub enum MonthlySpec { | |
| 250 | + | /// Specific day of month (1-31), clamped to the month's length. | |
| 251 | + | DayOfMonth { day: u32 }, | |
| 252 | + | /// Nth weekday of the month (e.g., 2nd Friday, last Monday). | |
| 253 | + | /// `week`: 1-4 for fixed, -1 for last. | |
| 254 | + | /// `weekday`: 0=Mon..6=Sun. | |
| 255 | + | NthWeekday { week: i8, weekday: u8 }, | |
| 256 | + | } | |
| 257 | + | ||
| 140 | 258 | /// Sort direction for queries. | |
| 141 | 259 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 142 | 260 | #[serde(rename_all = "lowercase")] |
| @@ -15,7 +15,7 @@ use crate::constants::{ | |||
| 15 | 15 | }; | |
| 16 | 16 | use crate::id_types::{TaskId, ProjectId, MilestoneId, ContactId, EmailId, AnnotationId, SubtaskId}; | |
| 17 | 17 | use super::time_session::TimeSession; | |
| 18 | - | use super::shared::{CssClass, DbValue, ParseableEnum, Recurrence, SortDirection}; | |
| 18 | + | use super::shared::{CssClass, DbValue, ParseableEnum, Recurrence, RecurrenceRule, SortDirection}; | |
| 19 | 19 | ||
| 20 | 20 | // ============ Task Types ============ | |
| 21 | 21 | ||
| @@ -206,8 +206,10 @@ pub struct Task { | |||
| 206 | 206 | pub tags: Vec<String>, | |
| 207 | 207 | /// Calculated urgency score for sorting. | |
| 208 | 208 | pub urgency: f64, | |
| 209 | - | /// Recurrence pattern for repeating tasks. | |
| 209 | + | /// Recurrence pattern for repeating tasks (legacy). | |
| 210 | 210 | pub recurrence: Recurrence, | |
| 211 | + | /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`. | |
| 212 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 211 | 213 | /// Original task ID if this is a recurrence instance. | |
| 212 | 214 | pub recurrence_parent_id: Option<TaskId>, | |
| 213 | 215 | /// Email this task was created from, if any. | |
| @@ -283,7 +285,13 @@ impl Task { | |||
| 283 | 285 | ||
| 284 | 286 | /// Returns true if the task has a recurrence pattern set (not `None`). | |
| 285 | 287 | pub fn has_recurrence(&self) -> bool { | |
| 286 | - | self.recurrence != Recurrence::None | |
| 288 | + | self.recurrence_rule.is_some() || self.recurrence != Recurrence::None | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// Returns the effective recurrence rule, synthesizing from the legacy | |
| 292 | + | /// column if no explicit rule is set. | |
| 293 | + | pub fn effective_recurrence_rule(&self) -> Option<RecurrenceRule> { | |
| 294 | + | RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence) | |
| 287 | 295 | } | |
| 288 | 296 | ||
| 289 | 297 | /// Returns the project name, or `"-"` if unset. The dash fallback is used | |
| @@ -483,6 +491,8 @@ pub struct NewTask { | |||
| 483 | 491 | pub tags: Vec<String>, | |
| 484 | 492 | /// Recurrence pattern (None, Daily, Weekly, Monthly). | |
| 485 | 493 | pub recurrence: Recurrence, | |
| 494 | + | /// Rich recurrence configuration (JSON). | |
| 495 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 486 | 496 | /// Pre-calculated urgency score based on priority, due date, age, and tags. | |
| 487 | 497 | pub urgency: f64, | |
| 488 | 498 | /// Email this task was created from, if any (set by email-to-task flow). | |
| @@ -526,6 +536,7 @@ pub struct NewTaskBuilder { | |||
| 526 | 536 | due: Option<DateTime<Utc>>, | |
| 527 | 537 | tags: Vec<String>, | |
| 528 | 538 | recurrence: Recurrence, | |
| 539 | + | recurrence_rule: Option<RecurrenceRule>, | |
| 529 | 540 | urgency: f64, | |
| 530 | 541 | source_email_id: Option<EmailId>, | |
| 531 | 542 | scheduled_start: Option<DateTime<Utc>>, | |
| @@ -545,6 +556,7 @@ impl NewTaskBuilder { | |||
| 545 | 556 | due: None, | |
| 546 | 557 | tags: Vec::new(), | |
| 547 | 558 | recurrence: Recurrence::default(), | |
| 559 | + | recurrence_rule: None, | |
| 548 | 560 | urgency: 0.0, | |
| 549 | 561 | source_email_id: None, | |
| 550 | 562 | scheduled_start: None, | |
| @@ -601,6 +613,12 @@ impl NewTaskBuilder { | |||
| 601 | 613 | self | |
| 602 | 614 | } | |
| 603 | 615 | ||
| 616 | + | /// Sets the rich recurrence rule. | |
| 617 | + | pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self { | |
| 618 | + | self.recurrence_rule = Some(rule); | |
| 619 | + | self | |
| 620 | + | } | |
| 621 | + | ||
| 604 | 622 | /// Sets the urgency score. | |
| 605 | 623 | pub fn urgency(mut self, urgency: f64) -> Self { | |
| 606 | 624 | self.urgency = urgency; | |
| @@ -642,6 +660,7 @@ impl NewTaskBuilder { | |||
| 642 | 660 | due: self.due, | |
| 643 | 661 | tags: self.tags, | |
| 644 | 662 | recurrence: self.recurrence, | |
| 663 | + | recurrence_rule: self.recurrence_rule, | |
| 645 | 664 | urgency: self.urgency, | |
| 646 | 665 | source_email_id: self.source_email_id, | |
| 647 | 666 | scheduled_start: self.scheduled_start, |
| @@ -1,7 +1,8 @@ | |||
| 1 | 1 | //! Recurring task and event scheduling logic. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc}; | |
| 4 | - | use crate::models::Recurrence; | |
| 4 | + | use uuid::Uuid; | |
| 5 | + | use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec}; | |
| 5 | 6 | ||
| 6 | 7 | /// Calculate the next due date based on recurrence type. | |
| 7 | 8 | /// | |
| @@ -112,6 +113,195 @@ pub fn should_recur(recurrence: &Recurrence) -> bool { | |||
| 112 | 113 | !matches!(recurrence, Recurrence::None) | |
| 113 | 114 | } | |
| 114 | 115 | ||
| 116 | + | // ============ Rich Recurrence ============ | |
| 117 | + | ||
| 118 | + | /// Namespace UUID for generating deterministic v5 IDs for recurring instances. | |
| 119 | + | const RECURRENCE_NS: Uuid = Uuid::from_bytes([ | |
| 120 | + | 0x8a, 0x3f, 0xc7, 0x12, 0xe0, 0x4b, 0x4d, 0x9a, | |
| 121 | + | 0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, | |
| 122 | + | ]); | |
| 123 | + | ||
| 124 | + | /// Calculate the next due date using a rich recurrence rule. | |
| 125 | + | /// | |
| 126 | + | /// Handles intervals, weekday selection, and monthly specifications. | |
| 127 | + | /// Falls back to simple recurrence for rules with interval=1 and no extras. | |
| 128 | + | pub fn calculate_next_due_rich( | |
| 129 | + | current_due: Option<&DateTime<Utc>>, | |
| 130 | + | rule: &RecurrenceRule, | |
| 131 | + | ) -> Option<DateTime<Utc>> { | |
| 132 | + | if matches!(rule.pattern, Recurrence::None) { | |
| 133 | + | return None; | |
| 134 | + | } | |
| 135 | + | let base = current_due.copied().unwrap_or_else(Utc::now); | |
| 136 | + | let interval = rule.interval.max(1) as i64; | |
| 137 | + | ||
| 138 | + | match rule.pattern { | |
| 139 | + | Recurrence::Daily => { | |
| 140 | + | Some(base + Duration::days(interval)) | |
| 141 | + | } | |
| 142 | + | Recurrence::Weekly => { | |
| 143 | + | if rule.weekdays.is_empty() { | |
| 144 | + | return Some(base + Duration::weeks(interval)); | |
| 145 | + | } | |
| 146 | + | // Find next matching weekday | |
| 147 | + | let current_wd = base.weekday().num_days_from_monday() as u8; | |
| 148 | + | let mut sorted_days = rule.weekdays.clone(); | |
| 149 | + | sorted_days.sort_unstable(); | |
| 150 | + | sorted_days.dedup(); | |
| 151 | + | ||
| 152 | + | // Look for next day in the current week (after current weekday) | |
| 153 | + | if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) { | |
| 154 | + | let diff = (next_wd - current_wd) as i64; | |
| 155 | + | return Some(base + Duration::days(diff)); | |
| 156 | + | } | |
| 157 | + | // Wrap to first day of next interval-week | |
| 158 | + | let first_wd = sorted_days[0]; | |
| 159 | + | let days_to_end = 7 - current_wd as i64; | |
| 160 | + | let skip_weeks = (interval - 1) * 7; | |
| 161 | + | let days = days_to_end + skip_weeks + first_wd as i64; | |
| 162 | + | Some(base + Duration::days(days)) | |
| 163 | + | } | |
| 164 | + | Recurrence::Monthly => { | |
| 165 | + | match &rule.monthly_spec { | |
| 166 | + | Some(MonthlySpec::DayOfMonth { day }) => { | |
| 167 | + | let next = add_months(base, interval as i32, Some(*day)); | |
| 168 | + | Some(next) | |
| 169 | + | } | |
| 170 | + | Some(MonthlySpec::NthWeekday { week, weekday }) => { | |
| 171 | + | let next_base = add_months(base, interval as i32, None); | |
| 172 | + | let target = nth_weekday_in_month( | |
| 173 | + | next_base.year(), next_base.month(), | |
| 174 | + | *week, *weekday, | |
| 175 | + | next_base.hour(), next_base.minute(), next_base.second(), | |
| 176 | + | ); | |
| 177 | + | Some(target.unwrap_or(next_base)) | |
| 178 | + | } | |
| 179 | + | None => { | |
| 180 | + | // Same as legacy monthly | |
| 181 | + | let target_day = { | |
| 182 | + | let day = base.day(); | |
| 183 | + | let month_len = days_in_month(base.year(), base.month()); | |
| 184 | + | if day == month_len && day >= 29 && day < 31 { Some(31) } else { None } | |
| 185 | + | }; | |
| 186 | + | Some(add_months(base, interval as i32, target_day)) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | Recurrence::None => None, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Find the Nth weekday in a given month. | |
| 195 | + | /// `week`: 1-4 for ordinal, -1 for last. | |
| 196 | + | /// `weekday`: 0=Mon..6=Sun. | |
| 197 | + | fn nth_weekday_in_month( | |
| 198 | + | year: i32, month: u32, | |
| 199 | + | week: i8, weekday: u8, | |
| 200 | + | hour: u32, minute: u32, second: u32, | |
| 201 | + | ) -> Option<DateTime<Utc>> { | |
| 202 | + | use chrono::NaiveDate; | |
| 203 | + | ||
| 204 | + | let weekday_chrono = match weekday { | |
| 205 | + | 0 => chrono::Weekday::Mon, | |
| 206 | + | 1 => chrono::Weekday::Tue, | |
| 207 | + | 2 => chrono::Weekday::Wed, | |
| 208 | + | 3 => chrono::Weekday::Thu, | |
| 209 | + | 4 => chrono::Weekday::Fri, | |
| 210 | + | 5 => chrono::Weekday::Sat, | |
| 211 | + | 6 => chrono::Weekday::Sun, | |
| 212 | + | _ => return None, | |
| 213 | + | }; | |
| 214 | + | ||
| 215 | + | if week == -1 { | |
| 216 | + | // Last occurrence: start from end of month, walk backward | |
| 217 | + | let last_day = days_in_month(year, month); | |
| 218 | + | let end = NaiveDate::from_ymd_opt(year, month, last_day)?; | |
| 219 | + | let mut d = end; | |
| 220 | + | while d.weekday() != weekday_chrono { | |
| 221 | + | d = d.pred_opt()?; | |
| 222 | + | } | |
| 223 | + | Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single() | |
| 224 | + | } else if (1..=5).contains(&week) { | |
| 225 | + | // Nth occurrence: start from day 1, find first matching weekday, skip N-1 | |
| 226 | + | let first = NaiveDate::from_ymd_opt(year, month, 1)?; | |
| 227 | + | let mut d = first; | |
| 228 | + | while d.weekday() != weekday_chrono { | |
| 229 | + | d = d.succ_opt()?; | |
| 230 | + | } | |
| 231 | + | // d is the 1st occurrence; advance (week-1) weeks | |
| 232 | + | d = d.checked_add_signed(chrono::TimeDelta::weeks((week - 1) as i64))?; | |
| 233 | + | if d.month() != month { | |
| 234 | + | return None; // e.g., 5th Monday doesn't exist | |
| 235 | + | } | |
| 236 | + | Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single() | |
| 237 | + | } else { | |
| 238 | + | None | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// Expand a recurring event into virtual instances within a date range. | |
| 243 | + | /// | |
| 244 | + | /// Returns clones of the parent event with adjusted times and synthetic IDs. | |
| 245 | + | /// The parent event itself is NOT included unless its `start_time` falls in range. | |
| 246 | + | /// Caps expansion at 500 iterations to prevent runaway loops. | |
| 247 | + | pub fn expand_recurrence( | |
| 248 | + | event: &Event, | |
| 249 | + | range_start: DateTime<Utc>, | |
| 250 | + | range_end: DateTime<Utc>, | |
| 251 | + | ) -> Vec<Event> { | |
| 252 | + | let rule = match event.effective_recurrence_rule() { | |
| 253 | + | Some(r) => r, | |
| 254 | + | None => return vec![], | |
| 255 | + | }; | |
| 256 | + | ||
| 257 | + | let event_duration = event.end_time | |
| 258 | + | .map(|e| e - event.start_time) | |
| 259 | + | .unwrap_or_else(|| Duration::hours(1)); | |
| 260 | + | ||
| 261 | + | let mut instances = Vec::new(); | |
| 262 | + | let mut cursor = event.start_time; | |
| 263 | + | let max_iterations = 500; | |
| 264 | + | ||
| 265 | + | for _ in 0..max_iterations { | |
| 266 | + | if cursor > range_end { | |
| 267 | + | break; | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | let instance_end = cursor + event_duration; | |
| 271 | + | ||
| 272 | + | // Check if this occurrence overlaps the range | |
| 273 | + | if instance_end >= range_start && cursor <= range_end { | |
| 274 | + | // Skip the original event (it exists in DB as-is) | |
| 275 | + | if cursor != event.start_time { | |
| 276 | + | let synthetic_id = generate_instance_id(event.id, cursor); | |
| 277 | + | let mut instance = event.clone(); | |
| 278 | + | instance.id = synthetic_id; | |
| 279 | + | instance.start_time = cursor; | |
| 280 | + | instance.end_time = Some(instance_end); | |
| 281 | + | instance.is_recurring_instance = true; | |
| 282 | + | instance.recurrence_parent_id = Some(event.id); | |
| 283 | + | instances.push(instance); | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // Advance to next occurrence | |
| 288 | + | match calculate_next_due_rich(Some(&cursor), &rule) { | |
| 289 | + | Some(next) if next > cursor => cursor = next, | |
| 290 | + | _ => break, // prevent infinite loop | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | instances | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// Generate a deterministic synthetic ID for a recurring event instance. | |
| 298 | + | fn generate_instance_id(parent_id: crate::id_types::EventId, occurrence_time: DateTime<Utc>) -> crate::id_types::EventId { | |
| 299 | + | let mut name = parent_id.as_uuid().as_bytes().to_vec(); | |
| 300 | + | name.extend_from_slice(&occurrence_time.timestamp().to_le_bytes()); | |
| 301 | + | let id = Uuid::new_v5(&RECURRENCE_NS, &name); | |
| 302 | + | crate::id_types::EventId::from_uuid(id) | |
| 303 | + | } | |
| 304 | + | ||
| 115 | 305 | #[cfg(test)] | |
| 116 | 306 | mod tests { | |
| 117 | 307 | use super::*; | |
| @@ -325,4 +515,195 @@ mod tests { | |||
| 325 | 515 | new_urgency | |
| 326 | 516 | ); | |
| 327 | 517 | } | |
| 518 | + | ||
| 519 | + | // ============ Rich Recurrence Tests ============ | |
| 520 | + | ||
| 521 | + | #[test] | |
| 522 | + | fn test_rich_daily_interval() { | |
| 523 | + | let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap(); | |
| 524 | + | let rule = RecurrenceRule { | |
| 525 | + | pattern: Recurrence::Daily, | |
| 526 | + | interval: 3, | |
| 527 | + | weekdays: vec![], | |
| 528 | + | monthly_spec: None, | |
| 529 | + | }; | |
| 530 | + | let next = calculate_next_due_rich(Some(&now), &rule).unwrap(); | |
| 531 | + | assert_eq!(next.day(), 4); // 3 days later | |
| 532 | + | assert_eq!(next.hour(), 9); | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | #[test] | |
| 536 | + | fn test_rich_weekly_weekdays() { | |
| 537 | + | // Monday, requesting Mon/Wed/Fri | |
| 538 | + | let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday | |
| 539 | + | let rule = RecurrenceRule { | |
| 540 | + | pattern: Recurrence::Weekly, | |
| 541 | + | interval: 1, | |
| 542 | + | weekdays: vec![0, 2, 4], // Mon, Wed, Fri | |
| 543 | + | monthly_spec: None, | |
| 544 | + | }; | |
| 545 | + | // Next after Monday should be Wednesday | |
| 546 | + | let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); | |
| 547 | + | assert_eq!(next.weekday(), chrono::Weekday::Wed); | |
| 548 | + | assert_eq!(next.day(), 4); | |
| 549 | + | ||
| 550 | + | // Next after Wednesday should be Friday | |
| 551 | + | let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap(); | |
| 552 | + | assert_eq!(next2.weekday(), chrono::Weekday::Fri); | |
| 553 | + | assert_eq!(next2.day(), 6); | |
| 554 | + | ||
| 555 | + | // Next after Friday should be Monday of next week | |
| 556 | + | let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap(); | |
| 557 | + | assert_eq!(next3.weekday(), chrono::Weekday::Mon); | |
| 558 | + | assert_eq!(next3.day(), 9); | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | #[test] | |
| 562 | + | fn test_rich_weekly_interval_2() { | |
| 563 | + | // Friday, every 2 weeks on Mon/Fri | |
| 564 | + | let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday | |
| 565 | + | let rule = RecurrenceRule { | |
| 566 | + | pattern: Recurrence::Weekly, | |
| 567 | + | interval: 2, | |
| 568 | + | weekdays: vec![0, 4], // Mon, Fri | |
| 569 | + | monthly_spec: None, | |
| 570 | + | }; | |
| 571 | + | // Next after Friday: wrap to Mon of 2-weeks-later | |
| 572 | + | let next = calculate_next_due_rich(Some(&fri), &rule).unwrap(); | |
| 573 | + | assert_eq!(next.weekday(), chrono::Weekday::Mon); | |
| 574 | + | assert_eq!(next.day(), 16); // 2 weeks later, Monday | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | #[test] | |
| 578 | + | fn test_rich_monthly_day_of_month() { | |
| 579 | + | let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); | |
| 580 | + | let rule = RecurrenceRule { | |
| 581 | + | pattern: Recurrence::Monthly, | |
| 582 | + | interval: 1, | |
| 583 | + | weekdays: vec![], | |
| 584 | + | monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }), | |
| 585 | + | }; | |
| 586 | + | let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); | |
| 587 | + | assert_eq!(next.month(), 2); | |
| 588 | + | assert_eq!(next.day(), 15); | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | #[test] | |
| 592 | + | fn test_rich_monthly_nth_weekday() { | |
| 593 | + | // 2nd Friday of January 2026 is Jan 9... let me compute | |
| 594 | + | // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri) | |
| 595 | + | let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap(); | |
| 596 | + | let rule = RecurrenceRule { | |
| 597 | + | pattern: Recurrence::Monthly, | |
| 598 | + | interval: 1, | |
| 599 | + | weekdays: vec![], | |
| 600 | + | monthly_spec: Some(MonthlySpec::NthWeekday { week: 2, weekday: 4 }), // 2nd Friday | |
| 601 | + | }; | |
| 602 | + | let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); | |
| 603 | + | // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri) | |
| 604 | + | assert_eq!(next.month(), 2); | |
| 605 | + | assert_eq!(next.day(), 13); | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | #[test] | |
| 609 | + | fn test_rich_monthly_last_weekday() { | |
| 610 | + | let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap(); | |
| 611 | + | let rule = RecurrenceRule { | |
| 612 | + | pattern: Recurrence::Monthly, | |
| 613 | + | interval: 1, | |
| 614 | + | weekdays: vec![], | |
| 615 | + | monthly_spec: Some(MonthlySpec::NthWeekday { week: -1, weekday: 0 }), // Last Monday | |
| 616 | + | }; | |
| 617 | + | let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); | |
| 618 | + | // Feb 2026: last Monday is Feb 23 | |
| 619 | + | assert_eq!(next.month(), 2); | |
| 620 | + | assert_eq!(next.day(), 23); | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | #[test] | |
| 624 | + | fn test_expand_recurrence_weekly() { | |
| 625 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday | |
| 626 | + | let event = Event { | |
| 627 | + | id: crate::id_types::EventId::new(), | |
| 628 | + | user_id: None, | |
| 629 | + | project_id: None, | |
| 630 | + | project_name: None, | |
| 631 | + | contact_id: None, | |
| 632 | + | contact_name: None, | |
| 633 | + | title: "Weekly meeting".to_string(), | |
| 634 | + | description: String::new(), | |
| 635 | + | start_time: start, | |
| 636 | + | end_time: Some(start + Duration::hours(1)), | |
| 637 | + | location: None, | |
| 638 | + | linked_task_id: None, | |
| 639 | + | recurrence: Recurrence::Weekly, | |
| 640 | + | recurrence_rule: Some(RecurrenceRule { | |
| 641 | + | pattern: Recurrence::Weekly, | |
| 642 | + | interval: 1, | |
| 643 | + | weekdays: vec![], | |
| 644 | + | monthly_spec: None, | |
| 645 | + | }), | |
| 646 | + | recurrence_parent_id: None, | |
| 647 | + | is_recurring_instance: false, | |
| 648 | + | block_type: None, | |
| 649 | + | external_source: None, | |
| 650 | + | external_id: None, | |
| 651 | + | is_read_only: false, | |
| 652 | + | }; | |
| 653 | + | ||
| 654 | + | let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); | |
| 655 | + | let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(); | |
| 656 | + | ||
| 657 | + | let instances = expand_recurrence(&event, range_start, range_end); | |
| 658 | + | // Original is March 2 (Mon). Instances: Mar 9, 16, 23, 30 = 4 expanded | |
| 659 | + | assert_eq!(instances.len(), 4); | |
| 660 | + | assert_eq!(instances[0].start_time.day(), 9); | |
| 661 | + | assert_eq!(instances[1].start_time.day(), 16); | |
| 662 | + | assert_eq!(instances[2].start_time.day(), 23); | |
| 663 | + | assert_eq!(instances[3].start_time.day(), 30); | |
| 664 | + | ||
| 665 | + | // All should be marked as recurring instances | |
| 666 | + | assert!(instances.iter().all(|e| e.is_recurring_instance)); | |
| 667 | + | // All should have unique deterministic IDs | |
| 668 | + | let ids: std::collections::HashSet<_> = instances.iter().map(|e| e.id).collect(); | |
| 669 | + | assert_eq!(ids.len(), 4); | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | #[test] | |
| 673 | + | fn test_expand_recurrence_deterministic_ids() { | |
| 674 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); | |
| 675 | + | let event = Event { | |
| 676 | + | id: crate::id_types::EventId::new(), | |
| 677 | + | user_id: None, | |
| 678 | + | project_id: None, | |
| 679 | + | project_name: None, | |
| 680 | + | contact_id: None, | |
| 681 | + | contact_name: None, | |
| 682 | + | title: "Test".to_string(), | |
| 683 | + | description: String::new(), | |
| 684 | + | start_time: start, | |
| 685 | + | end_time: Some(start + Duration::hours(1)), | |
| 686 | + | location: None, | |
| 687 | + | linked_task_id: None, | |
| 688 | + | recurrence: Recurrence::Daily, | |
| 689 | + | recurrence_rule: None, | |
| 690 | + | recurrence_parent_id: None, | |
| 691 | + | is_recurring_instance: false, | |
| 692 | + | block_type: None, | |
| 693 | + | external_source: None, | |
| 694 | + | external_id: None, | |
| 695 | + | is_read_only: false, | |
| 696 | + | }; | |
| 697 | + | ||
| 698 | + | let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap(); | |
| 699 | + | let range_end = Utc.with_ymd_and_hms(2026, 3, 5, 23, 59, 59).unwrap(); | |
| 700 | + | ||
| 701 | + | let instances1 = expand_recurrence(&event, range_start, range_end); | |
| 702 | + | let instances2 = expand_recurrence(&event, range_start, range_end); | |
| 703 | + | // Same inputs produce same IDs | |
| 704 | + | assert_eq!(instances1.len(), instances2.len()); | |
| 705 | + | for (a, b) in instances1.iter().zip(instances2.iter()) { | |
| 706 | + | assert_eq!(a.id, b.id); | |
| 707 | + | } | |
| 708 | + | } | |
| 328 | 709 | } |
| @@ -255,6 +255,9 @@ pub trait EventRepository: Send + Sync { | |||
| 255 | 255 | /// Lists events within a date range (for weekly review), ordered by `start_time ASC`. | |
| 256 | 256 | async fn list_between(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>>; | |
| 257 | 257 | ||
| 258 | + | /// Lists all recurring events (recurrence != 'None' or recurrence_rule is set). | |
| 259 | + | async fn list_recurring(&self, user_id: UserId) -> Result<Vec<Event>>; | |
| 260 | + | ||
| 258 | 261 | /// Finds an event by external source and ID (for dedup during import). | |
| 259 | 262 | async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Event>>; | |
| 260 | 263 | } | |
| @@ -270,7 +273,9 @@ pub trait EmailRepository: Send + Sync { | |||
| 270 | 273 | ||
| 271 | 274 | /// Lists emails grouped by thread, with metadata pre-computed and pagination. | |
| 272 | 275 | /// Returns (threads, total_count) sorted by most recent email (newest first). | |
| 273 | - | async fn list_threaded(&self, user_id: UserId, include_archived: bool, offset: Option<i64>, limit: Option<i64>) -> Result<(Vec<EmailThread>, i64)>; | |
| 276 | + | /// Optional `folder` filter restricts to emails from a specific source_folder. | |
| 277 | + | /// Optional `label` filter restricts to emails with a specific label. | |
| 278 | + | async fn list_threaded(&self, user_id: UserId, include_archived: bool, offset: Option<i64>, limit: Option<i64>, folder: Option<&str>, label: Option<&str>) -> Result<(Vec<EmailThread>, i64)>; | |
| 274 | 279 | ||
| 275 | 280 | /// Lists emails linked to a specific project. | |
| 276 | 281 | async fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>>; | |
| @@ -347,6 +352,22 @@ pub trait EmailRepository: Send + Sync { | |||
| 347 | 352 | ||
| 348 | 353 | /// Gets an email by its Message-ID header. | |
| 349 | 354 | async fn get_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<Option<Email>>; | |
| 355 | + | ||
| 356 | + | /// Updates labels/tags on an email. | |
| 357 | + | async fn update_labels(&self, id: EmailId, user_id: UserId, labels: &[String]) -> Result<Option<Email>>; | |
| 358 | + | ||
| 359 | + | /// Lists distinct source_folder values across all non-draft emails. | |
| 360 | + | async fn list_folders(&self, user_id: UserId) -> Result<Vec<String>>; | |
| 361 | + | ||
| 362 | + | /// Lists all distinct labels used across all emails. | |
| 363 | + | async fn list_labels(&self, user_id: UserId) -> Result<Vec<String>>; | |
| 364 | + | ||
| 365 | + | /// Lists all draft emails. | |
| 366 | + | async fn list_drafts(&self, user_id: UserId) -> Result<Vec<Email>>; | |
| 367 | + | ||
| 368 | + | /// Creates or updates a draft email. | |
| 369 | + | #[allow(clippy::too_many_arguments)] | |
| 370 | + | async fn save_draft(&self, id: EmailId, user_id: UserId, from: &str, to: &str, cc: Option<&str>, bcc: Option<&str>, subject: &str, body: &str, account_id: Option<EmailAccountId>, in_reply_to: Option<&str>, references: Option<&str>, thread_id: Option<&str>) -> Result<Email>; | |
| 350 | 371 | } | |
| 351 | 372 | ||
| 352 | 373 | /// Repository for user account operations. | |
| @@ -465,6 +486,12 @@ pub trait EmailAccountRepository: Send + Sync { | |||
| 465 | 486 | /// Updates the sync interval setting for an account. | |
| 466 | 487 | async fn update_sync_interval(&self, id: EmailAccountId, user_id: UserId, interval_minutes: Option<i32>) -> Result<Option<EmailAccount>>; | |
| 467 | 488 | ||
| 489 | + | /// Updates the email signature for an account. | |
| 490 | + | async fn update_signature(&self, id: EmailAccountId, user_id: UserId, signature: Option<&str>) -> Result<Option<EmailAccount>>; | |
| 491 | + | ||
| 492 | + | /// Updates the notification preference for an account. | |
| 493 | + | async fn update_notify_new_emails(&self, id: EmailAccountId, user_id: UserId, enabled: bool) -> Result<Option<EmailAccount>>; | |
| 494 | + | ||
| 468 | 495 | /// Lists accounts that need automatic sync based on their sync_interval_minutes. | |
| 469 | 496 | /// Returns accounts where sync is enabled and last_sync_at + interval < now. | |
| 470 | 497 | async fn list_accounts_needing_sync(&self, user_id: UserId) -> Result<Vec<EmailAccount>>; |
| @@ -198,6 +198,7 @@ mod tests { | |||
| 198 | 198 | due: None, | |
| 199 | 199 | tags: vec!["work".to_string()], | |
| 200 | 200 | recurrence: Recurrence::None, | |
| 201 | + | recurrence_rule: None, | |
| 201 | 202 | urgency: 5.0, | |
| 202 | 203 | source_email_id: None, | |
| 203 | 204 | scheduled_start: None, | |
| @@ -218,6 +219,7 @@ mod tests { | |||
| 218 | 219 | due: None, | |
| 219 | 220 | tags: vec![], | |
| 220 | 221 | recurrence: Recurrence::None, | |
| 222 | + | recurrence_rule: None, | |
| 221 | 223 | urgency: 5.0, | |
| 222 | 224 | source_email_id: None, | |
| 223 | 225 | scheduled_start: None, | |
| @@ -239,6 +241,7 @@ mod tests { | |||
| 239 | 241 | due: None, | |
| 240 | 242 | tags: vec![], | |
| 241 | 243 | recurrence: Recurrence::None, | |
| 244 | + | recurrence_rule: None, | |
| 242 | 245 | urgency: 5.0, | |
| 243 | 246 | source_email_id: None, | |
| 244 | 247 | scheduled_start: None, | |
| @@ -264,6 +267,7 @@ mod tests { | |||
| 264 | 267 | location: None, | |
| 265 | 268 | linked_task_id: None, | |
| 266 | 269 | recurrence: Recurrence::None, | |
| 270 | + | recurrence_rule: None, | |
| 267 | 271 | contact_id: None, | |
| 268 | 272 | block_type: None, | |
| 269 | 273 | }; | |
| @@ -283,6 +287,7 @@ mod tests { | |||
| 283 | 287 | location: None, | |
| 284 | 288 | linked_task_id: None, | |
| 285 | 289 | recurrence: Recurrence::None, | |
| 290 | + | recurrence_rule: None, | |
| 286 | 291 | contact_id: None, | |
| 287 | 292 | block_type: None, | |
| 288 | 293 | }; | |
| @@ -303,6 +308,7 @@ mod tests { | |||
| 303 | 308 | location: None, | |
| 304 | 309 | linked_task_id: None, | |
| 305 | 310 | recurrence: Recurrence::None, | |
| 311 | + | recurrence_rule: None, | |
| 306 | 312 | contact_id: None, | |
| 307 | 313 | block_type: None, | |
| 308 | 314 | }; | |
| @@ -319,6 +325,7 @@ mod tests { | |||
| 319 | 325 | due: None, | |
| 320 | 326 | tags: vec!["valid".to_string(), "".to_string()], | |
| 321 | 327 | recurrence: Recurrence::None, | |
| 328 | + | recurrence_rule: None, | |
| 322 | 329 | urgency: 5.0, | |
| 323 | 330 | source_email_id: None, | |
| 324 | 331 | scheduled_start: None, | |
| @@ -340,6 +347,7 @@ mod tests { | |||
| 340 | 347 | due: None, | |
| 341 | 348 | tags: vec!["a".repeat(61)], // exceeds tagtree max_length of 60 | |
| 342 | 349 | recurrence: Recurrence::None, | |
| 350 | + | recurrence_rule: None, | |
| 343 | 351 | urgency: 5.0, | |
| 344 | 352 | source_email_id: None, | |
| 345 | 353 | scheduled_start: None, | |
| @@ -361,6 +369,7 @@ mod tests { | |||
| 361 | 369 | due: None, | |
| 362 | 370 | tags: vec![], | |
| 363 | 371 | recurrence: Recurrence::None, | |
| 372 | + | recurrence_rule: None, | |
| 364 | 373 | urgency: 5.0, | |
| 365 | 374 | source_email_id: None, | |
| 366 | 375 | scheduled_start: None, | |
| @@ -394,6 +403,7 @@ mod tests { | |||
| 394 | 403 | due: None, | |
| 395 | 404 | tags: vec![], | |
| 396 | 405 | recurrence: Recurrence::None, | |
| 406 | + | recurrence_rule: None, | |
| 397 | 407 | urgency: 5.0, | |
| 398 | 408 | source_email_id: None, | |
| 399 | 409 | scheduled_start: None, | |
| @@ -419,6 +429,7 @@ mod tests { | |||
| 419 | 429 | location: None, | |
| 420 | 430 | linked_task_id: None, | |
| 421 | 431 | recurrence: Recurrence::None, | |
| 432 | + | recurrence_rule: None, | |
| 422 | 433 | contact_id: None, | |
| 423 | 434 | block_type: None, | |
| 424 | 435 | }; | |
| @@ -439,6 +450,7 @@ mod tests { | |||
| 439 | 450 | location: None, | |
| 440 | 451 | linked_task_id: None, | |
| 441 | 452 | recurrence: Recurrence::None, | |
| 453 | + | recurrence_rule: None, | |
| 442 | 454 | contact_id: None, | |
| 443 | 455 | block_type: None, | |
| 444 | 456 | }; | |
| @@ -485,6 +497,7 @@ mod tests { | |||
| 485 | 497 | due: None, | |
| 486 | 498 | tags: vec![], | |
| 487 | 499 | recurrence: Recurrence::None, | |
| 500 | + | recurrence_rule: None, | |
| 488 | 501 | urgency: 5.0, | |
| 489 | 502 | source_email_id: None, | |
| 490 | 503 | scheduled_start: None, | |
| @@ -506,6 +519,7 @@ mod tests { | |||
| 506 | 519 | location: None, | |
| 507 | 520 | linked_task_id: None, | |
| 508 | 521 | recurrence: Recurrence::None, | |
| 522 | + | recurrence_rule: None, | |
| 509 | 523 | contact_id: None, | |
| 510 | 524 | block_type: None, | |
| 511 | 525 | }; | |
| @@ -564,6 +578,7 @@ mod tests { | |||
| 564 | 578 | location: Some("Room B".to_string()), | |
| 565 | 579 | linked_task_id: None, | |
| 566 | 580 | recurrence: Recurrence::None, | |
| 581 | + | recurrence_rule: None, | |
| 567 | 582 | block_type: Some(BlockType::Focus), | |
| 568 | 583 | }; | |
| 569 | 584 | assert!(event.validate().is_ok()); | |
| @@ -582,6 +597,7 @@ mod tests { | |||
| 582 | 597 | location: None, | |
| 583 | 598 | linked_task_id: None, | |
| 584 | 599 | recurrence: Recurrence::None, | |
| 600 | + | recurrence_rule: None, | |
| 585 | 601 | block_type: None, | |
| 586 | 602 | }; | |
| 587 | 603 | let err = event.validate().unwrap_err(); | |
| @@ -601,6 +617,7 @@ mod tests { | |||
| 601 | 617 | location: None, | |
| 602 | 618 | linked_task_id: None, | |
| 603 | 619 | recurrence: Recurrence::None, | |
| 620 | + | recurrence_rule: None, | |
| 604 | 621 | block_type: None, | |
| 605 | 622 | }; | |
| 606 | 623 | let err = event.validate().unwrap_err(); | |
| @@ -620,6 +637,7 @@ mod tests { | |||
| 620 | 637 | location: None, | |
| 621 | 638 | linked_task_id: None, | |
| 622 | 639 | recurrence: Recurrence::None, | |
| 640 | + | recurrence_rule: None, | |
| 623 | 641 | block_type: None, | |
| 624 | 642 | }; | |
| 625 | 643 | let err = event.validate().unwrap_err(); | |
| @@ -639,6 +657,7 @@ mod tests { | |||
| 639 | 657 | location: None, | |
| 640 | 658 | linked_task_id: None, | |
| 641 | 659 | recurrence: Recurrence::None, | |
| 660 | + | recurrence_rule: None, | |
| 642 | 661 | block_type: None, | |
| 643 | 662 | }; | |
| 644 | 663 | let err = event.validate().unwrap_err(); |
| @@ -465,6 +465,7 @@ mod tests { | |||
| 465 | 465 | tags: vec![], | |
| 466 | 466 | urgency: 0.0, | |
| 467 | 467 | recurrence: Recurrence::None, | |
| 468 | + | recurrence_rule: None, | |
| 468 | 469 | recurrence_parent_id: None, | |
| 469 | 470 | source_email_id: None, | |
| 470 | 471 | snoozed_until: None, | |
| @@ -631,6 +632,8 @@ mod tests { | |||
| 631 | 632 | location: None, | |
| 632 | 633 | linked_task_id: None, | |
| 633 | 634 | recurrence: Recurrence::None, | |
| 635 | + | recurrence_rule: None, | |
| 636 | + | is_recurring_instance: false, | |
| 634 | 637 | recurrence_parent_id: None, | |
| 635 | 638 | block_type: None, | |
| 636 | 639 | external_id: None, |
| @@ -11,7 +11,7 @@ use chrono::NaiveDate; | |||
| 11 | 11 | use sqlx::SqlitePool; | |
| 12 | 12 | use goingson_core::{ | |
| 13 | 13 | BlockType, CoreError, DbValue, Event, EventId, EventRepository, NewEvent, ParseableEnum, | |
| 14 | - | ProjectId, Recurrence, Result, TaskId, UpdateEvent, UserId, | |
| 14 | + | ProjectId, Recurrence, RecurrenceRule, 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}; | |
| @@ -19,7 +19,7 @@ use crate::utils::{format_datetime, format_datetime_opt, parse_datetime, parse_u | |||
| 19 | 19 | /// Column list for SELECT queries - avoids duplication across methods. | |
| 20 | 20 | const EVENT_SELECT_COLUMNS: &str = r#"e.id, e.user_id, e.project_id, p.name as project_name, | |
| 21 | 21 | e.title, e.description, e.start_time, e.end_time, e.location, | |
| 22 | - | e.linked_task_id, e.recurrence, e.recurrence_parent_id, | |
| 22 | + | e.linked_task_id, e.recurrence, e.recurrence_rule, e.recurrence_parent_id, | |
| 23 | 23 | e.contact_id, ct.display_name as contact_name, e.block_type, | |
| 24 | 24 | e.external_source, e.external_id, e.is_read_only"#; | |
| 25 | 25 | ||
| @@ -36,6 +36,7 @@ struct EventRow { | |||
| 36 | 36 | pub location: Option<String>, | |
| 37 | 37 | pub linked_task_id: Option<String>, | |
| 38 | 38 | pub recurrence: String, | |
| 39 | + | pub recurrence_rule: Option<String>, | |
| 39 | 40 | pub recurrence_parent_id: Option<String>, | |
| 40 | 41 | pub contact_id: Option<String>, | |
| 41 | 42 | pub contact_name: Option<String>, | |
| @@ -61,7 +62,11 @@ impl TryFrom<EventRow> for Event { | |||
| 61 | 62 | location: row.location, | |
| 62 | 63 | linked_task_id: parse_uuid_opt(row.linked_task_id.as_deref())?.map(Into::into), | |
| 63 | 64 | recurrence: Recurrence::from_str_or_default(&row.recurrence), | |
| 65 | + | recurrence_rule: row.recurrence_rule | |
| 66 | + | .as_deref() | |
| 67 | + | .and_then(|s| serde_json::from_str::<RecurrenceRule>(s).ok()), | |
| 64 | 68 | recurrence_parent_id: parse_uuid_opt(row.recurrence_parent_id.as_deref())?.map(Into::into), | |
| 69 | + | is_recurring_instance: false, | |
| 65 | 70 | contact_id: parse_uuid_opt(row.contact_id.as_deref())?.map(Into::into), | |
| 66 | 71 | contact_name: row.contact_name, | |
| 67 | 72 | block_type: row.block_type.as_deref().and_then(BlockType::from_str_opt), | |
| @@ -143,8 +148,11 @@ impl EventRepository for SqliteEventRepository { | |||
| 143 | 148 | let start_str = format_datetime(&event.start_time); | |
| 144 | 149 | let end_str = format_datetime_opt(event.end_time); | |
| 145 | 150 | ||
| 151 | + | let recurrence_rule_json = event.recurrence_rule.as_ref() | |
| 152 | + | .map(|r| serde_json::to_string(r).unwrap_or_default()); | |
| 153 | + | ||
| 146 | 154 | sqlx::query( | |
| 147 | - | "INSERT INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, contact_id, block_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 155 | + | "INSERT INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, contact_id, block_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 148 | 156 | ) | |
| 149 | 157 | .bind(id.to_string()) | |
| 150 | 158 | .bind(user_id.to_string()) | |
| @@ -156,6 +164,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 156 | 164 | .bind(&event.location) | |
| 157 | 165 | .bind(event.linked_task_id.map(|t| t.to_string())) | |
| 158 | 166 | .bind(event.recurrence.db_value()) | |
| 167 | + | .bind(&recurrence_rule_json) | |
| 159 | 168 | .bind(event.contact_id.map(|c| c.to_string())) | |
| 160 | 169 | .bind(event.block_type.as_ref().map(|b| b.db_value())) | |
| 161 | 170 | .execute(&self.pool) | |
| @@ -170,8 +179,11 @@ impl EventRepository for SqliteEventRepository { | |||
| 170 | 179 | let start_str = format_datetime(&event.start_time); | |
| 171 | 180 | let end_str = format_datetime_opt(event.end_time); | |
| 172 | 181 | ||
| 182 | + | let recurrence_rule_json = event.recurrence_rule.as_ref() | |
| 183 | + | .map(|r| serde_json::to_string(r).unwrap_or_default()); | |
| 184 | + | ||
| 173 | 185 | let result = sqlx::query( | |
| 174 | - | "UPDATE events SET project_id = ?, title = ?, description = ?, start_time = ?, end_time = ?, location = ?, linked_task_id = ?, recurrence = ?, contact_id = ?, block_type = ? WHERE id = ? AND user_id = ?", | |
| 186 | + | "UPDATE events SET project_id = ?, title = ?, description = ?, start_time = ?, end_time = ?, location = ?, linked_task_id = ?, recurrence = ?, recurrence_rule = ?, contact_id = ?, block_type = ? WHERE id = ? AND user_id = ?", | |
| 175 | 187 | ) | |
| 176 | 188 | .bind(event.project_id.map(|p| p.to_string())) | |
| 177 | 189 | .bind(&event.title) | |
| @@ -181,6 +193,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 181 | 193 | .bind(&event.location) | |
| 182 | 194 | .bind(event.linked_task_id.map(|t| t.to_string())) | |
| 183 | 195 | .bind(event.recurrence.db_value()) | |
| 196 | + | .bind(&recurrence_rule_json) | |
| 184 | 197 | .bind(event.contact_id.map(|c| c.to_string())) | |
| 185 | 198 | .bind(event.block_type.as_ref().map(|b| b.db_value())) | |
| 186 | 199 | .bind(id.to_string()) | |
| @@ -287,6 +300,21 @@ impl EventRepository for SqliteEventRepository { | |||
| 287 | 300 | } | |
| 288 | 301 | ||
| 289 | 302 | #[tracing::instrument(skip_all)] | |
| 303 | + | async fn list_recurring(&self, user_id: UserId) -> Result<Vec<Event>> { | |
| 304 | + | let query = format!( | |
| 305 | + | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND (e.recurrence != 'None' OR e.recurrence_rule IS NOT NULL) ORDER BY e.start_time ASC", | |
| 306 | + | EVENT_SELECT_COLUMNS | |
| 307 | + | ); | |
| 308 | + | let rows = sqlx::query_as::<_, EventRow>(&query) | |
| 309 | + | .bind(user_id.to_string()) | |
| 310 | + | .bind(user_id.to_string()) | |
| 311 | + | .fetch_all(&self.pool) | |
| 312 | + | .await | |
| 313 | + | .map_err(CoreError::database)?; | |
| 314 | + | rows.into_iter().map(Event::try_from).collect() | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[tracing::instrument(skip_all)] | |
| 290 | 318 | async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Event>> { | |
| 291 | 319 | let query = format!( | |
| 292 | 320 | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND e.external_source = ? AND e.external_id = ?", |
| @@ -50,7 +50,7 @@ pub(crate) const TASK_SELECT_COLUMNS: &str = r#"t.id, t.project_id, p.name as pr | |||
| 50 | 50 | t.contact_id, ct.display_name as contact_name, | |
| 51 | 51 | t.milestone_id, | |
| 52 | 52 | t.description, t.status, | |
| 53 | - | t.priority, t.due, t.tags, t.urgency, t.recurrence, t.recurrence_parent_id, t.source_email_id, | |
| 53 | + | t.priority, t.due, t.tags, t.urgency, t.recurrence, t.recurrence_rule, t.recurrence_parent_id, t.source_email_id, | |
| 54 | 54 | t.snoozed_until, t.waiting_for_response, t.waiting_since, t.expected_response_date, | |
| 55 | 55 | t.scheduled_start, t.scheduled_duration, | |
| 56 | 56 | t.estimated_minutes, t.actual_minutes, | |
| @@ -72,6 +72,7 @@ pub(crate) struct TaskRowWithProject { | |||
| 72 | 72 | pub tags: String, | |
| 73 | 73 | pub urgency: f64, | |
| 74 | 74 | pub recurrence: String, | |
| 75 | + | pub recurrence_rule: Option<String>, | |
| 75 | 76 | pub recurrence_parent_id: Option<String>, | |
| 76 | 77 | pub source_email_id: Option<String>, | |
| 77 | 78 | pub snoozed_until: Option<String>, | |
| @@ -104,6 +105,9 @@ impl TaskRowWithProject { | |||
| 104 | 105 | tags: parse_tags(&self.tags), | |
| 105 | 106 | urgency: self.urgency, | |
| 106 | 107 | recurrence: Recurrence::from_str_or_default(&self.recurrence), | |
| 108 | + | recurrence_rule: self.recurrence_rule | |
| 109 | + | .as_deref() | |
| 110 | + | .and_then(|s| serde_json::from_str(s).ok()), | |
| 107 | 111 | recurrence_parent_id: parse_uuid_opt(self.recurrence_parent_id.as_deref())?.map(Into::into), | |
| 108 | 112 | source_email_id: parse_uuid_opt(self.source_email_id.as_deref())?.map(Into::into), | |
| 109 | 113 | snoozed_until: self.snoozed_until.as_ref().map(|s| parse_datetime(s)).transpose()?, | |
| @@ -436,8 +440,8 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 436 | 440 | ||
| 437 | 441 | sqlx::query( | |
| 438 | 442 | r#" | |
| 439 | - | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 440 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 443 | + | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 444 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 441 | 445 | "#, | |
| 442 | 446 | ) | |
| 443 | 447 | .bind(id.to_string()) | |
| @@ -450,6 +454,7 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 450 | 454 | .bind(&due_str) | |
| 451 | 455 | .bind(&tags_json) | |
| 452 | 456 | .bind(task.recurrence.db_value()) | |
| 457 | + | .bind(task.recurrence_rule.as_ref().map(|r| serde_json::to_string(r).unwrap_or_default())) | |
| 453 | 458 | .bind(task.urgency) | |
| 454 | 459 | .bind(task.source_email_id.map(|e| e.to_string())) | |
| 455 | 460 | .bind(&scheduled_start_str) | |
| @@ -605,8 +610,8 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 605 | 610 | ||
| 606 | 611 | sqlx::query( | |
| 607 | 612 | r#" | |
| 608 | - | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 609 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 613 | + | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 614 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 610 | 615 | "#, | |
| 611 | 616 | ) | |
| 612 | 617 | .bind(nid.to_string()) | |
| @@ -619,6 +624,7 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 619 | 624 | .bind(&due_str) | |
| 620 | 625 | .bind(&tags_json) | |
| 621 | 626 | .bind(new_task.recurrence.db_value()) | |
| 627 | + | .bind(new_task.recurrence_rule.as_ref().map(|r| serde_json::to_string(r).unwrap_or_default())) | |
| 622 | 628 | .bind(new_task.urgency) | |
| 623 | 629 | .bind(new_task.source_email_id.map(|e| e.to_string())) | |
| 624 | 630 | .bind(&scheduled_start_str) |
| @@ -25,6 +25,7 @@ async fn test_create_and_get_event() { | |||
| 25 | 25 | location: Some("Conference Room A".to_string()), | |
| 26 | 26 | linked_task_id: None, | |
| 27 | 27 | recurrence: Recurrence::None, | |
| 28 | + | recurrence_rule: None, | |
| 28 | 29 | contact_id: None, | |
| 29 | 30 | block_type: None, | |
| 30 | 31 | }; | |
| @@ -55,6 +56,7 @@ async fn test_get_upcoming_events() { | |||
| 55 | 56 | location: None, | |
| 56 | 57 | linked_task_id: None, | |
| 57 | 58 | recurrence: Recurrence::None, | |
| 59 | + | recurrence_rule: None, | |
| 58 | 60 | contact_id: None, | |
| 59 | 61 | block_type: None, | |
| 60 | 62 | }; | |
| @@ -72,6 +74,7 @@ async fn test_get_upcoming_events() { | |||
| 72 | 74 | location: None, | |
| 73 | 75 | linked_task_id: None, | |
| 74 | 76 | recurrence: Recurrence::None, | |
| 77 | + | recurrence_rule: None, | |
| 75 | 78 | contact_id: None, | |
| 76 | 79 | block_type: None, | |
| 77 | 80 | }; | |
| @@ -109,6 +112,7 @@ async fn test_list_events_for_date() { | |||
| 109 | 112 | location: None, | |
| 110 | 113 | linked_task_id: None, | |
| 111 | 114 | recurrence: Recurrence::None, | |
| 115 | + | recurrence_rule: None, | |
| 112 | 116 | contact_id: None, | |
| 113 | 117 | block_type: None, | |
| 114 | 118 | }; | |
| @@ -125,6 +129,7 @@ async fn test_list_events_for_date() { | |||
| 125 | 129 | location: None, | |
| 126 | 130 | linked_task_id: None, | |
| 127 | 131 | recurrence: Recurrence::None, | |
| 132 | + | recurrence_rule: None, | |
| 128 | 133 | contact_id: None, | |
| 129 | 134 | block_type: None, | |
| 130 | 135 | }; | |
| @@ -153,6 +158,7 @@ async fn test_update_event() { | |||
| 153 | 158 | location: None, | |
| 154 | 159 | linked_task_id: None, | |
| 155 | 160 | recurrence: Recurrence::None, | |
| 161 | + | recurrence_rule: None, | |
| 156 | 162 | contact_id: None, | |
| 157 | 163 | block_type: None, | |
| 158 | 164 | }; | |
| @@ -169,6 +175,7 @@ async fn test_update_event() { | |||
| 169 | 175 | location: Some("New Location".to_string()), | |
| 170 | 176 | linked_task_id: None, | |
| 171 | 177 | recurrence: Recurrence::Weekly, | |
| 178 | + | recurrence_rule: None, | |
| 172 | 179 | contact_id: None, | |
| 173 | 180 | block_type: None, | |
| 174 | 181 | }; | |
| @@ -197,6 +204,7 @@ async fn test_delete_event() { | |||
| 197 | 204 | location: None, | |
| 198 | 205 | linked_task_id: None, | |
| 199 | 206 | recurrence: Recurrence::None, | |
| 207 | + | recurrence_rule: None, | |
| 200 | 208 | contact_id: None, | |
| 201 | 209 | block_type: None, | |
| 202 | 210 | }; | |
| @@ -228,6 +236,7 @@ async fn test_event_with_linked_task() { | |||
| 228 | 236 | location: None, | |
| 229 | 237 | linked_task_id: Some(task_id), | |
| 230 | 238 | recurrence: Recurrence::None, | |
| 239 | + | recurrence_rule: None, | |
| 231 | 240 | contact_id: None, | |
| 232 | 241 | block_type: None, | |
| 233 | 242 | }; | |
| @@ -266,6 +275,7 @@ async fn test_event_ordering() { | |||
| 266 | 275 | location: None, | |
| 267 | 276 | linked_task_id: None, | |
| 268 | 277 | recurrence: Recurrence::None, | |
| 278 | + | recurrence_rule: None, | |
| 269 | 279 | contact_id: None, | |
| 270 | 280 | block_type: None, | |
| 271 | 281 | }; | |
| @@ -298,6 +308,7 @@ async fn test_list_all_events() { | |||
| 298 | 308 | location: None, | |
| 299 | 309 | linked_task_id: None, | |
| 300 | 310 | recurrence: Recurrence::None, | |
| 311 | + | recurrence_rule: None, | |
| 301 | 312 | contact_id: None, | |
| 302 | 313 | block_type: None, | |
| 303 | 314 | }; |
| @@ -18,6 +18,7 @@ async fn create_task_with_desc(pool: &sqlx::SqlitePool, user_id: UserId, descrip | |||
| 18 | 18 | due: None, | |
| 19 | 19 | tags: vec![], | |
| 20 | 20 | recurrence: goingson_core::Recurrence::None, | |
| 21 | + | recurrence_rule: None, | |
| 21 | 22 | urgency: 0.0, | |
| 22 | 23 | source_email_id: None, | |
| 23 | 24 | scheduled_start: None, | |
| @@ -44,6 +45,7 @@ async fn create_task_with_priority( | |||
| 44 | 45 | due: None, | |
| 45 | 46 | tags: vec![], | |
| 46 | 47 | recurrence: goingson_core::Recurrence::None, | |
| 48 | + | recurrence_rule: None, | |
| 47 | 49 | urgency: 0.0, | |
| 48 | 50 | source_email_id: None, | |
| 49 | 51 | scheduled_start: None, | |
| @@ -193,6 +195,7 @@ async fn search_tag_include() { | |||
| 193 | 195 | due: None, | |
| 194 | 196 | tags: vec!["backend".to_string()], | |
| 195 | 197 | recurrence: goingson_core::Recurrence::None, | |
| 198 | + | recurrence_rule: None, | |
| 196 | 199 | urgency: 0.0, | |
| 197 | 200 | source_email_id: None, | |
| 198 | 201 | scheduled_start: None, | |
| @@ -233,6 +236,7 @@ async fn search_tag_exclude() { | |||
| 233 | 236 | due: None, | |
| 234 | 237 | tags: vec!["wontfix".to_string()], | |
| 235 | 238 | recurrence: goingson_core::Recurrence::None, | |
| 239 | + | recurrence_rule: None, | |
| 236 | 240 | urgency: 0.0, | |
| 237 | 241 | source_email_id: None, | |
| 238 | 242 | scheduled_start: None, |
| @@ -1,13 +1,20 @@ | |||
| 1 | 1 | # GoingsOn Todo | |
| 2 | 2 | ||
| 3 | - | Done: Phases 1-9. Active: Phase 5 sync tests. Next: Phase 4 live sync, desktop distribution. | |
| 3 | + | Done: Phases 1-9, Phase 5 attachments, code fuzz, email compose (10/10). Next: sync monetization, live sync, desktop distribution. | |
| 4 | 4 | ||
| 5 | - | v0.3.1. Audit grade A. ~762 tests. Code fuzz: 17/18 fixed. | |
| 5 | + | v0.3.1. Audit grade A. ~313 tests. Code fuzz: 18/18 resolved. Migrations 041-044. | |
| 6 | 6 | ||
| 7 | 7 | Completed items: [todo_done.md](./todo_done.md) | |
| 8 | 8 | ||
| 9 | 9 | --- | |
| 10 | 10 | ||
| 11 | + | ## Audit Items (Run 19, 2026-05-04) | |
| 12 | + | ||
| 13 | + | - [x] Fix symlink canonicalization in plugin loader — already fixed (verified 2026-05-05) | |
| 14 | + | - [ ] Add execution timeout to Rhai engine (in addition to operation limit) — deferred, low priority | |
| 15 | + | ||
| 16 | + | --- | |
| 17 | + | ||
| 11 | 18 | ## Sync Monetization | |
| 12 | 19 | ||
| 13 | 20 | GO is free. Cloud sync is the only revenue source. Includes full metadata + attachment blob sync. See `MNW/server/docs/internal/business/app_sync_pricing.md` for full pricing rationale. | |
| @@ -50,12 +57,6 @@ GO is free. Cloud sync is the only revenue source. Includes full metadata + atta | |||
| 50 | 57 | ||
| 51 | 58 | --- | |
| 52 | 59 | ||
| 53 | - | ## Phase 5: File Attachments | |
| 54 | - | ||
| 55 | - | - [x] Sync tests: attachment in changelog, table_columns whitelist, UPSERT/DELETE ordering | |
| 56 | - | ||
| 57 | - | --- | |
| 58 | - | ||
| 59 | 60 | ## Phase 6: Passkey Authentication (WebAuthn/FIDO2) | |
| 60 | 61 | ||
| 61 | 62 | ### Pre-beta | |
| @@ -101,21 +102,6 @@ GO is free. Cloud sync is the only revenue source. Includes full metadata + atta | |||
| 101 | 102 | ||
| 102 | 103 | --- | |
| 103 | 104 | ||
| 104 | - | ## Email Compose (pre-beta) | |
| 105 | - | ||
| 106 | - | - [ ] Attachment sending — can receive but not attach to outbound | |
| 107 | - | - [ ] Signatures — per-account email signature | |
| 108 | - | - [ ] Drafts (real) — current drafts save as regular emails with draft@local address | |
| 109 | - | - [ ] Contact autocomplete — recipient address completion | |
| 110 | - | - [ ] Email search UI — FTS backend exists, no email-specific search in UI | |
| 111 | - | - [ ] Attachment download/open — metadata extracted, no UI to save/open | |
| 112 | - | - [ ] Keyboard shortcuts — only Cmd+Enter and Escape currently | |
| 113 | - | - [ ] Quoted text collapse — threads get unreadable without it | |
| 114 | - | - [ ] Labels / folders — only INBOX + Archive, no custom organization | |
| 115 | - | - [ ] Notifications — system notifications on new mail | |
| 116 | - | ||
| 117 | - | --- | |
| 118 | - | ||
| 119 | 105 | ## Shared Code Extraction (Cross-Project) | |
| 120 | 106 | ||
| 121 | 107 | - [ ] Updater UI: extract nearly-identical updater.js from GO/BB into shared module | |
| @@ -130,30 +116,17 @@ GO is free. Cloud sync is the only revenue source. Includes full metadata + atta | |||
| 130 | 116 | ||
| 131 | 117 | ### Discoverability | |
| 132 | 118 | - [ ] Add touch gesture hints on first mobile use (long-press, swipe, pull-to-refresh) | |
| 133 | - | - [x] Surface hidden features in task detail modal — subtasks, annotations, focus mode, and time tracking are only accessible via right-click context menu; add visible buttons/sections in the task detail view | |
| 134 | 119 | - [ ] Add global search / command palette (Cmd+K) — backend FTS5 exists but has no UI; search across tasks, projects, emails, contacts, events | |
| 135 | - | - [x] Add `g`-prefix visual feedback — pressing `g` gives no indication a key sequence is active; show a brief "Go to..." overlay listing destinations | |
| 136 | - | - [x] Show keyboard shortcut hints on major buttons — e.g. "[q] Quick Add", "[n] New Task", "[?] Shortcuts" as title attributes or subtle inline labels | |
| 137 | - | - [x] Add quick-add syntax popover — show syntax help when user types `@`, `#`, or `+` in the quick-add field | |
| 138 | - | ||
| 139 | - | ### Learnability | |
| 140 | - | - [x] Enhance welcome flow with first-action guidance or "Load sample data" option | |
| 141 | - | - [x] Add frontend error message mapper — humanize backend error codes for toasts | |
| 142 | - | - [x] Add real-time date parse preview — show parsed date below Due Date input as user types (e.g. "next friday" → "Friday, May 8, 2026") | |
| 143 | - | - [x] Add tooltip/help text for domain-specific terms — "Snooze" ("hide until a chosen date"), "Milestone" ("group tasks into project phases"), "Recurrence" ("auto-create copy after completion") | |
| 144 | 120 | ||
| 145 | 121 | ### Complexity | |
| 146 | - | - [x] Use natural language date parsing for milestone target dates (currently requires YYYY-MM-DD) | |
| 147 | - | - [x] Simplify email account setup — make OAuth the hero path; hide IMAP server/port/TLS fields behind "Advanced" toggle; auto-detect from domain; move sync interval to post-setup settings | |
| 148 | - | - [x] Extend undo toast window from 5s to 15s — accidental deletions are irreversible if user misses the short toast | |
| 149 | 122 | - [ ] Add batch project linking — let users select multiple tasks via checkboxes and link them all to a project in one action | |
| 150 | 123 | ||
| 151 | 124 | ### Feature Completeness | |
| 152 | - | - [ ] Recurring events — table stakes for any calendar feature | |
| 125 | + | - [x] Recurring events — virtual expansion (store once, compute instances at query time). Events form now has recurrence field. Expansion integrated into list_events, day planner, weekly review. | |
| 153 | 126 | - [ ] Calendar month/week grid view — even a basic one (events currently list-only) | |
| 154 | 127 | - [ ] Separate planning and visualization dashboards — day/week/month views should be distinct from weekly review; weekly review timeline is useful but planning and reviewing are different workflows | |
| 155 | 128 | - [ ] Habit tracking — daily checklist with streak/completion visualization (e.g. "gym 6/7 days", "music 5/7 days"). Recurring tasks exist but there's no "did I do this every day this week" view. Key for routine-building use cases. | |
| 156 | - | - [ ] Richer recurrence rules — Daily recurrence should support specifying which hours; Weekly should support specifying which days; Monthly should support specifying which weeks. E.g. "every weekday at 7am" or "weekly on Mon/Wed/Fri" instead of needing 3 separate weekly recurring items. Applies to both tasks and events. | |
| 129 | + | - [x] Richer recurrence rules — RecurrenceRule struct with interval, weekday selection (Mon/Wed/Fri), monthly day-of-month or Nth-weekday (2nd Friday, last Monday). Config UI in both task and event forms. Tasks use rich rules for next-due calculation on completion. | |
| 157 | 130 | - [ ] Time tracking reports — per-project breakdown, estimated-vs-actual, weekly/monthly summaries | |
| 158 | 131 | - [ ] Contacts export to vCard — import exists but no export (asymmetric) | |
| 159 | 132 | - [ ] Bulk operations for contacts (tag, delete) and events (delete) | |
| @@ -162,19 +135,6 @@ GO is free. Cloud sync is the only revenue source. Includes full metadata + atta | |||
| 162 | 135 | ||
| 163 | 136 | --- | |
| 164 | 137 | ||
| 165 | - | ## Code Fuzz Remaining (2026-05-03) | |
| 166 | - | ||
| 167 | - | ### Serious | |
| 168 | - | - [x] `create_initial_snapshot` called outside sync_lock — TOCTOU gap (commands/sync.rs:269-276) | |
| 169 | - | ||
| 170 | - | ### Minor | |
| 171 | - | - [x] iCal DST spring-forward gap falls back to UTC interpretation (ical.rs:129). Fixed: fall back to `.latest()` for spring-forward gaps. | |
| 172 | - | - [x] Blob files loaded entirely into memory for sync upload (blob_sync.rs:53-61). Non-issue: attachments capped at 50 MB (attachment.rs:79), uploaded sequentially (one at a time), so worst case is ~100 MB transient (plaintext + ciphertext). XChaCha20-Poly1305 AEAD requires full plaintext for sealing. | |
| 173 | - | - [x] Temp HTML files from "Open in Browser" never cleaned up (commands/email.rs:345). Fixed: delayed cleanup + startup sweep. | |
| 174 | - | - [x] Migration FK update failures silently swallowed (migrations.rs:74). Fixed: propagate error. | |
| 175 | - | ||
| 176 | - | --- | |
| 177 | - | ||
| 178 | 138 | ## Deferred | |
| 179 | 139 | ||
| 180 | 140 | - [ ] Co-working feature: E2E encrypted project sharing (XChaCha20-Poly1305, X25519, Argon2, CRDTs, 7 phases) | |
| @@ -198,9 +158,9 @@ crates/plugin-runtime/ Rhai plugin system | |||
| 198 | 158 | plugins/ Bundled reference plugins | |
| 199 | 159 | src-tauri/ Desktop app (Tauri 2 + vanilla JS) | |
| 200 | 160 | src-tauri/src/commands/ Tauri command layer (24 modules) | |
| 201 | - | src-tauri/frontend/js/ Frontend JS (45 IIFE modules under GoingsOn namespace) | |
| 161 | + | src-tauri/frontend/js/ Frontend JS (46 IIFE modules under GoingsOn namespace) | |
| 202 | 162 | src-tauri/frontend/css/ Styles (Skeubrute design system) | |
| 203 | - | migrations/sqlite/ SQLite migrations (36 files) | |
| 163 | + | migrations/sqlite/ SQLite migrations (44 files) | |
| 204 | 164 | docs/architecture.md Crate structure and data flow | |
| 205 | 165 | docs/audit_review.md Audit grades and action items | |
| 206 | 166 | docs/todo/todo_mobile.md Mobile port subtodo |
| @@ -0,0 +1,148 @@ | |||
| 1 | + | -- Rich recurrence rules: JSON configuration for recurring events and tasks. | |
| 2 | + | -- The existing 'recurrence' column (Daily/Weekly/Monthly/None) is preserved | |
| 3 | + | -- for backward compat. When recurrence_rule is non-null, it takes precedence. | |
| 4 | + | ||
| 5 | + | ALTER TABLE events ADD COLUMN recurrence_rule TEXT; | |
| 6 | + | ALTER TABLE tasks ADD COLUMN recurrence_rule TEXT; | |
| 7 | + | ||
| 8 | + | -- ── Update event sync triggers to include recurrence_rule (17 cols total) ── | |
| 9 | + | ||
| 10 | + | DROP TRIGGER IF EXISTS sync_trg_events_insert; | |
| 11 | + | CREATE TRIGGER IF NOT EXISTS sync_trg_events_insert | |
| 12 | + | AFTER INSERT ON events | |
| 13 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 14 | + | BEGIN | |
| 15 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 16 | + | VALUES ('events', 'INSERT', NEW.id, json_object( | |
| 17 | + | 'id', NEW.id, | |
| 18 | + | 'project_id', NEW.project_id, | |
| 19 | + | 'title', NEW.title, | |
| 20 | + | 'description', NEW.description, | |
| 21 | + | 'start_time', NEW.start_time, | |
| 22 | + | 'end_time', NEW.end_time, | |
| 23 | + | 'location', NEW.location, | |
| 24 | + | 'user_id', NEW.user_id, | |
| 25 | + | 'linked_task_id', NEW.linked_task_id, | |
| 26 | + | 'recurrence', NEW.recurrence, | |
| 27 | + | 'recurrence_parent_id', NEW.recurrence_parent_id, | |
| 28 | + | 'recurrence_rule', NEW.recurrence_rule, | |
| 29 | + | 'contact_id', NEW.contact_id, | |
| 30 | + | 'block_type', NEW.block_type, | |
| 31 | + | 'external_source', NEW.external_source, | |
| 32 | + | 'external_id', NEW.external_id, | |
| 33 | + | 'is_read_only', NEW.is_read_only | |
| 34 | + | )); | |
| 35 | + | END; | |
| 36 | + | ||
| 37 | + | DROP TRIGGER IF EXISTS sync_trg_events_update; | |
| 38 | + | CREATE TRIGGER IF NOT EXISTS sync_trg_events_update | |
| 39 | + | AFTER UPDATE ON events | |
| 40 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 41 | + | BEGIN | |
| 42 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 43 | + | VALUES ('events', 'UPDATE', NEW.id, json_object( | |
| 44 | + | 'id', NEW.id, | |
| 45 | + | 'project_id', NEW.project_id, | |
| 46 | + | 'title', NEW.title, | |
| 47 | + | 'description', NEW.description, | |
| 48 | + | 'start_time', NEW.start_time, | |
| 49 | + | 'end_time', NEW.end_time, | |
| 50 | + | 'location', NEW.location, | |
| 51 | + | 'user_id', NEW.user_id, | |
| 52 | + | 'linked_task_id', NEW.linked_task_id, | |
| 53 | + | 'recurrence', NEW.recurrence, | |
| 54 | + | 'recurrence_parent_id', NEW.recurrence_parent_id, | |
| 55 | + | 'recurrence_rule', NEW.recurrence_rule, | |
| 56 | + | 'contact_id', NEW.contact_id, | |
| 57 | + | 'block_type', NEW.block_type, | |
| 58 | + | 'external_source', NEW.external_source, | |
| 59 | + | 'external_id', NEW.external_id, | |
| 60 | + | 'is_read_only', NEW.is_read_only | |
| 61 | + | )); | |
| 62 | + | END; | |
| 63 | + | ||
| 64 | + | -- DELETE trigger unchanged (no data payload), but recreate for consistency | |
| 65 | + | DROP TRIGGER IF EXISTS sync_trg_events_delete; | |
| 66 | + | CREATE TRIGGER IF NOT EXISTS sync_trg_events_delete | |
| 67 | + | AFTER DELETE ON events | |
| 68 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 69 | + | BEGIN | |
| 70 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 71 | + | VALUES ('events', 'DELETE', OLD.id, NULL); | |
| 72 | + | END; | |
| 73 | + | ||
| 74 | + | -- ── Update task sync triggers to include recurrence_rule (28 cols total) ── | |
| 75 | + | ||
| 76 | + | DROP TRIGGER IF EXISTS sync_trg_tasks_insert; | |
| 77 | + | CREATE TRIGGER IF NOT EXISTS sync_trg_tasks_insert | |
| 78 | + | AFTER INSERT ON tasks | |
| 79 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 80 | + | BEGIN | |
| 81 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 82 | + | VALUES ('tasks', 'INSERT', NEW.id, json_object( | |
| 83 | + | 'id', NEW.id, | |
| 84 | + | 'project_id', NEW.project_id, | |
| 85 | + | 'description', NEW.description, | |
| 86 | + | 'status', NEW.status, | |
| 87 | + | 'priority', NEW.priority, | |
| 88 | + | 'due', NEW.due, | |
| 89 | + | 'tags', NEW.tags, | |
| 90 | + | 'urgency', NEW.urgency, | |
| 91 | + | 'recurrence', NEW.recurrence, | |
| 92 | + | 'recurrence_rule', NEW.recurrence_rule, | |
| 93 | + | 'created_at', NEW.created_at, | |
| 94 | + | 'user_id', NEW.user_id, | |
| 95 | + | 'recurrence_parent_id', NEW.recurrence_parent_id, | |
| 96 | + | 'source_email_id', NEW.source_email_id, | |
| 97 | + | 'snoozed_until', NEW.snoozed_until, | |
| 98 | + | 'waiting_for_response', NEW.waiting_for_response, | |
| 99 | + | 'waiting_since', NEW.waiting_since, | |
| 100 | + | 'expected_response_date', NEW.expected_response_date, | |
| 101 | + | 'scheduled_start', NEW.scheduled_start, | |
| 102 | + | 'scheduled_duration', NEW.scheduled_duration, | |
| 103 | + | 'is_focus', NEW.is_focus, | |
| 104 | + | 'focus_set_at', NEW.focus_set_at, | |
| 105 | + | 'contact_id', NEW.contact_id, | |
| 106 | + | 'milestone_id', NEW.milestone_id, | |
| 107 | + | 'completed_at', NEW.completed_at, | |
| 108 | + | 'estimated_minutes', NEW.estimated_minutes, | |
| 109 | + | 'actual_minutes', NEW.actual_minutes | |
| 110 | + | )); | |
| 111 | + | END; | |
| 112 | + | ||
| 113 | + | DROP TRIGGER IF EXISTS sync_trg_tasks_update; | |
| 114 | + | CREATE TRIGGER IF NOT EXISTS sync_trg_tasks_update | |
| 115 | + | AFTER UPDATE ON tasks | |
| 116 | + | WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1' | |
| 117 | + | BEGIN | |
| 118 | + | INSERT INTO sync_changelog (table_name, op, row_id, data) | |
| 119 | + | VALUES ('tasks', 'UPDATE', NEW.id, json_object( | |
| 120 | + | 'id', NEW.id, | |
| 121 | + | 'project_id', NEW.project_id, | |
| 122 | + | 'description', NEW.description, | |
| 123 | + | 'status', NEW.status, | |
| 124 | + | 'priority', NEW.priority, | |
| 125 | + | 'due', NEW.due, | |
| 126 | + | 'tags', NEW.tags, | |
| 127 | + | 'urgency', NEW.urgency, | |
| 128 | + | 'recurrence', NEW.recurrence, | |
| 129 | + | 'recurrence_rule', NEW.recurrence_rule, | |
| 130 | + | 'created_at', NEW.created_at, | |
| 131 | + | 'user_id', NEW.user_id, | |
| 132 | + | 'recurrence_parent_id', NEW.recurrence_parent_id, | |
| 133 | + | 'source_email_id', NEW.source_email_id, | |
| 134 | + | 'snoozed_until', NEW.snoozed_until, | |
| 135 | + | 'waiting_for_response', NEW.waiting_for_response, | |
| 136 | + | 'waiting_since', NEW.waiting_since, | |
| 137 | + | 'expected_response_date', NEW.expected_response_date, | |
| 138 | + | 'scheduled_start', NEW.scheduled_start, | |
| 139 | + | 'scheduled_duration', NEW.scheduled_duration, | |
| 140 | + | 'is_focus', NEW.is_focus, | |
| 141 | + | 'focus_set_at', NEW.focus_set_at, | |
| 142 | + | 'contact_id', NEW.contact_id, | |
| 143 | + | 'milestone_id', NEW.milestone_id, | |
| 144 | + | 'completed_at', NEW.completed_at, | |
| 145 | + | 'estimated_minutes', NEW.estimated_minutes, | |
| 146 | + | 'actual_minutes', NEW.actual_minutes | |
| 147 | + | )); | |
| 148 | + | END; |
| @@ -1585,6 +1585,33 @@ body { | |||
| 1585 | 1585 | display: none; | |
| 1586 | 1586 | } | |
| 1587 | 1587 | ||
| 1588 | + | .recurrence-weekday-label { | |
| 1589 | + | display: inline-flex; | |
| 1590 | + | align-items: center; | |
| 1591 | + | gap: 0.25rem; | |
| 1592 | + | padding: 0.25rem 0.5rem; | |
| 1593 | + | font-size: 0.8rem; | |
| 1594 | + | border: 1px solid var(--border); | |
| 1595 | + | border-radius: var(--radius-sm); | |
| 1596 | + | cursor: pointer; | |
| 1597 | + | } | |
| 1598 | + | ||
| 1599 | + | .recurrence-weekday-label:has(input:checked) { | |
| 1600 | + | background: var(--accent-primary); | |
| 1601 | + | color: var(--bg-primary); | |
| 1602 | + | border-color: var(--accent-primary); | |
| 1603 | + | } | |
| 1604 | + | ||
| 1605 | + | .recurrence-weekday-label input[type="checkbox"] { | |
| 1606 | + | display: none; | |
| 1607 | + | } | |
| 1608 | + | ||
| 1609 | + | .recurrence-config .form-input, | |
| 1610 | + | .recurrence-config .form-select { | |
| 1611 | + | font-size: 0.85rem; | |
| 1612 | + | padding: 0.25rem 0.5rem; | |
| 1613 | + | } | |
| 1614 | + | ||
| 1588 | 1615 | .form-label { | |
| 1589 | 1616 | display: block; | |
| 1590 | 1617 | font-size: 0.9rem; | |
| @@ -2127,6 +2154,74 @@ kbd { | |||
| 2127 | 2154 | font-style: italic; | |
| 2128 | 2155 | } | |
| 2129 | 2156 | ||
| 2157 | + | /* Collapsible quoted text */ | |
| 2158 | + | .email-quote-toggle { | |
| 2159 | + | display: inline-block; | |
| 2160 | + | color: var(--text-muted); | |
| 2161 | + | font-size: 0.8125rem; | |
| 2162 | + | cursor: pointer; | |
| 2163 | + | padding: 0.25rem 0; | |
| 2164 | + | user-select: none; | |
| 2165 | + | } | |
| 2166 | + | ||
| 2167 | + | .email-quote-toggle:hover { | |
| 2168 | + | color: var(--accent-blue); | |
| 2169 | + | } | |
| 2170 | + | ||
| 2171 | + | .email-quote-block { | |
| 2172 | + | border-left: 3px solid var(--text-muted); | |
| 2173 | + | padding-left: 1rem; | |
| 2174 | + | margin: 0.25rem 0 0.5rem; | |
| 2175 | + | color: var(--text-secondary); | |
| 2176 | + | } | |
| 2177 | + | ||
| 2178 | + | .email-quote-block.hidden { | |
| 2179 | + | display: none; | |
| 2180 | + | } | |
| 2181 | + | ||
| 2182 | + | /* Email address autocomplete */ | |
| 2183 | + | .autocomplete-dropdown { | |
| 2184 | + | background: var(--bg-card); | |
| 2185 | + | border: 1px solid var(--border-color); | |
| 2186 | + | border-radius: var(--radius-sm); | |
| 2187 | + | box-shadow: var(--shadow-brutal); | |
| 2188 | + | z-index: 100; | |
| 2189 | + | max-height: 200px; | |
| 2190 | + | overflow-y: auto; | |
| 2191 | + | } | |
| 2192 | + | ||
| 2193 | + | .autocomplete-item { | |
| 2194 | + | padding: 0.5rem 0.75rem; | |
| 2195 | + | cursor: pointer; | |
| 2196 | + | font-size: 0.875rem; | |
| 2197 | + | } | |
| 2198 | + | ||
| 2199 | + | .autocomplete-item:hover, | |
| 2200 | + | .autocomplete-item.active { | |
| 2201 | + | background: var(--bg-secondary); | |
| 2202 | + | } | |
| 2203 | + | ||
| 2204 | + | .autocomplete-name { | |
| 2205 | + | font-weight: 500; | |
| 2206 | + | } | |
| 2207 | + | ||
| 2208 | + | .autocomplete-email { | |
| 2209 | + | color: var(--text-secondary); | |
| 2210 | + | margin-left: 0.25rem; | |
| 2211 | + | } | |
| 2212 | + | ||
| 2213 | + | /* Email label badges */ | |
| 2214 | + | .email-label-badge { | |
| 2215 | + | display: inline-block; | |
| 2216 | + | font-size: 0.6875rem; | |
| 2217 | + | padding: 0.125rem 0.375rem; | |
| 2218 | + | border-radius: var(--radius-sm); | |
| 2219 | + | background: var(--accent-blue); | |
| 2220 | + | color: var(--bg-primary); | |
| 2221 | + | font-weight: 600; | |
| 2222 | + | vertical-align: middle; | |
| 2223 | + | } | |
| 2224 | + | ||
| 2130 | 2225 | /* Email reader container for large modal */ | |
| 2131 | 2226 | .email-reader-container { | |
| 2132 | 2227 | display: flex; |
| @@ -83,6 +83,20 @@ | |||
| 83 | 83 | }, | |
| 84 | 84 | ]; | |
| 85 | 85 | ||
| 86 | + | // Recurrence select field | |
| 87 | + | const RECURRENCE_OPTIONS = GoingsOn.taskForms.RECURRENCE_OPTIONS; | |
| 88 | + | fields.push({ | |
| 89 | + | name: 'recurrence', | |
| 90 | + | type: 'select', | |
| 91 | + | label: 'Recurrence', | |
| 92 | + | hint: 'Recurring events appear automatically on matching days' + GoingsOn.taskForms.buildRecurrenceConfigHtml(event?.recurrenceRule, 'event'), | |
| 93 | + | options: RECURRENCE_OPTIONS.map(r => ({ | |
| 94 | + | ...r, | |
| 95 | + | selected: r.value === (event?.recurrence || 'None'), | |
| 96 | + | })), | |
| 97 | + | value: event?.recurrence || 'None', | |
| 98 | + | }); | |
| 99 | + | ||
| 86 | 100 | // Block type select field | |
| 87 | 101 | fields.push({ | |
| 88 | 102 | name: 'block_type', | |
| @@ -287,6 +301,7 @@ | |||
| 287 | 301 | fields: getEventFormFields(), | |
| 288 | 302 | onSubmit: create, | |
| 289 | 303 | }); | |
| 304 | + | GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence'); | |
| 290 | 305 | } | |
| 291 | 306 | ||
| 292 | 307 | /** | |
| @@ -310,6 +325,7 @@ | |||
| 310 | 325 | </div> | |
| 311 | 326 | ` : '', | |
| 312 | 327 | }); | |
| 328 | + | GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence'); | |
| 313 | 329 | } | |
| 314 | 330 | ||
| 315 | 331 | /** | |
| @@ -317,6 +333,8 @@ | |||
| 317 | 333 | * @param {Object} data - Form data with title, description, start_time, end_time, location, etc. | |
| 318 | 334 | */ | |
| 319 | 335 | async function create(data) { | |
| 336 | + | const form = document.querySelector('.modal-content form'); | |
| 337 | + | const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null; | |
| 320 | 338 | const input = { | |
| 321 | 339 | title: data.title, | |
| 322 | 340 | description: data.description || '', | |
| @@ -324,6 +342,8 @@ | |||
| 324 | 342 | startTime: new Date(data.start_time).toISOString(), | |
| 325 | 343 | endTime: data.end_time ? new Date(data.end_time).toISOString() : null, | |
| 326 | 344 | location: data.location || null, | |
| 345 | + | recurrence: data.recurrence || 'None', | |
| 346 | + | recurrenceRule, | |
| 327 | 347 | contactId: data.contact_id || null, | |
| 328 | 348 | blockType: data.block_type || null, | |
| 329 | 349 | }; | |
| @@ -420,6 +440,7 @@ | |||
| 420 | 440 | fields: getEventFormFields(event), | |
| 421 | 441 | onSubmit: (data) => update(id, data), | |
| 422 | 442 | }); | |
| 443 | + | GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence'); | |
| 423 | 444 | } catch (err) { | |
| 424 | 445 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error'); | |
| 425 | 446 | } | |
| @@ -431,12 +452,16 @@ | |||
| 431 | 452 | * @param {Object} data - Form data with title, description, start_time, etc. | |
| 432 | 453 | */ | |
| 433 | 454 | async function update(id, data) { | |
| 455 | + | const form = document.querySelector('.modal-content form'); | |
| 456 | + | const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null; | |
| 434 | 457 | const input = { | |
| 435 | 458 | title: data.title, | |
| 436 | 459 | description: data.description || '', | |
| 437 | 460 | startTime: new Date(data.start_time).toISOString(), | |
| 438 | 461 | endTime: data.end_time ? new Date(data.end_time).toISOString() : null, | |
| 439 | 462 | location: data.location || null, | |
| 463 | + | recurrence: data.recurrence || 'None', | |
| 464 | + | recurrenceRule, | |
| 440 | 465 | contactId: data.contact_id || null, | |
| 441 | 466 | blockType: data.block_type || null, | |
| 442 | 467 | }; |
| @@ -24,6 +24,137 @@ | |||
| 24 | 24 | { value: 'Monthly', label: 'Monthly' }, | |
| 25 | 25 | ]; | |
| 26 | 26 | ||
| 27 | + | const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * Build recurrence config HTML (interval, weekday checkboxes, monthly spec). | |
| 31 | + | * Shown below the recurrence select when pattern != None. | |
| 32 | + | * @param {Object|null} rule - Existing recurrence rule, or null | |
| 33 | + | * @param {string} prefix - ID prefix for form fields | |
| 34 | + | * @returns {string} HTML string for the config panel | |
| 35 | + | */ | |
| 36 | + | function buildRecurrenceConfigHtml(rule, prefix) { | |
| 37 | + | const interval = rule?.interval || 1; | |
| 38 | + | const weekdays = rule?.weekdays || []; | |
| 39 | + | const monthlySpec = rule?.monthlySpec || null; | |
| 40 | + | ||
| 41 | + | const weekdayCheckboxes = WEEKDAY_LABELS.map((label, i) => { | |
| 42 | + | const checked = weekdays.includes(i) ? 'checked' : ''; | |
| 43 | + | return `<label class="recurrence-weekday-label"><input type="checkbox" name="${prefix}-weekday-${i}" value="${i}" ${checked}><span>${label}</span></label>`; | |
| 44 | + | }).join(''); | |
| 45 | + | ||
| 46 | + | const monthlyDom = monthlySpec?.type === 'dayOfMonth' ? monthlySpec.day : ''; | |
| 47 | + | const monthlyWeek = monthlySpec?.type === 'nthWeekday' ? monthlySpec.week : 1; | |
| 48 | + | const monthlyWd = monthlySpec?.type === 'nthWeekday' ? monthlySpec.weekday : 0; | |
| 49 | + | const monthlyType = monthlySpec?.type || 'dayOfMonth'; | |
| 50 | + | ||
| 51 | + | return ` | |
| 52 | + | <div id="${prefix}-recurrence-config" class="recurrence-config" style="display: none; margin-top: 0.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm);"> | |
| 53 | + | <div class="form-group" style="margin-bottom: 0.5rem;"> | |
| 54 | + | <label class="form-label" style="font-size: 0.75rem;">Every</label> | |
| 55 | + | <div style="display: flex; align-items: center; gap: 0.5rem;"> | |
| 56 | + | <input type="number" class="form-input" name="${prefix}-interval" value="${interval}" min="1" max="99" style="width: 4rem;"> | |
| 57 | + | <span id="${prefix}-interval-unit" style="font-size: 0.85rem;">time(s)</span> | |
| 58 | + | </div> | |
| 59 | + | </div> | |
| 60 | + | <div id="${prefix}-weekdays-section" class="form-group" style="margin-bottom: 0.5rem; display: none;"> | |
| 61 | + | <label class="form-label" style="font-size: 0.75rem;">On days</label> | |
| 62 | + | <div class="recurrence-weekdays" style="display: flex; gap: 0.25rem; flex-wrap: wrap;">${weekdayCheckboxes}</div> | |
| 63 | + | </div> | |
| 64 | + | <div id="${prefix}-monthly-section" class="form-group" style="margin-bottom: 0; display: none;"> | |
| 65 | + | <label class="form-label" style="font-size: 0.75rem;">On</label> | |
| 66 | + | <div style="display: flex; flex-direction: column; gap: 0.5rem;"> | |
| 67 | + | <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem;"> | |
| 68 | + | <input type="radio" name="${prefix}-monthly-type" value="dayOfMonth" ${monthlyType === 'dayOfMonth' ? 'checked' : ''}> | |
| 69 | + | Day <input type="number" class="form-input" name="${prefix}-monthly-day" value="${monthlyDom || 1}" min="1" max="31" style="width: 4rem;"> of the month | |
| 70 | + | </label> | |
| 71 | + | <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem;"> | |
| 72 | + | <input type="radio" name="${prefix}-monthly-type" value="nthWeekday" ${monthlyType === 'nthWeekday' ? 'checked' : ''}> | |
| 73 | + | <select class="form-select" name="${prefix}-monthly-week" style="width: auto;"> | |
| 74 | + | <option value="1" ${monthlyWeek == 1 ? 'selected' : ''}>1st</option> | |
| 75 | + | <option value="2" ${monthlyWeek == 2 ? 'selected' : ''}>2nd</option> | |
| 76 | + | <option value="3" ${monthlyWeek == 3 ? 'selected' : ''}>3rd</option> | |
| 77 | + | <option value="4" ${monthlyWeek == 4 ? 'selected' : ''}>4th</option> | |
| 78 | + | <option value="-1" ${monthlyWeek == -1 ? 'selected' : ''}>Last</option> | |
| 79 | + | </select> | |
| 80 | + | <select class="form-select" name="${prefix}-monthly-weekday" style="width: auto;"> | |
| 81 | + | ${WEEKDAY_LABELS.map((l, i) => `<option value="${i}" ${monthlyWd == i ? 'selected' : ''}>${l}</option>`).join('')} | |
| 82 | + | </select> | |
| 83 | + | </label> | |
| 84 | + | </div> | |
| 85 | + | </div> | |
| 86 | + | </div> | |
| 87 | + | `; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** | |
| 91 | + | * Initialize recurrence config panel visibility and event handlers. | |
| 92 | + | * Call after the form modal is rendered. | |
| 93 | + | * @param {string} prefix - ID prefix matching buildRecurrenceConfigHtml | |
| 94 | + | * @param {string} selectName - Name of the recurrence select element | |
| 95 | + | */ | |
| 96 | + | function initRecurrenceConfig(prefix, selectName) { | |
| 97 | + | const form = document.querySelector('.modal-content form'); | |
| 98 | + | if (!form) return; | |
| 99 | + | ||
| 100 | + | const select = form.elements[selectName]; | |
| 101 | + | const config = document.getElementById(`${prefix}-recurrence-config`); | |
| 102 | + | const weekdaysSection = document.getElementById(`${prefix}-weekdays-section`); | |
| 103 | + | const monthlySection = document.getElementById(`${prefix}-monthly-section`); | |
| 104 | + | const intervalUnit = document.getElementById(`${prefix}-interval-unit`); | |
| 105 | + | if (!select || !config) return; | |
| 106 | + | ||
| 107 | + | function updateVisibility() { | |
| 108 | + | const pattern = select.value; | |
| 109 | + | config.style.display = pattern === 'None' ? 'none' : 'block'; | |
| 110 | + | if (weekdaysSection) weekdaysSection.style.display = pattern === 'Weekly' ? 'block' : 'none'; | |
| 111 | + | if (monthlySection) monthlySection.style.display = pattern === 'Monthly' ? 'block' : 'none'; | |
| 112 | + | if (intervalUnit) { | |
| 113 | + | const units = { Daily: 'day(s)', Weekly: 'week(s)', Monthly: 'month(s)' }; | |
| 114 | + | intervalUnit.textContent = units[pattern] || 'time(s)'; | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | select.addEventListener('change', updateVisibility); | |
| 119 | + | updateVisibility(); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /** | |
| 123 | + | * Collect recurrence rule from the config panel form elements. | |
| 124 | + | * @param {HTMLFormElement} form - The form element | |
| 125 | + | * @param {string} prefix - ID prefix | |
| 126 | + | * @param {string} pattern - Current recurrence pattern value | |
| 127 | + | * @returns {Object|null} RecurrenceRule JSON object, or null if None | |
| 128 | + | */ | |
| 129 | + | function collectRecurrenceRule(form, prefix, pattern) { | |
| 130 | + | if (pattern === 'None') return null; | |
| 131 | + | ||
| 132 | + | const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1; | |
| 133 | + | const rule = { pattern, interval, weekdays: [], monthlySpec: null }; | |
| 134 | + | ||
| 135 | + | if (pattern === 'Weekly') { | |
| 136 | + | for (let i = 0; i < 7; i++) { | |
| 137 | + | const cb = form.elements[`${prefix}-weekday-${i}`]; | |
| 138 | + | if (cb?.checked) rule.weekdays.push(i); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | if (pattern === 'Monthly') { | |
| 143 | + | const monthlyType = form.querySelector(`[name="${prefix}-monthly-type"]:checked`)?.value || 'dayOfMonth'; | |
| 144 | + | if (monthlyType === 'dayOfMonth') { | |
| 145 | + | const day = parseInt(form.elements[`${prefix}-monthly-day`]?.value) || 1; | |
| 146 | + | rule.monthlySpec = { type: 'dayOfMonth', day }; | |
| 147 | + | } else { | |
| 148 | + | const week = parseInt(form.elements[`${prefix}-monthly-week`]?.value) || 1; | |
| 149 | + | const weekday = parseInt(form.elements[`${prefix}-monthly-weekday`]?.value) || 0; | |
| 150 | + | rule.monthlySpec = { type: 'nthWeekday', week, weekday }; | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // If it's a simple rule (interval 1, no extras), still return it for consistency | |
| 155 | + | return rule; | |
| 156 | + | } | |
| 157 | + | ||
| 27 | 158 | const STATUS_OPTIONS = [ | |
| 28 | 159 | { value: 'Pending', label: 'Pending' }, | |
| 29 | 160 | { value: 'Started', label: 'Started' }, | |
| @@ -139,7 +270,7 @@ | |||
| 139 | 270 | name: 'recurrence', | |
| 140 | 271 | type: 'select', | |
| 141 | 272 | label: 'Recurrence', | |
| 142 | - | hint: 'Completing a recurring task auto-creates the next occurrence', | |
| 273 | + | hint: 'Completing a recurring task auto-creates the next occurrence' + buildRecurrenceConfigHtml(task?.recurrenceRule, 'task'), | |
| 143 | 274 | options: RECURRENCE_OPTIONS.map(r => ({ | |
| 144 | 275 | ...r, | |
| 145 | 276 | selected: task?.recurrence === r.value, | |
| @@ -242,6 +373,9 @@ | |||
| 242 | 373 | getContactOptions, | |
| 243 | 374 | getTaskFormFields, | |
| 244 | 375 | setupMilestoneSelect, | |
| 376 | + | buildRecurrenceConfigHtml, | |
| 377 | + | initRecurrenceConfig, | |
| 378 | + | collectRecurrenceRule, | |
| 245 | 379 | }; | |
| 246 | 380 | ||
| 247 | 381 | })(); |
| @@ -183,6 +183,7 @@ | |||
| 183 | 183 | onSubmit: create, | |
| 184 | 184 | }); | |
| 185 | 185 | setupMilestoneSelect('task', 'new'); | |
| 186 | + | GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence'); | |
| 186 | 187 | } | |
| 187 | 188 | ||
| 188 | 189 | /** | |
| @@ -206,6 +207,8 @@ | |||
| 206 | 207 | */ | |
| 207 | 208 | async function create(data) { | |
| 208 | 209 | const tagsValue = data.tags?.trim() || ''; | |
| 210 | + | const form = document.querySelector('.modal-content form'); | |
| 211 | + | const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null; | |
| 209 | 212 | const input = { | |
| 210 | 213 | description: data.description, | |
| 211 | 214 | projectId: data.project_id || null, | |
| @@ -213,6 +216,7 @@ | |||
| 213 | 216 | due: data.due ? new Date(data.due).toISOString() : null, | |
| 214 | 217 | tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [], | |
| 215 | 218 | recurrence: data.recurrence, | |
| 219 | + | recurrenceRule, | |
| 216 | 220 | contactId: data.contact_id || null, | |
| 217 | 221 | milestoneId: data.milestone_id || null, | |
| 218 | 222 | estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null, | |
| @@ -294,6 +298,7 @@ | |||
| 294 | 298 | extraContent, | |
| 295 | 299 | }); | |
| 296 | 300 | setupMilestoneSelect('task', 'edit', task.projectId, task.milestoneId); | |
| 301 | + | GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence'); | |
| 297 | 302 | } catch (err) { | |
| 298 | 303 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error'); | |
| 299 | 304 | } | |
| @@ -306,6 +311,8 @@ | |||
| 306 | 311 | */ | |
| 307 | 312 | async function update(id, data) { | |
| 308 | 313 | const tagsValue = data.tags?.trim() || ''; | |
| 314 | + | const form = document.querySelector('.modal-content form'); | |
| 315 | + | const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null; | |
| 309 | 316 | const input = { | |
| 310 | 317 | description: data.description, | |
| 311 | 318 | projectId: data.project_id || null, | |
| @@ -314,6 +321,7 @@ | |||
| 314 | 321 | due: data.due ? new Date(data.due).toISOString() : null, | |
| 315 | 322 | tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [], | |
| 316 | 323 | recurrence: data.recurrence, | |
| 324 | + | recurrenceRule, | |
| 317 | 325 | contactId: data.contact_id || null, | |
| 318 | 326 | milestoneId: data.milestone_id || null, | |
| 319 | 327 | estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null, |
| @@ -3,13 +3,13 @@ | |||
| 3 | 3 | //! Provides functionality for viewing and managing a daily timeline | |
| 4 | 4 | //! of scheduled tasks and events, including conflict detection. | |
| 5 | 5 | ||
| 6 | - | use chrono::{DateTime, Utc}; | |
| 6 | + | use chrono::{DateTime, Duration, Utc}; | |
| 7 | 7 | use serde::{Deserialize, Serialize}; | |
| 8 | 8 | use std::sync::Arc; | |
| 9 | 9 | use tauri::State; | |
| 10 | 10 | use tracing::instrument; | |
| 11 | 11 | ||
| 12 | - | use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts}; | |
| 12 | + | use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence}; | |
| 13 | 13 | use chrono::Datelike; | |
| 14 | 14 | ||
| 15 | 15 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -69,9 +69,33 @@ pub async fn get_day_planning( | |||
| 69 | 69 | None => false, | |
| 70 | 70 | }; | |
| 71 | 71 | ||
| 72 | - | let events = state.events | |
| 73 | - | .list_for_date(DESKTOP_USER_ID, parsed_date) | |
| 74 | - | .await?; | |
| 72 | + | // Fetch events for the date + expand recurring events | |
| 73 | + | let day_start = parsed_date.and_hms_opt(0, 0, 0) | |
| 74 | + | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 75 | + | .unwrap_or_else(Utc::now); | |
| 76 | + | let day_end = day_start + Duration::days(1) - Duration::seconds(1); | |
| 77 | + | ||
| 78 | + | let (date_events, recurring) = tokio::join!( | |
| 79 | + | state.events.list_for_date(DESKTOP_USER_ID, parsed_date), | |
| 80 | + | state.events.list_recurring(DESKTOP_USER_ID), | |
| 81 | + | ); | |
| 82 | + | let mut events = date_events?; | |
| 83 | + | let recurring = recurring?; | |
| 84 | + | let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); | |
| 85 | + | for r in recurring { | |
| 86 | + | if !existing_ids.contains(&r.id) { | |
| 87 | + | let expanded = expand_recurrence(&r, day_start, day_end); | |
| 88 | + | events.extend(expanded); | |
| 89 | + | // Check if the original also falls on this day | |
| 90 | + | let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1)); | |
| 91 | + | if effective_end >= day_start && r.start_time <= day_end { | |
| 92 | + | if !existing_ids.contains(&r.id) { | |
| 93 | + | events.push(r); | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | events.sort_by_key(|e| e.start_time); | |
| 75 | 99 | ||
| 76 | 100 | let unscheduled_tasks = state.tasks | |
| 77 | 101 | .list_unscheduled_due_on_date(DESKTOP_USER_ID, parsed_date) | |
| @@ -175,6 +199,7 @@ pub async fn schedule_task( | |||
| 175 | 199 | location: None, | |
| 176 | 200 | linked_task_id: Some(id), | |
| 177 | 201 | recurrence: Recurrence::None, | |
| 202 | + | recurrence_rule: None, | |
| 178 | 203 | contact_id: task.contact_id, | |
| 179 | 204 | block_type: None, | |
| 180 | 205 | }; | |
| @@ -192,6 +217,7 @@ pub async fn schedule_task( | |||
| 192 | 217 | location: None, | |
| 193 | 218 | linked_task_id: Some(id), | |
| 194 | 219 | recurrence: Recurrence::None, | |
| 220 | + | recurrence_rule: None, | |
| 195 | 221 | contact_id: task.contact_id, | |
| 196 | 222 | block_type: None, | |
| 197 | 223 | }; |
| @@ -3,13 +3,13 @@ | |||
| 3 | 3 | //! Provides CRUD operations for events (calendar entries). | |
| 4 | 4 | //! Events can be standalone or linked to tasks via linked_task_id. | |
| 5 | 5 | ||
| 6 | - | use chrono::{DateTime, Local, TimeZone, Utc}; | |
| 6 | + | use chrono::{DateTime, Duration, Local, TimeZone, Utc}; | |
| 7 | 7 | use serde::{Deserialize, Serialize}; | |
| 8 | 8 | use std::sync::Arc; | |
| 9 | 9 | use tauri::State; | |
| 10 | 10 | use tracing::instrument; | |
| 11 | 11 | ||
| 12 | - | use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, TaskId, UpdateEvent, Validate}; | |
| 12 | + | use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence}; | |
| 13 | 13 | ||
| 14 | 14 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 15 | 15 | use super::{ApiError, OptionNotFound}; | |
| @@ -41,6 +41,8 @@ pub struct EventInput { | |||
| 41 | 41 | pub contact_id: Option<ContactId>, | |
| 42 | 42 | /// Block type as a string ("focus", "meeting", etc.), parsed to `BlockType`. | |
| 43 | 43 | pub block_type: Option<String>, | |
| 44 | + | /// Rich recurrence configuration (JSON). | |
| 45 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | #[derive(Debug, Serialize)] | |
| @@ -57,6 +59,9 @@ pub struct EventResponse { | |||
| 57 | 59 | pub location: Option<String>, | |
| 58 | 60 | pub linked_task_id: Option<TaskId>, | |
| 59 | 61 | pub recurrence: String, | |
| 62 | + | pub recurrence_rule: Option<RecurrenceRule>, | |
| 63 | + | pub recurrence_display: String, | |
| 64 | + | pub is_recurring_instance: bool, | |
| 60 | 65 | pub contact_id: Option<ContactId>, | |
| 61 | 66 | pub contact_name: Option<String>, | |
| 62 | 67 | pub block_type: Option<String>, | |
| @@ -142,6 +147,11 @@ impl From<Event> for EventResponse { | |||
| 142 | 147 | event_local.format("%-I:%M %p").to_string() | |
| 143 | 148 | }; | |
| 144 | 149 | ||
| 150 | + | let recurrence_display = e.effective_recurrence_rule() | |
| 151 | + | .map(|r| r.display()) | |
| 152 | + | .unwrap_or_default(); | |
| 153 | + | let recurrence_str = e.recurrence.as_str().to_string(); | |
| 154 | + | ||
| 145 | 155 | EventResponse { | |
| 146 | 156 | id: e.id, | |
| 147 | 157 | project_id: e.project_id, | |
| @@ -153,7 +163,10 @@ impl From<Event> for EventResponse { | |||
| 153 | 163 | end_time: e.end_time, | |
| 154 | 164 | location: e.location, | |
| 155 | 165 | linked_task_id: e.linked_task_id, | |
| 156 | - | recurrence: e.recurrence.as_str().to_string(), | |
| 166 | + | recurrence: recurrence_str, | |
| 167 | + | recurrence_display, | |
| 168 | + | recurrence_rule: e.recurrence_rule, | |
| 169 | + | is_recurring_instance: e.is_recurring_instance, | |
| 157 | 170 | contact_id: e.contact_id, | |
| 158 | 171 | contact_name: e.contact_name, | |
| 159 | 172 | block_type: e.block_type.as_ref().map(|b| b.db_value().to_string()), | |
| @@ -170,9 +183,35 @@ impl From<Event> for EventResponse { | |||
| 170 | 183 | } | |
| 171 | 184 | } | |
| 172 | 185 | ||
| 186 | + | // ============ Recurrence Expansion ============ | |
| 187 | + | ||
| 188 | + | /// Expand recurring events for a date range and merge with non-recurring events. | |
| 189 | + | /// Returns all events sorted by start_time ASC. | |
| 190 | + | fn expand_and_merge(events: Vec<Event>, range_start: DateTime<Utc>, range_end: DateTime<Utc>) -> Vec<Event> { | |
| 191 | + | let mut result: Vec<Event> = Vec::new(); | |
| 192 | + | ||
| 193 | + | for event in events { | |
| 194 | + | if event.has_recurrence() && !event.is_recurring_instance { | |
| 195 | + | // Add virtual instances within the range | |
| 196 | + | let expanded = expand_recurrence(&event, range_start, range_end); | |
| 197 | + | result.extend(expanded); | |
| 198 | + | // Include the original if it falls within range | |
| 199 | + | let effective_end = event.end_time.unwrap_or(event.start_time + Duration::hours(1)); | |
| 200 | + | if effective_end >= range_start && event.start_time <= range_end { | |
| 201 | + | result.push(event); | |
| 202 | + | } | |
| 203 | + | } else { | |
| 204 | + | result.push(event); | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | result.sort_by_key(|e| e.start_time); | |
| 209 | + | result | |
| 210 | + | } | |
| 211 | + | ||
| 173 | 212 | // ============ Commands ============ | |
| 174 | 213 | ||
| 175 | - | /// Lists all events for the current user. | |
| 214 | + | /// Lists all events for the current user, with recurring events expanded. | |
| 176 | 215 | /// | |
| 177 | 216 | /// # Errors | |
| 178 | 217 | /// | |
| @@ -180,8 +219,29 @@ impl From<Event> for EventResponse { | |||
| 180 | 219 | #[tauri::command] | |
| 181 | 220 | #[instrument(skip_all)] | |
| 182 | 221 | pub async fn list_events(state: State<'_, Arc<AppState>>) -> Result<Vec<EventResponse>, ApiError> { | |
| 183 | - | let events = state.events.list_all(DESKTOP_USER_ID).await?; | |
| 184 | - | Ok(events.into_iter().map(EventResponse::from).collect()) | |
| 222 | + | let now = Utc::now(); | |
| 223 | + | let range_start = now - Duration::days(30); | |
| 224 | + | let range_end = now + Duration::days(90); | |
| 225 | + | ||
| 226 | + | // Fetch all non-recurring events and all recurring parents | |
| 227 | + | let (all_events, recurring) = tokio::join!( | |
| 228 | + | state.events.list_all(DESKTOP_USER_ID), | |
| 229 | + | state.events.list_recurring(DESKTOP_USER_ID), | |
| 230 | + | ); | |
| 231 | + | let mut events = all_events?; | |
| 232 | + | ||
| 233 | + | // Add recurring parents that might not be in the all_events result | |
| 234 | + | // (their start_time might be far in the past) | |
| 235 | + | let recurring = recurring?; | |
| 236 | + | let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); | |
| 237 | + | for r in recurring { | |
| 238 | + | if !existing_ids.contains(&r.id) { | |
| 239 | + | events.push(r); | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | let expanded = expand_and_merge(events, range_start, range_end); | |
| 244 | + | Ok(expanded.into_iter().map(EventResponse::from).collect()) | |
| 185 | 245 | } | |
| 186 | 246 | ||
| 187 | 247 | /// Retrieves a single event by ID. | |
| @@ -241,6 +301,7 @@ pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) -> | |||
| 241 | 301 | location: input.location, | |
| 242 | 302 | linked_task_id: None, | |
| 243 | 303 | recurrence, | |
| 304 | + | recurrence_rule: input.recurrence_rule.clone(), | |
| 244 | 305 | contact_id: input.contact_id, | |
| 245 | 306 | block_type, | |
| 246 | 307 | }; | |
| @@ -285,6 +346,7 @@ pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: E | |||
| 285 | 346 | location: input.location, | |
| 286 | 347 | linked_task_id: existing.linked_task_id, | |
| 287 | 348 | recurrence, | |
| 349 | + | recurrence_rule: input.recurrence_rule.clone(), | |
| 288 | 350 | contact_id: input.contact_id, | |
| 289 | 351 | block_type, | |
| 290 | 352 | }; | |
| @@ -318,8 +380,24 @@ pub async fn delete_event(state: State<'_, Arc<AppState>>, id: EventId) -> Resul | |||
| 318 | 380 | #[tauri::command] | |
| 319 | 381 | #[instrument(skip_all)] | |
| 320 | 382 | pub async fn list_upcoming_events(state: State<'_, Arc<AppState>>) -> Result<Vec<EventResponse>, ApiError> { | |
| 321 | - | let events = state.events.get_upcoming(DESKTOP_USER_ID, 7).await?; | |
| 322 | - | Ok(events.into_iter().map(EventResponse::from).collect()) | |
| 383 | + | let now = Utc::now(); | |
| 384 | + | let range_end = now + Duration::days(7); | |
| 385 | + | ||
| 386 | + | let (upcoming, recurring) = tokio::join!( | |
| 387 | + | state.events.get_upcoming(DESKTOP_USER_ID, 7), | |
| 388 | + | state.events.list_recurring(DESKTOP_USER_ID), | |
| 389 | + | ); | |
| 390 | + | let mut events = upcoming?; | |
| 391 | + | let recurring = recurring?; | |
| 392 | + | let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); | |
| 393 | + | for r in recurring { | |
| 394 | + | if !existing_ids.contains(&r.id) { | |
| 395 | + | events.push(r); | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | let expanded = expand_and_merge(events, now, range_end); | |
| 400 | + | Ok(expanded.into_iter().map(EventResponse::from).collect()) | |
| 323 | 401 | } | |
| 324 | 402 | ||
| 325 | 403 | // ============ Project Dashboard Commands ============ |