Skip to main content

max / goingson

12.2 KB · 376 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, TaskSortColumn, SortDirection,
27 /// 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
287 #[cfg(test)]
288 mod tests {
289 use super::*;
290
291 fn rule(pattern: Recurrence, interval: u32, weekdays: Vec<u8>, monthly_spec: Option<MonthlySpec>) -> RecurrenceRule {
292 RecurrenceRule { pattern, interval, weekdays, monthly_spec }
293 }
294
295 #[test]
296 fn display_simple_intervals() {
297 assert_eq!(rule(Recurrence::Daily, 1, vec![], None).display(), "Every day");
298 assert_eq!(rule(Recurrence::Weekly, 1, vec![], None).display(), "Every week");
299 assert_eq!(rule(Recurrence::Monthly, 1, vec![], None).display(), "Every month");
300 assert_eq!(rule(Recurrence::Weekly, 2, vec![], None).display(), "Every 2 weeks");
301 assert_eq!(rule(Recurrence::Daily, 3, vec![], None).display(), "Every 3 days");
302 }
303
304 #[test]
305 fn display_weekly_with_weekdays() {
306 assert_eq!(
307 rule(Recurrence::Weekly, 2, vec![0, 2, 4], None).display(),
308 "Every 2 weeks on Mon, Wed, Fri"
309 );
310 assert_eq!(
311 rule(Recurrence::Weekly, 1, vec![6], None).display(),
312 "Every week on Sun"
313 );
314 }
315
316 #[test]
317 fn display_ignores_out_of_range_weekday_bytes() {
318 // A hostile/corrupt weekday byte (>6) must be filtered, not panic or index out of bounds.
319 assert_eq!(
320 rule(Recurrence::Weekly, 1, vec![9, 1], None).display(),
321 "Every week on Tue"
322 );
323 // Weekdays are only meaningful for Weekly; a Daily rule ignores them.
324 assert_eq!(rule(Recurrence::Daily, 1, vec![0, 1], None).display(), "Every day");
325 }
326
327 #[test]
328 fn display_monthly_specs() {
329 assert_eq!(
330 rule(Recurrence::Monthly, 1, vec![], Some(MonthlySpec::DayOfMonth { day: 15 })).display(),
331 "Every month on day 15"
332 );
333 assert_eq!(
334 rule(Recurrence::Monthly, 1, vec![], Some(MonthlySpec::NthWeekday { week: 2, weekday: 4 })).display(),
335 "Every month on the 2nd Fri"
336 );
337 assert_eq!(
338 rule(Recurrence::Monthly, 1, vec![], Some(MonthlySpec::NthWeekday { week: -1, weekday: 0 })).display(),
339 "Every month on the last Mon"
340 );
341 }
342
343 #[test]
344 fn display_none_pattern_is_empty() {
345 assert_eq!(rule(Recurrence::None, 1, vec![], None).display(), "");
346 }
347
348 #[test]
349 fn ordinal_suffix_edge_cases() {
350 assert_eq!(ordinal_suffix(1), "st");
351 assert_eq!(ordinal_suffix(2), "nd");
352 assert_eq!(ordinal_suffix(3), "rd");
353 assert_eq!(ordinal_suffix(4), "th");
354 assert_eq!(ordinal_suffix(11), "th");
355 assert_eq!(ordinal_suffix(12), "th");
356 assert_eq!(ordinal_suffix(13), "th");
357 assert_eq!(ordinal_suffix(21), "st");
358 }
359
360 #[test]
361 fn from_legacy_and_effective() {
362 assert!(RecurrenceRule::from_legacy(&Recurrence::None).is_none());
363 let weekly = RecurrenceRule::from_legacy(&Recurrence::Weekly).unwrap();
364 assert_eq!(weekly.pattern, Recurrence::Weekly);
365 assert_eq!(weekly.interval, 1);
366
367 // effective prefers an explicit rule over the legacy column.
368 let explicit = rule(Recurrence::Weekly, 3, vec![1], None);
369 let eff = RecurrenceRule::effective(Some(&explicit), &Recurrence::Daily).unwrap();
370 assert_eq!(eff.interval, 3);
371 // with no explicit rule, it synthesizes from the legacy value.
372 let eff = RecurrenceRule::effective(None, &Recurrence::Monthly).unwrap();
373 assert_eq!(eff.pattern, Recurrence::Monthly);
374 }
375 }
376