Skip to main content

max / goingson

8.8 KB · 286 lines History Blame Raw
1 //! Cross-domain shared types and traits.
2
3 use serde::{Deserialize, Serialize};
4 use strum_macros::EnumString;
5
6 // ============ Shared Traits ============
7
8 /// Trait for types that have a database-storable string representation.
9 pub trait DbValue {
10 /// Returns the database storage value for this variant.
11 fn db_value(&self) -> &'static str;
12 }
13
14 /// Trait for types that have an associated CSS class.
15 pub trait CssClass {
16 /// Returns the CSS class name for this variant.
17 fn css_class(&self) -> &'static str;
18 }
19
20 /// Trait for enums that can be parsed from a string with a fallback to `Default`.
21 ///
22 /// Provides a default `from_str_or_default()` method for enums that derive
23 /// both `EnumString` and `Default`. This replaces identical boilerplate
24 /// across 6+ enums.
25 ///
26 /// Enums with custom parsing logic (Priority, LlmProviderType, TaskSortColumn,
27 /// SortDirection, EmailAuthType) keep their manual implementations.
28 pub trait ParseableEnum: std::str::FromStr + Default {
29 /// Parses a string into this enum, falling back to `Default` on invalid input.
30 ///
31 /// This intentional fallback ensures database reads and frontend input never fail,
32 /// even if the value is from a newer schema version or corrupted.
33 #[allow(clippy::should_implement_trait)]
34 fn from_str_or_default(s: &str) -> Self {
35 s.parse().unwrap_or_default()
36 }
37 }
38
39 // ============ Shared Enums ============
40
41 /// Type of time block for protecting intentional time on the calendar.
42 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, EnumString)]
43 pub enum BlockType {
44 /// Unstructured free time.
45 #[strum(serialize = "free_time")]
46 FreeTime,
47 /// Personal errands, appointments, etc.
48 #[strum(serialize = "personal")]
49 Personal,
50 /// Vacation / time off.
51 #[strum(serialize = "vacation")]
52 Vacation,
53 /// Deep work / focus time.
54 #[strum(serialize = "focus")]
55 Focus,
56 }
57
58 impl BlockType {
59 /// Returns a human-readable display string.
60 pub fn as_str(&self) -> &'static str {
61 match self {
62 BlockType::FreeTime => "Free Time",
63 BlockType::Personal => "Personal",
64 BlockType::Vacation => "Vacation",
65 BlockType::Focus => "Focus",
66 }
67 }
68
69 /// Parses a string into a BlockType, returning None on invalid input.
70 pub fn from_str_opt(s: &str) -> Option<Self> {
71 s.parse().ok()
72 }
73 }
74
75 impl DbValue for BlockType {
76 fn db_value(&self) -> &'static str {
77 match self {
78 BlockType::FreeTime => "free_time",
79 BlockType::Personal => "personal",
80 BlockType::Vacation => "vacation",
81 BlockType::Focus => "focus",
82 }
83 }
84 }
85
86 /// Recurrence pattern for repeating tasks and events.
87 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)]
88 pub enum Recurrence {
89 /// Repeats every day.
90 #[strum(serialize = "Daily")]
91 Daily,
92 /// Repeats every week.
93 #[strum(serialize = "Weekly")]
94 Weekly,
95 /// Repeats every month.
96 #[strum(serialize = "Monthly")]
97 Monthly,
98 /// No recurrence.
99 #[strum(serialize = "None")]
100 #[default]
101 None,
102 }
103
104 impl Recurrence {
105 /// Returns a human-readable display string.
106 pub fn as_str(&self) -> &'static str {
107 match self {
108 Recurrence::Daily => "Daily",
109 Recurrence::Weekly => "Weekly",
110 Recurrence::Monthly => "Monthly",
111 Recurrence::None => "",
112 }
113 }
114
115 /// Returns an icon representation for UI display.
116 pub fn icon(&self) -> &'static str {
117 match self {
118 Recurrence::Daily => "\u{21bb}D",
119 Recurrence::Weekly => "\u{21bb}W",
120 Recurrence::Monthly => "\u{21bb}M",
121 Recurrence::None => "",
122 }
123 }
124
125 }
126
127 impl ParseableEnum for Recurrence {}
128
129 impl DbValue for Recurrence {
130 fn db_value(&self) -> &'static str {
131 match self {
132 Recurrence::Daily => "Daily",
133 Recurrence::Weekly => "Weekly",
134 Recurrence::Monthly => "Monthly",
135 Recurrence::None => "None",
136 }
137 }
138 }
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
258 /// Sort direction for queries.
259 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
260 #[serde(rename_all = "lowercase")]
261 pub enum SortDirection {
262 /// Ascending order (A-Z, 0-9, oldest first)
263 #[default]
264 Asc,
265 /// Descending order (Z-A, 9-0, newest first)
266 Desc,
267 }
268
269 impl SortDirection {
270 /// Parses a string to SortDirection, defaulting to Asc if unrecognized.
271 pub fn from_str_or_default(s: &str) -> Self {
272 match s.to_lowercase().as_str() {
273 "desc" | "descending" => Self::Desc,
274 _ => Self::Asc,
275 }
276 }
277
278 /// Returns the SQL keyword for this direction.
279 pub fn sql(&self) -> &'static str {
280 match self {
281 Self::Asc => "ASC",
282 Self::Desc => "DESC",
283 }
284 }
285 }
286