//! Cross-domain shared types and traits. use serde::{Deserialize, Serialize}; use strum_macros::EnumString; // ============ Shared Traits ============ /// Trait for types that have a database-storable string representation. pub trait DbValue { /// Returns the database storage value for this variant. fn db_value(&self) -> &'static str; } /// Trait for types that have an associated CSS class. pub trait CssClass { /// Returns the CSS class name for this variant. fn css_class(&self) -> &'static str; } /// Trait for enums that can be parsed from a string with a fallback to `Default`. /// /// Provides a default `from_str_or_default()` method for enums that derive /// both `EnumString` and `Default`. This replaces identical boilerplate /// across 6+ enums. /// /// Enums with custom parsing logic (Priority, LlmProviderType, TaskSortColumn, /// SortDirection, EmailAuthType) keep their manual implementations. pub trait ParseableEnum: std::str::FromStr + Default { /// Parses a string into this enum, falling back to `Default` on invalid input. /// /// This intentional fallback ensures database reads and frontend input never fail, /// even if the value is from a newer schema version or corrupted. #[allow(clippy::should_implement_trait)] fn from_str_or_default(s: &str) -> Self { s.parse().unwrap_or_default() } } // ============ Shared Enums ============ /// Type of time block for protecting intentional time on the calendar. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, EnumString)] pub enum BlockType { /// Unstructured free time. #[strum(serialize = "free_time")] FreeTime, /// Personal errands, appointments, etc. #[strum(serialize = "personal")] Personal, /// Vacation / time off. #[strum(serialize = "vacation")] Vacation, /// Deep work / focus time. #[strum(serialize = "focus")] Focus, } impl BlockType { /// Returns a human-readable display string. pub fn as_str(&self) -> &'static str { match self { BlockType::FreeTime => "Free Time", BlockType::Personal => "Personal", BlockType::Vacation => "Vacation", BlockType::Focus => "Focus", } } /// Parses a string into a BlockType, returning None on invalid input. pub fn from_str_opt(s: &str) -> Option { s.parse().ok() } } impl DbValue for BlockType { fn db_value(&self) -> &'static str { match self { BlockType::FreeTime => "free_time", BlockType::Personal => "personal", BlockType::Vacation => "vacation", BlockType::Focus => "focus", } } } /// Recurrence pattern for repeating tasks and events. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)] pub enum Recurrence { /// Repeats every day. #[strum(serialize = "Daily")] Daily, /// Repeats every week. #[strum(serialize = "Weekly")] Weekly, /// Repeats every month. #[strum(serialize = "Monthly")] Monthly, /// No recurrence. #[strum(serialize = "None")] #[default] None, } impl Recurrence { /// Returns a human-readable display string. pub fn as_str(&self) -> &'static str { match self { Recurrence::Daily => "Daily", Recurrence::Weekly => "Weekly", Recurrence::Monthly => "Monthly", Recurrence::None => "", } } /// Returns an icon representation for UI display. pub fn icon(&self) -> &'static str { match self { Recurrence::Daily => "\u{21bb}D", Recurrence::Weekly => "\u{21bb}W", Recurrence::Monthly => "\u{21bb}M", Recurrence::None => "", } } } impl ParseableEnum for Recurrence {} impl DbValue for Recurrence { fn db_value(&self) -> &'static str { match self { Recurrence::Daily => "Daily", Recurrence::Weekly => "Weekly", Recurrence::Monthly => "Monthly", Recurrence::None => "None", } } } // ============ Rich Recurrence Rules ============ /// Rich recurrence configuration stored as JSON. /// /// Extends the simple `Recurrence` enum with interval, weekday selection, /// and monthly specification. When absent in the database, a default rule /// is synthesized from the legacy `recurrence` column. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RecurrenceRule { /// Base pattern: Daily, Weekly, Monthly. pub pattern: Recurrence, /// Repeat every N intervals (e.g., every 2 weeks). Default 1. #[serde(default = "default_interval")] pub interval: u32, /// For Weekly: which days (0=Mon, 1=Tue, ..., 6=Sun). /// Empty means same weekday as `start_time`. #[serde(default)] pub weekdays: Vec, /// For Monthly: day-of-month or Nth weekday specification. #[serde(default)] pub monthly_spec: Option, } fn default_interval() -> u32 { 1 } impl RecurrenceRule { /// Create a simple rule from a legacy `Recurrence` enum value. pub fn from_legacy(recurrence: &Recurrence) -> Option { if matches!(recurrence, Recurrence::None) { return None; } Some(Self { pattern: recurrence.clone(), interval: 1, weekdays: vec![], monthly_spec: None, }) } /// Returns the effective rule: the explicit rule if set, or one /// synthesized from the legacy recurrence column. pub fn effective(rule: Option<&RecurrenceRule>, recurrence: &Recurrence) -> Option { rule.cloned().or_else(|| RecurrenceRule::from_legacy(recurrence)) } /// Human-readable display string (e.g., "Every 2 weeks on Mon, Wed, Fri"). pub fn display(&self) -> String { let freq = match self.pattern { Recurrence::Daily => "day", Recurrence::Weekly => "week", Recurrence::Monthly => "month", Recurrence::None => return String::new(), }; let mut parts = Vec::new(); if self.interval == 1 { parts.push(format!("Every {}", freq)); } else { parts.push(format!("Every {} {}s", self.interval, freq)); } if !self.weekdays.is_empty() && matches!(self.pattern, Recurrence::Weekly) { let day_names: Vec<&str> = self.weekdays.iter().filter_map(|&d| { WEEKDAY_NAMES.get(d as usize).copied() }).collect(); if !day_names.is_empty() { parts.push(format!("on {}", day_names.join(", "))); } } if let Some(ref spec) = self.monthly_spec { match spec { MonthlySpec::DayOfMonth { day } => { parts.push(format!("on day {}", day)); } MonthlySpec::NthWeekday { week, weekday } => { let week_label = match week { -1 => "last".to_string(), w => format!("{}{}", w, ordinal_suffix(*w as i32)), }; let day_name = WEEKDAY_NAMES.get(*weekday as usize).unwrap_or(&"?"); parts.push(format!("on the {} {}", week_label, day_name)); } } } parts.join(" ") } } const WEEKDAY_NAMES: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; fn ordinal_suffix(n: i32) -> &'static str { match (n % 10, n % 100) { (1, 11) => "th", (2, 12) => "th", (3, 13) => "th", (1, _) => "st", (2, _) => "nd", (3, _) => "rd", _ => "th", } } /// Monthly recurrence specification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "camelCase")] pub enum MonthlySpec { /// Specific day of month (1-31), clamped to the month's length. DayOfMonth { day: u32 }, /// Nth weekday of the month (e.g., 2nd Friday, last Monday). /// `week`: 1-4 for fixed, -1 for last. /// `weekday`: 0=Mon..6=Sun. NthWeekday { week: i8, weekday: u8 }, } /// Sort direction for queries. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SortDirection { /// Ascending order (A-Z, 0-9, oldest first) #[default] Asc, /// Descending order (Z-A, 9-0, newest first) Desc, } impl SortDirection { /// Parses a string to SortDirection, defaulting to Asc if unrecognized. pub fn from_str_or_default(s: &str) -> Self { match s.to_lowercase().as_str() { "desc" | "descending" => Self::Desc, _ => Self::Asc, } } /// Returns the SQL keyword for this direction. pub fn sql(&self) -> &'static str { match self { Self::Asc => "ASC", Self::Desc => "DESC", } } }