1#![allow(dead_code)]
3
4use std::cmp::Ordering;
5
6use chrono::NaiveDate;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StatusCategory {
16 Awarded,
17 Completed,
18 InProgress,
19 None,
20}
21
22impl StatusCategory {
23 pub fn as_str(&self) -> &'static str {
25 match self {
26 StatusCategory::Awarded => "awarded",
27 StatusCategory::Completed => "completed",
28 StatusCategory::InProgress => "in_progress",
29 StatusCategory::None => "none",
30 }
31 }
32}
33
34pub const DEFAULT_BADGE_STATUS: &str = "In Progress";
36
37pub const UNKNOWN_DATE: &str = "?";
39
40pub const STATUS_AWARDED: &str = "Awarded";
42pub const STATUS_LEADER_APPROVED: &str = "Leader Approved";
43pub const STATUS_COUNSELOR_APPROVED: &str = "Counselor Approved";
44pub const DEFAULT_AWARD_STATUS: &str = "Unknown";
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48pub enum ScoutRank {
49 Unknown = 0,
50 Scout = 1,
51 Tenderfoot = 2,
52 SecondClass = 3,
53 FirstClass = 4,
54 Star = 5,
55 Life = 6,
56 Eagle = 7,
57}
58
59impl ScoutRank {
60 pub fn parse(s: Option<&str>) -> Self {
63 match s {
64 Some(rank) => {
65 let lower = rank.to_lowercase();
66 if lower.contains("eagle") {
67 ScoutRank::Eagle
68 } else if lower.contains("life") {
69 ScoutRank::Life
70 } else if lower.contains("star") {
71 ScoutRank::Star
72 } else if lower.contains("first class") {
73 ScoutRank::FirstClass
74 } else if lower.contains("second class") {
75 ScoutRank::SecondClass
76 } else if lower.contains("tenderfoot") {
77 ScoutRank::Tenderfoot
78 } else if lower == "scout" {
79 ScoutRank::Scout
80 } else {
81 ScoutRank::Unknown
82 }
83 }
84 None => ScoutRank::Unknown,
85 }
86 }
87
88 pub fn order(&self) -> usize {
90 *self as usize
91 }
92
93 pub fn all_display_order() -> &'static [ScoutRank] {
95 &[
96 ScoutRank::Eagle,
97 ScoutRank::Life,
98 ScoutRank::Star,
99 ScoutRank::FirstClass,
100 ScoutRank::SecondClass,
101 ScoutRank::Tenderfoot,
102 ScoutRank::Scout,
103 ScoutRank::Unknown,
104 ]
105 }
106
107 pub fn abbreviation(&self) -> &'static str {
109 match self {
110 ScoutRank::Unknown => "Xovr",
111 ScoutRank::Scout => "Sct",
112 ScoutRank::Tenderfoot => "TF",
113 ScoutRank::SecondClass => "2C",
114 ScoutRank::FirstClass => "1C",
115 ScoutRank::Star => "Star",
116 ScoutRank::Life => "Life",
117 ScoutRank::Eagle => "Eagle",
118 }
119 }
120
121 pub fn display_name(&self) -> &'static str {
123 match self {
124 ScoutRank::Unknown => "Crossover",
125 ScoutRank::Scout => "Scout",
126 ScoutRank::Tenderfoot => "Tenderfoot",
127 ScoutRank::SecondClass => "Second Class",
128 ScoutRank::FirstClass => "First Class",
129 ScoutRank::Star => "Star",
130 ScoutRank::Life => "Life",
131 ScoutRank::Eagle => "Eagle",
132 }
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct AdvancementDashboard {
138 #[serde(rename = "rankStats")]
139 pub rank_stats: Option<Vec<RankStats>>,
140 #[serde(rename = "meritBadgeCount")]
141 pub merit_badge_count: Option<i32>,
142 #[serde(rename = "activeYouthCount")]
143 pub active_youth_count: Option<i32>,
144 #[serde(rename = "readyToAwardCount")]
145 pub ready_to_award_count: Option<i32>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RankStats {
150 #[serde(rename = "rankName")]
151 pub rank_name: String,
152 pub count: i32,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ReadyToAward {
157 #[serde(rename = "userId")]
158 pub user_id: i64,
159 #[serde(rename = "firstName")]
160 pub first_name: String,
161 #[serde(rename = "lastName")]
162 pub last_name: String,
163 #[serde(rename = "advancementType")]
164 pub advancement_type: String,
165 #[serde(rename = "advancementName")]
166 pub advancement_name: String,
167 #[serde(rename = "dateCompleted")]
168 pub date_completed: Option<String>,
169}
170
171impl ReadyToAward {
172 pub fn full_name(&self) -> String {
173 format!("{} {}", self.first_name, self.last_name)
174 }
175
176 pub fn display_name(&self) -> String {
177 format!("{}, {}", self.last_name, self.first_name)
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct RanksResponse {
184 pub status: Option<String>,
185 pub program: Vec<ProgramRanks>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ProgramRanks {
190 #[serde(rename = "programId")]
191 pub program_id: i32,
192 pub program: String,
193 #[serde(rename = "totalNumberOfRanks")]
194 pub total_number_of_ranks: Option<i32>,
195 pub ranks: Vec<RankFromApi>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct RankFromApi {
200 pub id: i64,
201 #[serde(rename = "versionId")]
202 pub version_id: Option<i64>,
203 pub name: String,
204 #[serde(rename = "dateEarned")]
205 pub date_earned: Option<String>,
206 pub awarded: Option<bool>,
207 #[serde(rename = "awardedDate")]
208 pub awarded_date: Option<String>,
209 #[serde(rename = "percentCompleted")]
210 pub percent_completed: Option<f32>,
211 pub level: Option<i32>,
212 pub status: Option<String>,
213 #[serde(rename = "programId")]
214 pub program_id: Option<i32>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct RankProgress {
220 pub rank_id: i64,
221 pub version_id: Option<i64>,
222 pub rank_name: String,
223 pub date_completed: Option<String>,
224 pub date_awarded: Option<String>,
225 pub requirements_completed: Option<i32>,
226 pub requirements_total: Option<i32>,
227 pub percent_completed: Option<f32>,
228 pub level: Option<i32>,
229}
230
231impl RankProgress {
232 pub fn sort_order(&self) -> i32 {
234 self.level.unwrap_or_else(|| {
236 ScoutRank::parse(Some(&self.rank_name)).order() as i32
237 })
238 }
239
240 pub fn from_api(rank: &RankFromApi) -> Self {
241 let date_completed = rank.date_earned.clone()
243 .filter(|s| !s.is_empty());
244 let date_awarded = rank.awarded_date.clone()
245 .filter(|s| !s.is_empty());
246
247 Self {
248 rank_id: rank.id,
249 version_id: rank.version_id,
250 rank_name: rank.name.clone(),
251 date_completed,
252 date_awarded,
253 requirements_completed: None,
254 requirements_total: None,
255 percent_completed: rank.percent_completed,
256 level: rank.level,
257 }
258 }
259
260 pub fn is_completed(&self) -> bool {
262 self.date_completed.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
263 }
264
265 pub fn is_awarded(&self) -> bool {
266 self.date_awarded.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
267 }
268
269 pub fn progress_percent(&self) -> Option<i32> {
270 self.percent_completed.map(|p| (p * 100.0).round() as i32)
271 }
272
273 pub fn sort_date(&self) -> String {
275 self.date_awarded.clone()
276 .or_else(|| self.date_completed.clone())
277 .unwrap_or_default()
278 }
279
280 pub fn display_date(&self) -> String {
282 if self.is_awarded() {
283 let d = format_date(self.date_awarded.as_deref());
284 if d == UNKNOWN_DATE { format_date(self.date_completed.as_deref()) } else { d }
285 } else {
286 format_date(self.date_completed.as_deref())
287 }
288 }
289
290 pub fn status_display(&self) -> (StatusCategory, String) {
293 if self.is_awarded() {
294 let date = format_date(self.date_awarded.as_deref());
295 let display = if date == UNKNOWN_DATE { "Awarded".to_string() } else { date };
296 (StatusCategory::Awarded, display)
297 } else if self.is_completed() {
298 let date = format_date(self.date_completed.as_deref());
299 let display = if date == UNKNOWN_DATE { "Completed".to_string() } else { date };
300 (StatusCategory::Completed, display)
301 } else if let Some(pct) = self.progress_percent() {
302 if pct > 0 {
303 (StatusCategory::InProgress, format!("{}%", pct))
304 } else {
305 (StatusCategory::None, String::new())
306 }
307 } else {
308 (StatusCategory::None, String::new())
309 }
310 }
311}
312
313#[derive(Debug, Clone, Deserialize)]
315pub struct RankWithRequirements {
316 pub id: i64,
317 pub name: String,
318 #[serde(default)]
319 pub requirements: Vec<RankRequirement>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct MeritBadgeWithRequirements {
326 #[serde(default, deserialize_with = "deserialize_string_or_number")]
327 pub id: Option<String>,
328 pub name: String,
329 #[serde(default)]
330 pub version: Option<String>,
331 #[serde(default)]
332 pub requirements: Vec<MeritBadgeRequirement>,
333 #[serde(rename = "assignedCounselorUser")]
334 pub assigned_counselor: Option<CounselorInfo>,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct MeritBadgeCatalogEntry {
340 #[serde(deserialize_with = "deserialize_string_or_number")]
341 pub id: Option<String>,
342 pub name: String,
343 #[serde(rename = "isEagleRequired")]
344 pub is_eagle_required: Option<bool>,
345 pub category: Option<String>,
346 #[serde(rename = "categoryId")]
347 pub category_id: Option<String>,
348 #[serde(rename = "bsaNumber")]
349 pub bsa_number: Option<String>,
350 #[serde(rename = "shortName")]
351 pub short_name: Option<String>,
352 pub version: Option<String>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct MeritBadgeRequirement {
359 #[serde(default, deserialize_with = "deserialize_string_or_number")]
360 pub id: Option<String>,
361 #[serde(rename = "number")]
362 pub requirement_number: Option<String>,
363 #[serde(rename = "listNumber")]
364 pub list_number: Option<String>,
365 pub name: Option<String>,
366 pub short: Option<String>,
367 #[serde(rename = "dateCompleted")]
368 pub date_completed: Option<String>,
369 #[serde(rename = "leaderApprovedDate")]
370 pub leader_approved_date: Option<String>,
371 #[serde(default, deserialize_with = "deserialize_string_bool")]
372 pub completed: bool,
373 pub status: Option<String>,
374 #[serde(rename = "percentCompleted")]
375 pub percent_completed: Option<String>,
376}
377
378fn deserialize_string_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
380where
381 D: serde::Deserializer<'de>,
382{
383 use serde::de;
384
385 struct BoolVisitor;
386
387 impl<'de> de::Visitor<'de> for BoolVisitor {
388 type Value = bool;
389
390 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
391 formatter.write_str("a boolean or string 'True'/'False'")
392 }
393
394 fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E> {
395 Ok(v)
396 }
397
398 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
399 where
400 E: de::Error,
401 {
402 match v.to_lowercase().as_str() {
403 "true" => Ok(true),
404 "false" | "" => Ok(false),
405 _ => Ok(false),
406 }
407 }
408 }
409
410 deserializer.deserialize_any(BoolVisitor)
411}
412
413fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
415where
416 D: serde::Deserializer<'de>,
417{
418 use serde::de;
419
420 struct StringOrNumberVisitor;
421
422 impl<'de> de::Visitor<'de> for StringOrNumberVisitor {
423 type Value = Option<String>;
424
425 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
426 formatter.write_str("a string or number")
427 }
428
429 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> {
430 if v.is_empty() {
431 Ok(None)
432 } else {
433 Ok(Some(v.to_string()))
434 }
435 }
436
437 fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E> {
438 Ok(Some(v.to_string()))
439 }
440
441 fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> {
442 Ok(Some(v.to_string()))
443 }
444
445 fn visit_none<E>(self) -> Result<Self::Value, E> {
446 Ok(None)
447 }
448
449 fn visit_unit<E>(self) -> Result<Self::Value, E> {
450 Ok(None)
451 }
452 }
453
454 deserializer.deserialize_any(StringOrNumberVisitor)
455}
456
457impl MeritBadgeRequirement {
458 pub fn completion_count(reqs: &[MeritBadgeRequirement]) -> (usize, usize) {
460 let completed = reqs.iter().filter(|r| r.is_completed()).count();
461 (completed, reqs.len())
462 }
463
464 pub fn is_completed(&self) -> bool {
465 self.completed
466 || self.date_completed.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
467 || matches!(self.status.as_deref(), Some(STATUS_LEADER_APPROVED) | Some(STATUS_AWARDED) | Some(STATUS_COUNSELOR_APPROVED))
468 }
469
470 pub fn number(&self) -> String {
471 self.list_number.clone()
472 .filter(|s| !s.is_empty())
473 .or_else(|| self.requirement_number.clone().filter(|s| !s.is_empty()))
474 .unwrap_or_else(|| "-".to_string())
475 }
476
477 pub fn text(&self) -> String {
478 self.short.clone()
479 .filter(|s| !s.is_empty())
480 .unwrap_or_else(|| self.name.clone().unwrap_or_default())
481 }
482
483 pub fn full_text(&self) -> String {
484 self.name.clone().unwrap_or_default()
485 }
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct RankRequirement {
491 pub id: Option<i64>,
492 #[serde(rename = "requirementNumber")]
493 pub requirement_number: Option<String>,
494 #[serde(rename = "listNumber")]
495 pub list_number: Option<String>,
496 pub name: Option<String>,
498 pub short: Option<String>,
500 #[serde(rename = "dateCompleted")]
501 pub date_completed: Option<String>,
502 #[serde(rename = "leaderApprovedDate")]
503 pub leader_approved_date: Option<String>,
504 #[serde(rename = "leaderApprovedFirstName")]
505 pub leader_approved_first_name: Option<String>,
506 #[serde(rename = "leaderApprovedLastName")]
507 pub leader_approved_last_name: Option<String>,
508 pub completed: Option<bool>,
509 pub status: Option<String>,
510}
511
512impl RankRequirement {
513 pub fn completion_count(reqs: &[RankRequirement]) -> (usize, usize) {
515 let completed = reqs.iter().filter(|r| r.is_completed()).count();
516 (completed, reqs.len())
517 }
518
519 pub fn is_completed(&self) -> bool {
520 self.completed.unwrap_or(false)
521 || self.date_completed.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
522 || matches!(self.status.as_deref(), Some(STATUS_LEADER_APPROVED) | Some(STATUS_AWARDED))
523 }
524
525 pub fn number(&self) -> String {
526 self.list_number.clone()
527 .or_else(|| self.requirement_number.clone())
528 .unwrap_or_else(|| "-".to_string())
529 }
530
531 pub fn text(&self) -> String {
532 self.short.clone().unwrap_or_else(||
534 self.name.clone().unwrap_or_default()
535 )
536 }
537
538 pub fn full_text(&self) -> String {
539 self.name.clone().unwrap_or_default()
540 }
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize, Default)]
545pub struct CounselorInfo {
546 #[serde(rename = "userId")]
547 pub user_id: Option<String>,
548 #[serde(rename = "firstName")]
549 pub first_name: Option<String>,
550 #[serde(rename = "middleName")]
551 pub middle_name: Option<String>,
552 #[serde(rename = "lastName")]
553 pub last_name: Option<String>,
554 #[serde(rename = "homePhone")]
555 pub home_phone: Option<String>,
556 #[serde(rename = "mobilePhone")]
557 pub mobile_phone: Option<String>,
558 pub email: Option<String>,
559 pub picture: Option<String>,
560}
561
562impl CounselorInfo {
563 pub fn full_name(&self) -> String {
565 let first = self.first_name.as_deref().unwrap_or("");
566 let last = self.last_name.as_deref().unwrap_or("");
567 format!("{} {}", first, last).trim().to_string()
568 }
569
570 pub fn phone(&self) -> Option<&str> {
572 self.mobile_phone.as_deref()
573 .filter(|s| !s.is_empty())
574 .or_else(|| self.home_phone.as_deref().filter(|s| !s.is_empty()))
575 }
576}
577
578#[derive(Debug, Clone, Default, Serialize)]
580#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
581#[cfg_attr(feature = "ts", ts(export))]
582pub struct BadgeSummary {
583 pub completed: usize,
584 pub in_progress: usize,
585 pub eagle_completed: usize,
586 pub eagle_required_total: usize,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct MeritBadgeProgress {
592 pub id: i64,
593 pub name: String,
594 #[serde(rename = "dateStarted")]
595 pub date_started: Option<String>,
596 #[serde(rename = "dateCompleted")]
597 pub date_completed: Option<String>,
598 #[serde(rename = "awardedDate")]
599 pub awarded_date: Option<String>,
600 #[serde(rename = "percentCompleted")]
601 pub percent_completed: Option<f32>,
602 #[serde(rename = "isEagleRequired")]
603 pub is_eagle_required: Option<bool>,
604 pub status: Option<String>,
605 #[serde(rename = "assignedCounselorUser")]
606 pub assigned_counselor: Option<CounselorInfo>,
607 #[serde(skip)]
609 pub requirements_completed: Option<i32>,
610 #[serde(skip)]
611 pub requirements_total: Option<i32>,
612}
613
614impl MeritBadgeProgress {
615 pub fn is_completed(&self) -> bool {
617 matches!(
618 self.status.as_deref(),
619 Some(STATUS_AWARDED) | Some(STATUS_LEADER_APPROVED)
620 )
621 }
622
623 pub fn is_awarded(&self) -> bool {
624 self.status.as_deref() == Some(STATUS_AWARDED)
625 }
626
627 pub fn progress_percent(&self) -> Option<i32> {
628 self.percent_completed.map(|p| (p * 100.0).round() as i32)
629 }
630
631 pub fn summarize(badges: &[MeritBadgeProgress]) -> BadgeSummary {
633 let mut completed = 0usize;
634 let mut in_progress = 0usize;
635 let mut eagle_completed = 0usize;
636 for b in badges {
637 if b.is_completed() {
638 completed += 1;
639 if b.is_eagle_required.unwrap_or(false) {
640 eagle_completed += 1;
641 }
642 } else {
643 in_progress += 1;
644 }
645 }
646 BadgeSummary { completed, in_progress, eagle_completed, eagle_required_total: EAGLE_REQUIRED_COUNT }
647 }
648
649 pub fn cmp_by_progress(a: &MeritBadgeProgress, b: &MeritBadgeProgress) -> Ordering {
652 let status_order = |m: &MeritBadgeProgress| -> u8 {
654 if m.is_awarded() { 2 }
655 else if m.is_completed() { 1 }
656 else { 0 }
657 };
658 let a_status = status_order(a);
659 let b_status = status_order(b);
660
661 if a_status == 0 && b_status == 0 {
662 let pct_a = a.percent_completed.unwrap_or(0.0);
664 let pct_b = b.percent_completed.unwrap_or(0.0);
665 pct_b.partial_cmp(&pct_a).unwrap_or(Ordering::Equal)
666 } else if a_status == 0 || b_status == 0 {
667 a_status.cmp(&b_status)
669 } else {
670 let sort_date = |m: &MeritBadgeProgress| -> String {
672 m.awarded_date.clone()
673 .or_else(|| m.date_completed.clone())
674 .unwrap_or_default()
675 };
676 b_status.cmp(&a_status)
677 .then_with(|| sort_date(b).cmp(&sort_date(a)))
678 }
679 }
680
681 pub fn status_display(&self) -> (StatusCategory, String) {
684 if self.is_awarded() {
685 let date = format_date(self.awarded_date.as_deref());
686 let display = if date == UNKNOWN_DATE {
687 let fallback = format_date(self.date_completed.as_deref());
689 if fallback == UNKNOWN_DATE { "Awarded".to_string() } else { fallback }
690 } else {
691 date
692 };
693 (StatusCategory::Awarded, display)
694 } else if self.is_completed() {
695 let date = format_date(self.date_completed.as_deref());
696 let display = if date == UNKNOWN_DATE { "Completed".to_string() } else { date };
697 (StatusCategory::Completed, display)
698 } else if let Some(pct) = self.progress_percent() {
699 (StatusCategory::InProgress, format!("{}%", pct))
700 } else {
701 (StatusCategory::None, self.status.clone().unwrap_or_else(|| DEFAULT_BADGE_STATUS.to_string()))
702 }
703 }
704
705 pub fn sort_date(&self) -> String {
707 self.awarded_date.clone()
708 .or_else(|| self.date_completed.clone())
709 .unwrap_or_default()
710 }
711
712 pub fn has_counselor(&self) -> bool {
714 self.assigned_counselor.as_ref()
715 .map(|c| !c.full_name().is_empty())
716 .unwrap_or(false)
717 }
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct LeadershipPosition {
723 pub position: Option<String>,
724 #[serde(rename = "startDate")]
725 pub start_date: Option<String>,
726 #[serde(rename = "endDate")]
727 pub end_date: Option<String>,
728 #[serde(rename = "numberOfDaysInPosition")]
729 pub days_served: Option<i32>,
730 pub patrol: Option<String>,
731 pub rank: Option<String>,
732}
733
734impl LeadershipPosition {
735 pub fn name(&self) -> &str {
737 self.position.as_deref().unwrap_or("Unknown Position")
738 }
739
740 pub fn is_current(&self) -> bool {
742 self.end_date.is_none() || self.end_date.as_deref() == Some("")
743 }
744
745 pub fn date_range(&self) -> String {
747 let start = format_date(self.start_date.as_deref());
748 if self.is_current() {
749 format!("{} - Present", start)
750 } else {
751 let end = format_date(self.end_date.as_deref());
752 format!("{} - {}", start, end)
753 }
754 }
755
756 pub fn sort_for_display(positions: &mut [LeadershipPosition]) {
758 positions.sort_by(|a, b| {
759 match (a.is_current(), b.is_current()) {
760 (true, false) => Ordering::Less,
761 (false, true) => Ordering::Greater,
762 _ => b.start_date.cmp(&a.start_date),
763 }
764 });
765 }
766
767 pub fn days_display(&self) -> String {
769 match self.days_served {
770 Some(days) if days > 0 => format!("{} days", days),
771 _ => String::new(),
772 }
773 }
774}
775
776#[derive(Debug, Clone, Default, Serialize, Deserialize)]
778#[serde(default)]
779pub struct Award {
780 #[serde(alias = "awardId")]
781 pub id: Option<i64>,
782 pub name: Option<String>,
783 #[serde(rename = "dateStarted")]
784 pub date_started: Option<String>,
785 #[serde(rename = "dateCompleted", alias = "markedCompletedDate")]
786 pub date_completed: Option<String>,
787 #[serde(rename = "dateEarned")]
788 pub date_earned: Option<String>,
789 #[serde(rename = "dateAwarded", alias = "awardedDate")]
790 pub date_awarded: Option<String>,
791 #[serde(rename = "awardType")]
792 pub award_type: Option<String>,
793 pub status: Option<String>,
794 pub awarded: Option<bool>,
796 #[serde(rename = "percentCompleted")]
797 pub percent_completed: Option<f32>,
798 #[serde(rename = "leaderApprovedDate")]
799 pub leader_approved_date: Option<String>,
800}
801
802impl Award {
803 pub fn sort_for_display(awards: &mut [Award]) {
805 awards.sort_by(|a, b| {
806 match (a.is_awarded(), b.is_awarded()) {
807 (true, false) => Ordering::Less,
808 (false, true) => Ordering::Greater,
809 _ => b.date_awarded.cmp(&a.date_awarded),
810 }
811 });
812 }
813
814 pub fn name(&self) -> &str {
816 self.name.as_deref().unwrap_or("Unknown Award")
817 }
818
819 pub fn is_awarded(&self) -> bool {
821 self.awarded == Some(true)
822 || self.status.as_deref() == Some(STATUS_AWARDED)
823 || self.date_awarded.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
824 }
825
826 pub fn is_completed(&self) -> bool {
828 matches!(
829 self.status.as_deref(),
830 Some(STATUS_AWARDED) | Some(STATUS_LEADER_APPROVED)
831 ) || self.date_completed.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
832 || self.leader_approved_date.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
833 }
834
835 pub fn date_display(&self) -> String {
837 if let Some(ref date) = self.date_awarded {
838 if !date.is_empty() {
839 return format_date(Some(date));
840 }
841 }
842 if let Some(ref date) = self.date_completed {
843 if !date.is_empty() {
844 return format_date(Some(date));
845 }
846 }
847 if let Some(ref date) = self.date_earned {
848 if !date.is_empty() {
849 return format!("{} (earned)", format_date(Some(date)));
850 }
851 }
852 if let Some(ref date) = self.date_started {
853 if !date.is_empty() {
854 return format!("{} (started)", format_date(Some(date)));
855 }
856 }
857 UNKNOWN_DATE.to_string()
858 }
859
860 pub fn type_display(&self) -> &str {
862 self.award_type.as_deref().unwrap_or("")
863 }
864
865 pub fn progress_percent(&self) -> Option<i32> {
867 self.percent_completed.map(|p| (p * 100.0).round() as i32)
868 }
869}
870
871pub const EAGLE_REQUIRED_COUNT: usize = 13;
873
874pub fn format_date(date: Option<&str>) -> String {
876 match date {
877 Some(d) if d.len() >= 10 => {
878 if let Ok(parsed) = NaiveDate::parse_from_str(&d[..10], "%Y-%m-%d") {
879 parsed.format("%b %d, %Y").to_string()
880 } else {
881 d.to_string()
882 }
883 }
884 Some(d) => d.to_string(),
885 None => UNKNOWN_DATE.to_string(),
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 #[test]
894 fn test_award_v2_deserialization() {
895 let json = r#"{"awardId": 33, "name": "Honor Medal", "status": "Started", "awarded": false, "percentCompleted": 0}"#;
896 let award: Award = serde_json::from_str(json).expect("Failed to parse");
897 assert_eq!(award.id, Some(33));
898 assert_eq!(award.name, Some("Honor Medal".to_string()));
899 assert_eq!(award.status, Some("Started".to_string()));
900 assert_eq!(award.awarded, Some(false));
901 }
902
903 #[test]
904 fn test_merit_badge_with_counselor() {
905 let json = r#"{
906 "id": 24,
907 "name": "Citizenship in the Community",
908 "dateStarted": "2024-12-01",
909 "percentCompleted": 0.43,
910 "isEagleRequired": true,
911 "status": "Started",
912 "assignedCounselorUser": {
913 "userId": "1234567",
914 "firstName": "John",
915 "middleName": "Q",
916 "lastName": "Smith",
917 "homePhone": "5551234567",
918 "mobilePhone": "5559876543",
919 "email": "john.smith@example.com",
920 "picture": null
921 }
922 }"#;
923 let badge: MeritBadgeProgress = serde_json::from_str(json).expect("Failed to parse");
924 assert_eq!(badge.id, 24);
925 assert_eq!(badge.name, "Citizenship in the Community");
926 assert!(badge.has_counselor());
927
928 let counselor = badge.assigned_counselor.as_ref().unwrap();
929 assert_eq!(counselor.full_name(), "John Smith");
930 assert_eq!(counselor.phone(), Some("5559876543"));
931 assert_eq!(counselor.email.as_deref(), Some("john.smith@example.com"));
932 }
933
934 #[test]
935 fn test_merit_badge_without_counselor() {
936 let json = r#"{
937 "id": 25,
938 "name": "Swimming",
939 "status": "Started"
940 }"#;
941 let badge: MeritBadgeProgress = serde_json::from_str(json).expect("Failed to parse");
942 assert_eq!(badge.id, 25);
943 assert!(!badge.has_counselor());
944 assert!(badge.assigned_counselor.is_none());
945 }
946
947 fn make_badge(name: &str, status: &str, pct: Option<f32>, date_completed: Option<&str>, eagle: bool) -> MeritBadgeProgress {
948 MeritBadgeProgress {
949 id: 1,
950 name: name.to_string(),
951 date_started: None,
952 date_completed: date_completed.map(|s| s.to_string()),
953 awarded_date: None,
954 percent_completed: pct,
955 is_eagle_required: Some(eagle),
956 status: Some(status.to_string()),
957 assigned_counselor: None,
958 requirements_completed: None,
959 requirements_total: None,
960 }
961 }
962
963 #[test]
964 fn test_badge_summary() {
965 let badges = vec![
966 make_badge("Swimming", "Awarded", None, Some("2025-01-01"), false),
967 make_badge("Citizenship", "Awarded", None, Some("2025-02-01"), true),
968 make_badge("Camping", "Started", Some(0.5), None, true),
969 make_badge("Cooking", "Started", Some(0.2), None, false),
970 ];
971 let summary = MeritBadgeProgress::summarize(&badges);
972 assert_eq!(summary.completed, 2);
973 assert_eq!(summary.in_progress, 2);
974 assert_eq!(summary.eagle_completed, 1);
975 }
976
977 #[test]
978 fn test_cmp_by_progress() {
979 let in_progress_high = make_badge("A", "Started", Some(0.8), None, false);
980 let in_progress_low = make_badge("B", "Started", Some(0.2), None, false);
981 let completed_new = make_badge("C", "Awarded", None, Some("2025-06-01"), false);
982 let completed_old = make_badge("D", "Awarded", None, Some("2024-01-01"), false);
983
984 assert_eq!(MeritBadgeProgress::cmp_by_progress(&in_progress_high, &completed_new), Ordering::Less);
986 assert_eq!(MeritBadgeProgress::cmp_by_progress(&in_progress_high, &in_progress_low), Ordering::Less);
988 assert_eq!(MeritBadgeProgress::cmp_by_progress(&completed_new, &completed_old), Ordering::Less);
990 }
991
992 #[test]
993 fn test_leadership_sort_for_display() {
994 let mut positions = vec![
995 LeadershipPosition {
996 position: Some("Patrol Leader".to_string()),
997 start_date: Some("2024-06-01".to_string()),
998 end_date: Some("2025-01-01".to_string()),
999 days_served: Some(214),
1000 patrol: None,
1001 rank: None,
1002 },
1003 LeadershipPosition {
1004 position: Some("SPL".to_string()),
1005 start_date: Some("2025-01-01".to_string()),
1006 end_date: None,
1007 days_served: None,
1008 patrol: None,
1009 rank: None,
1010 },
1011 LeadershipPosition {
1012 position: Some("Scribe".to_string()),
1013 start_date: Some("2023-01-01".to_string()),
1014 end_date: Some("2023-06-01".to_string()),
1015 days_served: Some(151),
1016 patrol: None,
1017 rank: None,
1018 },
1019 ];
1020 LeadershipPosition::sort_for_display(&mut positions);
1021 assert_eq!(positions[0].position.as_deref(), Some("SPL"));
1023 assert_eq!(positions[1].position.as_deref(), Some("Patrol Leader"));
1024 assert_eq!(positions[2].position.as_deref(), Some("Scribe"));
1025 }
1026
1027 #[test]
1028 fn test_award_sort_for_display() {
1029 let mut awards = vec![
1030 Award { name: Some("50-Miler".to_string()), awarded: Some(false), date_awarded: None, ..Default::default() },
1031 Award { name: Some("Eagle Palm".to_string()), awarded: Some(true), date_awarded: Some("2025-03-01".to_string()), ..Default::default() },
1032 Award { name: Some("Honor Medal".to_string()), awarded: Some(true), date_awarded: Some("2024-06-01".to_string()), ..Default::default() },
1033 ];
1034 Award::sort_for_display(&mut awards);
1035 assert_eq!(awards[0].name.as_deref(), Some("Eagle Palm"));
1037 assert_eq!(awards[1].name.as_deref(), Some("Honor Medal"));
1038 assert_eq!(awards[2].name.as_deref(), Some("50-Miler"));
1039 }
1040
1041 #[test]
1042 fn test_rank_status_display() {
1043 let awarded = RankProgress {
1044 rank_id: 1, version_id: None, rank_name: "Eagle".to_string(),
1045 date_completed: Some("2025-01-15".to_string()),
1046 date_awarded: Some("2025-02-01".to_string()),
1047 requirements_completed: None, requirements_total: None,
1048 percent_completed: None, level: Some(7),
1049 };
1050 let (cat, text) = awarded.status_display();
1051 assert_eq!(cat, StatusCategory::Awarded);
1052 assert!(text.contains("2025")); let in_progress = RankProgress {
1055 rank_id: 2, version_id: None, rank_name: "Star".to_string(),
1056 date_completed: None, date_awarded: None,
1057 requirements_completed: None, requirements_total: None,
1058 percent_completed: Some(0.65), level: Some(5),
1059 };
1060 let (cat, text) = in_progress.status_display();
1061 assert_eq!(cat, StatusCategory::InProgress);
1062 assert_eq!(text, "65%");
1063 }
1064
1065 #[test]
1066 fn test_badge_status_display() {
1067 let awarded = make_badge("Swimming", "Awarded", None, Some("2025-03-01"), false);
1068 let (cat, _) = awarded.status_display();
1069 assert_eq!(cat, StatusCategory::Awarded);
1070
1071 let in_progress = make_badge("Cooking", "Started", Some(0.5), None, false);
1072 let (cat, text) = in_progress.status_display();
1073 assert_eq!(cat, StatusCategory::InProgress);
1074 assert_eq!(text, "50%");
1075
1076 let not_started = make_badge("Hiking", "Started", Some(0.0), None, false);
1077 let (cat, text) = not_started.status_display();
1078 assert_eq!(cat, StatusCategory::InProgress);
1079 assert_eq!(text, "0%");
1080 }
1081
1082 #[test]
1083 fn test_scout_rank_all_display_order() {
1084 let order = ScoutRank::all_display_order();
1085 assert_eq!(order.len(), 8);
1086 assert_eq!(order[0], ScoutRank::Eagle);
1087 assert_eq!(order[7], ScoutRank::Unknown);
1088 }
1089
1090 #[test]
1091 fn test_cmp_by_progress_awarded_before_completed() {
1092 let awarded = make_badge("A", "Awarded", None, Some("2025-01-01"), false);
1093 let completed = make_badge("B", "Leader Approved", None, Some("2025-06-01"), false);
1094 assert_eq!(MeritBadgeProgress::cmp_by_progress(&awarded, &completed), Ordering::Less);
1096 }
1097
1098 #[test]
1099 fn test_badge_sort_date() {
1100 let with_awarded = make_badge("A", "Awarded", None, Some("2025-01-01"), false);
1101 assert_eq!(with_awarded.sort_date(), "2025-01-01");
1103 }
1104}