Skip to main content

utf8proj_render/
lib.rs

1//! # utf8proj-render
2//!
3//! Rendering backends for utf8proj schedules.
4//!
5//! This crate provides:
6//! - Interactive HTML Gantt chart rendering
7//! - SVG Gantt chart rendering
8//! - MermaidJS Gantt chart rendering (for Markdown/docs)
9//! - PlantUML Gantt chart rendering (for wikis and documentation)
10//! - Excel costing reports (for corporate project quoting)
11//! - Text-based output
12//! - Custom renderer trait
13//!
14//! ## Example
15//!
16//! ```rust,ignore
17//! use utf8proj_core::{Project, Schedule, Renderer};
18//! use utf8proj_render::{HtmlGanttRenderer, SvgRenderer, MermaidRenderer, PlantUmlRenderer};
19//!
20//! // Interactive HTML Gantt chart
21//! let renderer = HtmlGanttRenderer::new();
22//! let html = renderer.render(&project, &schedule)?;
23//!
24//! // Pure SVG output
25//! let svg_renderer = SvgRenderer::default();
26//! let svg = svg_renderer.render(&project, &schedule)?;
27//!
28//! // MermaidJS for Markdown/documentation
29//! let mermaid_renderer = MermaidRenderer::new();
30//! let mermaid = mermaid_renderer.render(&project, &schedule)?;
31//!
32//! // PlantUML for wikis and documentation
33//! let plantuml_renderer = PlantUmlRenderer::new();
34//! let plantuml = plantuml_renderer.render(&project, &schedule)?;
35//!
36//! // Excel costing report
37//! let excel_renderer = ExcelRenderer::new().currency("€");
38//! let xlsx_bytes = excel_renderer.render(&project, &schedule)?;
39//! std::fs::write("project_cost.xlsx", xlsx_bytes)?;
40//! ```
41
42pub mod excel;
43pub mod gantt;
44pub mod mermaid;
45pub mod plantuml;
46
47pub use excel::{ExcelConfig, ExcelRenderer, ScheduleGranularity};
48pub use gantt::{FocusConfig, GanttTheme, HtmlGanttRenderer, TaskVisibility};
49pub use mermaid::MermaidRenderer;
50pub use plantuml::PlantUmlRenderer;
51
52use chrono::NaiveDate;
53use svg::node::element::{Group, Line, Rectangle, Text};
54use svg::Document;
55use utf8proj_core::{Project, RenderError, Renderer, Schedule, ScheduledTask};
56
57/// Display mode for task labels in charts
58#[derive(Clone, Copy, Debug, Default, PartialEq)]
59pub enum DisplayMode {
60    /// Show task display name only (default)
61    #[default]
62    Name,
63    /// Show task ID only
64    Id,
65    /// Show both: [task_id] Display Name
66    Verbose,
67}
68
69impl DisplayMode {
70    /// Format a task label according to the display mode
71    pub fn format_label(&self, task_id: &str, task_name: &str, max_width: usize) -> String {
72        let label = match self {
73            DisplayMode::Name => {
74                if task_name.is_empty() || task_name == task_id {
75                    task_id.to_string()
76                } else {
77                    task_name.to_string()
78                }
79            }
80            DisplayMode::Id => task_id.to_string(),
81            DisplayMode::Verbose => {
82                if task_name.is_empty() || task_name == task_id {
83                    format!("[{}]", task_id)
84                } else {
85                    format!("[{}] {}", task_id, task_name)
86                }
87            }
88        };
89
90        // Truncate if needed
91        if label.len() > max_width && max_width > 3 {
92            format!("{}...", &label[..max_width - 3])
93        } else {
94            label
95        }
96    }
97}
98
99/// SVG Gantt chart renderer configuration
100#[derive(Clone, Debug)]
101pub struct SvgRenderer {
102    /// Width of the chart area (excluding labels) in pixels
103    pub chart_width: u32,
104    /// Height per task row in pixels
105    pub row_height: u32,
106    /// Width of the label column in pixels
107    pub label_width: u32,
108    /// Header height in pixels
109    pub header_height: u32,
110    /// Padding around the chart
111    pub padding: u32,
112    /// Color for critical path tasks
113    pub critical_color: String,
114    /// Color for normal tasks
115    pub normal_color: String,
116    /// Color for milestones
117    pub milestone_color: String,
118    /// Background color
119    pub background_color: String,
120    /// Grid line color
121    pub grid_color: String,
122    /// Text color
123    pub text_color: String,
124    /// Font family
125    pub font_family: String,
126    /// Font size in pixels
127    pub font_size: u32,
128    /// Display mode for task labels
129    pub display_mode: DisplayMode,
130}
131
132impl Default for SvgRenderer {
133    fn default() -> Self {
134        Self {
135            chart_width: 800,
136            row_height: 28,
137            label_width: 180,
138            header_height: 50,
139            padding: 20,
140            critical_color: "#e74c3c".into(),
141            normal_color: "#3498db".into(),
142            milestone_color: "#9b59b6".into(),
143            background_color: "#ffffff".into(),
144            grid_color: "#ecf0f1".into(),
145            text_color: "#2c3e50".into(),
146            font_family: "system-ui, -apple-system, sans-serif".into(),
147            font_size: 12,
148            display_mode: DisplayMode::Name,
149        }
150    }
151}
152
153impl SvgRenderer {
154    pub fn new() -> Self {
155        Self::default()
156    }
157
158    /// Configure chart width
159    pub fn chart_width(mut self, width: u32) -> Self {
160        self.chart_width = width;
161        self
162    }
163
164    /// Configure row height
165    pub fn row_height(mut self, height: u32) -> Self {
166        self.row_height = height;
167        self
168    }
169
170    /// Configure label column width
171    pub fn label_width(mut self, width: u32) -> Self {
172        self.label_width = width;
173        self
174    }
175
176    /// Configure display mode for task labels
177    pub fn display_mode(mut self, mode: DisplayMode) -> Self {
178        self.display_mode = mode;
179        self
180    }
181
182    /// Calculate the total width of the SVG
183    fn total_width(&self) -> u32 {
184        self.padding * 2 + self.label_width + self.chart_width
185    }
186
187    /// Calculate the total height based on number of tasks
188    fn total_height(&self, task_count: usize) -> u32 {
189        self.padding * 2 + self.header_height + (task_count as u32 * self.row_height)
190    }
191
192    /// Calculate pixels per day based on date range
193    fn pixels_per_day(&self, start: NaiveDate, end: NaiveDate) -> f64 {
194        let days = (end - start).num_days().max(1) as f64;
195        self.chart_width as f64 / days
196    }
197
198    /// Convert a date to x position
199    fn date_to_x(&self, date: NaiveDate, project_start: NaiveDate, px_per_day: f64) -> f64 {
200        let days = (date - project_start).num_days() as f64;
201        self.padding as f64 + self.label_width as f64 + (days * px_per_day)
202    }
203
204    /// Create the header with date labels
205    fn render_header(
206        &self,
207        project_start: NaiveDate,
208        project_end: NaiveDate,
209        px_per_day: f64,
210    ) -> Group {
211        let mut group = Group::new().set("class", "header");
212
213        // Background for header
214        let header_bg = Rectangle::new()
215            .set("x", self.padding)
216            .set("y", self.padding)
217            .set("width", self.label_width + self.chart_width)
218            .set("height", self.header_height)
219            .set("fill", "#f8f9fa");
220        group = group.add(header_bg);
221
222        // Calculate appropriate date interval
223        let total_days = (project_end - project_start).num_days();
224        let interval_days = if total_days <= 14 {
225            1 // Show every day
226        } else if total_days <= 60 {
227            7 // Show weekly
228        } else if total_days <= 180 {
229            14 // Show bi-weekly
230        } else {
231            30 // Show monthly
232        };
233
234        // Draw date labels
235        let mut current = project_start;
236        while current <= project_end {
237            let x = self.date_to_x(current, project_start, px_per_day);
238
239            // Vertical grid line
240            let line = Line::new()
241                .set("x1", x)
242                .set("y1", self.padding + self.header_height - 10)
243                .set("x2", x)
244                .set("y2", self.padding + self.header_height)
245                .set("stroke", self.text_color.as_str())
246                .set("stroke-width", 1);
247            group = group.add(line);
248
249            // Date label
250            let label = if interval_days == 1 {
251                current.format("%d").to_string()
252            } else if interval_days <= 7 {
253                current.format("%b %d").to_string()
254            } else {
255                current.format("%b %d").to_string()
256            };
257
258            let text = Text::new(label)
259                .set("x", x)
260                .set("y", self.padding + self.header_height - 15)
261                .set("font-family", self.font_family.as_str())
262                .set("font-size", self.font_size - 1)
263                .set("fill", self.text_color.as_str())
264                .set("text-anchor", "middle");
265            group = group.add(text);
266
267            current += chrono::Duration::days(interval_days);
268        }
269
270        // Month/Year label at the top
271        let month_label = project_start.format("%B %Y").to_string();
272        let month_text = Text::new(month_label)
273            .set("x", self.padding + self.label_width + self.chart_width / 2)
274            .set("y", self.padding + 18)
275            .set("font-family", self.font_family.as_str())
276            .set("font-size", self.font_size + 2)
277            .set("font-weight", "bold")
278            .set("fill", self.text_color.as_str())
279            .set("text-anchor", "middle");
280        group = group.add(month_text);
281
282        group
283    }
284
285    /// Render grid lines
286    fn render_grid(
287        &self,
288        task_count: usize,
289        project_start: NaiveDate,
290        project_end: NaiveDate,
291        px_per_day: f64,
292    ) -> Group {
293        let mut group = Group::new().set("class", "grid");
294
295        let chart_top = self.padding + self.header_height;
296        let chart_bottom = chart_top + (task_count as u32 * self.row_height);
297
298        // Horizontal lines for each row
299        for i in 0..=task_count {
300            let y = chart_top + (i as u32 * self.row_height);
301            let line = Line::new()
302                .set("x1", self.padding)
303                .set("y1", y)
304                .set("x2", self.padding + self.label_width + self.chart_width)
305                .set("y2", y)
306                .set("stroke", self.grid_color.as_str())
307                .set("stroke-width", 1);
308            group = group.add(line);
309        }
310
311        // Vertical lines for days/weeks
312        let total_days = (project_end - project_start).num_days();
313        let interval = if total_days <= 30 { 1 } else { 7 };
314
315        let mut current = project_start;
316        while current <= project_end {
317            let x = self.date_to_x(current, project_start, px_per_day);
318            let line = Line::new()
319                .set("x1", x)
320                .set("y1", chart_top)
321                .set("x2", x)
322                .set("y2", chart_bottom)
323                .set("stroke", self.grid_color.as_str())
324                .set("stroke-width", 1);
325            group = group.add(line);
326            current += chrono::Duration::days(interval);
327        }
328
329        group
330    }
331
332    /// Render a single task bar
333    fn render_task(
334        &self,
335        task: &ScheduledTask,
336        task_name: &str,
337        row: usize,
338        project_start: NaiveDate,
339        px_per_day: f64,
340    ) -> Group {
341        let mut group = Group::new().set("class", "task");
342
343        let y = self.padding + self.header_height + (row as u32 * self.row_height);
344        let bar_height = (self.row_height as f64 * 0.6) as u32;
345        let bar_y = y + (self.row_height - bar_height) / 2;
346
347        // Task label (already formatted with display mode and truncated)
348        let label = Text::new(task_name)
349            .set("x", self.padding + 8)
350            .set("y", y + self.row_height / 2 + 4)
351            .set("font-family", self.font_family.as_str())
352            .set("font-size", self.font_size)
353            .set("fill", self.text_color.as_str());
354        group = group.add(label);
355
356        // Calculate bar position and width
357        let x_start = self.date_to_x(task.start, project_start, px_per_day);
358        let x_end = self.date_to_x(task.finish, project_start, px_per_day);
359        let bar_width = (x_end - x_start).max(4.0); // Minimum width for visibility
360
361        // Determine if this is a milestone (zero duration)
362        let is_milestone = task.duration.minutes == 0;
363
364        if is_milestone {
365            // Draw diamond for milestone
366            let cx = x_start;
367            let cy = (bar_y + bar_height / 2) as f64;
368            let size = (bar_height as f64) / 2.0;
369
370            let diamond = svg::node::element::Polygon::new()
371                .set(
372                    "points",
373                    format!(
374                        "{},{} {},{} {},{} {},{}",
375                        cx,
376                        cy - size,
377                        cx + size,
378                        cy,
379                        cx,
380                        cy + size,
381                        cx - size,
382                        cy
383                    ),
384                )
385                .set("fill", self.milestone_color.as_str());
386            group = group.add(diamond);
387        } else {
388            // Draw bar for regular task
389            let color = if task.is_critical {
390                self.critical_color.as_str()
391            } else {
392                self.normal_color.as_str()
393            };
394
395            let bar = Rectangle::new()
396                .set("x", x_start)
397                .set("y", bar_y)
398                .set("width", bar_width)
399                .set("height", bar_height)
400                .set("rx", 3)
401                .set("ry", 3)
402                .set("fill", color);
403            group = group.add(bar);
404
405            // Add subtle gradient effect
406            let highlight = Rectangle::new()
407                .set("x", x_start)
408                .set("y", bar_y)
409                .set("width", bar_width)
410                .set("height", bar_height / 3)
411                .set("rx", 3)
412                .set("ry", 3)
413                .set("fill", "rgba(255,255,255,0.2)");
414            group = group.add(highlight);
415        }
416
417        group
418    }
419
420    /// Render the legend
421    fn render_legend(&self, y_offset: u32) -> Group {
422        let mut group = Group::new().set("class", "legend");
423        let x_start = self.padding as f64;
424        let y = y_offset as f64 + 15.0;
425        let box_size = 12.0;
426        let spacing = 120.0;
427
428        // Critical path
429        let critical_box = Rectangle::new()
430            .set("x", x_start)
431            .set("y", y - box_size + 2.0)
432            .set("width", box_size)
433            .set("height", box_size)
434            .set("rx", 2)
435            .set("fill", self.critical_color.as_str());
436        group = group.add(critical_box);
437
438        let critical_label = Text::new("Critical Path")
439            .set("x", x_start + box_size + 5.0)
440            .set("y", y)
441            .set("font-family", self.font_family.as_str())
442            .set("font-size", self.font_size - 1)
443            .set("fill", self.text_color.as_str());
444        group = group.add(critical_label);
445
446        // Normal task
447        let normal_box = Rectangle::new()
448            .set("x", x_start + spacing)
449            .set("y", y - box_size + 2.0)
450            .set("width", box_size)
451            .set("height", box_size)
452            .set("rx", 2)
453            .set("fill", self.normal_color.as_str());
454        group = group.add(normal_box);
455
456        let normal_label = Text::new("Normal Task")
457            .set("x", x_start + spacing + box_size + 5.0)
458            .set("y", y)
459            .set("font-family", self.font_family.as_str())
460            .set("font-size", self.font_size - 1)
461            .set("fill", self.text_color.as_str());
462        group = group.add(normal_label);
463
464        // Milestone
465        let mx = x_start + spacing * 2.0 + box_size / 2.0;
466        let my = y - box_size / 2.0 + 2.0;
467        let msize = box_size / 2.0;
468
469        let milestone = svg::node::element::Polygon::new()
470            .set(
471                "points",
472                format!(
473                    "{},{} {},{} {},{} {},{}",
474                    mx,
475                    my - msize,
476                    mx + msize,
477                    my,
478                    mx,
479                    my + msize,
480                    mx - msize,
481                    my
482                ),
483            )
484            .set("fill", self.milestone_color.as_str());
485        group = group.add(milestone);
486
487        let milestone_label = Text::new("Milestone")
488            .set("x", x_start + spacing * 2.0 + box_size + 5.0)
489            .set("y", y)
490            .set("font-family", self.font_family.as_str())
491            .set("font-size", self.font_size - 1)
492            .set("fill", self.text_color.as_str());
493        group = group.add(milestone_label);
494
495        group
496    }
497}
498
499impl Renderer for SvgRenderer {
500    type Output = String;
501
502    fn render(&self, project: &Project, schedule: &Schedule) -> Result<String, RenderError> {
503        // Sort tasks by start date
504        let mut tasks: Vec<&ScheduledTask> = schedule.tasks.values().collect();
505        tasks.sort_by_key(|t| t.start);
506
507        if tasks.is_empty() {
508            return Err(RenderError::InvalidData("No tasks to render".into()));
509        }
510
511        let task_count = tasks.len();
512        let project_start = project.start;
513        let project_end = schedule.project_end;
514        let px_per_day = self.pixels_per_day(project_start, project_end);
515
516        // Calculate dimensions
517        let width = self.total_width();
518        let height = self.total_height(task_count) + 30; // Extra space for legend
519
520        // Create document
521        let mut document = Document::new()
522            .set("width", width)
523            .set("height", height)
524            .set("viewBox", (0, 0, width, height))
525            .set("xmlns", "http://www.w3.org/2000/svg");
526
527        // Background
528        let background = Rectangle::new()
529            .set("width", "100%")
530            .set("height", "100%")
531            .set("fill", self.background_color.as_str());
532        document = document.add(background);
533
534        // Title
535        let title = Text::new(project.name.as_str())
536            .set("x", self.padding)
537            .set("y", self.padding + 15)
538            .set("font-family", self.font_family.as_str())
539            .set("font-size", self.font_size + 4)
540            .set("font-weight", "bold")
541            .set("fill", self.text_color.as_str());
542        document = document.add(title);
543
544        // Grid
545        document =
546            document.add(self.render_grid(task_count, project_start, project_end, px_per_day));
547
548        // Header
549        document = document.add(self.render_header(project_start, project_end, px_per_day));
550
551        // Task bars
552        // Calculate max characters based on label width (~8px per char)
553        let max_chars = (self.label_width as usize / 8).max(10);
554
555        for (row, scheduled_task) in tasks.iter().enumerate() {
556            // Get the task name from the project
557            let task_name = project
558                .get_task(&scheduled_task.task_id)
559                .map(|t| t.name.as_str())
560                .unwrap_or(&scheduled_task.task_id);
561
562            // Format label according to display mode
563            let label =
564                self.display_mode
565                    .format_label(&scheduled_task.task_id, task_name, max_chars);
566
567            document = document.add(self.render_task(
568                scheduled_task,
569                &label,
570                row,
571                project_start,
572                px_per_day,
573            ));
574        }
575
576        // Legend
577        let legend_y =
578            self.padding + self.header_height + (task_count as u32 * self.row_height) + 10;
579        document = document.add(self.render_legend(legend_y));
580
581        // Convert to string
582        let mut output = Vec::new();
583        svg::write(&mut output, &document)
584            .map_err(|e| RenderError::Format(format!("Failed to write SVG: {}", e)))?;
585
586        String::from_utf8(output).map_err(|e| RenderError::Format(format!("Invalid UTF-8: {}", e)))
587    }
588}
589
590/// Plain text renderer for console output
591#[derive(Default)]
592pub struct TextRenderer;
593
594impl Renderer for TextRenderer {
595    type Output = String;
596
597    fn render(&self, project: &Project, _schedule: &Schedule) -> Result<String, RenderError> {
598        Ok(format!("Project: {}\n", project.name))
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use chrono::NaiveDate;
606    use std::collections::HashMap;
607    use utf8proj_core::{Duration, Schedule, ScheduledTask, TaskStatus};
608
609    fn create_test_project() -> Project {
610        let mut project = Project::new("Test Project");
611        project.start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
612        project.tasks.push(
613            utf8proj_core::Task::new("task1")
614                .name("Design Phase")
615                .duration(Duration::days(3)),
616        );
617        project.tasks.push(
618            utf8proj_core::Task::new("task2")
619                .name("Implementation")
620                .duration(Duration::days(5))
621                .depends_on("task1"),
622        );
623        project
624    }
625
626    fn create_test_schedule() -> Schedule {
627        let mut tasks = HashMap::new();
628
629        let start1 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
630        let finish1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
631        tasks.insert(
632            "task1".to_string(),
633            ScheduledTask {
634                task_id: "task1".to_string(),
635                start: start1,
636                finish: finish1,
637                duration: Duration::days(3),
638                assignments: vec![],
639                slack: Duration::zero(),
640                is_critical: true,
641                early_start: start1,
642                early_finish: finish1,
643                late_start: start1,
644                late_finish: finish1,
645                forecast_start: start1,
646                forecast_finish: finish1,
647                remaining_duration: Duration::days(3),
648                percent_complete: 0,
649                status: TaskStatus::NotStarted,
650                cost_range: None,
651                has_abstract_assignments: false,
652                baseline_start: start1,
653                baseline_finish: finish1,
654                start_variance_days: 0,
655                finish_variance_days: 0,
656            },
657        );
658
659        let start2 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
660        let finish2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
661        tasks.insert(
662            "task2".to_string(),
663            ScheduledTask {
664                task_id: "task2".to_string(),
665                start: start2,
666                finish: finish2,
667                duration: Duration::days(5),
668                assignments: vec![],
669                slack: Duration::zero(),
670                is_critical: true,
671                early_start: start2,
672                early_finish: finish2,
673                late_start: start2,
674                late_finish: finish2,
675                forecast_start: start2,
676                forecast_finish: finish2,
677                remaining_duration: Duration::days(5),
678                percent_complete: 0,
679                status: TaskStatus::NotStarted,
680                cost_range: None,
681                has_abstract_assignments: false,
682                baseline_start: start2,
683                baseline_finish: finish2,
684                start_variance_days: 0,
685                finish_variance_days: 0,
686            },
687        );
688
689        let project_end = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
690        Schedule {
691            tasks,
692            critical_path: vec!["task1".to_string(), "task2".to_string()],
693            project_duration: Duration::days(8),
694            project_end,
695            total_cost: None,
696            total_cost_range: None,
697            project_progress: 0,
698            project_baseline_finish: project_end,
699            project_forecast_finish: project_end,
700            project_variance_days: 0,
701            planned_value: 0,
702            earned_value: 0,
703            spi: 1.0,
704        }
705    }
706
707    #[test]
708    fn svg_renderer_creation() {
709        let renderer = SvgRenderer::new();
710        assert_eq!(renderer.chart_width, 800);
711        assert_eq!(renderer.row_height, 28);
712    }
713
714    #[test]
715    fn svg_renderer_with_config() {
716        let renderer = SvgRenderer::new().chart_width(1000).row_height(40);
717        assert_eq!(renderer.chart_width, 1000);
718        assert_eq!(renderer.row_height, 40);
719    }
720
721    #[test]
722    fn svg_render_produces_valid_svg() {
723        let renderer = SvgRenderer::new();
724        let project = create_test_project();
725        let schedule = create_test_schedule();
726
727        let result = renderer.render(&project, &schedule);
728        assert!(result.is_ok());
729
730        let svg = result.unwrap();
731        assert!(svg.starts_with("<svg"));
732        assert!(svg.contains("</svg>"));
733        assert!(svg.contains("Test Project"));
734        assert!(svg.contains("Design Phase"));
735    }
736
737    #[test]
738    fn svg_render_includes_critical_path_styling() {
739        let renderer = SvgRenderer::new();
740        let project = create_test_project();
741        let schedule = create_test_schedule();
742
743        let svg = renderer.render(&project, &schedule).unwrap();
744        assert!(svg.contains(&renderer.critical_color));
745    }
746
747    #[test]
748    fn svg_render_empty_schedule_fails() {
749        let renderer = SvgRenderer::new();
750        let project = Project::new("Empty");
751        let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
752        let schedule = Schedule {
753            tasks: HashMap::new(),
754            critical_path: vec![],
755            project_duration: Duration::zero(),
756            project_end,
757            total_cost: None,
758            total_cost_range: None,
759            project_progress: 0,
760            project_baseline_finish: project_end,
761            project_forecast_finish: project_end,
762            project_variance_days: 0,
763            planned_value: 0,
764            earned_value: 0,
765            spi: 1.0,
766        };
767
768        let result = renderer.render(&project, &schedule);
769        assert!(result.is_err());
770    }
771
772    #[test]
773    fn text_renderer_basic() {
774        let renderer = TextRenderer;
775        let project = create_test_project();
776        let schedule = create_test_schedule();
777
778        let result = renderer.render(&project, &schedule);
779        assert!(result.is_ok());
780        let text = result.unwrap();
781        assert!(text.contains("Test Project"));
782    }
783
784    #[test]
785    fn svg_render_with_milestone() {
786        let mut project = Project::new("Milestone Test");
787        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
788        project.tasks.push(
789            utf8proj_core::Task::new("dev")
790                .name("Development")
791                .duration(Duration::days(5)),
792        );
793        project.tasks.push(
794            utf8proj_core::Task::new("release")
795                .name("Release")
796                .milestone()
797                .depends_on("dev"),
798        );
799
800        let mut tasks = HashMap::new();
801        let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
802        let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
803        tasks.insert(
804            "dev".to_string(),
805            ScheduledTask {
806                task_id: "dev".to_string(),
807                start: start1,
808                finish: finish1,
809                duration: Duration::days(5),
810                assignments: vec![],
811                slack: Duration::zero(),
812                is_critical: true,
813                early_start: start1,
814                early_finish: finish1,
815                late_start: start1,
816                late_finish: finish1,
817                forecast_start: start1,
818                forecast_finish: finish1,
819                remaining_duration: Duration::days(5),
820                percent_complete: 0,
821                status: TaskStatus::NotStarted,
822                cost_range: None,
823                has_abstract_assignments: false,
824                baseline_start: start1,
825                baseline_finish: finish1,
826                start_variance_days: 0,
827                finish_variance_days: 0,
828            },
829        );
830        let ms_date = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
831        tasks.insert(
832            "release".to_string(),
833            ScheduledTask {
834                task_id: "release".to_string(),
835                start: ms_date,
836                finish: ms_date,
837                duration: Duration::zero(),
838                assignments: vec![],
839                slack: Duration::zero(),
840                is_critical: true,
841                early_start: ms_date,
842                early_finish: ms_date,
843                late_start: ms_date,
844                late_finish: ms_date,
845                forecast_start: ms_date,
846                forecast_finish: ms_date,
847                remaining_duration: Duration::zero(),
848                percent_complete: 0,
849                status: TaskStatus::NotStarted,
850                cost_range: None,
851                has_abstract_assignments: false,
852                baseline_start: ms_date,
853                baseline_finish: ms_date,
854                start_variance_days: 0,
855                finish_variance_days: 0,
856            },
857        );
858
859        let schedule = Schedule {
860            tasks,
861            critical_path: vec!["dev".to_string(), "release".to_string()],
862            project_duration: Duration::days(5),
863            project_end: ms_date,
864            total_cost: None,
865            total_cost_range: None,
866            project_progress: 0,
867            project_baseline_finish: ms_date,
868            project_forecast_finish: ms_date,
869            project_variance_days: 0,
870            planned_value: 0,
871            earned_value: 0,
872            spi: 1.0,
873        };
874
875        let renderer = SvgRenderer::new();
876        let svg = renderer.render(&project, &schedule).unwrap();
877
878        // Milestone should render as polygon (diamond)
879        assert!(svg.contains("polygon"));
880        assert!(svg.contains("Release"));
881    }
882
883    #[test]
884    fn svg_render_non_critical_tasks() {
885        let mut project = Project::new("Non-Critical");
886        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
887        project.tasks.push(
888            utf8proj_core::Task::new("task1")
889                .name("Task 1")
890                .duration(Duration::days(5)),
891        );
892
893        let mut tasks = HashMap::new();
894        let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
895        let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
896        tasks.insert(
897            "task1".to_string(),
898            ScheduledTask {
899                task_id: "task1".to_string(),
900                start: start1,
901                finish: finish1,
902                duration: Duration::days(5),
903                assignments: vec![],
904                slack: Duration::days(5), // Has slack, so not critical
905                is_critical: false,
906                early_start: start1,
907                early_finish: finish1,
908                late_start: start1,
909                late_finish: finish1,
910                forecast_start: start1,
911                forecast_finish: finish1,
912                remaining_duration: Duration::days(5),
913                percent_complete: 0,
914                status: TaskStatus::NotStarted,
915                cost_range: None,
916                has_abstract_assignments: false,
917                baseline_start: start1,
918                baseline_finish: finish1,
919                start_variance_days: 0,
920                finish_variance_days: 0,
921            },
922        );
923
924        let schedule = Schedule {
925            tasks,
926            critical_path: vec![],
927            project_duration: Duration::days(5),
928            project_end: finish1,
929            total_cost: None,
930            total_cost_range: None,
931            project_progress: 0,
932            project_baseline_finish: finish1,
933            project_forecast_finish: finish1,
934            project_variance_days: 0,
935            planned_value: 0,
936            earned_value: 0,
937            spi: 1.0,
938        };
939
940        let renderer = SvgRenderer::new();
941        let svg = renderer.render(&project, &schedule).unwrap();
942
943        // Non-critical tasks should use normal color
944        assert!(svg.contains(&renderer.normal_color));
945    }
946
947    #[test]
948    fn svg_render_long_project() {
949        // Test project > 60 days to trigger bi-weekly interval
950        let mut project = Project::new("Long Project");
951        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
952        project.tasks.push(
953            utf8proj_core::Task::new("phase1")
954                .name("Phase 1")
955                .duration(Duration::days(50)),
956        );
957        project.tasks.push(
958            utf8proj_core::Task::new("phase2")
959                .name("Phase 2")
960                .duration(Duration::days(50))
961                .depends_on("phase1"),
962        );
963
964        let mut tasks = HashMap::new();
965        let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
966        let finish1 = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap(); // ~50 working days
967        tasks.insert(
968            "phase1".to_string(),
969            ScheduledTask {
970                task_id: "phase1".to_string(),
971                start: start1,
972                finish: finish1,
973                duration: Duration::days(50),
974                assignments: vec![],
975                slack: Duration::zero(),
976                is_critical: true,
977                early_start: start1,
978                early_finish: finish1,
979                late_start: start1,
980                late_finish: finish1,
981                forecast_start: start1,
982                forecast_finish: finish1,
983                remaining_duration: Duration::days(50),
984                percent_complete: 0,
985                status: TaskStatus::NotStarted,
986                cost_range: None,
987                has_abstract_assignments: false,
988                baseline_start: start1,
989                baseline_finish: finish1,
990                start_variance_days: 0,
991                finish_variance_days: 0,
992            },
993        );
994        let start2 = NaiveDate::from_ymd_opt(2025, 3, 17).unwrap();
995        let finish2 = NaiveDate::from_ymd_opt(2025, 5, 23).unwrap();
996        tasks.insert(
997            "phase2".to_string(),
998            ScheduledTask {
999                task_id: "phase2".to_string(),
1000                start: start2,
1001                finish: finish2,
1002                duration: Duration::days(50),
1003                assignments: vec![],
1004                slack: Duration::zero(),
1005                is_critical: true,
1006                early_start: start2,
1007                early_finish: finish2,
1008                late_start: start2,
1009                late_finish: finish2,
1010                forecast_start: start2,
1011                forecast_finish: finish2,
1012                remaining_duration: Duration::days(50),
1013                percent_complete: 0,
1014                status: TaskStatus::NotStarted,
1015                cost_range: None,
1016                has_abstract_assignments: false,
1017                baseline_start: start2,
1018                baseline_finish: finish2,
1019                start_variance_days: 0,
1020                finish_variance_days: 0,
1021            },
1022        );
1023
1024        let schedule = Schedule {
1025            tasks,
1026            critical_path: vec!["phase1".to_string(), "phase2".to_string()],
1027            project_duration: Duration::days(100),
1028            project_end: finish2,
1029            total_cost: None,
1030            total_cost_range: None,
1031            project_progress: 0,
1032            project_baseline_finish: finish2,
1033            project_forecast_finish: finish2,
1034            project_variance_days: 0,
1035            planned_value: 0,
1036            earned_value: 0,
1037            spi: 1.0,
1038        };
1039
1040        let renderer = SvgRenderer::new();
1041        let svg = renderer.render(&project, &schedule).unwrap();
1042
1043        assert!(svg.contains("Long Project"));
1044        assert!(svg.contains("Phase 1"));
1045    }
1046
1047    #[test]
1048    fn svg_render_very_long_project() {
1049        // Test project > 180 days to trigger monthly interval
1050        let mut project = Project::new("Very Long Project");
1051        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1052        project.tasks.push(
1053            utf8proj_core::Task::new("year")
1054                .name("Year Long Task")
1055                .duration(Duration::days(200)),
1056        );
1057
1058        let mut tasks = HashMap::new();
1059        let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1060        let finish1 = NaiveDate::from_ymd_opt(2025, 10, 31).unwrap();
1061        tasks.insert(
1062            "year".to_string(),
1063            ScheduledTask {
1064                task_id: "year".to_string(),
1065                start: start1,
1066                finish: finish1,
1067                duration: Duration::days(200),
1068                assignments: vec![],
1069                slack: Duration::zero(),
1070                is_critical: true,
1071                early_start: start1,
1072                early_finish: finish1,
1073                late_start: start1,
1074                late_finish: finish1,
1075                forecast_start: start1,
1076                forecast_finish: finish1,
1077                remaining_duration: Duration::days(200),
1078                percent_complete: 0,
1079                status: TaskStatus::NotStarted,
1080                cost_range: None,
1081                has_abstract_assignments: false,
1082                baseline_start: start1,
1083                baseline_finish: finish1,
1084                start_variance_days: 0,
1085                finish_variance_days: 0,
1086            },
1087        );
1088
1089        let schedule = Schedule {
1090            tasks,
1091            critical_path: vec!["year".to_string()],
1092            project_duration: Duration::days(200),
1093            project_end: finish1,
1094            total_cost: None,
1095            total_cost_range: None,
1096            project_progress: 0,
1097            project_baseline_finish: finish1,
1098            project_forecast_finish: finish1,
1099            project_variance_days: 0,
1100            planned_value: 0,
1101            earned_value: 0,
1102            spi: 1.0,
1103        };
1104
1105        let renderer = SvgRenderer::new();
1106        let svg = renderer.render(&project, &schedule).unwrap();
1107
1108        assert!(svg.contains("Very Long Project"));
1109    }
1110}