1use std::collections::BTreeMap;
18use std::path::Path;
19
20use borg::clock::{self, DAY_MS};
21use borg::collective::Share;
22use borg::{learn, stats};
23use serde_json::{Value, json};
24
25fn week(ms: i64) -> i64 {
27 let d = ms.div_euclid(DAY_MS);
28 d - (d + 3).rem_euclid(7)
29}
30
31fn week_of_day(day: &str) -> Option<i64> {
33 let mut it = day.split('-').map(|p| p.parse::<i64>().ok());
34 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
35 Some(week(clock::days_from_civil(y, m, d) * DAY_MS))
36}
37
38#[derive(Default)]
40struct Weekly(BTreeMap<String, BTreeMap<i64, u64>>);
41
42impl Weekly {
43 fn add(&mut self, series: &str, week: i64, n: u64) {
44 if n > 0 {
45 *self.0.entry(series.to_string()).or_default().entry(week).or_default() += n;
46 }
47 }
48
49 fn weeks(&self) -> impl Iterator<Item = i64> + '_ {
50 self.0.values().flat_map(|w| w.keys().copied())
51 }
52
53 fn of(&self, series: &str, weeks: &[i64]) -> Vec<u64> {
55 let w = self.0.get(series);
56 weeks.iter().map(|k| w.and_then(|w| w.get(k)).copied().unwrap_or(0)).collect()
57 }
58}
59
60fn span(all: &[&Weekly], now: i64) -> Vec<i64> {
64 let ks: Vec<i64> = all.iter().flat_map(|w| w.weeks()).collect();
65 match (ks.iter().min(), ks.iter().max()) {
66 (Some(&a), Some(&b)) => (a..=b.max(week(now))).step_by(7).collect(),
67 _ => Vec::new(),
68 }
69}
70
71fn known(counts: Vec<u64>, weeks: &[i64], from: Option<i64>) -> Vec<Option<u64>> {
73 counts.into_iter().zip(weeks).map(|(n, w)| from.is_some_and(|f| *w >= f).then_some(n)).collect()
74}
75
76pub fn view(name: &str, home: &Path, share: Share) -> Value {
77 view_at(name, home, share, learn::now_ms())
78}
79
80fn view_at(name: &str, home: &Path, share: Share, now: i64) -> Value {
81 let synced = if share == Share::Team { "team" } else { "this machine" };
82 let local = "this machine";
83 let buckets = stats::current(home);
86 let live_week = stats::live_since(home).map(week);
87 let mut sources = Weekly::default();
89 for e in stats::live(home) {
90 if e.kind == stats::LESSON_NEW {
91 sources.add(if e.why.is_empty() { "session" } else { &e.why }, week(e.ms), 1);
92 }
93 }
94 let mut team = Weekly::default();
96 const KINDS: [&str; 7] = [stats::LESSON_MERGED, stats::CHANGED, stats::IGNORED, stats::CORRECTED, stats::NOT_CORRECTED, stats::VETO, stats::VETO_REPEAT];
97 for b in &buckets {
98 let Some(w) = week_of_day(&b.day) else { continue };
99 for k in KINDS {
100 team.add(k, w, b.counts.get(k).copied().unwrap_or(0));
101 }
102 }
103 let lessons = learn::lessons(&home.join("lessons.jsonl"));
106 let mut first: BTreeMap<&str, i64> = BTreeMap::new();
107 for l in &lessons {
108 let ms = first.entry(l.id.as_str()).or_insert(l.origin_ms);
109 *ms = (*ms).min(l.origin_ms);
110 }
111 for ms in first.values().filter(|ms| **ms > 0) {
112 team.add(stats::LESSON_NEW, week(*ms), 1);
113 }
114 let weeks = span(&[&sources, &team], now);
115 let mut names: Vec<(&String, u64)> = sources.0.iter().map(|(k, w)| (k, w.values().sum())).collect();
116 names.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
117 let by_source: Vec<Value> = names.iter().map(|(s, n)| json!({"source": s, "total": n, "weeks": known(sources.of(s, &weeks), &weeks, live_week)})).collect();
118 let t = |k: &str| team.of(k, &weeks);
119 let since = |k: &str| known(t(k), &weeks, live_week);
120 json!({
121 "collective": name,
122 "share": if share == Share::Team { "team" } else { "local" },
123 "weeks": weeks.iter().map(|w| stats::day(w * DAY_MS)).collect::<Vec<_>>(),
124 "partial_week": weeks.last().is_some_and(|w| *w == week(now)),
126 "live_from": live_week.map(|w| stats::day(w * DAY_MS)),
128 "lessons": {"scope": local, "by_source": by_source,
129 "team": {"scope": synced, "new": t(stats::LESSON_NEW), "merged": since(stats::LESSON_MERGED)}},
130 "asked": {"scope": synced, "changed": t(stats::CHANGED), "ignored": t(stats::IGNORED), "corrected": t(stats::CORRECTED),
131 "not_corrected": t(stats::NOT_CORRECTED)},
132 "vetoes": {"scope": synced, "veto": t(stats::VETO), "repeat": since(stats::VETO_REPEAT)},
133 })
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn weeks_start_on_monday() {
142 let monday = week_of_day("2026-10-19").unwrap();
144 assert_eq!(stats::day(monday * DAY_MS), "2026-10-19");
145 assert_eq!(week_of_day("2026-10-25"), Some(monday));
146 assert_eq!(week_of_day("2026-10-26"), Some(monday + 7));
147 assert_eq!(week(0), week_of_day("1969-12-29").unwrap(), "1970-01-01 was a Thursday");
148 }
149
150 #[test]
151 fn the_weeks_run_to_the_current_one_and_unknown_weeks_are_null() {
152 let (a, b) = (week_of_day("2026-10-12").unwrap(), week_of_day("2026-10-19").unwrap());
155 let mut w = Weekly::default();
156 w.add("x", a, 2);
157 w.add("x", b, 1);
158 let now = (week_of_day("2026-11-30").unwrap() + 1) * DAY_MS + 3_600_000;
159 let weeks = span(&[&w], now);
160 assert_eq!(weeks.len(), 8);
161 assert_eq!(stats::day(weeks[7] * DAY_MS), "2026-11-30");
162 assert_eq!(w.of("x", &weeks), [2, 1, 0, 0, 0, 0, 0, 0]);
163 assert!(span(&[&Weekly::default()], now).is_empty(), "nothing counted: no weeks");
164 assert_eq!(known(vec![4, 5, 6], &weeks[..3], Some(b)), [None, Some(5), Some(6)]);
165 assert_eq!(known(vec![4], &weeks[..1], None), [None], "no live counting yet: unknown");
166 }
167}