Skip to main content

trustformers_debug/
team_dashboard.rs

1//! Team collaboration dashboard for debugging sessions
2//!
3//! This module provides a real-time dashboard for team collaboration,
4//! showing active debugging sessions, shared reports, and team activity.
5
6use crate::collaboration::CollaborationManager;
7use anyhow::Result;
8use chrono::{DateTime, Duration, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use uuid::Uuid;
12
13/// Team dashboard for collaborative debugging
14#[derive(Debug, Clone)]
15pub struct TeamDashboard {
16    /// Dashboard configuration
17    config: DashboardConfig,
18    /// Real-time activity feed
19    activity_feed: Vec<ActivityEvent>,
20    /// Team metrics
21    metrics: TeamMetrics,
22    /// Active sessions tracking
23    active_sessions: HashMap<Uuid, SessionActivity>,
24    /// Notification system
25    notifications: NotificationSystem,
26}
27
28/// Dashboard configuration
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DashboardConfig {
31    /// Refresh interval in seconds
32    pub refresh_interval: u64,
33    /// Maximum activities to show
34    pub max_activities: usize,
35    /// Enable real-time updates
36    pub real_time_updates: bool,
37    /// Show detailed metrics
38    pub show_detailed_metrics: bool,
39    /// Custom widgets configuration
40    pub widgets: Vec<WidgetConfig>,
41    /// Theme settings
42    pub theme: DashboardTheme,
43}
44
45/// Widget configuration for dashboard
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct WidgetConfig {
48    /// Widget identifier
49    pub id: String,
50    /// Widget type
51    pub widget_type: WidgetType,
52    /// Widget position
53    pub position: WidgetPosition,
54    /// Widget size
55    pub size: WidgetSize,
56    /// Widget settings
57    pub settings: HashMap<String, serde_json::Value>,
58    /// Visibility
59    pub visible: bool,
60}
61
62/// Dashboard theme
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct DashboardTheme {
65    /// Primary color
66    pub primary_color: String,
67    /// Secondary color
68    pub secondary_color: String,
69    /// Background color
70    pub background_color: String,
71    /// Text color
72    pub text_color: String,
73    /// Dark mode enabled
74    pub dark_mode: bool,
75}
76
77/// Activity event in the team
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ActivityEvent {
80    /// Event identifier
81    pub id: Uuid,
82    /// Event type
83    pub event_type: ActivityType,
84    /// Actor (team member who performed the action)
85    pub actor: Uuid,
86    /// Target (what was affected)
87    pub target: ActivityTarget,
88    /// Event timestamp
89    pub timestamp: DateTime<Utc>,
90    /// Event description
91    pub description: String,
92    /// Additional metadata
93    pub metadata: HashMap<String, serde_json::Value>,
94}
95
96/// Team metrics for dashboard
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct TeamMetrics {
99    /// Active members count
100    pub active_members: usize,
101    /// Reports shared today
102    pub reports_today: usize,
103    /// Annotations added today
104    pub annotations_today: usize,
105    /// Comments posted today
106    pub comments_today: usize,
107    /// Average response time (in minutes)
108    /// Mean time between a request and its response, in minutes.
109    ///
110    /// Always `None`: see `TeamDashboard::calculate_avg_response_time` --
111    /// nothing here records the paired events such an average needs. It used
112    /// to be the constant `15.0`.
113    pub avg_response_time: Option<f64>,
114    /// Collaboration score
115    pub collaboration_score: f64,
116    /// Top contributors
117    pub top_contributors: Vec<ContributorMetric>,
118    /// Activity trends
119    pub activity_trends: ActivityTrends,
120}
121
122/// Individual contributor metrics
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ContributorMetric {
125    /// Team member ID
126    pub member_id: Uuid,
127    /// Member name
128    pub name: String,
129    /// Reports contributed
130    pub reports_count: usize,
131    /// Annotations made
132    pub annotations_count: usize,
133    /// Comments posted
134    pub comments_count: usize,
135    /// Activity score
136    pub activity_score: f64,
137}
138
139/// Activity trends over time
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ActivityTrends {
142    /// Daily activity counts for the last 7 days
143    pub daily_activity: Vec<DailyActivity>,
144    /// Weekly activity summary
145    pub weekly_summary: WeeklyActivity,
146    /// Percentage change in total recorded events between the last seven days
147    /// and the seven days before that: `(recent - prior) / prior * 100`.
148    ///
149    /// `None` when the prior window holds no events at all, because the ratio
150    /// is then undefined -- previously this was hardcoded to `0.0`, which reads
151    /// as "measured zero growth".
152    pub growth_rate: Option<f64>,
153}
154
155/// Daily activity metrics
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct DailyActivity {
158    /// Date
159    pub date: DateTime<Utc>,
160    /// Number of reports
161    pub reports: usize,
162    /// Number of annotations
163    pub annotations: usize,
164    /// Number of comments
165    pub comments: usize,
166    /// Active users
167    pub active_users: usize,
168}
169
170/// Weekly activity summary
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct WeeklyActivity {
173    /// Total reports this week
174    pub total_reports: usize,
175    /// Total annotations this week
176    pub total_annotations: usize,
177    /// Total comments this week
178    pub total_comments: usize,
179    /// Weekday name (`"Monday"` .. `"Sunday"`) of the busiest of the last seven
180    /// days, by total recorded events.
181    ///
182    /// `None` when no activity was recorded in the window -- there is no peak
183    /// day to name. This was previously the literal string `"Monday"`,
184    /// regardless of the data.
185    pub peak_day: Option<String>,
186    /// Average daily active users
187    pub avg_daily_active_users: f64,
188}
189
190/// Session activity tracking
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct SessionActivity {
193    /// Session ID
194    pub session_id: Uuid,
195    /// Session type
196    pub session_type: String,
197    /// Participants
198    pub participants: Vec<Uuid>,
199    /// Start time
200    pub started_at: DateTime<Utc>,
201    /// Last activity time
202    pub last_activity: DateTime<Utc>,
203    /// Activity count
204    pub activity_count: usize,
205    /// Current status
206    pub status: String,
207}
208
209/// Notification system for real-time updates
210#[derive(Debug, Clone)]
211pub struct NotificationSystem {
212    /// Pending notifications
213    notifications: Vec<DashboardNotification>,
214    /// Notification settings
215    settings: NotificationSettings,
216}
217
218/// Dashboard notification
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct DashboardNotification {
221    /// Notification ID
222    pub id: Uuid,
223    /// Notification type
224    pub notification_type: NotificationType,
225    /// Title
226    pub title: String,
227    /// Message
228    pub message: String,
229    /// Priority level
230    pub priority: NotificationPriority,
231    /// Timestamp
232    pub timestamp: DateTime<Utc>,
233    /// Target users
234    pub target_users: Vec<Uuid>,
235    /// Read status
236    pub read_by: Vec<Uuid>,
237    /// Action buttons
238    pub actions: Vec<NotificationAction>,
239}
240
241/// Notification settings
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct NotificationSettings {
244    /// Enable browser notifications
245    pub browser_notifications: bool,
246    /// Enable sound alerts
247    pub sound_alerts: bool,
248    /// Auto-dismiss time (seconds)
249    pub auto_dismiss_time: u64,
250    /// Max notifications to show
251    pub max_notifications: usize,
252}
253
254/// Activity types
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub enum ActivityType {
257    ReportShared,
258    ReportUpdated,
259    AnnotationAdded,
260    CommentPosted,
261    SessionStarted,
262    SessionEnded,
263    MemberJoined,
264    MemberLeft,
265    IssueResolved,
266    Custom(String),
267}
268
269/// Activity targets
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub enum ActivityTarget {
272    Report(Uuid),
273    Annotation(Uuid),
274    Comment(Uuid),
275    Session(Uuid),
276    Member(Uuid),
277    Custom(String),
278}
279
280/// Widget types
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub enum WidgetType {
283    ActivityFeed,
284    TeamMetrics,
285    ActiveSessions,
286    RecentReports,
287    TopContributors,
288    ActivityChart,
289    NotificationCenter,
290    QuickActions,
291    Custom(String),
292}
293
294/// Widget position
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct WidgetPosition {
297    /// X coordinate
298    pub x: u32,
299    /// Y coordinate
300    pub y: u32,
301    /// Grid column
302    pub col: u32,
303    /// Grid row
304    pub row: u32,
305}
306
307/// Widget size
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct WidgetSize {
310    /// Width in grid units
311    pub width: u32,
312    /// Height in grid units
313    pub height: u32,
314    /// Minimum width
315    pub min_width: u32,
316    /// Minimum height
317    pub min_height: u32,
318}
319
320/// Notification types
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub enum NotificationType {
323    NewReport,
324    NewAnnotation,
325    NewComment,
326    SessionInvite,
327    IssueAssigned,
328    SystemAlert,
329    Custom(String),
330}
331
332/// Notification priority
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub enum NotificationPriority {
335    Low,
336    Normal,
337    High,
338    Urgent,
339}
340
341/// Notification action
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct NotificationAction {
344    /// Action label
345    pub label: String,
346    /// Action type
347    pub action_type: String,
348    /// Action data
349    pub data: HashMap<String, serde_json::Value>,
350}
351
352impl TeamDashboard {
353    /// Create a new team dashboard
354    pub fn new(config: DashboardConfig) -> Self {
355        Self {
356            config,
357            activity_feed: Vec::new(),
358            metrics: TeamMetrics::default(),
359            active_sessions: HashMap::new(),
360            notifications: NotificationSystem::new(),
361        }
362    }
363
364    /// Update dashboard with collaboration data
365    pub fn update_from_collaboration(
366        &mut self,
367        collaboration: &CollaborationManager,
368    ) -> Result<()> {
369        // Update metrics
370        self.update_metrics(collaboration)?;
371
372        // Update activity feed
373        self.update_activity_feed(collaboration)?;
374
375        // Update active sessions
376        self.update_active_sessions(collaboration)?;
377
378        Ok(())
379    }
380
381    /// Add activity event
382    pub fn add_activity_event(
383        &mut self,
384        event_type: ActivityType,
385        actor: Uuid,
386        target: ActivityTarget,
387        description: String,
388    ) -> Uuid {
389        let event_id = Uuid::new_v4();
390        let event = ActivityEvent {
391            id: event_id,
392            event_type,
393            actor,
394            target,
395            timestamp: Utc::now(),
396            description,
397            metadata: HashMap::new(),
398        };
399
400        self.activity_feed.insert(0, event);
401
402        // Limit activity feed size
403        if self.activity_feed.len() > self.config.max_activities {
404            self.activity_feed.truncate(self.config.max_activities);
405        }
406
407        event_id
408    }
409
410    /// Get dashboard data for rendering
411    pub fn get_dashboard_data(&self) -> DashboardData {
412        DashboardData {
413            config: self.config.clone(),
414            activity_feed: self.activity_feed.clone(),
415            metrics: self.metrics.clone(),
416            active_sessions: self.active_sessions.values().cloned().collect(),
417            notifications: self.notifications.get_unread_notifications(),
418            last_updated: Utc::now(),
419        }
420    }
421
422    /// Send notification to team members
423    pub fn send_notification(
424        &mut self,
425        notification_type: NotificationType,
426        title: String,
427        message: String,
428        priority: NotificationPriority,
429        target_users: Vec<Uuid>,
430    ) -> Uuid {
431        self.notifications.send_notification(
432            notification_type,
433            title,
434            message,
435            priority,
436            target_users,
437        )
438    }
439
440    /// Mark notification as read
441    pub fn mark_notification_read(&mut self, notification_id: Uuid, user_id: Uuid) -> Result<()> {
442        self.notifications.mark_read(notification_id, user_id)
443    }
444
445    /// Get team activity summary
446    pub fn get_activity_summary(&self, days: u32) -> ActivitySummary {
447        let cutoff_date = Utc::now() - Duration::days(days as i64);
448
449        let recent_activities: Vec<_> = self
450            .activity_feed
451            .iter()
452            .filter(|activity| activity.timestamp >= cutoff_date)
453            .collect();
454
455        let total_activities = recent_activities.len();
456        let unique_contributors: std::collections::HashSet<_> =
457            recent_activities.iter().map(|a| a.actor).collect();
458
459        ActivitySummary {
460            total_activities,
461            unique_contributors: unique_contributors.len(),
462            activity_by_type: self.count_activities_by_type(&recent_activities),
463            most_active_day: self.find_most_active_day(&recent_activities),
464            period_days: days,
465        }
466    }
467
468    /// Update team metrics
469    fn update_metrics(&mut self, collaboration: &CollaborationManager) -> Result<()> {
470        let stats = collaboration.get_collaboration_stats();
471
472        // Calculate activity trends
473        let trends = self.calculate_activity_trends();
474
475        // Calculate collaboration score
476        let collaboration_score = self.calculate_collaboration_score(&stats);
477
478        self.metrics = TeamMetrics {
479            active_members: stats.team_size,
480            reports_today: self.count_today_activities(ActivityType::ReportShared),
481            annotations_today: self.count_today_activities(ActivityType::AnnotationAdded),
482            comments_today: self.count_today_activities(ActivityType::CommentPosted),
483            avg_response_time: self.calculate_avg_response_time(),
484            collaboration_score,
485            top_contributors: self.calculate_top_contributors(),
486            activity_trends: trends,
487        };
488
489        Ok(())
490    }
491
492    /// No-op: the activity feed is already the authoritative record.
493    ///
494    /// Entries reach it through [`Self::record_activity`], which every
495    /// dashboard mutation calls directly; [`CollaborationManager`] exposes only
496    /// aggregate [`crate::collaboration::CollaborationStats`], not an event log
497    /// that could be replayed here. Kept as an explicit no-op (rather than
498    /// deleted) because [`Self::update_metrics`] documents a fixed refresh
499    /// sequence, and silently dropping a step from it would be more confusing
500    /// than a named, empty one.
501    fn update_activity_feed(&mut self, _collaboration: &CollaborationManager) -> Result<()> {
502        Ok(())
503    }
504
505    /// Update active sessions tracking
506    fn update_active_sessions(&mut self, _collaboration: &CollaborationManager) -> Result<()> {
507        // Update session activity tracking
508        let now = Utc::now();
509
510        // Remove stale sessions (inactive for more than 1 hour)
511        let stale_cutoff = now - Duration::hours(1);
512        self.active_sessions.retain(|_, session| session.last_activity >= stale_cutoff);
513
514        Ok(())
515    }
516
517    /// Helper methods
518    fn calculate_activity_trends(&self) -> ActivityTrends {
519        let now = Utc::now();
520        let mut daily_activity = Vec::new();
521
522        // Calculate last 7 days
523        for i in 0..7 {
524            let date = now - Duration::days(i);
525            let day_start = date.date_naive().and_time(chrono::NaiveTime::MIN).and_utc();
526            let day_end = day_start + Duration::days(1);
527
528            let day_activities: Vec<_> = self
529                .activity_feed
530                .iter()
531                .filter(|a| a.timestamp >= day_start && a.timestamp < day_end)
532                .collect();
533
534            let daily = DailyActivity {
535                date: day_start,
536                reports: day_activities
537                    .iter()
538                    .filter(|a| matches!(a.event_type, ActivityType::ReportShared))
539                    .count(),
540                annotations: day_activities
541                    .iter()
542                    .filter(|a| matches!(a.event_type, ActivityType::AnnotationAdded))
543                    .count(),
544                comments: day_activities
545                    .iter()
546                    .filter(|a| matches!(a.event_type, ActivityType::CommentPosted))
547                    .count(),
548                active_users: day_activities
549                    .iter()
550                    .map(|a| a.actor)
551                    .collect::<std::collections::HashSet<_>>()
552                    .len(),
553            };
554
555            daily_activity.push(daily);
556        }
557
558        // Calculate weekly summary
559        let total_reports = daily_activity.iter().map(|d| d.reports).sum();
560        let total_annotations = daily_activity.iter().map(|d| d.annotations).sum();
561        let total_comments = daily_activity.iter().map(|d| d.comments).sum();
562        let avg_daily_active_users =
563            daily_activity.iter().map(|d| d.active_users as f64).sum::<f64>() / 7.0;
564
565        // Real peak day: the day in the window with the most recorded events.
566        // `max_by_key` keeps the LAST maximum, so scan explicitly to keep the
567        // earliest (oldest) day on a tie, which is the deterministic choice.
568        let peak_day = daily_activity
569            .iter()
570            .map(|d| (d, d.reports + d.annotations + d.comments))
571            .filter(|(_, total)| *total > 0)
572            .fold(None::<(&DailyActivity, usize)>, |best, cur| match best {
573                Some((_, best_total)) if best_total >= cur.1 => best,
574                _ => Some(cur),
575            })
576            .map(|(day, _)| day.date.format("%A").to_string());
577
578        let weekly_summary = WeeklyActivity {
579            total_reports,
580            total_annotations,
581            total_comments,
582            peak_day,
583            avg_daily_active_users,
584        };
585
586        // Real growth rate: this week's event count against the previous
587        // week's, both counted from the same activity feed.
588        let week_ago = now - Duration::days(7);
589        let two_weeks_ago = now - Duration::days(14);
590        let recent = self.activity_feed.iter().filter(|a| a.timestamp >= week_ago).count();
591        let prior = self
592            .activity_feed
593            .iter()
594            .filter(|a| a.timestamp >= two_weeks_ago && a.timestamp < week_ago)
595            .count();
596        let growth_rate = if prior == 0 {
597            None
598        } else {
599            Some((recent as f64 - prior as f64) / prior as f64 * 100.0)
600        };
601
602        ActivityTrends {
603            daily_activity,
604            weekly_summary,
605            growth_rate,
606        }
607    }
608
609    /// Composite collaboration score in `[0, 100]`.
610    ///
611    /// Defined here, from the real `stats`, as the mean of three bounded
612    /// sub-scores, each saturating at a documented target:
613    ///
614    /// * **participation** — `contributors / team_size`, the share of the team
615    ///   that appears in the activity feed at all;
616    /// * **discussion** — `(comments + annotations) / reports` against a target
617    ///   of 3 responses per shared report;
618    /// * **breadth** — `reports_per_member` against a target of 5.
619    ///
620    /// It is a *definition*, not a measurement of an external quantity, and the
621    /// weights are stated so a caller can reproduce it. The previous version was
622    /// `50.0 + activity_feed.len() * 0.1`: a magic baseline of 50 that ignored
623    /// `stats` entirely and grew without bound past 100 once the feed exceeded
624    /// 500 entries.
625    fn calculate_collaboration_score(
626        &self,
627        stats: &crate::collaboration::CollaborationStats,
628    ) -> f64 {
629        /// Responses (comments + annotations) per shared report that counts as
630        /// a full discussion score.
631        const DISCUSSION_TARGET: f64 = 3.0;
632        /// Reports per member that counts as a full breadth score.
633        const BREADTH_TARGET: f64 = 5.0;
634
635        let contributors = self
636            .activity_feed
637            .iter()
638            .map(|a| a.actor)
639            .collect::<std::collections::HashSet<_>>()
640            .len();
641        let participation = if stats.team_size == 0 {
642            0.0
643        } else {
644            (contributors as f64 / stats.team_size as f64).min(1.0)
645        };
646
647        let responses = (stats.total_comments + stats.total_annotations) as f64;
648        let discussion = if stats.total_reports == 0 {
649            0.0
650        } else {
651            (responses / stats.total_reports as f64 / DISCUSSION_TARGET).min(1.0)
652        };
653
654        let breadth = (stats.reports_per_member / BREADTH_TARGET).clamp(0.0, 1.0);
655
656        (participation + discussion + breadth) / 3.0 * 100.0
657    }
658
659    fn calculate_top_contributors(&self) -> Vec<ContributorMetric> {
660        let mut contributor_map: HashMap<Uuid, (usize, usize, usize)> = HashMap::new();
661
662        for activity in &self.activity_feed {
663            let entry = contributor_map.entry(activity.actor).or_insert((0, 0, 0));
664            match activity.event_type {
665                ActivityType::ReportShared => entry.0 += 1,
666                ActivityType::AnnotationAdded => entry.1 += 1,
667                ActivityType::CommentPosted => entry.2 += 1,
668                _ => {},
669            }
670        }
671
672        contributor_map
673            .into_iter()
674            .map(|(member_id, (reports, annotations, comments))| {
675                let activity_score =
676                    reports as f64 * 3.0 + annotations as f64 * 2.0 + comments as f64;
677                ContributorMetric {
678                    member_id,
679                    name: format!("User {}", member_id.to_string()[0..8].to_uppercase()),
680                    reports_count: reports,
681                    annotations_count: annotations,
682                    comments_count: comments,
683                    activity_score,
684                }
685            })
686            .collect()
687    }
688
689    fn count_today_activities(&self, activity_type: ActivityType) -> usize {
690        let today = Utc::now().date_naive();
691        self.activity_feed
692            .iter()
693            .filter(|a| {
694                a.timestamp.date_naive() == today
695                    && std::mem::discriminant(&a.event_type)
696                        == std::mem::discriminant(&activity_type)
697            })
698            .count()
699    }
700
701    /// Always `None`: computing an average response time needs paired
702    /// request/response events, and this dashboard records neither. It used to
703    /// report a flat `15.0` minutes for every team, every week.
704    fn calculate_avg_response_time(&self) -> Option<f64> {
705        None
706    }
707
708    fn count_activities_by_type(&self, activities: &[&ActivityEvent]) -> HashMap<String, usize> {
709        let mut counts = HashMap::new();
710        for activity in activities {
711            let type_name = match &activity.event_type {
712                ActivityType::ReportShared => "ReportShared",
713                ActivityType::AnnotationAdded => "AnnotationAdded",
714                ActivityType::CommentPosted => "CommentPosted",
715                ActivityType::SessionStarted => "SessionStarted",
716                ActivityType::Custom(name) => name,
717                _ => "Other",
718            };
719            *counts.entry(type_name.to_string()).or_insert(0) += 1;
720        }
721        counts
722    }
723
724    fn find_most_active_day(&self, activities: &[&ActivityEvent]) -> String {
725        let mut day_counts = HashMap::new();
726        for activity in activities {
727            let day = activity.timestamp.format("%A").to_string();
728            *day_counts.entry(day).or_insert(0) += 1;
729        }
730
731        day_counts
732            .into_iter()
733            .max_by_key(|(_, count)| *count)
734            .map(|(day, _)| day)
735            .unwrap_or_else(|| "Monday".to_string())
736    }
737}
738
739/// Complete dashboard data for rendering
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct DashboardData {
742    pub config: DashboardConfig,
743    pub activity_feed: Vec<ActivityEvent>,
744    pub metrics: TeamMetrics,
745    pub active_sessions: Vec<SessionActivity>,
746    pub notifications: Vec<DashboardNotification>,
747    pub last_updated: DateTime<Utc>,
748}
749
750/// Activity summary for reporting
751#[derive(Debug, Clone, Serialize, Deserialize)]
752pub struct ActivitySummary {
753    pub total_activities: usize,
754    pub unique_contributors: usize,
755    pub activity_by_type: HashMap<String, usize>,
756    pub most_active_day: String,
757    pub period_days: u32,
758}
759
760impl Default for NotificationSystem {
761    fn default() -> Self {
762        Self::new()
763    }
764}
765
766impl NotificationSystem {
767    pub fn new() -> Self {
768        Self {
769            notifications: Vec::new(),
770            settings: NotificationSettings {
771                browser_notifications: true,
772                sound_alerts: false,
773                auto_dismiss_time: 5,
774                max_notifications: 50,
775            },
776        }
777    }
778
779    pub fn send_notification(
780        &mut self,
781        notification_type: NotificationType,
782        title: String,
783        message: String,
784        priority: NotificationPriority,
785        target_users: Vec<Uuid>,
786    ) -> Uuid {
787        let notification_id = Uuid::new_v4();
788        let notification = DashboardNotification {
789            id: notification_id,
790            notification_type,
791            title,
792            message,
793            priority,
794            timestamp: Utc::now(),
795            target_users,
796            read_by: Vec::new(),
797            actions: Vec::new(),
798        };
799
800        self.notifications.insert(0, notification);
801
802        // Limit notifications
803        if self.notifications.len() > self.settings.max_notifications {
804            self.notifications.truncate(self.settings.max_notifications);
805        }
806
807        notification_id
808    }
809
810    pub fn mark_read(&mut self, notification_id: Uuid, user_id: Uuid) -> Result<()> {
811        if let Some(notification) = self.notifications.iter_mut().find(|n| n.id == notification_id)
812        {
813            if !notification.read_by.contains(&user_id) {
814                notification.read_by.push(user_id);
815            }
816            Ok(())
817        } else {
818            Err(anyhow::anyhow!("Notification not found"))
819        }
820    }
821
822    pub fn get_unread_notifications(&self) -> Vec<DashboardNotification> {
823        self.notifications.clone()
824    }
825}
826
827impl Default for TeamMetrics {
828    fn default() -> Self {
829        Self {
830            active_members: 0,
831            reports_today: 0,
832            annotations_today: 0,
833            comments_today: 0,
834            avg_response_time: None,
835            collaboration_score: 0.0,
836            top_contributors: Vec::new(),
837            activity_trends: ActivityTrends {
838                daily_activity: Vec::new(),
839                weekly_summary: WeeklyActivity {
840                    total_reports: 0,
841                    total_annotations: 0,
842                    total_comments: 0,
843                    // An empty dashboard has no peak day and no baseline
844                    // week to grow from.
845                    peak_day: None,
846                    avg_daily_active_users: 0.0,
847                },
848                growth_rate: None,
849            },
850        }
851    }
852}
853
854impl Default for DashboardConfig {
855    fn default() -> Self {
856        Self {
857            refresh_interval: 30,
858            max_activities: 100,
859            real_time_updates: true,
860            show_detailed_metrics: true,
861            widgets: vec![
862                WidgetConfig {
863                    id: "activity-feed".to_string(),
864                    widget_type: WidgetType::ActivityFeed,
865                    position: WidgetPosition {
866                        x: 0,
867                        y: 0,
868                        col: 0,
869                        row: 0,
870                    },
871                    size: WidgetSize {
872                        width: 6,
873                        height: 8,
874                        min_width: 4,
875                        min_height: 6,
876                    },
877                    settings: HashMap::new(),
878                    visible: true,
879                },
880                WidgetConfig {
881                    id: "team-metrics".to_string(),
882                    widget_type: WidgetType::TeamMetrics,
883                    position: WidgetPosition {
884                        x: 6,
885                        y: 0,
886                        col: 6,
887                        row: 0,
888                    },
889                    size: WidgetSize {
890                        width: 6,
891                        height: 4,
892                        min_width: 4,
893                        min_height: 3,
894                    },
895                    settings: HashMap::new(),
896                    visible: true,
897                },
898            ],
899            theme: DashboardTheme {
900                primary_color: "#007acc".to_string(),
901                secondary_color: "#6c757d".to_string(),
902                background_color: "#ffffff".to_string(),
903                text_color: "#333333".to_string(),
904                dark_mode: false,
905            },
906        }
907    }
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913
914    /// Push an event with an explicit timestamp (the public API always stamps
915    /// `Utc::now()`), so trend windows can be exercised deterministically.
916    fn push_event_at(
917        dashboard: &mut TeamDashboard,
918        event_type: ActivityType,
919        actor: Uuid,
920        timestamp: DateTime<Utc>,
921    ) {
922        dashboard.activity_feed.push(ActivityEvent {
923            id: Uuid::new_v4(),
924            event_type,
925            actor,
926            target: ActivityTarget::Report(Uuid::new_v4()),
927            timestamp,
928            description: String::new(),
929            metadata: HashMap::new(),
930        });
931    }
932
933    #[test]
934    fn peak_day_is_the_real_busiest_day_not_the_literal_monday() {
935        let mut dashboard = TeamDashboard::new(DashboardConfig::default());
936        let actor = Uuid::new_v4();
937        let now = Utc::now();
938        // Three events two days ago, one event today.
939        let busy = now - Duration::days(2);
940        for _ in 0..3 {
941            push_event_at(&mut dashboard, ActivityType::ReportShared, actor, busy);
942        }
943        push_event_at(&mut dashboard, ActivityType::CommentPosted, actor, now);
944
945        let trends = dashboard.calculate_activity_trends();
946        let expected = busy.format("%A").to_string();
947        assert_eq!(
948            trends.weekly_summary.peak_day,
949            Some(expected.clone()),
950            "peak day must be the real argmax ({expected}), not a constant"
951        );
952    }
953
954    #[test]
955    fn peak_day_is_absent_when_nothing_happened() {
956        let dashboard = TeamDashboard::new(DashboardConfig::default());
957        let trends = dashboard.calculate_activity_trends();
958        assert_eq!(
959            trends.weekly_summary.peak_day, None,
960            "no activity => no peak day"
961        );
962        assert_eq!(
963            trends.growth_rate, None,
964            "no prior week => growth is undefined"
965        );
966    }
967
968    #[test]
969    fn growth_rate_compares_this_week_against_the_previous_week() {
970        let mut dashboard = TeamDashboard::new(DashboardConfig::default());
971        let actor = Uuid::new_v4();
972        let now = Utc::now();
973        // 2 events in the prior week, 3 in the current week => +50%.
974        for _ in 0..2 {
975            push_event_at(
976                &mut dashboard,
977                ActivityType::ReportShared,
978                actor,
979                now - Duration::days(10),
980            );
981        }
982        for _ in 0..3 {
983            push_event_at(
984                &mut dashboard,
985                ActivityType::ReportShared,
986                actor,
987                now - Duration::days(2),
988            );
989        }
990        let trends = dashboard.calculate_activity_trends();
991        let rate = trends.growth_rate.expect("a prior week exists, so growth is defined");
992        assert!((rate - 50.0).abs() < 1e-9, "expected +50%, got {rate}");
993    }
994
995    #[test]
996    fn growth_rate_can_be_negative() {
997        let mut dashboard = TeamDashboard::new(DashboardConfig::default());
998        let actor = Uuid::new_v4();
999        let now = Utc::now();
1000        for _ in 0..4 {
1001            push_event_at(
1002                &mut dashboard,
1003                ActivityType::ReportShared,
1004                actor,
1005                now - Duration::days(9),
1006            );
1007        }
1008        push_event_at(
1009            &mut dashboard,
1010            ActivityType::ReportShared,
1011            actor,
1012            now - Duration::days(1),
1013        );
1014        let rate = dashboard.calculate_activity_trends().growth_rate.expect("prior week non-empty");
1015        assert!((rate + 75.0).abs() < 1e-9, "1 vs 4 is -75%, got {rate}");
1016    }
1017
1018    #[test]
1019    fn collaboration_score_responds_to_stats_and_stays_bounded() {
1020        let mut dashboard = TeamDashboard::new(DashboardConfig::default());
1021        let empty = crate::collaboration::CollaborationStats {
1022            total_reports: 0,
1023            total_annotations: 0,
1024            total_comments: 0,
1025            active_sessions: 0,
1026            team_size: 0,
1027            reports_per_member: 0.0,
1028            annotations_per_report: 0.0,
1029        };
1030        // The old formula returned 50.0 here (a magic baseline) regardless of
1031        // the fact that nothing at all had happened.
1032        assert_eq!(dashboard.calculate_collaboration_score(&empty), 0.0);
1033
1034        let actor = Uuid::new_v4();
1035        push_event_at(
1036            &mut dashboard,
1037            ActivityType::ReportShared,
1038            actor,
1039            Utc::now(),
1040        );
1041        let healthy = crate::collaboration::CollaborationStats {
1042            total_reports: 10,
1043            total_annotations: 20,
1044            total_comments: 20,
1045            active_sessions: 2,
1046            team_size: 1,
1047            reports_per_member: 10.0,
1048            annotations_per_report: 2.0,
1049        };
1050        let score = dashboard.calculate_collaboration_score(&healthy);
1051        assert!(
1052            (score - 100.0).abs() < 1e-9,
1053            "all three sub-scores saturate: {score}"
1054        );
1055
1056        // The old formula grew past 100 once the feed exceeded 500 entries.
1057        for _ in 0..600 {
1058            push_event_at(
1059                &mut dashboard,
1060                ActivityType::CommentPosted,
1061                actor,
1062                Utc::now(),
1063            );
1064        }
1065        let score = dashboard.calculate_collaboration_score(&healthy);
1066        assert!(
1067            (0.0..=100.0).contains(&score),
1068            "score must stay bounded: {score}"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_dashboard_creation() {
1074        let config = DashboardConfig::default();
1075        let dashboard = TeamDashboard::new(config);
1076
1077        assert_eq!(dashboard.activity_feed.len(), 0);
1078        assert_eq!(dashboard.metrics.active_members, 0);
1079    }
1080
1081    #[test]
1082    fn test_activity_event_addition() {
1083        let config = DashboardConfig::default();
1084        let mut dashboard = TeamDashboard::new(config);
1085
1086        let actor = Uuid::new_v4();
1087        let target = ActivityTarget::Report(Uuid::new_v4());
1088
1089        let event_id = dashboard.add_activity_event(
1090            ActivityType::ReportShared,
1091            actor,
1092            target,
1093            "Shared debugging report".to_string(),
1094        );
1095
1096        assert_eq!(dashboard.activity_feed.len(), 1);
1097        assert_eq!(dashboard.activity_feed[0].id, event_id);
1098    }
1099
1100    #[test]
1101    fn test_notification_system() {
1102        let mut notification_system = NotificationSystem::new();
1103
1104        let notification_id = notification_system.send_notification(
1105            NotificationType::NewReport,
1106            "New Report".to_string(),
1107            "A new debugging report has been shared".to_string(),
1108            NotificationPriority::Normal,
1109            vec![Uuid::new_v4()],
1110        );
1111
1112        assert_eq!(notification_system.notifications.len(), 1);
1113        assert_eq!(notification_system.notifications[0].id, notification_id);
1114    }
1115
1116    #[test]
1117    fn test_activity_summary() {
1118        let config = DashboardConfig::default();
1119        let mut dashboard = TeamDashboard::new(config);
1120
1121        // Add some test activities
1122        for i in 0..5 {
1123            dashboard.add_activity_event(
1124                ActivityType::ReportShared,
1125                Uuid::new_v4(),
1126                ActivityTarget::Report(Uuid::new_v4()),
1127                format!("Test activity {}", i),
1128            );
1129        }
1130
1131        let summary = dashboard.get_activity_summary(7);
1132        assert_eq!(summary.total_activities, 5);
1133        assert_eq!(summary.period_days, 7);
1134    }
1135}