Skip to main content

chasm/analytics/
reports.rs

1// Copyright (c) 2024-2027 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Report generation module
4//!
5//! Generates PDF and CSV reports for analytics data.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::io::Write;
10use uuid::Uuid;
11
12use super::dashboard::{TeamDashboard, AnalyticsPeriod, MemberStats, ProviderStats};
13
14// ============================================================================
15// Report Types
16// ============================================================================
17
18/// Report format
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum ReportFormat {
22    Pdf,
23    Csv,
24    Json,
25    Html,
26}
27
28/// Report type
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ReportType {
32    /// Full team analytics report
33    TeamAnalytics,
34    /// Member activity report
35    MemberActivity,
36    /// Provider usage report
37    ProviderUsage,
38    /// Session summary report
39    SessionSummary,
40    /// Collaboration report
41    Collaboration,
42}
43
44/// Report request
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ReportRequest {
47    /// Team ID
48    pub team_id: Uuid,
49    /// Report type
50    pub report_type: ReportType,
51    /// Report format
52    pub format: ReportFormat,
53    /// Time period
54    pub period: AnalyticsPeriod,
55    /// Custom start date (for custom period)
56    pub start_date: Option<DateTime<Utc>>,
57    /// Custom end date (for custom period)
58    pub end_date: Option<DateTime<Utc>>,
59    /// Include detailed breakdowns
60    pub include_details: bool,
61    /// Requested by user ID
62    pub requested_by: Uuid,
63}
64
65/// Generated report
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Report {
68    /// Report ID
69    pub id: Uuid,
70    /// Report type
71    pub report_type: ReportType,
72    /// Report format
73    pub format: ReportFormat,
74    /// Team ID
75    pub team_id: Uuid,
76    /// Time period
77    pub period: AnalyticsPeriod,
78    /// Generated at
79    pub generated_at: DateTime<Utc>,
80    /// Report title
81    pub title: String,
82    /// File name
83    pub filename: String,
84    /// Content (base64 encoded for binary formats)
85    pub content: String,
86    /// Content size in bytes
87    pub size_bytes: usize,
88}
89
90// ============================================================================
91// Report Generator
92// ============================================================================
93
94/// Report generator
95pub struct ReportGenerator;
96
97impl ReportGenerator {
98    /// Create a new report generator
99    pub fn new() -> Self {
100        Self
101    }
102
103    /// Generate a report from dashboard data
104    pub fn generate(&self, request: &ReportRequest, dashboard: &TeamDashboard) -> Report {
105        let content = match request.format {
106            ReportFormat::Csv => self.generate_csv(request, dashboard),
107            ReportFormat::Json => self.generate_json(request, dashboard),
108            ReportFormat::Html => self.generate_html(request, dashboard),
109            ReportFormat::Pdf => self.generate_pdf_placeholder(request, dashboard),
110        };
111
112        let title = self.get_report_title(request);
113        let filename = self.get_filename(request);
114
115        Report {
116            id: Uuid::new_v4(),
117            report_type: request.report_type,
118            format: request.format,
119            team_id: request.team_id,
120            period: request.period,
121            generated_at: Utc::now(),
122            title,
123            filename,
124            size_bytes: content.len(),
125            content,
126        }
127    }
128
129    /// Generate CSV report
130    fn generate_csv(&self, request: &ReportRequest, dashboard: &TeamDashboard) -> String {
131        let mut csv = String::new();
132
133        match request.report_type {
134            ReportType::TeamAnalytics => {
135                // Overview
136                csv.push_str("Team Analytics Report\n\n");
137                csv.push_str("Metric,Value,Change\n");
138                csv.push_str(&format!(
139                    "Total Sessions,{},{:.1}%\n",
140                    dashboard.overview.total_sessions,
141                    dashboard.overview.sessions_change
142                ));
143                csv.push_str(&format!(
144                    "Total Messages,{},{:.1}%\n",
145                    dashboard.overview.total_messages,
146                    dashboard.overview.messages_change
147                ));
148                csv.push_str(&format!(
149                    "Total Tokens,{},{:.1}%\n",
150                    dashboard.overview.total_tokens,
151                    dashboard.overview.tokens_change
152                ));
153                csv.push_str(&format!(
154                    "Active Members,{},{:.1}%\n",
155                    dashboard.overview.active_members,
156                    dashboard.overview.active_members_change
157                ));
158                csv.push_str(&format!(
159                    "Avg Sessions/Member,{:.2}\n",
160                    dashboard.overview.avg_sessions_per_member
161                ));
162                csv.push_str(&format!(
163                    "Avg Messages/Session,{:.2}\n",
164                    dashboard.overview.avg_messages_per_session
165                ));
166
167                if request.include_details {
168                    // Provider breakdown
169                    csv.push_str("\nProvider Breakdown\n");
170                    csv.push_str("Provider,Sessions,Percentage,Messages,Tokens\n");
171                    for provider in &dashboard.provider_breakdown {
172                        csv.push_str(&format!(
173                            "{},{},{:.1}%,{},{}\n",
174                            provider.provider,
175                            provider.sessions,
176                            provider.session_percentage,
177                            provider.messages,
178                            provider.tokens
179                        ));
180                    }
181                }
182            }
183            ReportType::MemberActivity => {
184                csv.push_str("Member Activity Report\n\n");
185                csv.push_str("Member,Sessions,Messages,Tokens,Avg Session Length,Activity Score,Last Active\n");
186                for member in &dashboard.member_stats {
187                    csv.push_str(&format!(
188                        "{},{},{},{},{:.2},{},{}\n",
189                        member.display_name,
190                        member.sessions,
191                        member.messages,
192                        member.tokens,
193                        member.avg_session_length,
194                        member.activity_score,
195                        member.last_active.map(|d| d.to_rfc3339()).unwrap_or_default()
196                    ));
197                }
198            }
199            ReportType::ProviderUsage => {
200                csv.push_str("Provider Usage Report\n\n");
201                csv.push_str("Provider,Sessions,Percentage,Messages,Tokens\n");
202                for provider in &dashboard.provider_breakdown {
203                    csv.push_str(&format!(
204                        "{},{},{:.1}%,{},{}\n",
205                        provider.provider,
206                        provider.sessions,
207                        provider.session_percentage,
208                        provider.messages,
209                        provider.tokens
210                    ));
211                }
212            }
213            ReportType::SessionSummary => {
214                csv.push_str("Session Summary Report\n\n");
215                csv.push_str("Metric,Value\n");
216                csv.push_str(&format!(
217                    "Average Messages,{:.2}\n",
218                    dashboard.session_analytics.avg_messages
219                ));
220                csv.push_str(&format!(
221                    "Average Tokens,{:.2}\n",
222                    dashboard.session_analytics.avg_tokens
223                ));
224                csv.push_str(&format!(
225                    "Short Sessions (1-5 msgs),{}\n",
226                    dashboard.session_analytics.length_distribution.short
227                ));
228                csv.push_str(&format!(
229                    "Medium Sessions (6-20 msgs),{}\n",
230                    dashboard.session_analytics.length_distribution.medium
231                ));
232                csv.push_str(&format!(
233                    "Long Sessions (21-50 msgs),{}\n",
234                    dashboard.session_analytics.length_distribution.long
235                ));
236                csv.push_str(&format!(
237                    "Very Long Sessions (51+ msgs),{}\n",
238                    dashboard.session_analytics.length_distribution.very_long
239                ));
240
241                csv.push_str("\nTop Tags\n");
242                csv.push_str("Tag,Count,Percentage\n");
243                for tag in &dashboard.session_analytics.top_tags {
244                    csv.push_str(&format!(
245                        "{},{},{:.1}%\n",
246                        tag.tag, tag.count, tag.percentage
247                    ));
248                }
249            }
250            ReportType::Collaboration => {
251                csv.push_str("Collaboration Report\n\n");
252                csv.push_str("Metric,Value\n");
253                csv.push_str(&format!(
254                    "Shared Sessions,{}\n",
255                    dashboard.collaboration.shared_sessions
256                ));
257                csv.push_str(&format!(
258                    "Total Comments,{}\n",
259                    dashboard.collaboration.total_comments
260                ));
261                csv.push_str(&format!(
262                    "Active Collaborations,{}\n",
263                    dashboard.collaboration.active_collaborations
264                ));
265            }
266        }
267
268        csv
269    }
270
271    /// Generate JSON report
272    fn generate_json(&self, request: &ReportRequest, dashboard: &TeamDashboard) -> String {
273        match request.report_type {
274            ReportType::TeamAnalytics => {
275                serde_json::to_string_pretty(dashboard).unwrap_or_default()
276            }
277            ReportType::MemberActivity => {
278                serde_json::to_string_pretty(&dashboard.member_stats).unwrap_or_default()
279            }
280            ReportType::ProviderUsage => {
281                serde_json::to_string_pretty(&dashboard.provider_breakdown).unwrap_or_default()
282            }
283            ReportType::SessionSummary => {
284                serde_json::to_string_pretty(&dashboard.session_analytics).unwrap_or_default()
285            }
286            ReportType::Collaboration => {
287                serde_json::to_string_pretty(&dashboard.collaboration).unwrap_or_default()
288            }
289        }
290    }
291
292    /// Generate HTML report
293    fn generate_html(&self, request: &ReportRequest, dashboard: &TeamDashboard) -> String {
294        let title = self.get_report_title(request);
295        let mut html = String::new();
296
297        html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
298        html.push_str(&format!("<title>{}</title>\n", title));
299        html.push_str("<style>\n");
300        html.push_str("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; color: #333; }\n");
301        html.push_str("h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }\n");
302        html.push_str("h2 { color: #1f2937; margin-top: 30px; }\n");
303        html.push_str("table { border-collapse: collapse; width: 100%; margin: 20px 0; }\n");
304        html.push_str("th, td { border: 1px solid #e5e7eb; padding: 12px; text-align: left; }\n");
305        html.push_str("th { background: #f3f4f6; font-weight: 600; }\n");
306        html.push_str("tr:nth-child(even) { background: #f9fafb; }\n");
307        html.push_str(".metric-card { display: inline-block; background: #f3f4f6; padding: 20px; margin: 10px; border-radius: 8px; min-width: 150px; }\n");
308        html.push_str(".metric-value { font-size: 32px; font-weight: bold; color: #2563eb; }\n");
309        html.push_str(".metric-label { color: #6b7280; margin-top: 5px; }\n");
310        html.push_str(".change-positive { color: #059669; }\n");
311        html.push_str(".change-negative { color: #dc2626; }\n");
312        html.push_str(".footer { margin-top: 40px; color: #9ca3af; font-size: 12px; }\n");
313        html.push_str("</style>\n</head>\n<body>\n");
314
315        html.push_str(&format!("<h1>{}</h1>\n", title));
316        html.push_str(&format!(
317            "<p>Generated: {} | Period: {:?}</p>\n",
318            dashboard.generated_at.format("%Y-%m-%d %H:%M UTC"),
319            dashboard.period
320        ));
321
322        match request.report_type {
323            ReportType::TeamAnalytics => {
324                // Overview cards
325                html.push_str("<h2>Overview</h2>\n<div>\n");
326                html.push_str(&self.metric_card(
327                    "Total Sessions",
328                    &dashboard.overview.total_sessions.to_string(),
329                    dashboard.overview.sessions_change,
330                ));
331                html.push_str(&self.metric_card(
332                    "Total Messages",
333                    &dashboard.overview.total_messages.to_string(),
334                    dashboard.overview.messages_change,
335                ));
336                html.push_str(&self.metric_card(
337                    "Active Members",
338                    &dashboard.overview.active_members.to_string(),
339                    dashboard.overview.active_members_change,
340                ));
341                html.push_str("</div>\n");
342
343                if request.include_details {
344                    // Provider table
345                    html.push_str("<h2>Provider Breakdown</h2>\n");
346                    html.push_str(
347                        "<table>\n<tr><th>Provider</th><th>Sessions</th><th>%</th><th>Messages</th><th>Tokens</th></tr>\n"
348                    );
349                    for p in &dashboard.provider_breakdown {
350                        html.push_str(&format!(
351                            "<tr><td>{}</td><td>{}</td><td>{:.1}%</td><td>{}</td><td>{}</td></tr>\n",
352                            p.provider, p.sessions, p.session_percentage, p.messages, p.tokens
353                        ));
354                    }
355                    html.push_str("</table>\n");
356                }
357            }
358            ReportType::MemberActivity => {
359                html.push_str("<h2>Member Activity</h2>\n");
360                html.push_str(
361                    "<table>\n<tr><th>Member</th><th>Sessions</th><th>Messages</th><th>Tokens</th><th>Avg Length</th><th>Score</th></tr>\n"
362                );
363                for m in &dashboard.member_stats {
364                    html.push_str(&format!(
365                        "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{:.1}</td><td>{}</td></tr>\n",
366                        m.display_name, m.sessions, m.messages, m.tokens, m.avg_session_length, m.activity_score
367                    ));
368                }
369                html.push_str("</table>\n");
370            }
371            ReportType::ProviderUsage => {
372                html.push_str("<h2>Provider Usage</h2>\n");
373                html.push_str(
374                    "<table>\n<tr><th>Provider</th><th>Sessions</th><th>%</th><th>Messages</th><th>Tokens</th></tr>\n"
375                );
376                for p in &dashboard.provider_breakdown {
377                    html.push_str(&format!(
378                        "<tr><td>{}</td><td>{}</td><td>{:.1}%</td><td>{}</td><td>{}</td></tr>\n",
379                        p.provider, p.sessions, p.session_percentage, p.messages, p.tokens
380                    ));
381                }
382                html.push_str("</table>\n");
383            }
384            ReportType::SessionSummary => {
385                html.push_str("<h2>Session Statistics</h2>\n<div>\n");
386                html.push_str(&self.metric_card(
387                    "Avg Messages",
388                    &format!("{:.1}", dashboard.session_analytics.avg_messages),
389                    0.0,
390                ));
391                html.push_str(&self.metric_card(
392                    "Avg Tokens",
393                    &format!("{:.0}", dashboard.session_analytics.avg_tokens),
394                    0.0,
395                ));
396                html.push_str("</div>\n");
397
398                html.push_str("<h2>Session Length Distribution</h2>\n");
399                html.push_str("<table>\n<tr><th>Length</th><th>Count</th></tr>\n");
400                html.push_str(&format!(
401                    "<tr><td>Short (1-5)</td><td>{}</td></tr>\n",
402                    dashboard.session_analytics.length_distribution.short
403                ));
404                html.push_str(&format!(
405                    "<tr><td>Medium (6-20)</td><td>{}</td></tr>\n",
406                    dashboard.session_analytics.length_distribution.medium
407                ));
408                html.push_str(&format!(
409                    "<tr><td>Long (21-50)</td><td>{}</td></tr>\n",
410                    dashboard.session_analytics.length_distribution.long
411                ));
412                html.push_str(&format!(
413                    "<tr><td>Very Long (51+)</td><td>{}</td></tr>\n",
414                    dashboard.session_analytics.length_distribution.very_long
415                ));
416                html.push_str("</table>\n");
417            }
418            ReportType::Collaboration => {
419                html.push_str("<h2>Collaboration Metrics</h2>\n<div>\n");
420                html.push_str(&self.metric_card(
421                    "Shared Sessions",
422                    &dashboard.collaboration.shared_sessions.to_string(),
423                    0.0,
424                ));
425                html.push_str(&self.metric_card(
426                    "Total Comments",
427                    &dashboard.collaboration.total_comments.to_string(),
428                    0.0,
429                ));
430                html.push_str("</div>\n");
431            }
432        }
433
434        html.push_str("<div class=\"footer\">Generated by Chasm Analytics</div>\n");
435        html.push_str("</body>\n</html>");
436
437        html
438    }
439
440    /// Generate PDF placeholder (actual PDF generation would require a PDF library)
441    fn generate_pdf_placeholder(&self, request: &ReportRequest, dashboard: &TeamDashboard) -> String {
442        // In production, use a library like printpdf or wkhtmltopdf
443        // For now, return HTML that can be converted to PDF
444        self.generate_html(request, dashboard)
445    }
446
447    /// Create a metric card HTML
448    fn metric_card(&self, label: &str, value: &str, change: f64) -> String {
449        let change_class = if change >= 0.0 {
450            "change-positive"
451        } else {
452            "change-negative"
453        };
454        let change_str = if change != 0.0 {
455            format!(
456                " <span class=\"{}\">{:+.1}%</span>",
457                change_class, change
458            )
459        } else {
460            String::new()
461        };
462
463        format!(
464            "<div class=\"metric-card\"><div class=\"metric-value\">{}{}</div><div class=\"metric-label\">{}</div></div>\n",
465            value, change_str, label
466        )
467    }
468
469    /// Get report title
470    fn get_report_title(&self, request: &ReportRequest) -> String {
471        match request.report_type {
472            ReportType::TeamAnalytics => "Team Analytics Report".to_string(),
473            ReportType::MemberActivity => "Member Activity Report".to_string(),
474            ReportType::ProviderUsage => "Provider Usage Report".to_string(),
475            ReportType::SessionSummary => "Session Summary Report".to_string(),
476            ReportType::Collaboration => "Collaboration Report".to_string(),
477        }
478    }
479
480    /// Get filename for report
481    fn get_filename(&self, request: &ReportRequest) -> String {
482        let type_str = match request.report_type {
483            ReportType::TeamAnalytics => "team-analytics",
484            ReportType::MemberActivity => "member-activity",
485            ReportType::ProviderUsage => "provider-usage",
486            ReportType::SessionSummary => "session-summary",
487            ReportType::Collaboration => "collaboration",
488        };
489
490        let ext = match request.format {
491            ReportFormat::Csv => "csv",
492            ReportFormat::Json => "json",
493            ReportFormat::Html => "html",
494            ReportFormat::Pdf => "pdf",
495        };
496
497        let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
498        format!("chasm-{}-{}.{}", type_str, timestamp, ext)
499    }
500}
501
502impl Default for ReportGenerator {
503    fn default() -> Self {
504        Self::new()
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::analytics::dashboard::*;
512
513    fn create_test_dashboard() -> TeamDashboard {
514        TeamDashboard {
515            team_id: Uuid::new_v4(),
516            generated_at: Utc::now(),
517            period: AnalyticsPeriod::Last7Days,
518            overview: OverviewMetrics {
519                total_sessions: 100,
520                sessions_change: 10.5,
521                total_messages: 1000,
522                messages_change: 15.2,
523                total_tokens: 50000,
524                tokens_change: 8.3,
525                active_members: 5,
526                active_members_change: 0.0,
527                avg_sessions_per_member: 20.0,
528                avg_messages_per_session: 10.0,
529            },
530            trends: UsageTrends {
531                daily_sessions: vec![],
532                daily_messages: vec![],
533                daily_tokens: vec![],
534                hourly_distribution: vec![0; 24],
535                weekday_distribution: vec![0; 7],
536            },
537            member_stats: vec![MemberStats {
538                member_id: Uuid::new_v4(),
539                display_name: "Test User".to_string(),
540                sessions: 50,
541                messages: 500,
542                tokens: 25000,
543                favorite_provider: Some("copilot".to_string()),
544                avg_session_length: 10.0,
545                last_active: Some(Utc::now()),
546                activity_score: 85,
547            }],
548            provider_breakdown: vec![ProviderStats {
549                provider: "copilot".to_string(),
550                sessions: 60,
551                session_percentage: 60.0,
552                messages: 600,
553                tokens: 30000,
554                top_models: vec![],
555            }],
556            session_analytics: SessionAnalytics {
557                avg_duration_minutes: 15.0,
558                avg_messages: 10.0,
559                avg_tokens: 500.0,
560                length_distribution: SessionLengthDistribution {
561                    short: 20,
562                    medium: 50,
563                    long: 25,
564                    very_long: 5,
565                },
566                top_tags: vec![],
567                quality_distribution: QualityDistribution {
568                    excellent: 30,
569                    good: 40,
570                    average: 25,
571                    below_average: 5,
572                },
573            },
574            collaboration: CollaborationMetrics {
575                shared_sessions: 20,
576                total_comments: 100,
577                active_collaborations: 5,
578                top_collaborators: vec![],
579            },
580        }
581    }
582
583    #[test]
584    fn test_generate_csv_report() {
585        let generator = ReportGenerator::new();
586        let dashboard = create_test_dashboard();
587        let request = ReportRequest {
588            team_id: dashboard.team_id,
589            report_type: ReportType::TeamAnalytics,
590            format: ReportFormat::Csv,
591            period: AnalyticsPeriod::Last7Days,
592            start_date: None,
593            end_date: None,
594            include_details: true,
595            requested_by: Uuid::new_v4(),
596        };
597
598        let report = generator.generate(&request, &dashboard);
599        assert!(report.content.contains("Total Sessions,100"));
600        assert!(report.filename.ends_with(".csv"));
601    }
602
603    #[test]
604    fn test_generate_html_report() {
605        let generator = ReportGenerator::new();
606        let dashboard = create_test_dashboard();
607        let request = ReportRequest {
608            team_id: dashboard.team_id,
609            report_type: ReportType::MemberActivity,
610            format: ReportFormat::Html,
611            period: AnalyticsPeriod::Last7Days,
612            start_date: None,
613            end_date: None,
614            include_details: true,
615            requested_by: Uuid::new_v4(),
616        };
617
618        let report = generator.generate(&request, &dashboard);
619        assert!(report.content.contains("<html>"));
620        assert!(report.content.contains("Test User"));
621        assert!(report.filename.ends_with(".html"));
622    }
623}