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