Skip to main content

max / goingson

Add rich recurrence rules and virtual event expansion Implement full recurrence rule system with interval support (every N days/weeks/months), weekday selection for weekly (Mon/Wed/Fri), and monthly specifications (day-of-month or Nth weekday like 2nd Friday). Events now use virtual expansion: recurring events are stored once and computed at query time for any date range. Integrated into list_events, day planner, upcoming events, and weekly review. Tasks keep create-on-completion model but use rich rules for next-due calculation. Migration 045 adds recurrence_rule JSON column to both tables with updated sync triggers. Frontend: recurrence config UI (interval, weekday checkboxes, monthly spec) added to both task and event forms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-05 22:15 UTC
Commit: 3a616efef535893815a548a214ec2a38c7731507
Parent: 2326f8b
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,
M docs/todo/todo.md +13 -53
@@ -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 ============