Skip to main content

trailcache_core/cache/
offline.rs

1//! Shared offline caching logic.
2//!
3//! Pre-fetches all data needed for full offline operation:
4//! base roster/event data, per-youth ranks/badges/requirements,
5//! and per-event RSVP details.
6
7use tracing::warn;
8
9use crate::api::ApiClient;
10use crate::cache::CacheManager;
11
12/// Progress update sent during offline caching.
13#[derive(Debug, Clone)]
14pub struct CacheProgress {
15    pub current: u32,
16    pub total: u32,
17    pub description: String,
18}
19
20/// Cache all data for offline use.
21///
22/// Fetches base data (roster, events, advancement, etc.), then per-youth
23/// ranks/badges/requirements and per-event RSVP details. Progress is
24/// reported via the callback so each frontend can display it appropriately.
25///
26/// The `cache` parameter must already have its encryption key set.
27pub async fn cache_all_for_offline(
28    api: &ApiClient,
29    cache: &CacheManager,
30    org_guid: &str,
31    user_id: i64,
32    on_progress: impl Fn(CacheProgress),
33) -> anyhow::Result<String> {
34    // Phase 1: Base data (10 sources, fetched in parallel)
35    let base = super::refresh::refresh_base_data(api, cache, org_guid, user_id, &on_progress).await;
36    let successes = base.successes;
37    let _errors = base.errors;
38
39    let youth_ids: Vec<i64> = base.youth
40        .as_ref()
41        .map(|list| list.iter().filter_map(|y| y.user_id).collect())
42        .unwrap_or_default();
43
44    let events = base.events;
45
46    // Phase 2: Event RSVP details (concurrent)
47    let event_ids: Vec<i64> = events
48        .as_ref()
49        .map(|list| list.iter().map(|e| e.id).collect())
50        .unwrap_or_default();
51
52    if !event_ids.is_empty() {
53        use futures::future::join_all;
54        use std::collections::HashMap;
55
56        on_progress(CacheProgress {
57            current: 0,
58            total: event_ids.len() as u32,
59            description: "Caching event RSVP data...".into(),
60        });
61
62        // Fetch all event details concurrently in chunks
63        const MAX_CONCURRENT_EVENTS: usize = 10;
64        let mut rsvp_map: HashMap<i64, Vec<crate::models::event::InvitedUser>> = HashMap::new();
65
66        let mut completed = 0u32;
67        for chunk in event_ids.chunks(MAX_CONCURRENT_EVENTS) {
68            let futures: Vec<_> = chunk
69                .iter()
70                .map(|&eid| {
71                    let api = api.clone();
72                    async move {
73                        let detail = api.fetch_event_detail(eid).await.ok();
74                        (eid, detail)
75                    }
76                })
77                .collect();
78
79            let results = join_all(futures).await;
80            for (eid, detail) in results {
81                if let Some(detail) = detail {
82                    rsvp_map.insert(eid, detail.invited_users);
83                }
84                completed += 1;
85            }
86
87            on_progress(CacheProgress {
88                current: completed,
89                total: event_ids.len() as u32,
90                description: "Caching event RSVP data...".into(),
91            });
92        }
93
94        // Merge RSVP data into cached events and save
95        let mut cached_events = events.unwrap_or_default();
96        for ev in &mut cached_events {
97            if let Some(users) = rsvp_map.remove(&ev.id) {
98                ev.invited_users = users;
99            }
100        }
101        if let Err(e) = cache.save_events(&cached_events) {
102            warn!("Failed to save events to cache: {e}");
103        }
104    }
105
106    // Phase 3: Per-youth ranks, badges, and requirements (concurrent)
107    //
108    // Optimizations vs naive serial approach:
109    // - Process youth in concurrent chunks (5 at a time)
110    // - Fetch rank + badge lists concurrently per youth
111    // - Fetch all requirements concurrently per youth
112    // - Use fetch_badge_requirements_only (1 API call instead of 2)
113    if !youth_ids.is_empty() {
114        use futures::future::join_all;
115
116        let youth_total = youth_ids.len() as u32;
117        const MAX_CONCURRENT_YOUTH: usize = 5;
118
119        let mut completed = 0u32;
120        for chunk in youth_ids.chunks(MAX_CONCURRENT_YOUTH) {
121            let futures: Vec<_> = chunk
122                .iter()
123                .map(|&uid| {
124                    let api = api.clone();
125                    async move {
126                        // Fetch ranks and badges concurrently
127                        let (ranks_result, badges_result) = futures::future::join(
128                            api.fetch_youth_ranks(uid),
129                            api.fetch_youth_merit_badges(uid),
130                        )
131                        .await;
132
133                        let ranks = ranks_result.unwrap_or_default();
134                        let badges = badges_result.unwrap_or_default();
135
136                        // Fetch all requirements concurrently
137                        let rank_req_futures: Vec<_> = ranks
138                            .iter()
139                            .map(|r| {
140                                let api = api.clone();
141                                let rank_id = r.rank_id;
142                                async move {
143                                    let reqs = api.fetch_rank_requirements(uid, rank_id).await.ok();
144                                    (rank_id, reqs)
145                                }
146                            })
147                            .collect();
148
149                        let badge_req_futures: Vec<_> = badges
150                            .iter()
151                            .map(|b| {
152                                let api = api.clone();
153                                let badge_id = b.id;
154                                async move {
155                                    let reqs = api.fetch_badge_requirements_only(uid, badge_id).await.ok();
156                                    (badge_id, reqs)
157                                }
158                            })
159                            .collect();
160
161                        let (rank_reqs, badge_reqs) = futures::future::join(
162                            join_all(rank_req_futures),
163                            join_all(badge_req_futures),
164                        )
165                        .await;
166
167                        (uid, ranks, badges, rank_reqs, badge_reqs)
168                    }
169                })
170                .collect();
171
172            let results = join_all(futures).await;
173
174            // Save all results to cache
175            for (uid, ranks, badges, rank_reqs, badge_reqs) in results {
176                if let Err(e) = cache.save_youth_ranks(uid, &ranks) {
177                    warn!("Failed to save ranks for user {uid}: {e}");
178                }
179                if let Err(e) = cache.save_youth_merit_badges(uid, &badges) {
180                    warn!("Failed to save badges for user {uid}: {e}");
181                }
182
183                for (rank_id, reqs) in rank_reqs {
184                    if let Some(reqs) = reqs {
185                        if let Err(e) = cache.save_rank_requirements(uid, rank_id, &reqs) {
186                            warn!("Failed to save rank requirements for user {uid}, rank {rank_id}: {e}");
187                        }
188                    }
189                }
190
191                for (badge_id, reqs) in badge_reqs {
192                    if let Some((reqs, version)) = reqs {
193                        if let Err(e) = cache.save_badge_requirements(uid, badge_id, &reqs, &version) {
194                            warn!("Failed to save badge requirements for user {uid}, badge {badge_id}: {e}");
195                        }
196                    }
197                }
198
199                completed += 1;
200                on_progress(CacheProgress {
201                    current: completed,
202                    total: youth_total,
203                    description: format!("Caching scout advancement ({}/{})...", completed, youth_total),
204                });
205            }
206        }
207    }
208
209    // Phase 4: Verify cached data is complete
210    on_progress(CacheProgress {
211        current: 1,
212        total: 1,
213        description: "Verifying cache...".into(),
214    });
215
216    let gaps = verify_cache(cache, &youth_ids);
217
218    on_progress(CacheProgress {
219        current: 1,
220        total: 1,
221        description: if gaps.is_empty() {
222            "Caching complete".into()
223        } else {
224            format!("Caching complete ({} gaps)", gaps.len())
225        },
226    });
227
228    if gaps.is_empty() {
229        Ok(format!("Cached all {} data sources + requirements + RSVP — verified complete", successes))
230    } else {
231        let summary = gaps.join("; ");
232        Ok(format!(
233            "Caching complete with gaps: {}",
234            summary
235        ))
236    }
237}
238
239/// Verify that all expected offline data is present in the cache.
240/// Returns a list of human-readable descriptions of missing data.
241fn verify_cache(cache: &CacheManager, youth_ids: &[i64]) -> Vec<String> {
242    let mut gaps = Vec::new();
243
244    // Base data checks — verify each source was cached successfully
245    macro_rules! check {
246        ($label:expr, $load:expr) => {
247            match $load {
248                Ok(Some(_)) => {}
249                Ok(None) => gaps.push(format!("{}: missing", $label)),
250                Err(_) => gaps.push(format!("{}: unreadable", $label)),
251            }
252        };
253    }
254
255    check!("Scouts", cache.load_youth());
256    check!("Adults", cache.load_adults());
257    check!("Events", cache.load_events());
258    check!("Patrols", cache.load_patrols());
259    check!("Unit info", cache.load_unit_info());
260    check!("Key 3", cache.load_key3());
261    check!("Commissioners", cache.load_commissioners());
262    check!("Org profile", cache.load_org_profile());
263    check!("Parents", cache.load_parents());
264    check!("Advancement", cache.load_advancement_dashboard());
265
266    // Per-youth checks
267    let mut missing_ranks = 0u32;
268    let mut missing_badges = 0u32;
269    let mut missing_rank_reqs = 0u32;
270    let mut missing_badge_reqs = 0u32;
271
272    for &uid in youth_ids {
273        // Check ranks
274        let ranks = match cache.load_youth_ranks(uid) {
275            Ok(Some(cached)) => cached.data,
276            _ => {
277                missing_ranks += 1;
278                continue;
279            }
280        };
281
282        // Check rank requirements
283        for rank in &ranks {
284            if cache.load_rank_requirements(uid, rank.rank_id).ok().flatten().is_none() {
285                missing_rank_reqs += 1;
286            }
287        }
288
289        // Check badges
290        let badges = match cache.load_youth_merit_badges(uid) {
291            Ok(Some(cached)) => cached.data,
292            _ => {
293                missing_badges += 1;
294                continue;
295            }
296        };
297
298        // Check badge requirements
299        for badge in &badges {
300            if cache.load_badge_requirements(uid, badge.id).ok().flatten().is_none() {
301                missing_badge_reqs += 1;
302            }
303        }
304    }
305
306    if missing_ranks > 0 {
307        gaps.push(format!("{} scouts missing rank data", missing_ranks));
308    }
309    if missing_badges > 0 {
310        gaps.push(format!("{} scouts missing badge data", missing_badges));
311    }
312    if missing_rank_reqs > 0 {
313        gaps.push(format!("{} rank requirements missing", missing_rank_reqs));
314    }
315    if missing_badge_reqs > 0 {
316        gaps.push(format!("{} badge requirements missing", missing_badge_reqs));
317    }
318
319    gaps
320}