Skip to main content

trailcache_core/models/
event.rs

1use std::cmp::Ordering;
2
3use chrono::DateTime;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum RsvpStatus {
8    Going,
9    NotGoing,
10    NoResponse,
11}
12
13impl std::fmt::Display for RsvpStatus {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            RsvpStatus::Going => write!(f, "Going"),
17            RsvpStatus::NotGoing => write!(f, "Not Going"),
18            RsvpStatus::NoResponse => write!(f, "No Response"),
19        }
20    }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Event {
25    #[serde(default)]
26    pub id: i64,
27    pub name: String,
28    pub description: Option<String>,
29    #[serde(rename = "startDate")]
30    pub start_date: Option<String>,
31    #[serde(rename = "endDate")]
32    pub end_date: Option<String>,
33    pub location: Option<String>,
34    #[serde(rename = "eventType")]
35    pub event_type: Option<String>,
36    #[serde(default)]
37    pub rsvp: bool,
38    #[serde(rename = "slipsRequired", default)]
39    pub slips_required: bool,
40    // POST /events returns "invitedUsers", GET /events/{id} returns "users"
41    #[serde(rename = "invitedUsers", alias = "users", default)]
42    pub invited_users: Vec<InvitedUser>,
43    #[serde(default)]
44    pub units: Vec<EventUnit>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct EventUnit {
49    #[serde(rename = "unitId")]
50    pub unit_id: i64,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct InvitedUser {
55    #[serde(rename = "userId")]
56    pub user_id: i64,
57    #[serde(rename = "firstName")]
58    pub first_name: String,
59    #[serde(rename = "lastName")]
60    pub last_name: String,
61    pub rsvp: Option<String>,
62    #[serde(rename = "rsvpCode", default)]
63    pub rsvp_code: Option<String>,
64    #[serde(default)]
65    pub attended: bool,
66    #[serde(rename = "isAdult", default)]
67    pub is_adult: bool,
68}
69
70impl InvitedUser {
71    pub fn status(&self) -> RsvpStatus {
72        // Check rsvpCode first (Y/N), then fall back to rsvp field
73        if let Some(code) = &self.rsvp_code {
74            if code.eq_ignore_ascii_case("y") || code.eq_ignore_ascii_case("yes") {
75                return RsvpStatus::Going;
76            }
77            if code.eq_ignore_ascii_case("n") || code.eq_ignore_ascii_case("no") {
78                return RsvpStatus::NotGoing;
79            }
80        }
81        // Fall back to rsvp field
82        if let Some(rsvp) = &self.rsvp {
83            let rsvp_lower = rsvp.to_ascii_lowercase();
84            if rsvp_lower == "going" || rsvp_lower == "yes" {
85                return RsvpStatus::Going;
86            }
87            if rsvp_lower == "not going" || rsvp_lower == "not_going" || rsvp_lower == "no" {
88                return RsvpStatus::NotGoing;
89            }
90        }
91        RsvpStatus::NoResponse
92    }
93
94    pub fn display_name(&self) -> String {
95        format!("{}, {}", self.last_name, self.first_name)
96    }
97}
98
99#[allow(dead_code)] // Helper methods - some used, others for future use
100impl Event {
101    /// For compatibility with code that expects event_id
102    pub fn event_id(&self) -> i64 {
103        self.id
104    }
105
106    /// Get the unit ID from the first unit in the units array
107    pub fn unit_id(&self) -> Option<i64> {
108        self.units.first().map(|u| u.unit_id)
109    }
110
111    pub fn formatted_date(&self) -> String {
112        match &self.start_date {
113            Some(date) => {
114                // Try to parse and format the date nicely
115                if let Ok(dt) = DateTime::parse_from_rfc3339(date) {
116                    dt.format("%b %d, %Y").to_string()
117                } else {
118                    // Fall back to raw date string, truncate if too long
119                    date.chars().take(10).collect()
120                }
121            }
122            None => "TBD".to_string(),
123        }
124    }
125
126    pub fn formatted_time(&self) -> Option<String> {
127        self.start_date.as_ref().and_then(|date| {
128            if let Ok(dt) = DateTime::parse_from_rfc3339(date) {
129                Some(dt.format("%H:%M").to_string())
130            } else {
131                None
132            }
133        })
134    }
135
136    /// Compact date/time for list view: "Jan 26 5:00p"
137    pub fn formatted_datetime_short(&self) -> String {
138        match &self.start_date {
139            Some(date) => {
140                if let Ok(dt) = DateTime::parse_from_rfc3339(date) {
141                    let hour = dt.format("%I").to_string().trim_start_matches('0').to_string();
142                    let minute = dt.format("%M").to_string();
143                    let ampm = dt.format("%p").to_string().to_lowercase().chars().next().unwrap_or('a');
144                    if minute == "00" {
145                        dt.format(&format!("%b %d {}{}",  hour, ampm)).to_string()
146                    } else {
147                        dt.format(&format!("%b %d {}:{}{}",  hour, minute, ampm)).to_string()
148                    }
149                } else {
150                    date.chars().take(10).collect()
151                }
152            }
153            None => "TBD".to_string(),
154        }
155    }
156
157    /// Standard date/time format: "MM/DD/YYYY HH:mm"
158    pub fn formatted_datetime_standard(&self) -> String {
159        match &self.start_date {
160            Some(date) => {
161                if let Ok(dt) = DateTime::parse_from_rfc3339(date) {
162                    dt.format("%m/%d/%Y %H:%M").to_string()
163                } else {
164                    date.chars().take(16).collect()
165                }
166            }
167            None => "TBD".to_string(),
168        }
169    }
170
171    /// Formatted start datetime: "Feb 06, 2026 @ 07:00 PM"
172    pub fn formatted_start_datetime(&self) -> String {
173        Self::format_datetime_nice(&self.start_date)
174    }
175
176    /// Formatted end datetime: "Feb 08, 2026 @ 10:00 AM"
177    pub fn formatted_end_datetime(&self) -> String {
178        Self::format_datetime_nice(&self.end_date)
179    }
180
181    fn format_datetime_nice(date_opt: &Option<String>) -> String {
182        match date_opt {
183            Some(date) => {
184                if let Ok(dt) = DateTime::parse_from_rfc3339(date) {
185                    dt.format("%b %d, %Y @ %I:%M %p").to_string()
186                } else {
187                    date.chars().take(16).collect()
188                }
189            }
190            None => "TBD".to_string(),
191        }
192    }
193
194    /// Short event type for list view
195    pub fn event_type_short(&self) -> &str {
196        match self.event_type.as_deref() {
197            Some("Troop Meeting") => "Mtg",
198            Some("Camping") => "Camp",
199            Some("Hiking") => "Hike",
200            Some("Service") => "Svc",
201            Some("Other") => "Other",
202            Some(t) if t.len() <= 5 => t,
203            Some(t) => &t[..5],
204            None => "-",
205        }
206    }
207
208    /// Derive a meaningful event type from available fields
209    /// The API eventType is often just "Other", so we infer from other fields
210    pub fn derived_type(&self) -> &str {
211        // First check if eventType is something other than "Other"
212        if let Some(ref et) = self.event_type {
213            if et != "Other" && !et.is_empty() {
214                return et;
215            }
216        }
217
218        // Infer from name
219        let name_lower = self.name.to_lowercase();
220        if name_lower.contains("meeting") || name_lower.contains("mtg") {
221            return "Meeting";
222        }
223        if name_lower.contains("camp") {
224            return "Camping";
225        }
226        if name_lower.contains("hike") || name_lower.contains("hiking") {
227            return "Hike";
228        }
229        if name_lower.contains("ski") {
230            return "Outdoor";
231        }
232        if name_lower.contains("service") {
233            return "Service";
234        }
235
236        // Fall back to "Other"
237        "Other"
238    }
239
240    pub fn going_count(&self) -> i32 {
241        self.invited_users.iter()
242            .filter(|u| matches!(u.status(), RsvpStatus::Going))
243            .count() as i32
244    }
245
246    pub fn not_going_count(&self) -> i32 {
247        self.invited_users.iter()
248            .filter(|u| matches!(u.status(), RsvpStatus::NotGoing))
249            .count() as i32
250    }
251
252    pub fn no_response_count(&self) -> i32 {
253        self.invited_users.iter()
254            .filter(|u| matches!(u.status(), RsvpStatus::NoResponse))
255            .count() as i32
256    }
257
258    /// Adult RSVP counts: (going, not_going)
259    pub fn adult_rsvp_counts(&self) -> (i32, i32) {
260        let going = self.invited_users.iter()
261            .filter(|u| u.is_adult && matches!(u.status(), RsvpStatus::Going))
262            .count() as i32;
263        let not_going = self.invited_users.iter()
264            .filter(|u| u.is_adult && matches!(u.status(), RsvpStatus::NotGoing))
265            .count() as i32;
266        (going, not_going)
267    }
268
269    /// Scout RSVP counts: (going, not_going)
270    pub fn scout_rsvp_counts(&self) -> (i32, i32) {
271        let going = self.invited_users.iter()
272            .filter(|u| !u.is_adult && matches!(u.status(), RsvpStatus::Going))
273            .count() as i32;
274        let not_going = self.invited_users.iter()
275            .filter(|u| !u.is_adult && matches!(u.status(), RsvpStatus::NotGoing))
276            .count() as i32;
277        (going, not_going)
278    }
279
280    /// Check if this event matches a search query (case-insensitive).
281    /// Query should already be lowercased.
282    pub fn matches_search(&self, query_lowercase: &str) -> bool {
283        self.name.to_lowercase().contains(query_lowercase)
284            || self.location.as_ref().map(|s| s.to_lowercase().contains(query_lowercase)).unwrap_or(false)
285            || self.derived_type().to_lowercase().contains(query_lowercase)
286    }
287
288    /// Invited users who have responded (Going or Not Going), split by adult/youth.
289    pub fn respondents(&self) -> (Vec<&InvitedUser>, Vec<&InvitedUser>) {
290        let (mut adults, mut scouts) = (vec![], vec![]);
291        for u in &self.invited_users {
292            if matches!(u.status(), RsvpStatus::Going | RsvpStatus::NotGoing) {
293                if u.is_adult { adults.push(u); } else { scouts.push(u); }
294            }
295        }
296        (adults, scouts)
297    }
298
299    pub fn rsvp_summary(&self) -> String {
300        format!("Going: {} | Not: {} | ??: {}",
301            self.going_count(),
302            self.not_going_count(),
303            self.no_response_count())
304    }
305
306    /// Compare two events by the given column, with name as tiebreaker.
307    pub fn cmp_by_column(a: &Event, b: &Event, column: EventSortColumn) -> Ordering {
308        use crate::utils::cmp_ignore_case;
309
310        let name_cmp = || cmp_ignore_case(&a.name, &b.name);
311
312        match column {
313            EventSortColumn::Name => name_cmp(),
314            EventSortColumn::Date => {
315                let date_a = a.start_date.as_deref().unwrap_or("");
316                let date_b = b.start_date.as_deref().unwrap_or("");
317                date_a.cmp(date_b).then_with(name_cmp)
318            }
319            EventSortColumn::Location => {
320                cmp_ignore_case(
321                    a.location.as_deref().unwrap_or(""),
322                    b.location.as_deref().unwrap_or(""),
323                )
324                .then_with(name_cmp)
325            }
326            EventSortColumn::Type => {
327                cmp_ignore_case(a.derived_type(), b.derived_type())
328                    .then_with(name_cmp)
329            }
330        }
331    }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct EventGuest {
336    #[serde(rename = "userId")]
337    pub user_id: i64,
338    #[serde(rename = "firstName")]
339    pub first_name: String,
340    #[serde(rename = "lastName")]
341    pub last_name: String,
342    #[serde(rename = "rsvpStatus")]
343    pub rsvp_status: Option<String>,
344    #[serde(rename = "isYouth")]
345    pub is_youth: Option<bool>,
346}
347
348#[allow(dead_code)] // Helper methods for future guest display improvements
349impl EventGuest {
350    pub fn full_name(&self) -> String {
351        format!("{} {}", self.first_name, self.last_name)
352    }
353
354    pub fn display_name(&self) -> String {
355        format!("{}, {}", self.last_name, self.first_name)
356    }
357
358    pub fn status(&self) -> RsvpStatus {
359        if let Some(status) = &self.rsvp_status {
360            let status_lower = status.to_ascii_lowercase();
361            if status_lower == "going" || status_lower == "yes" {
362                return RsvpStatus::Going;
363            }
364            if status_lower == "not going" || status_lower == "not_going" || status_lower == "no" {
365                return RsvpStatus::NotGoing;
366            }
367        }
368        RsvpStatus::NoResponse
369    }
370}
371
372// Sorting options for events table
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
374pub enum EventSortColumn {
375    Name,
376    #[default]
377    Date,
378    Location,
379    Type,
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_event_matches_search() {
388        let event = Event {
389            id: 1,
390            name: "Summer Camp".to_string(),
391            description: None,
392            start_date: None,
393            end_date: None,
394            location: Some("Camp Parsons".to_string()),
395            event_type: Some("Camping".to_string()),
396            rsvp: false,
397            slips_required: false,
398            invited_users: vec![],
399            units: vec![],
400        };
401        assert!(event.matches_search("summer"));
402        assert!(event.matches_search("parsons"));
403        assert!(event.matches_search("camping"));
404        assert!(!event.matches_search("hiking"));
405    }
406}