Skip to main content

chasm/analytics/
dashboard.rs

1// Copyright (c) 2024-2027 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Analytics dashboard module
4//!
5//! Provides team usage analytics and insights.
6
7use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12// ============================================================================
13// Dashboard Types
14// ============================================================================
15
16/// Team analytics dashboard
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TeamDashboard {
19    /// Team ID
20    pub team_id: Uuid,
21    /// Dashboard generated at
22    pub generated_at: DateTime<Utc>,
23    /// Time period
24    pub period: AnalyticsPeriod,
25    /// Overview metrics
26    pub overview: OverviewMetrics,
27    /// Usage trends
28    pub trends: UsageTrends,
29    /// Member statistics
30    pub member_stats: Vec<MemberStats>,
31    /// Provider breakdown
32    pub provider_breakdown: Vec<ProviderStats>,
33    /// Session analytics
34    pub session_analytics: SessionAnalytics,
35    /// Collaboration metrics
36    pub collaboration: CollaborationMetrics,
37}
38
39/// Analytics time period
40#[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    /// Get start date for period
56    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), // Default for custom
94        }
95    }
96}
97
98/// Overview metrics
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct OverviewMetrics {
101    /// Total sessions in period
102    pub total_sessions: u64,
103    /// Sessions change from previous period
104    pub sessions_change: f64,
105    /// Total messages in period
106    pub total_messages: u64,
107    /// Messages change from previous period
108    pub messages_change: f64,
109    /// Total tokens used
110    pub total_tokens: u64,
111    /// Tokens change from previous period
112    pub tokens_change: f64,
113    /// Active members in period
114    pub active_members: u32,
115    /// Active members change
116    pub active_members_change: f64,
117    /// Average sessions per member
118    pub avg_sessions_per_member: f64,
119    /// Average messages per session
120    pub avg_messages_per_session: f64,
121}
122
123/// Usage trends over time
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct UsageTrends {
126    /// Daily session counts
127    pub daily_sessions: Vec<TimeSeriesPoint>,
128    /// Daily message counts
129    pub daily_messages: Vec<TimeSeriesPoint>,
130    /// Daily token usage
131    pub daily_tokens: Vec<TimeSeriesPoint>,
132    /// Hourly activity distribution (0-23)
133    pub hourly_distribution: Vec<u64>,
134    /// Day of week distribution (0=Sun, 6=Sat)
135    pub weekday_distribution: Vec<u64>,
136}
137
138/// Time series data point
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct TimeSeriesPoint {
141    /// Timestamp
142    pub timestamp: DateTime<Utc>,
143    /// Value
144    pub value: f64,
145}
146
147/// Individual member statistics
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct MemberStats {
150    /// Member ID
151    pub member_id: Uuid,
152    /// Display name
153    pub display_name: String,
154    /// Total sessions
155    pub sessions: u64,
156    /// Total messages
157    pub messages: u64,
158    /// Total tokens
159    pub tokens: u64,
160    /// Favorite provider
161    pub favorite_provider: Option<String>,
162    /// Average session length (messages)
163    pub avg_session_length: f64,
164    /// Last active
165    pub last_active: Option<DateTime<Utc>>,
166    /// Activity score (0-100)
167    pub activity_score: u8,
168}
169
170/// Provider statistics
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ProviderStats {
173    /// Provider name
174    pub provider: String,
175    /// Session count
176    pub sessions: u64,
177    /// Session percentage
178    pub session_percentage: f64,
179    /// Message count
180    pub messages: u64,
181    /// Token count
182    pub tokens: u64,
183    /// Most used models
184    pub top_models: Vec<ModelUsage>,
185}
186
187/// Model usage statistics
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ModelUsage {
190    /// Model name
191    pub model: String,
192    /// Usage count
193    pub count: u64,
194    /// Percentage
195    pub percentage: f64,
196}
197
198/// Session-level analytics
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct SessionAnalytics {
201    /// Average session duration (minutes)
202    pub avg_duration_minutes: f64,
203    /// Average messages per session
204    pub avg_messages: f64,
205    /// Average tokens per session
206    pub avg_tokens: f64,
207    /// Session length distribution
208    pub length_distribution: SessionLengthDistribution,
209    /// Top tags
210    pub top_tags: Vec<TagUsage>,
211    /// Quality score distribution
212    pub quality_distribution: QualityDistribution,
213}
214
215/// Session length distribution
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct SessionLengthDistribution {
218    /// 1-5 messages
219    pub short: u64,
220    /// 6-20 messages
221    pub medium: u64,
222    /// 21-50 messages
223    pub long: u64,
224    /// 51+ messages
225    pub very_long: u64,
226}
227
228/// Tag usage statistics
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct TagUsage {
231    /// Tag name
232    pub tag: String,
233    /// Usage count
234    pub count: u64,
235    /// Percentage
236    pub percentage: f64,
237}
238
239/// Session quality distribution
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct QualityDistribution {
242    /// Excellent (80-100)
243    pub excellent: u64,
244    /// Good (60-79)
245    pub good: u64,
246    /// Average (40-59)
247    pub average: u64,
248    /// Below average (0-39)
249    pub below_average: u64,
250}
251
252/// Collaboration metrics
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CollaborationMetrics {
255    /// Total shared sessions
256    pub shared_sessions: u64,
257    /// Total comments
258    pub total_comments: u64,
259    /// Active collaborations (sessions with multiple contributors)
260    pub active_collaborations: u64,
261    /// Most collaborative members
262    pub top_collaborators: Vec<CollaboratorStats>,
263}
264
265/// Collaborator statistics
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct CollaboratorStats {
268    /// Member ID
269    pub member_id: Uuid,
270    /// Display name
271    pub display_name: String,
272    /// Sessions shared
273    pub sessions_shared: u64,
274    /// Comments made
275    pub comments_made: u64,
276    /// Collaboration score
277    pub collaboration_score: u8,
278}
279
280// ============================================================================
281// Analytics Engine
282// ============================================================================
283
284/// Analytics engine for generating dashboards
285pub struct AnalyticsEngine {
286    /// Cached dashboards
287    cache: HashMap<(Uuid, AnalyticsPeriod), CachedDashboard>,
288    /// Cache TTL in seconds
289    cache_ttl: u64,
290}
291
292struct CachedDashboard {
293    dashboard: TeamDashboard,
294    cached_at: DateTime<Utc>,
295}
296
297impl AnalyticsEngine {
298    /// Create a new analytics engine
299    pub fn new() -> Self {
300        Self {
301            cache: HashMap::new(),
302            cache_ttl: 300, // 5 minutes
303        }
304    }
305
306    /// Generate dashboard for a team
307    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        // Check cache
315        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        // Filter data by period
327        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        // Calculate overview metrics
333        let overview = self.calculate_overview(&period_sessions, member_data, period);
334
335        // Calculate trends
336        let trends = self.calculate_trends(&period_sessions, start_date, now);
337
338        // Calculate member stats
339        let member_stats = self.calculate_member_stats(&period_sessions, member_data);
340
341        // Calculate provider breakdown
342        let provider_breakdown = self.calculate_provider_breakdown(&period_sessions);
343
344        // Calculate session analytics
345        let session_analytics = self.calculate_session_analytics(&period_sessions);
346
347        // Calculate collaboration metrics
348        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        // Cache dashboard
363        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        // Calculate changes (simplified - would need previous period data)
401        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        // Convert to time series
446        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        // Calculate averages and scores
510        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            // Simple activity score based on sessions
515            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        // Calculate percentages
545        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            // Length distribution
582            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            // Tags
590            for tag in &session.tags {
591                *tag_counts.entry(tag.clone()).or_insert(0) += 1;
592            }
593
594            // Quality (simplified)
595            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        // Top tags
616        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, // Would need timing data
634            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    /// Clear cache
659    pub fn clear_cache(&mut self) {
660        self.cache.clear();
661    }
662
663    /// Set cache TTL
664    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/// Session data for analytics
676#[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/// Member data for analytics
692#[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}