Skip to main content

trailcache_core/models/
advancement.rs

1// Allow dead code: API response structs have fields for completeness
2#![allow(dead_code)]
3
4use std::cmp::Ordering;
5
6use chrono::NaiveDate;
7use serde::{Deserialize, Serialize};
8
9// ============================================================================
10// Rank Ordering
11// ============================================================================
12
13/// Classification of a status_display() result.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StatusCategory {
16    Awarded,
17    Completed,
18    InProgress,
19    None,
20}
21
22impl StatusCategory {
23    /// Lowercase string representation for serialization to frontend.
24    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
34/// Default badge status text when no status is available.
35pub const DEFAULT_BADGE_STATUS: &str = "In Progress";
36
37/// Sentinel returned by `format_date()` when the date is missing or unparseable.
38pub const UNKNOWN_DATE: &str = "?";
39
40/// Status string constants used for advancement completion checks.
41pub 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/// Scout rank for sorting purposes
47#[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    /// Parse a rank string into a ScoutRank enum value.
61    /// Handles variations like "Eagle Scout", "Life Scout", etc.
62    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    /// Get the numeric order for sorting (0 = Unknown/Crossover, 7 = Eagle).
89    pub fn order(&self) -> usize {
90        *self as usize
91    }
92
93    /// Returns all ranks in display order (highest to lowest: Eagle → Unknown/Crossover).
94    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    /// Get a short abbreviation for this rank.
108    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    /// Get the display name for this rank.
122    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// API response wrapper for ranks endpoint
182#[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// Simplified rank progress for display
218#[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    /// Get sort order for rank (higher = more advanced, Eagle = 7, Scout = 1)
233    pub fn sort_order(&self) -> i32 {
234        // Use level if available, otherwise derive from name using ScoutRank
235        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        // Convert empty strings to None
242        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    /// A rank is completed only if it has a non-empty dateEarned
261    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    /// Sort date for ordering: prefers date_awarded, falls back to date_completed.
274    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    /// Pre-computed display date for the status column.
281    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    /// Classify the rank's display status.
291    /// Returns (category, display_text).
292    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// Wrapper for rank with requirements response
314#[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// Wrapper for merit badge with requirements response
323// Note: API returns id as string
324#[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/// Merit badge from the catalog endpoint (/advancements/meritBadges)
338#[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// Merit badge requirement from API
356// Note: API returns many fields as strings (e.g., "True"/"False" for booleans)
357#[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
378// Helper to deserialize "True"/"False" strings or actual bools
379fn 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
413// Helper to deserialize string or number as Option<String>
414fn 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    /// Count (completed, total) requirements in a slice.
459    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// Rank requirement from API
489#[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    /// The full requirement text (API uses "name" field)
497    pub name: Option<String>,
498    /// Short description
499    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    /// Count (completed, total) requirements in a slice.
514    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        // Use short description if available, otherwise name (full text)
533        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/// Merit badge counselor information from API
544#[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    /// Get the counselor's full name
564    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    /// Get the best phone number (prefer mobile, fall back to home)
571    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/// Summary of badge completion counts.
579#[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// Merit badge from API (flat array)
590#[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    // Keep old fields for compatibility
608    #[serde(skip)]
609    pub requirements_completed: Option<i32>,
610    #[serde(skip)]
611    pub requirements_total: Option<i32>,
612}
613
614impl MeritBadgeProgress {
615    /// A merit badge is completed if status is "Awarded" or "Leader Approved"
616    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    /// Summarize a slice of badges into completed/in-progress/eagle counts.
632    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    /// Compare two badges for display sorting: in-progress first (by % desc), then completed/awarded (by date desc).
650    /// Awarded items sort before merely completed within the completed group.
651    pub fn cmp_by_progress(a: &MeritBadgeProgress, b: &MeritBadgeProgress) -> Ordering {
652        // Status order: in-progress (0) < completed (1) < awarded (2)
653        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            // Both in-progress: sort by percent desc
663            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            // In-progress comes first
668            a_status.cmp(&b_status)
669        } else {
670            // Both completed/awarded: awarded first, then by sort date desc
671            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    /// Classify the badge's display status.
682    /// Returns (category, display_text).
683    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                // Fall back to date_completed if awarded_date is missing
688                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    /// Sort date for ordering: prefers awarded_date, falls back to date_completed.
706    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    /// Check if this badge has an assigned counselor
713    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// Leadership position history from API
721#[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    /// Returns the position name, or "Unknown Position" if not set
736    pub fn name(&self) -> &str {
737        self.position.as_deref().unwrap_or("Unknown Position")
738    }
739
740    /// Returns true if this is a current position (no end date or end date in future)
741    pub fn is_current(&self) -> bool {
742        self.end_date.is_none() || self.end_date.as_deref() == Some("")
743    }
744
745    /// Format the date range for display
746    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    /// Sort positions for display: current first, then by start_date desc.
757    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    /// Format days served for display
768    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// Youth award from API (e.g., Eagle Palm, 50-miler, etc.)
777#[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    /// v2 API uses boolean `awarded` field
795    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    /// Sort awards for display: awarded first, then by date_awarded desc.
804    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    /// Returns the award name, or "Unknown Award" if not set
815    pub fn name(&self) -> &str {
816        self.name.as_deref().unwrap_or("Unknown Award")
817    }
818
819    /// Returns true if this award has been awarded (status is "Awarded", awarded=true, or has awarded date)
820    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    /// Returns true if this award is completed (status is "Awarded" or "Leader Approved", or has leader approved date)
827    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    /// Format the date for display
836    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    /// Get the award type for display
861    pub fn type_display(&self) -> &str {
862        self.award_type.as_deref().unwrap_or("")
863    }
864
865    /// Get progress percentage if available
866    pub fn progress_percent(&self) -> Option<i32> {
867        self.percent_completed.map(|p| (p * 100.0).round() as i32)
868    }
869}
870
871/// Number of Eagle-required merit badges in the Scouts BSA program.
872pub const EAGLE_REQUIRED_COUNT: usize = 13;
873
874/// Format a date string from "YYYY-MM-DD" to "Month DD, YYYY"
875pub 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        // In-progress comes before completed
985        assert_eq!(MeritBadgeProgress::cmp_by_progress(&in_progress_high, &completed_new), Ordering::Less);
986        // Higher percent comes first among in-progress
987        assert_eq!(MeritBadgeProgress::cmp_by_progress(&in_progress_high, &in_progress_low), Ordering::Less);
988        // Newer date comes first among completed
989        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        // Current (SPL) first, then most recent ended
1022        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        // Awarded first (newer date first), then not awarded
1036        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")); // formatted date
1053
1054        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        // Awarded should come before merely completed
1095        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        // sort_date should prefer awarded_date, but our make_badge doesn't set it
1102        assert_eq!(with_awarded.sort_date(), "2025-01-01");
1103    }
1104}