1use crate::api::{AdminReport, DiscourseClient};
15use crate::cli::AnalyticsFormat;
16use crate::commands::common::{ensure_api_credentials, select_discourse};
17use crate::config::Config;
18use crate::utils::parse_since_cutoff;
19use anyhow::Result;
20use chrono::{DateTime, Datelike, Duration, Utc};
21use serde::Serialize;
22use serde_json::{Map, Value, json};
23use std::collections::HashMap;
24use std::io::{self, IsTerminal};
25use std::sync::{Arc, Mutex};
26use std::thread;
27
28const SCHEMA_VERSION: u32 = 1;
29
30const REPORT_IDS: &[&str] = &[
33 "topics",
34 "posts",
35 "likes",
36 "flags",
37 "new_contributors",
38 "trust_level_growth",
39 "time_to_first_response",
40 "topics_with_no_response",
41 "moderators_activity",
42];
43
44#[allow(clippy::too_many_arguments)]
49pub fn analytics(
50 config: &Config,
51 discourse_name: &str,
52 since: &str,
53 compare: bool,
54 snapshot: bool,
55 periods: Option<&str>,
56 section_filter: SectionFilter,
57 mut format: AnalyticsFormat,
58) -> Result<()> {
59 let discourse = select_discourse(config, Some(discourse_name))?;
60 ensure_api_credentials(discourse)?;
61 let client = DiscourseClient::new(discourse)?;
62 let now = Utc::now();
63
64 let windows = if snapshot {
68 let raw = periods.unwrap_or("24h,7d,30d,1y");
69 parse_periods(raw, now)?
70 } else if compare {
71 let cur = window_from_since(since, now)?;
72 let prev = previous_window_of(&cur);
73 vec![cur, prev]
74 } else {
75 vec![window_from_since(since, now)?]
76 };
77
78 let column_headers: Vec<String> = if snapshot {
79 windows.iter().map(|w| w.label.clone()).collect()
80 } else if compare {
81 vec!["current".to_string(), "previous".to_string()]
82 } else {
83 vec!["value".to_string()]
84 };
85
86 if matches!(format, AnalyticsFormat::Table) && !io::stdout().is_terminal() {
89 format = AnalyticsFormat::Text;
90 }
91
92 let cache = populate_cache(&client, &windows)?;
93 let report = build_report(
94 discourse_name,
95 &windows,
96 &column_headers,
97 section_filter,
98 snapshot,
99 &cache,
100 );
101 render(&report, format)
102}
103
104fn window_from_since(since: &str, now: DateTime<Utc>) -> Result<Window> {
109 let cutoff = parse_since_cutoff(since)?;
110 let (start, end) = if cutoff <= now { (cutoff, now) } else { (now, cutoff) };
111 Ok(Window {
112 since: start,
113 until: end,
114 label: since.to_string(),
115 clamped: false,
116 })
117}
118
119fn previous_window_of(w: &Window) -> Window {
120 let len = w.duration();
121 Window {
122 since: w.since - len,
123 until: w.since,
124 label: w.label.clone(),
125 clamped: false,
126 }
127}
128
129fn parse_periods(raw: &str, now: DateTime<Utc>) -> Result<Vec<Window>> {
130 let mut out = Vec::new();
131 for piece in raw.split(',') {
132 let p = piece.trim();
133 if p.is_empty() {
134 continue;
135 }
136 out.push(window_from_since(p, now)?);
137 }
138 if out.is_empty() {
139 anyhow::bail!("--periods must contain at least one duration");
140 }
141 Ok(out)
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub enum SectionFilter {
150 All,
151 Growth,
152 Activity,
153 Health,
154}
155
156#[derive(Clone, Debug, Serialize)]
157struct Window {
158 since: DateTime<Utc>,
159 until: DateTime<Utc>,
160 label: String,
161 clamped: bool,
162}
163
164impl Window {
165 fn iso_date_since(&self) -> String {
166 format_yyyy_mm_dd(&self.since)
167 }
168 fn iso_date_until(&self) -> String {
169 format_yyyy_mm_dd(&self.until)
170 }
171 fn duration(&self) -> Duration {
172 self.until - self.since
173 }
174}
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "lowercase")]
178enum Direction {
179 Up,
180 Down,
181 Neither,
182}
183
184#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "snake_case")]
186enum Unit {
187 Count,
188 Percent,
189 Minutes,
190 Hours,
191 Ratio,
192 PerThousandPosts,
193}
194
195#[derive(Clone, Debug, Serialize)]
196struct Metric {
197 label: String,
198 key: String,
199 values: Vec<Option<f64>>,
203 desirable: Direction,
204 unit: Unit,
205 not_implemented: bool,
206}
207
208impl Metric {
209 fn new(label: &str, key: &str, desirable: Direction, unit: Unit, n: usize) -> Self {
210 Self {
211 label: label.to_string(),
212 key: key.to_string(),
213 values: vec![None; n],
214 desirable,
215 unit,
216 not_implemented: false,
217 }
218 }
219 fn with_values(mut self, v: Vec<Option<f64>>) -> Self {
220 self.values = v;
221 self
222 }
223 fn stub(mut self) -> Self {
224 self.not_implemented = true;
225 self
226 }
227 fn delta_pct(&self) -> Option<f64> {
229 match (self.values.first().copied().flatten(), self.values.get(1).copied().flatten()) {
230 (Some(c), Some(p)) if p != 0.0 => Some(((c - p) / p) * 100.0),
231 _ => None,
232 }
233 }
234}
235
236#[derive(Clone, Debug, Serialize)]
237struct AnalyticsReport {
238 schema: u32,
239 discourse: String,
240 snapshot: bool,
241 windows: Vec<Window>,
242 column_headers: Vec<String>,
243 growth: Option<Vec<Metric>>,
244 activity: Option<Vec<Metric>>,
245 health: Option<Vec<Metric>>,
246}
247
248type ReportCache = HashMap<(String, usize), Option<AdminReport>>;
256
257const ANALYTICS_PARALLELISM: usize = 4;
262
263fn populate_cache(client: &DiscourseClient, windows: &[Window]) -> Result<ReportCache> {
264 let cache: Arc<Mutex<ReportCache>> = Arc::new(Mutex::new(HashMap::new()));
265
266 let tasks: Vec<(String, usize, String, String)> = windows
272 .iter()
273 .enumerate()
274 .flat_map(|(w_idx, window)| {
275 let start = window.iso_date_since();
276 let end = window.iso_date_until();
277 REPORT_IDS
278 .iter()
279 .map(move |id| (id.to_string(), w_idx, start.clone(), end.clone()))
280 })
281 .collect();
282 let queue = Arc::new(Mutex::new(tasks.into_iter()));
283
284 thread::scope(|scope| {
285 for _ in 0..ANALYTICS_PARALLELISM {
286 let client = client.clone();
287 let cache = cache.clone();
288 let queue = queue.clone();
289 scope.spawn(move || loop {
290 let next = { queue.lock().ok().and_then(|mut q| q.next()) };
291 let Some((id, w_idx, start, end)) = next else {
292 break;
293 };
294 let value = fetch_optional(&client, &id, &start, &end);
295 if let Ok(mut guard) = cache.lock() {
296 guard.insert((id, w_idx), value);
297 }
298 });
299 }
300 });
301
302 Ok(Arc::try_unwrap(cache)
303 .map_err(|_| anyhow::anyhow!("cache still has live references"))?
304 .into_inner()
305 .unwrap_or_default())
306}
307
308fn report_at<'a>(cache: &'a ReportCache, id: &str, w: usize) -> Option<&'a AdminReport> {
309 cache
310 .get(&(id.to_string(), w))
311 .and_then(|opt| opt.as_ref())
312}
313
314fn totals_for(cache: &ReportCache, id: &str, n_windows: usize) -> Vec<Option<f64>> {
317 (0..n_windows)
318 .map(|w| report_at(cache, id, w).map(|r: &AdminReport| r.current_total()))
319 .collect()
320}
321
322fn averages_for(cache: &ReportCache, id: &str, n_windows: usize) -> Vec<Option<f64>> {
323 (0..n_windows)
324 .map(|w| report_at(cache, id, w).and_then(|r: &AdminReport| r.average))
325 .collect()
326}
327
328fn ratio_per_window(num: &[Option<f64>], den: &[Option<f64>]) -> Vec<Option<f64>> {
329 num.iter()
330 .zip(den.iter())
331 .map(|(n, d)| match (n, d) {
332 (Some(n), Some(d)) if *d > 0.0 => Some(n / d),
333 _ => None,
334 })
335 .collect()
336}
337
338fn build_report(
343 discourse: &str,
344 windows: &[Window],
345 column_headers: &[String],
346 filter: SectionFilter,
347 snapshot: bool,
348 cache: &ReportCache,
349) -> AnalyticsReport {
350 let n = windows.len();
351 let growth = if matches!(filter, SectionFilter::All | SectionFilter::Growth) {
352 Some(build_growth(cache, n))
353 } else {
354 None
355 };
356 let activity = if matches!(filter, SectionFilter::All | SectionFilter::Activity) {
357 Some(build_activity(cache, n))
358 } else {
359 None
360 };
361 let health = if matches!(filter, SectionFilter::All | SectionFilter::Health) {
362 Some(build_health(cache, n))
363 } else {
364 None
365 };
366 AnalyticsReport {
367 schema: SCHEMA_VERSION,
368 discourse: discourse.to_string(),
369 snapshot,
370 windows: windows.to_vec(),
371 column_headers: column_headers.to_vec(),
372 growth,
373 activity,
374 health,
375 }
376}
377
378fn build_growth(cache: &ReportCache, n: usize) -> Vec<Metric> {
379 let mut out = Vec::new();
380
381 out.push(
382 Metric::new("new contributors", "new_contributors", Direction::Up, Unit::Count, n)
383 .with_values(totals_for(cache, "new_contributors", n)),
384 );
385 out.push(
386 Metric::new("reactivated users", "reactivated_users", Direction::Up, Unit::Count, n).stub(),
387 );
388 out.push(Metric::new("lost regulars", "lost_regulars", Direction::Down, Unit::Count, n).stub());
389 out.push(
390 Metric::new("net active change", "net_active_change", Direction::Up, Unit::Count, n).stub(),
391 );
392 out.push(
393 Metric::new("trust-level promotions", "trust_level_promotions", Direction::Up, Unit::Count, n)
394 .with_values(totals_for(cache, "trust_level_growth", n)),
395 );
396
397 out
398}
399
400fn build_activity(cache: &ReportCache, n: usize) -> Vec<Metric> {
401 let mut out = Vec::new();
402
403 let topics = totals_for(cache, "topics", n);
404 let posts = totals_for(cache, "posts", n);
405 let no_response = totals_for(cache, "topics_with_no_response", n);
406
407 out.push(
408 Metric::new("topics created", "topics_created", Direction::Up, Unit::Count, n)
409 .with_values(topics.clone()),
410 );
411 out.push(
412 Metric::new("posts created", "posts_created", Direction::Up, Unit::Count, n)
413 .with_values(posts.clone()),
414 );
415 out.push(
416 Metric::new("posts per topic", "posts_per_topic", Direction::Up, Unit::Ratio, n)
417 .with_values(ratio_per_window(&posts, &topics)),
418 );
419 out.push(
420 Metric::new("unique posters", "unique_posters", Direction::Up, Unit::Count, n).stub(),
421 );
422 out.push(
423 Metric::new("top-10 share", "top_10_share", Direction::Down, Unit::Percent, n).stub(),
424 );
425
426 let coverage: Vec<Option<f64>> = topics
427 .iter()
428 .zip(no_response.iter())
429 .map(|(t, nr)| match (t, nr) {
430 (Some(t), Some(nr)) if *t > 0.0 => Some(((t - nr) / t) * 100.0),
431 _ => None,
432 })
433 .collect();
434 out.push(
435 Metric::new("reply coverage", "reply_coverage", Direction::Up, Unit::Percent, n)
436 .with_values(coverage),
437 );
438
439 out.push(
440 Metric::new(
441 "median time to first reply",
442 "median_time_to_first_reply",
443 Direction::Down,
444 Unit::Minutes,
445 n,
446 )
447 .with_values(averages_for(cache, "time_to_first_response", n)),
448 );
449
450 out
451}
452
453fn build_health(cache: &ReportCache, n: usize) -> Vec<Metric> {
454 let mut out = Vec::new();
455 let likes = totals_for(cache, "likes", n);
456 let posts = totals_for(cache, "posts", n);
457 let mods = totals_for(cache, "moderators_activity", n);
458
459 out.push(
460 Metric::new("likes per post", "likes_per_post", Direction::Up, Unit::Ratio, n)
461 .with_values(ratio_per_window(&likes, &posts)),
462 );
463 out.push(
464 Metric::new(
465 "returning poster rate",
466 "returning_poster_rate",
467 Direction::Up,
468 Unit::Percent,
469 n,
470 )
471 .stub(),
472 );
473 out.push(
474 Metric::new("flags raised", "flags_raised", Direction::Down, Unit::Count, n)
475 .with_values(totals_for(cache, "flags", n)),
476 );
477 out.push(
478 Metric::new(
479 "flag resolution time",
480 "flag_resolution_time",
481 Direction::Down,
482 Unit::Hours,
483 n,
484 )
485 .stub(),
486 );
487
488 let mar: Vec<Option<f64>> = mods
489 .iter()
490 .zip(posts.iter())
491 .map(|(m, p)| match (m, p) {
492 (Some(m), Some(p)) if *p > 0.0 => Some((m / p) * 1000.0),
493 _ => None,
494 })
495 .collect();
496 out.push(
497 Metric::new(
498 "moderator action rate",
499 "moderator_action_rate",
500 Direction::Neither,
501 Unit::PerThousandPosts,
502 n,
503 )
504 .with_values(mar),
505 );
506 out.push(
507 Metric::new("solo-thread rate", "solo_thread_rate", Direction::Down, Unit::Percent, n)
508 .stub(),
509 );
510
511 out
512}
513
514fn render(report: &AnalyticsReport, format: AnalyticsFormat) -> Result<()> {
519 match format {
520 AnalyticsFormat::Text => render_text(report),
521 AnalyticsFormat::Table => render_table(report),
522 AnalyticsFormat::Json => render_json(report),
523 AnalyticsFormat::Yaml => render_yaml(report),
524 AnalyticsFormat::Markdown => render_markdown(report, false),
525 AnalyticsFormat::MarkdownTable => render_markdown(report, true),
526 AnalyticsFormat::Csv => render_csv(report),
527 }
528}
529
530fn render_text(report: &AnalyticsReport) -> Result<()> {
531 print_header_text(report);
532 let compare_mode = !report.snapshot && report.column_headers.len() == 2;
533 for (name, metrics) in iter_sections(report) {
534 println!();
535 println!("{}", name);
536 let label_w = metrics
537 .iter()
538 .map(|m| m.label.chars().count())
539 .max()
540 .unwrap_or(0)
541 .max(20);
542 let cols = report.column_headers.len();
543 let val_w = column_widths(metrics, cols);
544 for m in metrics {
545 print!(" {}", pad_right(&m.label, label_w));
546 for c in 0..cols {
547 let s = format_value(m.values.get(c).copied().flatten(), m.unit, m.not_implemented);
548 print!(" {}", right_align(&s, val_w[c]));
549 }
550 if compare_mode {
551 let pct = m
552 .delta_pct()
553 .map(|p| format!("({:+.0}%)", p))
554 .unwrap_or_default();
555 print!(" {}", pct);
556 }
557 println!();
558 }
559 }
560 Ok(())
561}
562
563fn render_table(report: &AnalyticsReport) -> Result<()> {
564 print_header_text(report);
565 let cols = report.column_headers.len();
566 let compare_mode = !report.snapshot && cols == 2;
567
568 for (name, metrics) in iter_sections(report) {
569 println!();
570 println!("{}", name);
571
572 let label_w = metrics
573 .iter()
574 .map(|m| m.label.chars().count())
575 .max()
576 .unwrap_or(0)
577 .max(6)
578 .max("metric".len());
579 let mut col_w = column_widths(metrics, cols);
580 for (i, h) in report.column_headers.iter().enumerate() {
582 let hw = h.chars().count();
583 if hw > col_w[i] {
584 col_w[i] = hw;
585 }
586 }
587 let pct_w = if compare_mode { 7 } else { 0 };
588
589 let mut widths: Vec<usize> = std::iter::once(label_w).chain(col_w.iter().copied()).collect();
591 if compare_mode {
592 widths.push(pct_w);
593 }
594 println!("{}", border_line('┌', '┬', '┐', &widths));
595
596 print!("│ {} ", pad_right("metric", label_w));
598 for (i, h) in report.column_headers.iter().enumerate() {
599 print!("│ {} ", center(h, col_w[i]));
600 }
601 if compare_mode {
602 print!("│ {} ", center("Δ", pct_w));
603 }
604 println!("│");
605
606 println!("{}", border_line('├', '┼', '┤', &widths));
608
609 for m in metrics {
610 print!("│ {} ", pad_right(&m.label, label_w));
611 for c in 0..cols {
612 let s = format_value(m.values.get(c).copied().flatten(), m.unit, m.not_implemented);
613 print!("│ {} ", right_align(&s, col_w[c]));
614 }
615 if compare_mode {
616 let pct = m
617 .delta_pct()
618 .map(|p| format!("{:+.0}%", p))
619 .unwrap_or_else(|| "—".to_string());
620 print!("│ {} ", right_align(&pct, pct_w));
621 }
622 println!("│");
623 }
624
625 println!("{}", border_line('└', '┴', '┘', &widths));
626 }
627 Ok(())
628}
629
630fn print_header_text(report: &AnalyticsReport) {
631 if report.snapshot {
632 let now = Utc::now();
633 println!(
634 "analytics for {} — snapshot at {} UTC",
635 report.discourse,
636 now.format("%Y-%m-%d %H:%M")
637 );
638 } else {
639 let w = &report.windows[0];
640 println!(
641 "analytics for {} — {} ({} → {})",
642 report.discourse,
643 w.label,
644 w.iso_date_since(),
645 w.iso_date_until()
646 );
647 if w.clamped {
648 println!("(window clamped — install is younger than --since)");
649 }
650 }
651}
652
653fn render_json(report: &AnalyticsReport) -> Result<()> {
654 println!("{}", serde_json::to_string_pretty(&report_to_json(report))?);
655 Ok(())
656}
657
658fn render_yaml(report: &AnalyticsReport) -> Result<()> {
659 println!("{}", serde_yaml::to_string(&report_to_json(report))?);
660 Ok(())
661}
662
663fn render_markdown(report: &AnalyticsReport, table: bool) -> Result<()> {
664 let cols = report.column_headers.len();
665 let compare_mode = !report.snapshot && cols == 2;
666 println!("# analytics for {}", report.discourse);
667 println!();
668 if report.snapshot {
669 println!("Snapshot at **{}**", Utc::now().format("%Y-%m-%d %H:%M UTC"));
670 } else {
671 let w = &report.windows[0];
672 println!(
673 "Window: **{}** ({} → {})",
674 w.label,
675 w.iso_date_since(),
676 w.iso_date_until()
677 );
678 }
679
680 for (name, metrics) in iter_sections(report) {
681 println!();
682 println!("## {}", name);
683 println!();
684 if table {
685 print!("| metric |");
686 for h in &report.column_headers {
687 print!(" {} |", h);
688 }
689 if compare_mode {
690 print!(" Δ |");
691 }
692 println!();
693 print!("| --- |");
694 for _ in 0..cols {
695 print!(" ---: |");
696 }
697 if compare_mode {
698 print!(" ---: |");
699 }
700 println!();
701 for m in metrics {
702 print!("| {} |", m.label);
703 for c in 0..cols {
704 let s = format_value(m.values.get(c).copied().flatten(), m.unit, m.not_implemented);
705 print!(" {} |", s);
706 }
707 if compare_mode {
708 let pct = m
709 .delta_pct()
710 .map(|p| format!("{:+.0}%", p))
711 .unwrap_or_else(|| "—".to_string());
712 print!(" {} |", pct);
713 }
714 println!();
715 }
716 } else {
717 for m in metrics {
718 print!("- **{}** —", m.label);
719 for (i, h) in report.column_headers.iter().enumerate() {
720 let s = format_value(m.values.get(i).copied().flatten(), m.unit, m.not_implemented);
721 if cols == 1 {
722 print!(" {}", s);
723 } else {
724 print!(" {}: {}", h, s);
725 if i + 1 < cols {
726 print!(",");
727 }
728 }
729 }
730 if compare_mode {
731 if let Some(p) = m.delta_pct() {
732 print!(" (`{:+.0}%`)", p);
733 }
734 }
735 println!();
736 }
737 }
738 }
739 Ok(())
740}
741
742fn render_csv(report: &AnalyticsReport) -> Result<()> {
743 let mut writer = csv::Writer::from_writer(io::stdout());
744 let mut header: Vec<String> = vec!["section".into(), "metric".into()];
745 for h in &report.column_headers {
746 header.push(h.clone());
747 }
748 header.push("desirable_direction".into());
749 header.push("unit".into());
750 writer.write_record(&header)?;
751
752 let cols = report.column_headers.len();
753 for (name, metrics) in iter_sections(report) {
754 for m in metrics {
755 let mut row: Vec<String> = vec![name.into(), m.label.clone()];
756 for c in 0..cols {
757 row.push(
758 m.values
759 .get(c)
760 .copied()
761 .flatten()
762 .map(|v| format!("{}", v))
763 .unwrap_or_default(),
764 );
765 }
766 row.push(
767 match m.desirable {
768 Direction::Up => "up",
769 Direction::Down => "down",
770 Direction::Neither => "neither",
771 }
772 .into(),
773 );
774 row.push(unit_str(m.unit).into());
775 writer.write_record(&row)?;
776 }
777 }
778 writer.flush()?;
779 Ok(())
780}
781
782fn iter_sections(report: &AnalyticsReport) -> Vec<(&'static str, &[Metric])> {
787 let mut v: Vec<(&'static str, &[Metric])> = Vec::new();
788 if let Some(g) = &report.growth {
789 v.push(("growth", g));
790 }
791 if let Some(a) = &report.activity {
792 v.push(("activity", a));
793 }
794 if let Some(h) = &report.health {
795 v.push(("health", h));
796 }
797 v
798}
799
800fn fetch_optional(
801 client: &DiscourseClient,
802 report_id: &str,
803 start: &str,
804 end: &str,
805) -> Option<AdminReport> {
806 match client.fetch_admin_report(report_id, start, end) {
807 Ok(r) => Some(r),
808 Err(err) => {
809 let msg = err.to_string();
810 let known_missing = msg.contains(" 404 ")
813 || msg.contains(" 403 ")
814 || msg.contains(" 500 ")
815 || msg.contains("not found");
816 if known_missing {
817 None
818 } else {
819 eprintln!("[analytics] warning fetching report '{}': {}", report_id, err);
820 None
821 }
822 }
823 }
824}
825
826fn column_widths(metrics: &[Metric], cols: usize) -> Vec<usize> {
827 (0..cols)
828 .map(|c| {
829 metrics
830 .iter()
831 .map(|m| {
832 visual_width(&format_value(
833 m.values.get(c).copied().flatten(),
834 m.unit,
835 m.not_implemented,
836 ))
837 })
838 .max()
839 .unwrap_or(0)
840 .max(6)
841 })
842 .collect()
843}
844
845fn visual_width(s: &str) -> usize {
846 s.chars().count()
847}
848
849fn pad_right(s: &str, width: usize) -> String {
850 let w = visual_width(s);
851 if w >= width {
852 s.to_string()
853 } else {
854 format!("{}{}", s, " ".repeat(width - w))
855 }
856}
857
858fn right_align(s: &str, width: usize) -> String {
859 let w = visual_width(s);
860 if w >= width {
861 s.to_string()
862 } else {
863 format!("{}{}", " ".repeat(width - w), s)
864 }
865}
866
867fn center(s: &str, width: usize) -> String {
868 let w = visual_width(s);
869 if w >= width {
870 return s.to_string();
871 }
872 let total = width - w;
873 let left = total / 2;
874 let right = total - left;
875 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
876}
877
878fn border_line(start: char, mid: char, end: char, widths: &[usize]) -> String {
879 let mut out = String::new();
880 out.push(start);
881 for (i, w) in widths.iter().enumerate() {
882 for _ in 0..(*w + 2) {
883 out.push('─');
884 }
885 out.push(if i + 1 == widths.len() { end } else { mid });
886 }
887 out
888}
889
890fn unit_str(u: Unit) -> &'static str {
891 match u {
892 Unit::Count => "count",
893 Unit::Percent => "percent",
894 Unit::Minutes => "minutes",
895 Unit::Hours => "hours",
896 Unit::Ratio => "ratio",
897 Unit::PerThousandPosts => "per_1k_posts",
898 }
899}
900
901fn format_value(v: Option<f64>, unit: Unit, not_impl: bool) -> String {
902 if not_impl {
903 return "— (n/i)".to_string();
904 }
905 let v = v.map(|x| if x == 0.0 { 0.0 } else { x });
906 match (v, unit) {
907 (None, _) => "—".to_string(),
908 (Some(x), Unit::Count) => format_count(x),
909 (Some(x), Unit::Percent) => format!("{:.0}%", x),
910 (Some(x), Unit::Minutes) => format_minutes(x),
911 (Some(x), Unit::Hours) => format!("{:.1}h", x),
912 (Some(x), Unit::Ratio) => format!("{:.1}", x),
913 (Some(x), Unit::PerThousandPosts) => format!("{:.1} / 1k", x),
914 }
915}
916
917fn format_count(x: f64) -> String {
919 let n = x as i64;
920 let neg = n < 0;
921 let digits = n.unsigned_abs().to_string();
922 let bytes: Vec<u8> = digits.into_bytes();
925 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
926 let len = bytes.len();
927 for (i, b) in bytes.iter().enumerate() {
928 let from_right = len - i;
929 if i > 0 && from_right % 3 == 0 {
930 out.push(',');
931 }
932 out.push(*b as char);
933 }
934 if neg {
935 out.insert(0, '-');
936 }
937 out
938}
939
940fn format_minutes(x: f64) -> String {
941 if x >= 60.0 {
942 let h = x / 60.0;
943 format!("{:.1}h", h)
944 } else {
945 format!("{:.0}m", x)
946 }
947}
948
949fn format_yyyy_mm_dd(d: &DateTime<Utc>) -> String {
950 format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day())
951}
952
953fn report_to_json(report: &AnalyticsReport) -> Value {
958 let mut top = Map::new();
959 top.insert("schema".to_string(), json!(report.schema));
960 top.insert("discourse".to_string(), json!(report.discourse));
961 top.insert("snapshot".to_string(), json!(report.snapshot));
962 top.insert(
963 "windows".to_string(),
964 Value::Array(
965 report
966 .windows
967 .iter()
968 .map(|w| {
969 json!({
970 "label": w.label,
971 "since": w.since.to_rfc3339(),
972 "until": w.until.to_rfc3339(),
973 })
974 })
975 .collect(),
976 ),
977 );
978 for (name, metrics) in iter_sections(report) {
979 top.insert(name.to_string(), section_to_json(metrics, &report.column_headers));
980 }
981 Value::Object(top)
982}
983
984fn section_to_json(metrics: &[Metric], headers: &[String]) -> Value {
985 let mut out = Map::new();
986 for m in metrics {
987 let mut entry = Map::new();
988 let mut values = Map::new();
989 for (i, h) in headers.iter().enumerate() {
990 values.insert(h.clone(), float_or_null(m.values.get(i).copied().flatten()));
991 }
992 entry.insert("values".to_string(), Value::Object(values));
993 entry.insert(
994 "desirable".to_string(),
995 json!(match m.desirable {
996 Direction::Up => "up",
997 Direction::Down => "down",
998 Direction::Neither => "neither",
999 }),
1000 );
1001 entry.insert("unit".to_string(), json!(unit_str(m.unit)));
1002 if m.not_implemented {
1003 entry.insert("not_implemented".to_string(), json!(true));
1004 }
1005 out.insert(m.key.clone(), Value::Object(entry));
1006 }
1007 Value::Object(out)
1008}
1009
1010fn float_or_null(v: Option<f64>) -> Value {
1011 match v {
1012 None => Value::Null,
1013 Some(x) if x.is_finite() => json!(x),
1014 _ => Value::Null,
1015 }
1016}
1017
1018#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 #[test]
1027 fn metric_delta_pct_works_on_compare_layout() {
1028 let m = Metric::new("x", "x", Direction::Up, Unit::Count, 2)
1029 .with_values(vec![Some(80.0), Some(100.0)]);
1030 assert_eq!(m.delta_pct(), Some(-20.0));
1031 }
1032
1033 #[test]
1034 fn metric_delta_pct_none_when_previous_zero() {
1035 let m = Metric::new("x", "x", Direction::Up, Unit::Count, 2)
1036 .with_values(vec![Some(10.0), Some(0.0)]);
1037 assert!(m.delta_pct().is_none());
1038 }
1039
1040 #[test]
1041 fn metric_delta_pct_none_for_single_window() {
1042 let m = Metric::new("x", "x", Direction::Up, Unit::Count, 1)
1043 .with_values(vec![Some(10.0)]);
1044 assert!(m.delta_pct().is_none());
1045 }
1046
1047 #[test]
1048 fn ratio_per_window_handles_zero_and_missing() {
1049 let n = vec![Some(10.0), Some(20.0), None];
1050 let d = vec![Some(2.0), Some(0.0), Some(5.0)];
1051 let r = ratio_per_window(&n, &d);
1052 assert_eq!(r, vec![Some(5.0), None, None]);
1053 }
1054
1055 #[test]
1056 fn format_value_em_dash_for_none() {
1057 assert_eq!(format_value(None, Unit::Count, false), "—");
1058 assert_eq!(format_value(Some(42.0), Unit::Count, true), "— (n/i)");
1059 }
1060
1061 #[test]
1062 fn format_count_inserts_thousand_separators() {
1063 assert_eq!(format_count(0.0), "0");
1064 assert_eq!(format_count(42.0), "42");
1065 assert_eq!(format_count(1_234.0), "1,234");
1066 assert_eq!(format_count(12_345.0), "12,345");
1067 assert_eq!(format_count(1_234_567.0), "1,234,567");
1068 assert_eq!(format_count(-1_500.0), "-1,500");
1069 }
1070
1071 #[test]
1072 fn format_minutes_rolls_to_hours() {
1073 assert_eq!(format_minutes(45.0), "45m");
1074 assert_eq!(format_minutes(90.0), "1.5h");
1075 }
1076
1077 #[test]
1078 fn parse_periods_default_set() {
1079 let now = Utc::now();
1080 let ws = parse_periods("24h,7d,30d,1y", now).unwrap();
1081 assert_eq!(ws.len(), 4);
1082 assert_eq!(ws[0].label, "24h");
1083 assert_eq!(ws[3].label, "1y");
1084 }
1085
1086 #[test]
1087 fn parse_periods_skips_blanks() {
1088 let now = Utc::now();
1089 let ws = parse_periods("7d, ,30d", now).unwrap();
1090 assert_eq!(ws.len(), 2);
1091 }
1092
1093 #[test]
1094 fn parse_periods_rejects_empty() {
1095 let now = Utc::now();
1096 assert!(parse_periods("", now).is_err());
1097 }
1098
1099 #[test]
1100 fn previous_window_is_immediately_preceding() {
1101 let now = Utc::now();
1102 let cur = window_from_since("7d", now).unwrap();
1103 let prev = previous_window_of(&cur);
1104 assert_eq!(prev.until, cur.since);
1105 assert_eq!(prev.duration(), cur.duration());
1106 }
1107
1108 #[test]
1109 fn border_line_lengths_match_widths() {
1110 let line = border_line('┌', '┬', '┐', &[6, 4]);
1111 let dashes = line.chars().filter(|c| *c == '─').count();
1113 assert_eq!(dashes, (6 + 2) + (4 + 2));
1114 assert!(line.starts_with('┌'));
1115 assert!(line.ends_with('┐'));
1116 assert!(line.contains('┬'));
1117 }
1118}