nu_analytics/core/report/formats/
html.rs

1//! HTML report generator
2//!
3//! Generates curriculum reports in HTML format with grid-based visualization.
4//! The generated HTML is self-contained with embedded CSS and JavaScript.
5
6use crate::core::metrics::CourseMetrics;
7use crate::core::report::{ReportContext, ReportGenerator};
8use std::error::Error;
9use std::fmt::Write;
10use std::fs;
11use std::path::Path;
12
13/// Embedded HTML report template
14const HTML_TEMPLATE: &str = include_str!("../templates/report.html");
15
16/// HTML report generator with interactive visualizations
17pub struct HtmlReporter;
18
19impl HtmlReporter {
20    /// Create a new HTML reporter
21    #[must_use]
22    pub const fn new() -> Self {
23        Self
24    }
25
26    /// Render the report using template substitution
27    #[allow(clippy::unused_self)]
28    fn render_template(&self, ctx: &ReportContext) -> String {
29        let mut output = HTML_TEMPLATE.to_string();
30
31        // Substitute header metadata
32        output = output.replace("{{plan_name}}", &ctx.plan.name);
33        output = output.replace("{{institution}}", ctx.institution_name());
34        output = output.replace("{{degree_name}}", &ctx.degree_name());
35        output = output.replace("{{system_type}}", ctx.system_type());
36        output = output.replace("{{cip_code}}", ctx.cip_code());
37        output = output.replace("{{years}}", &format!("{:.0}", ctx.years()));
38        output = output.replace("{{total_credits}}", &format!("{:.1}", ctx.total_credits()));
39        output = output.replace("{{course_count}}", &ctx.course_count().to_string());
40
41        // Substitute summary metrics
42        output = output.replace(
43            "{{total_complexity}}",
44            &ctx.summary.total_complexity.to_string(),
45        );
46        output = output.replace("{{longest_delay}}", &ctx.summary.longest_delay.to_string());
47        output = output.replace(
48            "{{longest_delay_course}}",
49            &ctx.summary.longest_delay_course,
50        );
51        output = output.replace(
52            "{{highest_centrality}}",
53            &ctx.summary.highest_centrality.to_string(),
54        );
55        output = output.replace(
56            "{{highest_centrality_course}}",
57            &ctx.summary.highest_centrality_course,
58        );
59
60        // Generate longest delay path
61        let delay_path = if ctx.summary.longest_delay_path.is_empty() {
62            "N/A".to_string()
63        } else {
64            ctx.summary.longest_delay_path.join(" → ")
65        };
66        output = output.replace("{{longest_delay_path}}", &delay_path);
67
68        // Generate term schedule HTML
69        let schedule_html = Self::generate_schedule_html(ctx);
70        output = output.replace("{{term_schedule}}", &schedule_html);
71
72        // Generate course metrics HTML
73        let metrics_html = Self::generate_metrics_html(ctx);
74        output = output.replace("{{course_metrics}}", &metrics_html);
75
76        // Generate term graph HTML (grid-based visualization)
77        let term_graph = Self::generate_term_graph(ctx);
78        output = output.replace("{{term_graph}}", &term_graph);
79
80        // Generate SVG paths with baked coordinates (server-side calculation)
81        let svg_paths = Self::generate_svg_paths(ctx);
82        output = output.replace("{{svg_paths}}", &svg_paths);
83
84        // Generate edge data for legacy JavaScript (kept for compatibility)
85        let edges = Self::generate_edge_data(ctx);
86        output = output.replace("{{graph_edges}}", &edges);
87
88        // Generate critical path IDs as JSON array for JavaScript highlighting
89        let critical_path_ids = Self::generate_critical_path_ids(ctx);
90        output = output.replace("{{critical_path_ids}}", &critical_path_ids);
91
92        output
93    }
94
95    /// Generate critical path course IDs as a JSON array
96    ///
97    /// Handles corequisite groups in the path (e.g., "(CSE1321+CSE1321L)") by
98    /// extracting all individual course IDs for JavaScript highlighting.
99    fn generate_critical_path_ids(ctx: &ReportContext) -> String {
100        let mut all_ids: Vec<String> = Vec::new();
101
102        for entry in &ctx.summary.longest_delay_path {
103            // Check if this is a grouped corequisite entry like "(A+B+C)"
104            let trimmed = entry.trim();
105            if trimmed.starts_with('(') && trimmed.ends_with(')') {
106                // Extract individual course IDs from the group
107                let inner = &trimmed[1..trimmed.len() - 1]; // Remove parens
108                for id in inner.split('+') {
109                    all_ids.push(format!("\"{}\"", id.trim()));
110                }
111            } else {
112                // Regular single course ID
113                all_ids.push(format!("\"{trimmed}\""));
114            }
115        }
116
117        format!("[{}]", all_ids.join(", "))
118    }
119
120    /// Generate HTML for the grid-based term visualization
121    fn generate_term_graph(ctx: &ReportContext) -> String {
122        let mut html = String::new();
123
124        for term in &ctx.term_plan.terms {
125            let _ = writeln!(html, "<div class=\"term-column\">");
126            let _ = writeln!(
127                html,
128                "  <div class=\"term-header\">Semester {}</div>",
129                term.number
130            );
131            let _ = writeln!(html, "  <div class=\"term-courses\">");
132
133            for course_key in &term.courses {
134                let course = ctx.school.get_course(course_key);
135                let metrics = ctx.metrics.get(course_key);
136
137                let name = course.map_or("", |c| &c.name);
138                let short_name = if name.len() > 25 { &name[..22] } else { name };
139                let complexity = metrics.map_or(0, |m| m.complexity);
140
141                let complexity_class = match complexity {
142                    0..=5 => "complexity-low",
143                    6..=15 => "complexity-medium",
144                    _ => "complexity-high",
145                };
146
147                let _ = writeln!(
148                    html,
149                    "    <div class=\"course-node\" data-course-id=\"{course_key}\">"
150                );
151                let _ = writeln!(
152                    html,
153                    "      <span class=\"complexity-badge {complexity_class}\">{complexity}</span>"
154                );
155                let _ = writeln!(html, "      <div class=\"course-id\">{course_key}</div>");
156                let _ = writeln!(html, "      <div class=\"course-name\">{short_name}</div>");
157                let _ = writeln!(html, "    </div>");
158            }
159
160            let _ = writeln!(html, "  </div>");
161            let _ = writeln!(html, "</div>");
162        }
163
164        html
165    }
166
167    /// Generate edge data as JSON for SVG connections
168    fn generate_edge_data(ctx: &ReportContext) -> String {
169        let mut edges = Vec::new();
170
171        // Prerequisite edges
172        for (course, prereqs) in &ctx.dag.dependencies {
173            if !ctx.plan.courses.contains(course) {
174                continue;
175            }
176            for prereq in prereqs {
177                if !ctx.plan.courses.contains(prereq) {
178                    continue;
179                }
180                edges.push(format!(
181                    "{{ \"from\": \"{prereq}\", \"to\": \"{course}\", \"dashes\": false }}"
182                ));
183            }
184        }
185
186        // Corequisite edges (dashed)
187        for (course, coreqs) in &ctx.dag.corequisites {
188            if !ctx.plan.courses.contains(course) {
189                continue;
190            }
191            for coreq in coreqs {
192                if !ctx.plan.courses.contains(coreq) {
193                    continue;
194                }
195                edges.push(format!(
196                    "{{ \"from\": \"{coreq}\", \"to\": \"{course}\", \"dashes\": true }}"
197                ));
198            }
199        }
200
201        format!("[{}]", edges.join(", "))
202    }
203
204    /// Generate the term-by-term schedule as HTML table rows
205    fn generate_schedule_html(ctx: &ReportContext) -> String {
206        let mut html = String::new();
207
208        for term in &ctx.term_plan.terms {
209            if term.courses.is_empty() {
210                continue;
211            }
212
213            let courses_html: Vec<String> = term
214                .courses
215                .iter()
216                .map(|key| {
217                    let name = ctx.school.get_course(key).map_or(key.as_str(), |c| &c.name);
218                    format!("<span class=\"course-badge\">{key}</span> {name}")
219                })
220                .collect();
221
222            let _ = writeln!(
223                html,
224                "<tr><td>{}</td><td>{}</td><td>{:.1}</td></tr>",
225                term.number,
226                courses_html.join("<br>"),
227                term.total_credits
228            );
229        }
230
231        // Add unscheduled courses if any
232        if !ctx.term_plan.unscheduled.is_empty() {
233            let _ = writeln!(
234                html,
235                "<tr class=\"unscheduled\"><td>⚠️</td><td>{}</td><td>-</td></tr>",
236                ctx.term_plan.unscheduled.join(", ")
237            );
238        }
239
240        html
241    }
242
243    /// Generate the course metrics as HTML table rows
244    fn generate_metrics_html(ctx: &ReportContext) -> String {
245        let mut html = String::new();
246
247        // Sort courses by complexity (descending)
248        let mut courses: Vec<_> = ctx.plan.courses.iter().collect();
249        courses.sort_by(|a, b| {
250            let ma = ctx.metrics.get(*a).map_or(0, |m| m.complexity);
251            let mb = ctx.metrics.get(*b).map_or(0, |m| m.complexity);
252            mb.cmp(&ma)
253        });
254
255        for course_key in courses {
256            let course = ctx.school.get_course(course_key);
257            let metrics = ctx.metrics.get(course_key);
258
259            let name = course.map_or("-", |c| &c.name);
260            let credits = course.map_or(0.0, |c| c.credit_hours);
261            let (complexity, blocking, delay, centrality) =
262                metrics.map_or((0, 0, 0, 0), CourseMetrics::as_export_tuple);
263
264            // Add complexity class for color coding
265            let complexity_class = match complexity {
266                0..=5 => "low",
267                6..=15 => "medium",
268                _ => "high",
269            };
270
271            let _ = writeln!(
272                html,
273                "<tr class=\"complexity-{complexity_class}\"><td>{course_key}</td><td>{name}</td><td>{credits:.1}</td><td>{complexity}</td><td>{blocking}</td><td>{delay}</td><td>{centrality}</td></tr>"
274            );
275        }
276
277        html
278    }
279
280    /// Generate SVG paths with baked coordinates (server-side calculation)
281    /// This avoids JavaScript positioning issues when printing to PDF
282    fn generate_svg_paths(ctx: &ReportContext) -> String {
283        // Grid layout constants
284        const TERM_WIDTH: f32 = 130.0;
285        const TERM_X_OFFSET: f32 = 20.0;
286        const COURSE_HEIGHT: f32 = 115.0;
287        const COURSE_Y_OFFSET: f32 = 50.0;
288        const COURSE_CENTER_X: f32 = 65.0;
289        const COURSE_CENTER_Y: f32 = 30.0;
290
291        // Build position map: course_id -> (x, y)
292        let mut positions = std::collections::HashMap::new();
293        for (term_idx, term) in ctx.term_plan.terms.iter().enumerate() {
294            #[allow(clippy::cast_precision_loss)]
295            let term_x = (term_idx as f32).mul_add(TERM_WIDTH, TERM_X_OFFSET);
296            for (course_idx, course_key) in term.courses.iter().enumerate() {
297                #[allow(clippy::cast_precision_loss)]
298                let course_y = (course_idx as f32).mul_add(COURSE_HEIGHT, COURSE_Y_OFFSET);
299                positions.insert(
300                    course_key.clone(),
301                    (term_x + COURSE_CENTER_X, course_y + COURSE_CENTER_Y),
302                );
303            }
304        }
305
306        let mut paths = Vec::new();
307
308        // Generate prerequisite paths
309        for (course, prereqs) in &ctx.dag.dependencies {
310            if !ctx.plan.courses.contains(course) || !positions.contains_key(course) {
311                continue;
312            }
313            for prereq in prereqs {
314                if !ctx.plan.courses.contains(prereq) || !positions.contains_key(prereq) {
315                    continue;
316                }
317
318                if let (Some(&(x1, y1)), Some(&(x2, y2))) =
319                    (positions.get(prereq), positions.get(course))
320                {
321                    // Curved path: quadratic Bezier from prereq to course
322                    let mid_x = f32::midpoint(x1, x2);
323                    let mid_y = f32::midpoint(y1, y2);
324                    let path = format!(
325                        "<path class=\"prereq-line\" d=\"M {x1:.1} {y1:.1} Q {mid_x:.1} {mid_y:.1} {x2:.1} {y2:.1}\" data-from=\"{prereq}\" data-to=\"{course}\"></path>"
326                    );
327                    paths.push(path);
328                }
329            }
330        }
331
332        // Generate corequisite paths (dashed)
333        for (course, coreqs) in &ctx.dag.corequisites {
334            if !ctx.plan.courses.contains(course) || !positions.contains_key(course) {
335                continue;
336            }
337            for coreq in coreqs {
338                if !ctx.plan.courses.contains(coreq) || !positions.contains_key(coreq) {
339                    continue;
340                }
341
342                if let (Some(&(x1, y1)), Some(&(x2, y2))) =
343                    (positions.get(coreq), positions.get(course))
344                {
345                    // Curved path for corequisites
346                    let mid_x = f32::midpoint(x1, x2);
347                    let mid_y = f32::midpoint(y1, y2);
348                    let path = format!(
349                        "<path class=\"coreq-line\" d=\"M {x1:.1} {y1:.1} Q {mid_x:.1} {mid_y:.1} {x2:.1} {y2:.1}\" data-from=\"{coreq}\" data-to=\"{course}\"></path>"
350                    );
351                    paths.push(path);
352                }
353            }
354        }
355
356        paths.join("\n")
357    }
358
359    /// Generate vis.js node and edge data as JSON arrays
360    /// Nodes are positioned by term (x-axis) with courses stacked vertically within each term
361    #[allow(dead_code)]
362    fn generate_graph_data(_ctx: &ReportContext) -> (String, String) {
363        // Deprecated - kept for potential future use
364        (String::from("[]"), String::from("[]"))
365    }
366}
367
368impl Default for HtmlReporter {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374impl ReportGenerator for HtmlReporter {
375    fn generate(&self, ctx: &ReportContext, output_path: &Path) -> Result<(), Box<dyn Error>> {
376        let report_content = self.render(ctx)?;
377        fs::write(output_path, report_content)?;
378        Ok(())
379    }
380
381    fn render(&self, ctx: &ReportContext) -> Result<String, Box<dyn Error>> {
382        Ok(self.render_template(ctx))
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::core::metrics::CourseMetrics;
390    use crate::core::metrics_export::CurriculumSummary;
391    use crate::core::models::{Course, Degree, Plan, School, DAG};
392    use crate::core::report::term_scheduler::TermPlan;
393    use std::collections::HashMap;
394
395    fn create_test_context() -> (
396        School,
397        Plan,
398        Degree,
399        HashMap<String, CourseMetrics>,
400        CurriculumSummary,
401        DAG,
402        TermPlan,
403    ) {
404        let mut school = School::new("Test University".to_string());
405
406        let cs101 = Course::new(
407            "Intro to CS".to_string(),
408            "CS".to_string(),
409            "101".to_string(),
410            3.0,
411        );
412        let mut cs201 = Course::new(
413            "Data Structures".to_string(),
414            "CS".to_string(),
415            "201".to_string(),
416            4.0,
417        );
418        cs201.add_prerequisite("CS101".to_string());
419
420        school.add_course(cs101);
421        school.add_course(cs201);
422
423        let degree = Degree::new(
424            "Computer Science".to_string(),
425            "BS".to_string(),
426            "11.0701".to_string(),
427            "semester".to_string(),
428        );
429
430        let mut plan = Plan::new("CS Plan".to_string(), degree.id());
431        plan.add_course("CS101".to_string());
432        plan.add_course("CS201".to_string());
433
434        let mut metrics = HashMap::new();
435        metrics.insert(
436            "CS101".to_string(),
437            CourseMetrics {
438                complexity: 3,
439                blocking: 1,
440                delay: 1,
441                centrality: 1,
442            },
443        );
444        metrics.insert(
445            "CS201".to_string(),
446            CourseMetrics {
447                complexity: 5,
448                blocking: 0,
449                delay: 2,
450                centrality: 1,
451            },
452        );
453
454        let summary = CurriculumSummary {
455            total_complexity: 8,
456            highest_centrality: 1,
457            highest_centrality_course: "CS101".to_string(),
458            longest_delay: 2,
459            longest_delay_course: "CS201".to_string(),
460            longest_delay_path: vec!["CS101".to_string(), "CS201".to_string()],
461        };
462
463        let mut dag = DAG::new();
464        dag.add_course("CS101".to_string());
465        dag.add_course("CS201".to_string());
466        dag.add_prerequisite("CS201".to_string(), "CS101");
467
468        let mut term_plan = TermPlan::new(8, false, 15.0);
469        term_plan.terms[0].add_course("CS101".to_string(), 3.0);
470        term_plan.terms[1].add_course("CS201".to_string(), 4.0);
471
472        (school, plan, degree, metrics, summary, dag, term_plan)
473    }
474
475    #[test]
476    fn test_html_reporter_new() {
477        let reporter = HtmlReporter::new();
478        // Verifies construction works - use in actual render test
479        let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
480        let ctx = ReportContext::new(
481            &school,
482            &plan,
483            Some(&degree),
484            &metrics,
485            &summary,
486            &dag,
487            &term_plan,
488        );
489        let result = reporter.render(&ctx);
490        assert!(result.is_ok());
491    }
492
493    #[test]
494    fn test_html_reporter_default() {
495        let reporter = HtmlReporter;
496        let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
497        let ctx = ReportContext::new(
498            &school,
499            &plan,
500            Some(&degree),
501            &metrics,
502            &summary,
503            &dag,
504            &term_plan,
505        );
506        let result = reporter.render(&ctx);
507        assert!(result.is_ok());
508    }
509
510    #[test]
511    fn test_render_produces_html() {
512        let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
513
514        let ctx = ReportContext::new(
515            &school,
516            &plan,
517            Some(&degree),
518            &metrics,
519            &summary,
520            &dag,
521            &term_plan,
522        );
523
524        let reporter = HtmlReporter::new();
525        let html = reporter.render(&ctx).unwrap();
526
527        // Verify key elements are present
528        assert!(html.contains("<!DOCTYPE html>"));
529        assert!(html.contains("Test University"));
530        assert!(html.contains("CS Plan"));
531        assert!(html.contains("CS101"));
532        assert!(html.contains("CS201"));
533    }
534
535    #[test]
536    fn test_generate_critical_path_ids() {
537        let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
538
539        let ctx = ReportContext::new(
540            &school,
541            &plan,
542            Some(&degree),
543            &metrics,
544            &summary,
545            &dag,
546            &term_plan,
547        );
548
549        let ids = HtmlReporter::generate_critical_path_ids(&ctx);
550
551        assert!(ids.contains("CS101"));
552        assert!(ids.contains("CS201"));
553        assert!(ids.starts_with('['));
554        assert!(ids.ends_with(']'));
555    }
556
557    #[test]
558    fn test_generate_critical_path_ids_with_corequisite_group() {
559        let summary = CurriculumSummary {
560            total_complexity: 10,
561            highest_centrality: 1,
562            highest_centrality_course: "CS101".to_string(),
563            longest_delay: 2,
564            longest_delay_course: "CS201".to_string(),
565            longest_delay_path: vec!["(CS101+CS101L)".to_string(), "CS201".to_string()],
566        };
567
568        let (school, plan, degree, metrics, _, dag, term_plan) = create_test_context();
569
570        let ctx = ReportContext::new(
571            &school,
572            &plan,
573            Some(&degree),
574            &metrics,
575            &summary,
576            &dag,
577            &term_plan,
578        );
579
580        let ids = HtmlReporter::generate_critical_path_ids(&ctx);
581
582        // Should extract both courses from the group
583        assert!(ids.contains("CS101"));
584        assert!(ids.contains("CS101L"));
585        assert!(ids.contains("CS201"));
586    }
587}