chub_core/team/
analytics.rs1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::chub_dir;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct FetchEvent {
11 pub entry_id: String,
12 pub timestamp: u64,
13 #[serde(default)]
14 pub agent: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize)]
19pub struct UsageStats {
20 pub most_fetched: Vec<(String, usize)>,
21 pub never_fetched_pins: Vec<String>,
22 pub total_fetches: usize,
23 pub period_days: u64,
24}
25
26fn analytics_path() -> PathBuf {
27 chub_dir().join("analytics.jsonl")
28}
29
30pub fn record_fetch(entry_id: &str, agent: Option<&str>) {
32 let event = FetchEvent {
33 entry_id: entry_id.to_string(),
34 timestamp: std::time::SystemTime::now()
35 .duration_since(std::time::UNIX_EPOCH)
36 .unwrap_or_default()
37 .as_secs(),
38 agent: agent.map(|s| s.to_string()),
39 };
40
41 let path = analytics_path();
42 let _ = fs::create_dir_all(path.parent().unwrap_or(&PathBuf::from(".")));
43
44 let line = serde_json::to_string(&event).unwrap_or_default();
46 let mut file = fs::OpenOptions::new().create(true).append(true).open(&path);
47
48 if let Ok(ref mut f) = file {
49 use std::io::Write;
50 let _ = writeln!(f, "{}", line);
51 }
52}
53
54fn load_events() -> Vec<FetchEvent> {
56 let path = analytics_path();
57 if !path.exists() {
58 return vec![];
59 }
60
61 let content = match fs::read_to_string(&path) {
62 Ok(c) => c,
63 Err(_) => return vec![],
64 };
65
66 content
67 .lines()
68 .filter(|l| !l.trim().is_empty())
69 .filter_map(|l| serde_json::from_str(l).ok())
70 .collect()
71}
72
73pub fn get_stats(days: u64) -> UsageStats {
75 let events = load_events();
76 let now = std::time::SystemTime::now()
77 .duration_since(std::time::UNIX_EPOCH)
78 .unwrap_or_default()
79 .as_secs();
80 let cutoff = now.saturating_sub(days * 86400);
81
82 let recent: Vec<&FetchEvent> = events.iter().filter(|e| e.timestamp >= cutoff).collect();
83
84 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
86 for event in &recent {
87 *counts.entry(event.entry_id.clone()).or_insert(0) += 1;
88 }
89
90 let mut most_fetched: Vec<(String, usize)> = counts.into_iter().collect();
91 most_fetched.sort_by(|a, b| b.1.cmp(&a.1));
92
93 let pins = crate::team::pins::list_pins();
95 let fetched_ids: std::collections::HashSet<&str> =
96 recent.iter().map(|e| e.entry_id.as_str()).collect();
97
98 let never_fetched_pins: Vec<String> = pins
99 .iter()
100 .filter(|p| !fetched_ids.contains(p.id.as_str()))
101 .map(|p| p.id.clone())
102 .collect();
103
104 UsageStats {
105 total_fetches: recent.len(),
106 most_fetched,
107 never_fetched_pins,
108 period_days: days,
109 }
110}