Skip to main content

trailcache_core/models/
pivot.rs

1//! Pivot aggregation logic shared across all interfaces.
2//!
3//! Groups youth by their highest rank or by badges they're working on,
4//! producing intermediate types that each interface converts for display.
5
6use std::collections::HashMap;
7use super::advancement::{RankProgress, MeritBadgeProgress, ScoutRank};
8use super::person::Youth;
9
10// ============================================================================
11// Rank Pivot
12// ============================================================================
13
14/// A scout grouped under their highest completed/awarded rank.
15#[derive(Debug, Clone)]
16pub struct RankGroupEntry {
17    pub user_id: i64,
18    pub display_name: String,
19    /// The rank that placed them in this group. None for crossover scouts.
20    pub rank: Option<RankProgress>,
21}
22
23/// A rank with its grouped scouts.
24#[derive(Debug, Clone)]
25pub struct RankGroup {
26    pub rank_name: String,
27    pub rank_order: usize,
28    pub scouts: Vec<RankGroupEntry>,
29}
30
31/// Group youth by their highest completed/awarded rank.
32///
33/// Each youth appears in exactly one group — the group for their highest
34/// completed or awarded rank. Youth with no completed ranks are placed
35/// in a "Crossover" group.
36///
37/// Scouts within each group are sorted: completed first (newest to oldest),
38/// then awarded (newest to oldest), then by name.
39pub fn group_youth_by_rank(
40    youth: &[Youth],
41    all_ranks: &HashMap<i64, Vec<RankProgress>>,
42) -> Vec<RankGroup> {
43    let mut by_rank: HashMap<String, Vec<RankGroupEntry>> = HashMap::new();
44    let mut crossover: Vec<RankGroupEntry> = Vec::new();
45
46    for y in youth {
47        let uid = match y.user_id {
48            Some(id) => id,
49            None => {
50                crossover.push(RankGroupEntry {
51                    user_id: 0,
52                    display_name: y.display_name(),
53                    rank: None,
54                });
55                continue;
56            }
57        };
58
59        let ranks = match all_ranks.get(&uid) {
60            Some(r) => r,
61            None => {
62                crossover.push(RankGroupEntry {
63                    user_id: uid,
64                    display_name: y.display_name(),
65                    rank: None,
66                });
67                continue;
68            }
69        };
70
71        // Find the highest completed or awarded rank
72        let current_rank = ranks
73            .iter()
74            .filter(|r| r.is_completed() || r.is_awarded())
75            .max_by_key(|r| r.level);
76
77        if let Some(rank) = current_rank {
78            by_rank
79                .entry(rank.rank_name.clone())
80                .or_default()
81                .push(RankGroupEntry {
82                    user_id: uid,
83                    display_name: y.display_name(),
84                    rank: Some(rank.clone()),
85                });
86        } else {
87            // No completed/awarded rank — Crossover scout.
88            // Store their lowest in-progress rank so drill-down can
89            // show requirement completion status.
90            let lowest_rank = ranks
91                .iter()
92                .min_by_key(|r| r.sort_order())
93                .cloned();
94            crossover.push(RankGroupEntry {
95                user_id: uid,
96                display_name: y.display_name(),
97                rank: lowest_rank,
98            });
99        }
100    }
101
102    if !crossover.is_empty() {
103        by_rank.insert(ScoutRank::Unknown.display_name().to_string(), crossover);
104    }
105
106    // Sort scouts within each rank: awarded first (by date desc), then completed (by date desc), then in-progress (by name)
107    for scouts in by_rank.values_mut() {
108        scouts.sort_by(|a, b| {
109            match (&a.rank, &b.rank) {
110                (None, None) => a.display_name.cmp(&b.display_name),
111                (None, Some(_)) => std::cmp::Ordering::Greater,
112                (Some(_), None) => std::cmp::Ordering::Less,
113                (Some(ar), Some(br)) => {
114                    let status_order = |r: &RankProgress| -> u8 {
115                        if r.is_awarded() { 2 } else if r.is_completed() { 1 } else { 0 }
116                    };
117                    let sa = status_order(ar);
118                    let sb = status_order(br);
119                    if sa != sb {
120                        return sb.cmp(&sa); // awarded first
121                    }
122                    if sa == 0 {
123                        // Both in-progress: sort by name
124                        return a.display_name.cmp(&b.display_name);
125                    }
126                    // Both completed or both awarded: sort by date desc
127                    let da = ar.date_awarded.as_deref()
128                        .or(ar.date_completed.as_deref())
129                        .unwrap_or("");
130                    let db = br.date_awarded.as_deref()
131                        .or(br.date_completed.as_deref())
132                        .unwrap_or("");
133                    db.cmp(da)
134                }
135            }
136        });
137    }
138
139    // Sort groups by rank order (Scout -> Eagle)
140    let mut groups: Vec<RankGroup> = by_rank
141        .into_iter()
142        .map(|(name, scouts)| {
143            let order = ScoutRank::parse(Some(&name)).order();
144            RankGroup {
145                rank_name: name,
146                rank_order: order,
147                scouts,
148            }
149        })
150        .collect();
151    groups.sort_by_key(|g| g.rank_order);
152    groups
153}
154
155// ============================================================================
156// Badge Pivot
157// ============================================================================
158
159/// A scout grouped under a badge they're working on or completed.
160#[derive(Debug, Clone)]
161pub struct BadgeGroupEntry {
162    pub user_id: i64,
163    pub display_name: String,
164    pub badge: MeritBadgeProgress,
165}
166
167/// A badge with its grouped scouts.
168#[derive(Debug, Clone)]
169pub struct BadgeGroup {
170    pub badge_name: String,
171    pub is_eagle_required: bool,
172    pub scouts: Vec<BadgeGroupEntry>,
173}
174
175/// Group youth badges into badge-centric groups.
176///
177/// Each badge gets a group containing all scouts who are working on or
178/// have completed that badge. Scouts within each group are sorted:
179/// in-progress first (by percent desc), then completed (by date desc).
180pub fn group_youth_by_badge(
181    youth: &[Youth],
182    all_badges: &HashMap<i64, Vec<MeritBadgeProgress>>,
183) -> Vec<BadgeGroup> {
184    let mut by_badge: HashMap<String, (bool, Vec<BadgeGroupEntry>)> = HashMap::new();
185
186    for y in youth {
187        let uid = match y.user_id {
188            Some(id) => id,
189            None => continue,
190        };
191
192        let badges = match all_badges.get(&uid) {
193            Some(b) => b,
194            None => continue,
195        };
196
197        for badge in badges {
198            if badge.name.is_empty() {
199                continue;
200            }
201            let entry = by_badge
202                .entry(badge.name.clone())
203                .or_insert_with(|| (badge.is_eagle_required.unwrap_or(false), Vec::new()));
204            if badge.is_eagle_required.unwrap_or(false) {
205                entry.0 = true;
206            }
207            entry.1.push(BadgeGroupEntry {
208                user_id: uid,
209                display_name: y.display_name(),
210                badge: badge.clone(),
211            });
212        }
213    }
214
215    // Sort scouts within each badge: awarded first (by date desc), then completed (by date desc), then in-progress (by % desc)
216    for (_, scouts) in by_badge.values_mut() {
217        scouts.sort_by(|a, b| {
218            let status_order = |s: &BadgeGroupEntry| -> u8 {
219                if s.badge.is_awarded() { 2 } else if s.badge.is_completed() { 1 } else { 0 }
220            };
221            let sa = status_order(a);
222            let sb = status_order(b);
223            if sa != sb {
224                return sb.cmp(&sa); // awarded first
225            }
226            if sa == 0 {
227                // Both in-progress: sort by percent desc
228                let pa = a.badge.percent_completed.unwrap_or(0.0);
229                let pb = b.badge.percent_completed.unwrap_or(0.0);
230                pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
231            } else {
232                // Both completed/awarded: sort by date desc
233                let da = a.badge.awarded_date.as_deref()
234                    .or(a.badge.date_completed.as_deref())
235                    .unwrap_or("");
236                let db = b.badge.awarded_date.as_deref()
237                    .or(b.badge.date_completed.as_deref())
238                    .unwrap_or("");
239                db.cmp(da)
240            }
241        });
242    }
243
244    let mut groups: Vec<BadgeGroup> = by_badge
245        .into_iter()
246        .map(|(name, (eagle, scouts))| BadgeGroup {
247            badge_name: name,
248            is_eagle_required: eagle,
249            scouts,
250        })
251        .collect();
252    groups.sort_by(|a, b| a.badge_name.to_lowercase().cmp(&b.badge_name.to_lowercase()));
253    groups
254}
255
256// ============================================================================
257// Badge/Rank List Aggregation
258// ============================================================================
259
260/// Summary of a badge across all youth (name, eagle-required flag, count).
261#[derive(Debug, Clone)]
262pub struct BadgeListEntry {
263    pub name: String,
264    pub is_eagle_required: bool,
265    pub count: usize,
266}
267
268/// Summary of a rank across all youth (name, count).
269#[derive(Debug, Clone)]
270pub struct RankListEntry {
271    pub name: String,
272    pub count: usize,
273}
274
275/// Build a sorted list of badges with counts.
276/// If `sort_by_count`, sorts by count desc with name tiebreaker.
277/// Otherwise sorts by name (case-insensitive).
278pub fn badge_list(
279    youth: &[Youth],
280    all_badges: &std::collections::HashMap<i64, Vec<MeritBadgeProgress>>,
281    sort_by_count: bool,
282) -> Vec<BadgeListEntry> {
283    let grouped = group_youth_by_badge(youth, all_badges);
284    let mut result: Vec<BadgeListEntry> = grouped
285        .into_iter()
286        .map(|g| BadgeListEntry {
287            name: g.badge_name,
288            is_eagle_required: g.is_eagle_required,
289            count: g.scouts.len(),
290        })
291        .collect();
292
293    if sort_by_count {
294        result.sort_by(|a, b| {
295            b.count.cmp(&a.count)
296                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
297        });
298    } else {
299        result.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
300    }
301
302    result
303}
304
305/// Build a sorted list of ranks with counts.
306/// If `sort_by_count`, sorts by count desc with rank-order tiebreaker.
307/// Otherwise sorts by canonical rank order.
308pub fn rank_list(
309    youth: &[Youth],
310    all_ranks: &std::collections::HashMap<i64, Vec<RankProgress>>,
311    sort_by_count: bool,
312) -> Vec<RankListEntry> {
313    let grouped = group_youth_by_rank(youth, all_ranks);
314    let mut result: Vec<RankListEntry> = grouped
315        .into_iter()
316        .map(|g| RankListEntry {
317            name: g.rank_name,
318            count: g.scouts.len(),
319        })
320        .collect();
321
322    if sort_by_count {
323        result.sort_by(|a, b| {
324            b.count.cmp(&a.count)
325                .then_with(|| {
326                    ScoutRank::parse(Some(&a.name)).order()
327                        .cmp(&ScoutRank::parse(Some(&b.name)).order())
328                })
329        });
330    } else {
331        result.sort_by(|a, b| {
332            ScoutRank::parse(Some(&a.name)).order()
333                .cmp(&ScoutRank::parse(Some(&b.name)).order())
334        });
335    }
336
337    result
338}