1use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TeamDashboard {
19 pub team_id: Uuid,
21 pub generated_at: DateTime<Utc>,
23 pub period: AnalyticsPeriod,
25 pub overview: OverviewMetrics,
27 pub trends: UsageTrends,
29 pub member_stats: Vec<MemberStats>,
31 pub provider_breakdown: Vec<ProviderStats>,
33 pub session_analytics: SessionAnalytics,
35 pub collaboration: CollaborationMetrics,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum AnalyticsPeriod {
43 Today,
44 Yesterday,
45 Last7Days,
46 Last30Days,
47 Last90Days,
48 ThisMonth,
49 LastMonth,
50 ThisYear,
51 Custom,
52}
53
54impl AnalyticsPeriod {
55 pub fn start_date(&self) -> DateTime<Utc> {
57 let now = Utc::now();
58 match self {
59 Self::Today => now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(),
60 Self::Yesterday => (now - Duration::days(1)).date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(),
61 Self::Last7Days => now - Duration::days(7),
62 Self::Last30Days => now - Duration::days(30),
63 Self::Last90Days => now - Duration::days(90),
64 Self::ThisMonth => {
65 let naive = now.date_naive();
66 chrono::NaiveDate::from_ymd_opt(naive.year(), naive.month(), 1)
67 .unwrap()
68 .and_hms_opt(0, 0, 0)
69 .unwrap()
70 .and_utc()
71 }
72 Self::LastMonth => {
73 let naive = now.date_naive();
74 let (year, month) = if naive.month() == 1 {
75 (naive.year() - 1, 12)
76 } else {
77 (naive.year(), naive.month() - 1)
78 };
79 chrono::NaiveDate::from_ymd_opt(year, month, 1)
80 .unwrap()
81 .and_hms_opt(0, 0, 0)
82 .unwrap()
83 .and_utc()
84 }
85 Self::ThisYear => {
86 let naive = now.date_naive();
87 chrono::NaiveDate::from_ymd_opt(naive.year(), 1, 1)
88 .unwrap()
89 .and_hms_opt(0, 0, 0)
90 .unwrap()
91 .and_utc()
92 }
93 Self::Custom => now - Duration::days(30), }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct OverviewMetrics {
101 pub total_sessions: u64,
103 pub sessions_change: f64,
105 pub total_messages: u64,
107 pub messages_change: f64,
109 pub total_tokens: u64,
111 pub tokens_change: f64,
113 pub active_members: u32,
115 pub active_members_change: f64,
117 pub avg_sessions_per_member: f64,
119 pub avg_messages_per_session: f64,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct UsageTrends {
126 pub daily_sessions: Vec<TimeSeriesPoint>,
128 pub daily_messages: Vec<TimeSeriesPoint>,
130 pub daily_tokens: Vec<TimeSeriesPoint>,
132 pub hourly_distribution: Vec<u64>,
134 pub weekday_distribution: Vec<u64>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct TimeSeriesPoint {
141 pub timestamp: DateTime<Utc>,
143 pub value: f64,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct MemberStats {
150 pub member_id: Uuid,
152 pub display_name: String,
154 pub sessions: u64,
156 pub messages: u64,
158 pub tokens: u64,
160 pub favorite_provider: Option<String>,
162 pub avg_session_length: f64,
164 pub last_active: Option<DateTime<Utc>>,
166 pub activity_score: u8,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ProviderStats {
173 pub provider: String,
175 pub sessions: u64,
177 pub session_percentage: f64,
179 pub messages: u64,
181 pub tokens: u64,
183 pub top_models: Vec<ModelUsage>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ModelUsage {
190 pub model: String,
192 pub count: u64,
194 pub percentage: f64,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct SessionAnalytics {
201 pub avg_duration_minutes: f64,
203 pub avg_messages: f64,
205 pub avg_tokens: f64,
207 pub length_distribution: SessionLengthDistribution,
209 pub top_tags: Vec<TagUsage>,
211 pub quality_distribution: QualityDistribution,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct SessionLengthDistribution {
218 pub short: u64,
220 pub medium: u64,
222 pub long: u64,
224 pub very_long: u64,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct TagUsage {
231 pub tag: String,
233 pub count: u64,
235 pub percentage: f64,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct QualityDistribution {
242 pub excellent: u64,
244 pub good: u64,
246 pub average: u64,
248 pub below_average: u64,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CollaborationMetrics {
255 pub shared_sessions: u64,
257 pub total_comments: u64,
259 pub active_collaborations: u64,
261 pub top_collaborators: Vec<CollaboratorStats>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct CollaboratorStats {
268 pub member_id: Uuid,
270 pub display_name: String,
272 pub sessions_shared: u64,
274 pub comments_made: u64,
276 pub collaboration_score: u8,
278}
279
280pub struct AnalyticsEngine {
286 cache: HashMap<(Uuid, AnalyticsPeriod), CachedDashboard>,
288 cache_ttl: u64,
290}
291
292struct CachedDashboard {
293 dashboard: TeamDashboard,
294 cached_at: DateTime<Utc>,
295}
296
297impl AnalyticsEngine {
298 pub fn new() -> Self {
300 Self {
301 cache: HashMap::new(),
302 cache_ttl: 300, }
304 }
305
306 pub fn generate_dashboard(
308 &mut self,
309 team_id: Uuid,
310 period: AnalyticsPeriod,
311 session_data: &[SessionAnalyticsData],
312 member_data: &[MemberAnalyticsData],
313 ) -> TeamDashboard {
314 let cache_key = (team_id, period);
316 if let Some(cached) = self.cache.get(&cache_key) {
317 let age = (Utc::now() - cached.cached_at).num_seconds() as u64;
318 if age < self.cache_ttl {
319 return cached.dashboard.clone();
320 }
321 }
322
323 let start_date = period.start_date();
324 let now = Utc::now();
325
326 let period_sessions: Vec<&SessionAnalyticsData> = session_data
328 .iter()
329 .filter(|s| s.created_at >= start_date && s.created_at <= now)
330 .collect();
331
332 let overview = self.calculate_overview(&period_sessions, member_data, period);
334
335 let trends = self.calculate_trends(&period_sessions, start_date, now);
337
338 let member_stats = self.calculate_member_stats(&period_sessions, member_data);
340
341 let provider_breakdown = self.calculate_provider_breakdown(&period_sessions);
343
344 let session_analytics = self.calculate_session_analytics(&period_sessions);
346
347 let collaboration = self.calculate_collaboration_metrics(&period_sessions, member_data);
349
350 let dashboard = TeamDashboard {
351 team_id,
352 generated_at: Utc::now(),
353 period,
354 overview,
355 trends,
356 member_stats,
357 provider_breakdown,
358 session_analytics,
359 collaboration,
360 };
361
362 self.cache.insert(
364 cache_key,
365 CachedDashboard {
366 dashboard: dashboard.clone(),
367 cached_at: Utc::now(),
368 },
369 );
370
371 dashboard
372 }
373
374 fn calculate_overview(
375 &self,
376 sessions: &[&SessionAnalyticsData],
377 _members: &[MemberAnalyticsData],
378 _period: AnalyticsPeriod,
379 ) -> OverviewMetrics {
380 let total_sessions = sessions.len() as u64;
381 let total_messages: u64 = sessions.iter().map(|s| s.message_count as u64).sum();
382 let total_tokens: u64 = sessions.iter().map(|s| s.token_count as u64).sum();
383
384 let active_member_ids: std::collections::HashSet<_> =
385 sessions.iter().map(|s| s.owner_id).collect();
386 let active_members = active_member_ids.len() as u32;
387
388 let avg_sessions_per_member = if active_members > 0 {
389 total_sessions as f64 / active_members as f64
390 } else {
391 0.0
392 };
393
394 let avg_messages_per_session = if total_sessions > 0 {
395 total_messages as f64 / total_sessions as f64
396 } else {
397 0.0
398 };
399
400 let sessions_change = 0.0;
402 let messages_change = 0.0;
403 let tokens_change = 0.0;
404 let active_members_change = 0.0;
405
406 OverviewMetrics {
407 total_sessions,
408 sessions_change,
409 total_messages,
410 messages_change,
411 total_tokens,
412 tokens_change,
413 active_members,
414 active_members_change,
415 avg_sessions_per_member,
416 avg_messages_per_session,
417 }
418 }
419
420 fn calculate_trends(
421 &self,
422 sessions: &[&SessionAnalyticsData],
423 start: DateTime<Utc>,
424 end: DateTime<Utc>,
425 ) -> UsageTrends {
426 let mut daily_sessions: HashMap<String, u64> = HashMap::new();
427 let mut daily_messages: HashMap<String, u64> = HashMap::new();
428 let mut daily_tokens: HashMap<String, u64> = HashMap::new();
429 let mut hourly: Vec<u64> = vec![0; 24];
430 let mut weekday: Vec<u64> = vec![0; 7];
431
432 for session in sessions {
433 let date_key = session.created_at.format("%Y-%m-%d").to_string();
434 *daily_sessions.entry(date_key.clone()).or_insert(0) += 1;
435 *daily_messages.entry(date_key.clone()).or_insert(0) += session.message_count as u64;
436 *daily_tokens.entry(date_key).or_insert(0) += session.token_count as u64;
437
438 let hour = session.created_at.hour() as usize;
439 hourly[hour] += 1;
440
441 let weekday_idx = session.created_at.weekday().num_days_from_sunday() as usize;
442 weekday[weekday_idx] += 1;
443 }
444
445 let mut current = start;
447 let mut sessions_ts = vec![];
448 let mut messages_ts = vec![];
449 let mut tokens_ts = vec![];
450
451 while current <= end {
452 let date_key = current.format("%Y-%m-%d").to_string();
453 sessions_ts.push(TimeSeriesPoint {
454 timestamp: current,
455 value: *daily_sessions.get(&date_key).unwrap_or(&0) as f64,
456 });
457 messages_ts.push(TimeSeriesPoint {
458 timestamp: current,
459 value: *daily_messages.get(&date_key).unwrap_or(&0) as f64,
460 });
461 tokens_ts.push(TimeSeriesPoint {
462 timestamp: current,
463 value: *daily_tokens.get(&date_key).unwrap_or(&0) as f64,
464 });
465 current += Duration::days(1);
466 }
467
468 UsageTrends {
469 daily_sessions: sessions_ts,
470 daily_messages: messages_ts,
471 daily_tokens: tokens_ts,
472 hourly_distribution: hourly,
473 weekday_distribution: weekday,
474 }
475 }
476
477 fn calculate_member_stats(
478 &self,
479 sessions: &[&SessionAnalyticsData],
480 members: &[MemberAnalyticsData],
481 ) -> Vec<MemberStats> {
482 let mut stats_map: HashMap<Uuid, MemberStats> = HashMap::new();
483
484 for session in sessions {
485 let entry = stats_map.entry(session.owner_id).or_insert_with(|| {
486 let member = members.iter().find(|m| m.member_id == session.owner_id);
487 MemberStats {
488 member_id: session.owner_id,
489 display_name: member.map(|m| m.display_name.clone()).unwrap_or_default(),
490 sessions: 0,
491 messages: 0,
492 tokens: 0,
493 favorite_provider: None,
494 avg_session_length: 0.0,
495 last_active: None,
496 activity_score: 0,
497 }
498 });
499
500 entry.sessions += 1;
501 entry.messages += session.message_count as u64;
502 entry.tokens += session.token_count as u64;
503
504 if entry.last_active.map(|la| session.created_at > la).unwrap_or(true) {
505 entry.last_active = Some(session.created_at);
506 }
507 }
508
509 for stats in stats_map.values_mut() {
511 if stats.sessions > 0 {
512 stats.avg_session_length = stats.messages as f64 / stats.sessions as f64;
513 }
514 stats.activity_score = (stats.sessions.min(100)) as u8;
516 }
517
518 let mut result: Vec<_> = stats_map.into_values().collect();
519 result.sort_by(|a, b| b.sessions.cmp(&a.sessions));
520 result
521 }
522
523 fn calculate_provider_breakdown(&self, sessions: &[&SessionAnalyticsData]) -> Vec<ProviderStats> {
524 let mut provider_map: HashMap<String, ProviderStats> = HashMap::new();
525 let total = sessions.len() as f64;
526
527 for session in sessions {
528 let entry = provider_map
529 .entry(session.provider.clone())
530 .or_insert_with(|| ProviderStats {
531 provider: session.provider.clone(),
532 sessions: 0,
533 session_percentage: 0.0,
534 messages: 0,
535 tokens: 0,
536 top_models: vec![],
537 });
538
539 entry.sessions += 1;
540 entry.messages += session.message_count as u64;
541 entry.tokens += session.token_count as u64;
542 }
543
544 for stats in provider_map.values_mut() {
546 stats.session_percentage = if total > 0.0 {
547 (stats.sessions as f64 / total) * 100.0
548 } else {
549 0.0
550 };
551 }
552
553 let mut result: Vec<_> = provider_map.into_values().collect();
554 result.sort_by(|a, b| b.sessions.cmp(&a.sessions));
555 result
556 }
557
558 fn calculate_session_analytics(&self, sessions: &[&SessionAnalyticsData]) -> SessionAnalytics {
559 let total = sessions.len();
560
561 let mut total_messages = 0u64;
562 let mut total_tokens = 0u64;
563 let mut length_dist = SessionLengthDistribution {
564 short: 0,
565 medium: 0,
566 long: 0,
567 very_long: 0,
568 };
569 let mut tag_counts: HashMap<String, u64> = HashMap::new();
570 let mut quality_dist = QualityDistribution {
571 excellent: 0,
572 good: 0,
573 average: 0,
574 below_average: 0,
575 };
576
577 for session in sessions {
578 total_messages += session.message_count as u64;
579 total_tokens += session.token_count as u64;
580
581 match session.message_count {
583 0..=5 => length_dist.short += 1,
584 6..=20 => length_dist.medium += 1,
585 21..=50 => length_dist.long += 1,
586 _ => length_dist.very_long += 1,
587 }
588
589 for tag in &session.tags {
591 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
592 }
593
594 match session.quality_score {
596 80..=100 => quality_dist.excellent += 1,
597 60..=79 => quality_dist.good += 1,
598 40..=59 => quality_dist.average += 1,
599 _ => quality_dist.below_average += 1,
600 }
601 }
602
603 let avg_messages = if total > 0 {
604 total_messages as f64 / total as f64
605 } else {
606 0.0
607 };
608
609 let avg_tokens = if total > 0 {
610 total_tokens as f64 / total as f64
611 } else {
612 0.0
613 };
614
615 let total_f = total as f64;
617 let mut top_tags: Vec<_> = tag_counts
618 .into_iter()
619 .map(|(tag, count)| TagUsage {
620 tag,
621 count,
622 percentage: if total_f > 0.0 {
623 (count as f64 / total_f) * 100.0
624 } else {
625 0.0
626 },
627 })
628 .collect();
629 top_tags.sort_by(|a, b| b.count.cmp(&a.count));
630 top_tags.truncate(10);
631
632 SessionAnalytics {
633 avg_duration_minutes: 0.0, avg_messages,
635 avg_tokens,
636 length_distribution: length_dist,
637 top_tags,
638 quality_distribution: quality_dist,
639 }
640 }
641
642 fn calculate_collaboration_metrics(
643 &self,
644 sessions: &[&SessionAnalyticsData],
645 _members: &[MemberAnalyticsData],
646 ) -> CollaborationMetrics {
647 let shared_sessions = sessions.iter().filter(|s| s.is_shared).count() as u64;
648 let total_comments: u64 = sessions.iter().map(|s| s.comment_count as u64).sum();
649
650 CollaborationMetrics {
651 shared_sessions,
652 total_comments,
653 active_collaborations: 0,
654 top_collaborators: vec![],
655 }
656 }
657
658 pub fn clear_cache(&mut self) {
660 self.cache.clear();
661 }
662
663 pub fn set_cache_ttl(&mut self, ttl_seconds: u64) {
665 self.cache_ttl = ttl_seconds;
666 }
667}
668
669impl Default for AnalyticsEngine {
670 fn default() -> Self {
671 Self::new()
672 }
673}
674
675#[derive(Debug, Clone)]
677pub struct SessionAnalyticsData {
678 pub session_id: String,
679 pub owner_id: Uuid,
680 pub provider: String,
681 pub model: Option<String>,
682 pub message_count: u32,
683 pub token_count: u32,
684 pub created_at: DateTime<Utc>,
685 pub tags: Vec<String>,
686 pub quality_score: u8,
687 pub is_shared: bool,
688 pub comment_count: u32,
689}
690
691#[derive(Debug, Clone)]
693pub struct MemberAnalyticsData {
694 pub member_id: Uuid,
695 pub display_name: String,
696 pub joined_at: DateTime<Utc>,
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn test_period_start_date() {
705 let start = AnalyticsPeriod::Last7Days.start_date();
706 let expected = Utc::now() - Duration::days(7);
707 assert!((start - expected).num_seconds().abs() < 2);
708 }
709
710 #[test]
711 fn test_generate_dashboard() {
712 let mut engine = AnalyticsEngine::new();
713 let team_id = Uuid::new_v4();
714 let owner_id = Uuid::new_v4();
715
716 let sessions = vec![SessionAnalyticsData {
717 session_id: "session-1".to_string(),
718 owner_id,
719 provider: "copilot".to_string(),
720 model: Some("gpt-4".to_string()),
721 message_count: 10,
722 token_count: 500,
723 created_at: Utc::now(),
724 tags: vec!["rust".to_string()],
725 quality_score: 85,
726 is_shared: false,
727 comment_count: 0,
728 }];
729
730 let members = vec![MemberAnalyticsData {
731 member_id: owner_id,
732 display_name: "Test User".to_string(),
733 joined_at: Utc::now() - Duration::days(30),
734 }];
735
736 let dashboard = engine.generate_dashboard(team_id, AnalyticsPeriod::Last7Days, &sessions, &members);
737
738 assert_eq!(dashboard.overview.total_sessions, 1);
739 assert_eq!(dashboard.overview.total_messages, 10);
740 }
741}