Skip to main content

trailcache_core/models/
stats.rs

1//! Aggregate statistics computed over collections of model objects.
2//!
3//! These functions consolidate business logic that was previously
4//! duplicated between the TUI and GUI interfaces.
5
6use std::collections::HashMap;
7
8use crate::models::person::{Adult, Youth};
9use crate::models::advancement::ScoutRank;
10use crate::utils::format::{check_expiration, ExpirationStatus};
11
12// ============================================================================
13// Training Statistics
14// ============================================================================
15
16/// Aggregated training status across all adults in a unit.
17#[derive(Debug, Clone, Default)]
18pub struct TrainingStats {
19    pub ypt_current: usize,
20    pub ypt_expiring: usize,
21    pub ypt_expired: usize,
22    /// (display_name, status_text) for adults with YPT issues, sorted by name.
23    pub ypt_issues: Vec<(String, String)>,
24    pub position_trained: usize,
25    pub position_not_trained: usize,
26    /// Display names of adults not position-trained, sorted.
27    pub position_not_trained_list: Vec<String>,
28}
29
30impl TrainingStats {
31    /// Compute training statistics from a slice of adults.
32    pub fn from_adults(adults: &[Adult]) -> Self {
33        let mut stats = TrainingStats::default();
34
35        for adult in adults {
36            // YPT status
37            if let Some(ref exp_str) = adult.ypt_expired_date {
38                if let Some((status, formatted)) = check_expiration(exp_str) {
39                    match status {
40                        ExpirationStatus::Expired => {
41                            stats.ypt_expired += 1;
42                            stats.ypt_issues.push((adult.display_name(), format!("Expired {}", formatted)));
43                        }
44                        ExpirationStatus::ExpiringSoon => {
45                            stats.ypt_expiring += 1;
46                            stats.ypt_issues.push((adult.display_name(), format!("Expires {}", formatted)));
47                        }
48                        ExpirationStatus::Active => {
49                            stats.ypt_current += 1;
50                        }
51                    }
52                }
53            }
54
55            // Position training
56            match adult.is_position_trained() {
57                Some(true) => stats.position_trained += 1,
58                Some(false) => {
59                    stats.position_not_trained += 1;
60                    stats.position_not_trained_list.push(adult.display_name());
61                }
62                None => {}
63            }
64        }
65
66        stats.ypt_issues.sort_by(|a, b| a.0.cmp(&b.0));
67        stats.position_not_trained_list.sort();
68        stats
69    }
70}
71
72// ============================================================================
73// Membership Renewal Statistics
74// ============================================================================
75
76/// Aggregated membership renewal status across youth and adults.
77#[derive(Debug, Clone, Default)]
78pub struct RenewalStats {
79    pub scouts_current: usize,
80    pub scouts_expiring: usize,
81    pub scouts_expired: usize,
82    /// (display_name, status_text) for scouts with renewal issues, sorted by name.
83    pub scout_issues: Vec<(String, String)>,
84    pub adults_current: usize,
85    pub adults_expiring: usize,
86    pub adults_expired: usize,
87    /// (display_name, status_text) for adults with renewal issues, sorted by name.
88    pub adult_issues: Vec<(String, String)>,
89}
90
91impl RenewalStats {
92    /// Compute renewal statistics from youth and adult slices.
93    pub fn compute(youth: &[Youth], adults: &[Adult]) -> Self {
94        let mut stats = RenewalStats::default();
95
96        // Scout renewals
97        for y in youth {
98            let exp_str = y.registrar_info.as_ref()
99                .and_then(|r| r.registration_expire_dt.as_ref());
100
101            match exp_str.and_then(|s| check_expiration(s)) {
102                Some((ExpirationStatus::Expired, formatted)) => {
103                    stats.scouts_expired += 1;
104                    stats.scout_issues.push((y.display_name(), format!("Expired {}", formatted)));
105                }
106                Some((ExpirationStatus::ExpiringSoon, formatted)) => {
107                    stats.scouts_expiring += 1;
108                    stats.scout_issues.push((y.display_name(), format!("Expires {}", formatted)));
109                }
110                Some((ExpirationStatus::Active, _)) | None => {
111                    stats.scouts_current += 1;
112                }
113            }
114        }
115
116        // Adult renewals
117        for a in adults {
118            let exp_str = a.registrar_info.as_ref()
119                .and_then(|r| r.registration_expire_dt.as_ref());
120
121            match exp_str.and_then(|s| check_expiration(s)) {
122                Some((ExpirationStatus::Expired, formatted)) => {
123                    stats.adults_expired += 1;
124                    stats.adult_issues.push((a.display_name(), format!("Expired {}", formatted)));
125                }
126                Some((ExpirationStatus::ExpiringSoon, formatted)) => {
127                    stats.adults_expiring += 1;
128                    stats.adult_issues.push((a.display_name(), format!("Expires {}", formatted)));
129                }
130                Some((ExpirationStatus::Active, _)) | None => {
131                    stats.adults_current += 1;
132                }
133            }
134        }
135
136        stats.scout_issues.sort_by(|a, b| a.0.cmp(&b.0));
137        stats.adult_issues.sort_by(|a, b| a.0.cmp(&b.0));
138        stats
139    }
140}
141
142// ============================================================================
143// Patrol Rank Breakdown
144// ============================================================================
145
146/// Rank breakdown within a single patrol.
147#[derive(Debug, Clone)]
148pub struct PatrolBreakdown {
149    pub member_count: usize,
150    /// Rank name → count of members at that rank.
151    pub rank_counts: HashMap<String, usize>,
152}
153
154/// Compute patrol rank breakdown from a slice of youth.
155///
156/// Returns a map of patrol_name → PatrolBreakdown.
157/// Youth with empty or missing patrol names are skipped.
158/// Rank names are normalized via `ScoutRank::parse()` and stored using `display_name()`
159/// (e.g. "Crossover" for unknown rank).
160pub fn patrol_rank_breakdown(youth: &[Youth]) -> HashMap<String, PatrolBreakdown> {
161    let mut result: HashMap<String, PatrolBreakdown> = HashMap::new();
162
163    for y in youth {
164        let patrol = match y.patrol_name.as_deref() {
165            Some(p) if !p.is_empty() => p,
166            _ => continue,
167        };
168
169        let scout_rank = ScoutRank::parse(y.current_rank.as_deref());
170        let rank = scout_rank.display_name();
171
172        let entry = result.entry(patrol.to_string()).or_insert_with(|| PatrolBreakdown {
173            member_count: 0,
174            rank_counts: HashMap::new(),
175        });
176        entry.member_count += 1;
177        *entry.rank_counts.entry(rank.to_string()).or_insert(0) += 1;
178    }
179
180    result
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::models::person::RegistrarInfo;
187
188    fn make_adult(ypt_expired: Option<&str>, position_trained: Option<&str>, reg_expire: Option<&str>) -> Adult {
189        Adult {
190            person_guid: None, member_id: None, person_full_name: None,
191            first_name: "Jane".to_string(), middle_name: None, last_name: "Doe".to_string(),
192            nick_name: None, position: Some("Scoutmaster".to_string()), position_id: None,
193            key3: None,
194            position_trained: position_trained.map(|s| s.to_string()),
195            ypt_status: None,
196            ypt_completed_date: None,
197            ypt_expired_date: ypt_expired.map(|s| s.to_string()),
198            registrar_info: reg_expire.map(|exp| RegistrarInfo {
199                date_of_birth: None, registration_id: None, registration_status_id: None,
200                registration_status: None, registration_effective_dt: None,
201                registration_expire_dt: Some(exp.to_string()),
202                renewal_status: None, is_yearly_membership: None,
203                is_manually_ended: None, is_auto_renewal_opted_out: None,
204            }),
205            primary_email_info: None, primary_phone_info: None,
206            primary_address_info: None, user_id: None, email: None, phone_number: None,
207        }
208    }
209
210    fn make_youth(patrol: Option<&str>, rank: Option<&str>, reg_expire: Option<&str>) -> Youth {
211        Youth {
212            person_guid: None, member_id: None, person_full_name: None,
213            first_name: "John".to_string(), middle_name: None, last_name: "Smith".to_string(),
214            nick_name: None, gender: None, name_suffix: None, ethnicity: None,
215            grade: None, grade_id: None, position: None, position_id: None,
216            program_id: None, program: None,
217            registrar_info: reg_expire.map(|exp| RegistrarInfo {
218                date_of_birth: None, registration_id: None, registration_status_id: None,
219                registration_status: None, registration_effective_dt: None,
220                registration_expire_dt: Some(exp.to_string()),
221                renewal_status: None, is_yearly_membership: None,
222                is_manually_ended: None, is_auto_renewal_opted_out: None,
223            }),
224            primary_email_info: None, primary_phone_info: None, primary_address_info: None,
225            user_id: None, email: None, phone_number: None,
226            patrol_name: patrol.map(|s| s.to_string()), patrol_guid: None,
227            is_patrol_leader: None, current_rank: rank.map(|s| s.to_string()),
228        }
229    }
230
231    #[test]
232    fn test_training_stats() {
233        let adults = vec![
234            make_adult(Some("2020-01-01"), Some("Trained"), None),    // expired YPT, trained
235            make_adult(Some("2099-01-01"), Some("Not Trained"), None), // active YPT, not trained
236            make_adult(None, None, None),                              // no YPT, unknown training
237        ];
238        let stats = TrainingStats::from_adults(&adults);
239        assert_eq!(stats.ypt_expired, 1);
240        assert_eq!(stats.ypt_current, 1);
241        assert_eq!(stats.position_trained, 1);
242        assert_eq!(stats.position_not_trained, 1);
243        assert_eq!(stats.ypt_issues.len(), 1);
244        assert_eq!(stats.position_not_trained_list.len(), 1);
245    }
246
247    #[test]
248    fn test_renewal_stats() {
249        let youth = vec![
250            make_youth(None, None, Some("2020-01-01")), // expired
251            make_youth(None, None, Some("2099-01-01")), // current
252            make_youth(None, None, None),                // no reg info, assume current
253        ];
254        let adults = vec![
255            make_adult(None, None, Some("2020-06-01")), // expired
256        ];
257        let stats = RenewalStats::compute(&youth, &adults);
258        assert_eq!(stats.scouts_expired, 1);
259        assert_eq!(stats.scouts_current, 2);
260        assert_eq!(stats.adults_expired, 1);
261        assert_eq!(stats.scout_issues.len(), 1);
262        assert_eq!(stats.adult_issues.len(), 1);
263    }
264
265    #[test]
266    fn test_patrol_rank_breakdown() {
267        let youth = vec![
268            make_youth(Some("Eagle"), Some("Eagle"), None),
269            make_youth(Some("Eagle"), Some("First Class"), None),
270            make_youth(Some("Hawk"), Some("Scout"), None),
271            make_youth(None, Some("Star"), None), // no patrol, skipped
272        ];
273        let breakdown = patrol_rank_breakdown(&youth);
274        assert_eq!(breakdown.len(), 2); // Eagle and Hawk patrols
275        let eagle = breakdown.get("Eagle").unwrap();
276        assert_eq!(eagle.member_count, 2);
277        assert_eq!(*eagle.rank_counts.get("Eagle").unwrap(), 1);
278        assert_eq!(*eagle.rank_counts.get("First Class").unwrap(), 1);
279        let hawk = breakdown.get("Hawk").unwrap();
280        assert_eq!(hawk.member_count, 1);
281    }
282}