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 #[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 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 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)] impl Event {
101 pub fn event_id(&self) -> i64 {
103 self.id
104 }
105
106 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 if let Ok(dt) = DateTime::parse_from_rfc3339(date) {
116 dt.format("%b %d, %Y").to_string()
117 } else {
118 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 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 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 pub fn formatted_start_datetime(&self) -> String {
173 Self::format_datetime_nice(&self.start_date)
174 }
175
176 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 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 pub fn derived_type(&self) -> &str {
211 if let Some(ref et) = self.event_type {
213 if et != "Other" && !et.is_empty() {
214 return et;
215 }
216 }
217
218 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 "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 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 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 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 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 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)] impl 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#[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}