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