1use std::collections::HashMap;
7
8use crate::models::person::{Adult, Youth};
9use crate::models::advancement::ScoutRank;
10use crate::utils::format::{check_expiration, ExpirationStatus};
11
12#[derive(Debug, Clone, Default)]
18pub struct TrainingStats {
19 pub ypt_current: usize,
20 pub ypt_expiring: usize,
21 pub ypt_expired: usize,
22 pub ypt_issues: Vec<(String, String)>,
24 pub position_trained: usize,
25 pub position_not_trained: usize,
26 pub position_not_trained_list: Vec<String>,
28}
29
30impl TrainingStats {
31 pub fn from_adults(adults: &[Adult]) -> Self {
33 let mut stats = TrainingStats::default();
34
35 for adult in adults {
36 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 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#[derive(Debug, Clone, Default)]
78pub struct RenewalStats {
79 pub scouts_current: usize,
80 pub scouts_expiring: usize,
81 pub scouts_expired: usize,
82 pub scout_issues: Vec<(String, String)>,
84 pub adults_current: usize,
85 pub adults_expiring: usize,
86 pub adults_expired: usize,
87 pub adult_issues: Vec<(String, String)>,
89}
90
91impl RenewalStats {
92 pub fn compute(youth: &[Youth], adults: &[Adult]) -> Self {
94 let mut stats = RenewalStats::default();
95
96 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 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#[derive(Debug, Clone)]
148pub struct PatrolBreakdown {
149 pub member_count: usize,
150 pub rank_counts: HashMap<String, usize>,
152}
153
154pub 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), make_adult(Some("2099-01-01"), Some("Not Trained"), None), make_adult(None, None, None), ];
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")), make_youth(None, None, Some("2099-01-01")), make_youth(None, None, None), ];
254 let adults = vec![
255 make_adult(None, None, Some("2020-06-01")), ];
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), ];
273 let breakdown = patrol_rank_breakdown(&youth);
274 assert_eq!(breakdown.len(), 2); 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}