1use chrono::NaiveDate;
13use std::collections::HashMap;
14use utf8proj_core::{Project, RenderError, Renderer, Schedule, ScheduledTask, Task};
15
16#[derive(Clone, Debug)]
18pub struct HtmlGanttRenderer {
19 pub chart_width: u32,
21 pub row_height: u32,
23 pub label_width: u32,
25 pub header_height: u32,
27 pub padding: u32,
29 pub theme: GanttTheme,
31 pub show_dependencies: bool,
33 pub interactive: bool,
35 pub focus: Option<FocusConfig>,
37}
38
39#[derive(Clone, Debug, Default)]
41pub struct FocusConfig {
42 pub focus_patterns: Vec<String>,
45 pub context_depth: usize,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum TaskVisibility {
52 Expanded,
54 Collapsed,
56 Hidden,
58}
59
60impl FocusConfig {
61 pub fn new(patterns: Vec<String>, context_depth: usize) -> Self {
63 Self {
64 focus_patterns: patterns,
65 context_depth,
66 }
67 }
68
69 pub fn matches_focus(&self, task_id: &str, task_name: &str) -> bool {
71 if self.focus_patterns.is_empty() {
72 return true; }
74 for pattern in &self.focus_patterns {
75 if task_id.starts_with(pattern) || task_id == pattern {
77 return true;
78 }
79 if task_name.contains(pattern) {
81 return true;
82 }
83 if self.glob_match(pattern, task_id) {
85 return true;
86 }
87 }
88 false
89 }
90
91 fn glob_match(&self, pattern: &str, text: &str) -> bool {
93 if !pattern.contains('*') {
94 return pattern == text;
95 }
96 let parts: Vec<&str> = pattern.split('*').collect();
97 if parts.is_empty() {
98 return true;
99 }
100 let mut pos = 0;
101 for (i, part) in parts.iter().enumerate() {
102 if part.is_empty() {
103 continue;
104 }
105 if let Some(found) = text[pos..].find(part) {
106 if i == 0 && found != 0 {
107 return false;
109 }
110 pos += found + part.len();
111 } else {
112 return false;
113 }
114 }
115 parts
117 .last()
118 .map_or(true, |p| p.is_empty() || pos == text.len())
119 }
120
121 pub fn get_visibility(
123 &self,
124 task_id: &str,
125 task_name: &str,
126 depth: usize,
127 is_ancestor_of_focused: bool,
128 is_descendant_of_focused: bool,
129 ) -> TaskVisibility {
130 if self.matches_focus(task_id, task_name) {
132 return TaskVisibility::Expanded;
133 }
134
135 if is_ancestor_of_focused {
137 return TaskVisibility::Expanded;
138 }
139
140 if is_descendant_of_focused {
142 return TaskVisibility::Expanded;
143 }
144
145 if depth < self.context_depth {
147 return TaskVisibility::Collapsed;
148 }
149
150 TaskVisibility::Hidden
151 }
152}
153
154#[derive(Clone, Debug)]
156pub struct GanttTheme {
157 pub critical_color: String,
158 pub normal_color: String,
159 pub milestone_color: String,
160 pub container_color: String,
161 pub background_color: String,
162 pub grid_color: String,
163 pub text_color: String,
164 pub header_bg: String,
165 pub arrow_color: String,
166 pub highlight_color: String,
167}
168
169impl Default for GanttTheme {
170 fn default() -> Self {
171 Self::light()
172 }
173}
174
175impl GanttTheme {
176 pub fn light() -> Self {
177 Self {
178 critical_color: "#e74c3c".into(),
179 normal_color: "#3498db".into(),
180 milestone_color: "#9b59b6".into(),
181 container_color: "#95a5a6".into(),
182 background_color: "#ffffff".into(),
183 grid_color: "#ecf0f1".into(),
184 text_color: "#2c3e50".into(),
185 header_bg: "#f8f9fa".into(),
186 arrow_color: "#7f8c8d".into(),
187 highlight_color: "#f39c12".into(),
188 }
189 }
190
191 pub fn dark() -> Self {
192 Self {
193 critical_color: "#e74c3c".into(),
194 normal_color: "#3498db".into(),
195 milestone_color: "#9b59b6".into(),
196 container_color: "#7f8c8d".into(),
197 background_color: "#1a1a2e".into(),
198 grid_color: "#2d2d44".into(),
199 text_color: "#eaeaea".into(),
200 header_bg: "#16213e".into(),
201 arrow_color: "#95a5a6".into(),
202 highlight_color: "#f39c12".into(),
203 }
204 }
205}
206
207impl Default for HtmlGanttRenderer {
208 fn default() -> Self {
209 Self {
210 chart_width: 900,
211 row_height: 32,
212 label_width: 450, header_height: 60,
214 padding: 20,
215 theme: GanttTheme::default(),
216 show_dependencies: true,
217 interactive: true,
218 focus: None,
219 }
220 }
221}
222
223impl HtmlGanttRenderer {
224 pub fn new() -> Self {
225 Self::default()
226 }
227
228 pub fn dark_theme(mut self) -> Self {
230 self.theme = GanttTheme::dark();
231 self
232 }
233
234 pub fn chart_width(mut self, width: u32) -> Self {
236 self.chart_width = width;
237 self
238 }
239
240 pub fn row_height(mut self, height: u32) -> Self {
242 self.row_height = height;
243 self
244 }
245
246 pub fn hide_dependencies(mut self) -> Self {
248 self.show_dependencies = false;
249 self
250 }
251
252 pub fn static_chart(mut self) -> Self {
254 self.interactive = false;
255 self
256 }
257
258 pub fn focus(mut self, patterns: Vec<String>) -> Self {
271 let context_depth = self.focus.as_ref().map(|f| f.context_depth).unwrap_or(1);
272 self.focus = Some(FocusConfig::new(patterns, context_depth));
273 self
274 }
275
276 pub fn context_depth(mut self, depth: usize) -> Self {
289 if let Some(ref mut focus) = self.focus {
290 focus.context_depth = depth;
291 } else {
292 self.focus = Some(FocusConfig::new(vec![], depth));
293 }
294 self
295 }
296
297 fn pixels_per_day(&self, start: NaiveDate, end: NaiveDate) -> f64 {
299 let days = (end - start).num_days().max(1) as f64;
300 self.chart_width as f64 / days
301 }
302
303 fn date_to_x(&self, date: NaiveDate, project_start: NaiveDate, px_per_day: f64) -> f64 {
305 let days = (date - project_start).num_days() as f64;
306 self.padding as f64 + self.label_width as f64 + (days * px_per_day)
307 }
308
309 fn flatten_tasks_for_display<'a>(
311 &self,
312 project: &'a Project,
313 schedule: &'a Schedule,
314 ) -> Vec<TaskDisplay<'a>> {
315 let mut all_tasks = Vec::new();
316 self.collect_tasks(&project.tasks, schedule, "", 0, &mut all_tasks);
317
318 let Some(ref focus) = self.focus else {
320 return all_tasks;
321 };
322
323 let focused_ids: std::collections::HashSet<String> = all_tasks
325 .iter()
326 .filter(|t| focus.matches_focus(&t.qualified_id, &t.task.name))
327 .map(|t| t.qualified_id.clone())
328 .collect();
329
330 let ancestor_ids: std::collections::HashSet<String> = all_tasks
332 .iter()
333 .filter(|t| {
334 focused_ids.iter().any(|fid| {
336 fid.starts_with(&t.qualified_id)
337 && fid.len() > t.qualified_id.len()
338 && fid.chars().nth(t.qualified_id.len()) == Some('.')
339 })
340 })
341 .map(|t| t.qualified_id.clone())
342 .collect();
343
344 let mut result = Vec::new();
346 let mut skip_children_of: Option<String> = None;
347
348 for mut task_display in all_tasks {
349 if let Some(ref skip_prefix) = skip_children_of {
351 if task_display.qualified_id.starts_with(skip_prefix)
352 && task_display.qualified_id.len() > skip_prefix.len()
353 {
354 continue;
355 }
356 skip_children_of = None;
357 }
358
359 let _is_focused = focused_ids.contains(&task_display.qualified_id);
360 let is_ancestor = ancestor_ids.contains(&task_display.qualified_id);
361 let is_descendant = focused_ids.iter().any(|fid| {
362 task_display.qualified_id.starts_with(fid)
363 && task_display.qualified_id.len() > fid.len()
364 });
365
366 let visibility = focus.get_visibility(
367 &task_display.qualified_id,
368 &task_display.task.name,
369 task_display.depth,
370 is_ancestor,
371 is_descendant,
372 );
373
374 match visibility {
375 TaskVisibility::Hidden => continue,
376 TaskVisibility::Collapsed => {
377 if task_display.is_container {
379 skip_children_of = Some(task_display.qualified_id.clone() + ".");
380 }
381 task_display.visibility = TaskVisibility::Collapsed;
382 result.push(task_display);
383 }
384 TaskVisibility::Expanded => {
385 task_display.visibility = TaskVisibility::Expanded;
386 result.push(task_display);
387 }
388 }
389 }
390
391 result
392 }
393
394 fn collect_tasks<'a>(
395 &self,
396 tasks: &'a [Task],
397 schedule: &'a Schedule,
398 prefix: &str,
399 depth: usize,
400 result: &mut Vec<TaskDisplay<'a>>,
401 ) {
402 for task in tasks {
403 let qualified_id = if prefix.is_empty() {
404 task.id.clone()
405 } else {
406 format!("{}.{}", prefix, task.id)
407 };
408
409 let scheduled = schedule.tasks.get(&qualified_id);
410 let is_container = !task.children.is_empty();
411
412 result.push(TaskDisplay {
413 task,
414 qualified_id: qualified_id.clone(),
415 scheduled,
416 depth,
417 is_container,
418 child_count: task.children.len(),
419 visibility: TaskVisibility::Expanded, });
421
422 if !task.children.is_empty() {
423 self.collect_tasks(&task.children, schedule, &qualified_id, depth + 1, result);
424 }
425 }
426 }
427
428 fn generate_html(
430 &self,
431 project: &Project,
432 schedule: &Schedule,
433 tasks: &[TaskDisplay],
434 ) -> String {
435 let project_start = project.start;
436 let project_end = schedule.project_end;
437 let px_per_day = self.pixels_per_day(project_start, project_end);
438
439 let total_width = self.padding * 2 + self.label_width + self.chart_width;
440 let total_height =
441 self.padding * 2 + self.header_height + (tasks.len() as u32 * self.row_height) + 50;
442
443 let svg_content = self.generate_svg(project, schedule, tasks, px_per_day);
444 let css = self.generate_css();
445 let js = if self.interactive {
446 self.generate_js(tasks)
447 } else {
448 String::new()
449 };
450
451 format!(
452 r#"<!DOCTYPE html>
453<html lang="en">
454<head>
455 <meta charset="UTF-8">
456 <meta name="viewport" content="width=device-width, initial-scale=1.0">
457 <title>{title} - Gantt Chart</title>
458 <style>
459{css}
460 </style>
461</head>
462<body>
463 <div class="gantt-container">
464 <div class="gantt-header">
465 <h1>{title}</h1>
466 <div class="gantt-controls">
467 <button onclick="zoomIn()" title="Zoom In">+</button>
468 <button onclick="zoomOut()" title="Zoom Out">−</button>
469 <button onclick="resetZoom()" title="Reset">Reset</button>
470 </div>
471 </div>
472 <div class="gantt-wrapper" id="gantt-wrapper">
473 <svg id="gantt-svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
474{svg_content}
475 </svg>
476 </div>
477 <div class="gantt-legend">
478 <span class="legend-item"><span class="legend-box critical"></span>Critical Path</span>
479 <span class="legend-item"><span class="legend-box normal"></span>Normal Task</span>
480 <span class="legend-item"><span class="legend-diamond"></span>Milestone</span>
481 <span class="legend-item"><span class="legend-box container"></span>Container</span>
482 </div>
483 <div id="tooltip" class="tooltip"></div>
484 </div>
485 <script>
486{js}
487 </script>
488</body>
489</html>"#,
490 title = html_escape(&project.name),
491 css = css,
492 width = total_width,
493 height = total_height,
494 svg_content = svg_content,
495 js = js,
496 )
497 }
498
499 fn generate_svg(
501 &self,
502 project: &Project,
503 schedule: &Schedule,
504 tasks: &[TaskDisplay],
505 px_per_day: f64,
506 ) -> String {
507 let mut svg = String::new();
508 let project_start = project.start;
509 let project_end = schedule.project_end;
510
511 svg.push_str(&format!(
513 r#" <rect width="100%" height="100%" fill="{}"/>"#,
514 self.theme.background_color
515 ));
516 svg.push('\n');
517
518 svg.push_str(&self.render_grid(tasks.len(), project_start, project_end, px_per_day));
520
521 svg.push_str(&self.render_header(project_start, project_end, px_per_day));
523
524 for (row, task_display) in tasks.iter().enumerate() {
526 svg.push_str(&self.render_task_row(task_display, row, project_start, px_per_day));
527 }
528
529 if self.show_dependencies {
531 svg.push_str(&self.render_dependencies(
532 project,
533 schedule,
534 tasks,
535 project_start,
536 px_per_day,
537 ));
538 }
539
540 svg
541 }
542
543 fn render_header(
545 &self,
546 project_start: NaiveDate,
547 project_end: NaiveDate,
548 px_per_day: f64,
549 ) -> String {
550 let mut svg = String::new();
551
552 svg.push_str(&format!(
554 r#" <rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
555 self.padding,
556 self.padding,
557 self.label_width + self.chart_width,
558 self.header_height,
559 self.theme.header_bg
560 ));
561 svg.push('\n');
562
563 let total_days = (project_end - project_start).num_days();
565 let interval_days = if total_days <= 14 {
566 1
567 } else if total_days <= 60 {
568 7
569 } else if total_days <= 180 {
570 14
571 } else {
572 30
573 };
574
575 let mut current = project_start;
577 while current <= project_end {
578 let x = self.date_to_x(current, project_start, px_per_day);
579
580 svg.push_str(&format!(
582 r#" <line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{color}" stroke-width="1"/>"#,
583 x = x,
584 y1 = self.padding + self.header_height - 10,
585 y2 = self.padding + self.header_height,
586 color = self.theme.text_color
587 ));
588 svg.push('\n');
589
590 let label = if interval_days == 1 {
592 current.format("%d").to_string()
593 } else {
594 current.format("%b %d").to_string()
595 };
596
597 svg.push_str(&format!(
598 r#" <text x="{x}" y="{y}" font-size="11" fill="{color}" text-anchor="middle">{label}</text>"#,
599 x = x,
600 y = self.padding + self.header_height - 15,
601 color = self.theme.text_color,
602 label = label
603 ));
604 svg.push('\n');
605
606 current += chrono::Duration::days(interval_days);
607 }
608
609 let month_label = project_start.format("%B %Y").to_string();
611 svg.push_str(&format!(
612 r#" <text x="{x}" y="{y}" font-size="14" font-weight="bold" fill="{color}" text-anchor="middle">{label}</text>"#,
613 x = self.padding + self.label_width + self.chart_width / 2,
614 y = self.padding + 22,
615 color = self.theme.text_color,
616 label = month_label
617 ));
618 svg.push('\n');
619
620 svg
621 }
622
623 fn render_grid(
625 &self,
626 task_count: usize,
627 project_start: NaiveDate,
628 project_end: NaiveDate,
629 px_per_day: f64,
630 ) -> String {
631 let mut svg = String::new();
632 let chart_top = self.padding + self.header_height;
633 let chart_bottom = chart_top + (task_count as u32 * self.row_height);
634
635 for i in 0..=task_count {
637 let y = chart_top + (i as u32 * self.row_height);
638 svg.push_str(&format!(
639 r#" <line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{color}" stroke-width="1"/>"#,
640 x1 = self.padding,
641 y = y,
642 x2 = self.padding + self.label_width + self.chart_width,
643 color = self.theme.grid_color
644 ));
645 svg.push('\n');
646 }
647
648 let total_days = (project_end - project_start).num_days();
650 let interval = if total_days <= 30 { 1 } else { 7 };
651
652 let mut current = project_start;
653 while current <= project_end {
654 let x = self.date_to_x(current, project_start, px_per_day);
655 svg.push_str(&format!(
656 r#" <line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{color}" stroke-width="1"/>"#,
657 x = x,
658 y1 = chart_top,
659 y2 = chart_bottom,
660 color = self.theme.grid_color
661 ));
662 svg.push('\n');
663 current += chrono::Duration::days(interval);
664 }
665
666 svg
667 }
668
669 fn render_task_row(
671 &self,
672 task_display: &TaskDisplay,
673 row: usize,
674 project_start: NaiveDate,
675 px_per_day: f64,
676 ) -> String {
677 let mut svg = String::new();
678
679 let y = self.padding + self.header_height + (row as u32 * self.row_height);
680 let bar_height = (self.row_height as f64 * 0.6) as u32;
681 let bar_y = y + (self.row_height - bar_height) / 2;
682
683 let is_collapsed = task_display.visibility == TaskVisibility::Collapsed;
685
686 let indent = task_display.depth as u32 * 16;
688 let label_x = self.padding + 8 + indent;
689
690 if task_display.is_container || is_collapsed {
693 let icon_x = label_x - 12;
694 let icon_y = y + self.row_height / 2;
695 let icon = if is_collapsed { "▶" } else { "▼" };
696 let icon_color = if is_collapsed {
697 "#9ca3af" } else {
699 &self.theme.text_color
700 };
701 svg.push_str(&format!(
702 r#" <text x="{x}" y="{y}" font-size="10" fill="{color}" class="collapse-icon" data-task="{id}" style="cursor:pointer">{icon}</text>"#,
703 x = icon_x,
704 y = icon_y + 4,
705 color = icon_color,
706 id = task_display.qualified_id,
707 icon = icon
708 ));
709 svg.push('\n');
710 }
711
712 let available_px = self.label_width.saturating_sub(indent as u32 + 20);
715 let max_chars = (available_px / 7) as usize;
716 let label = truncate(&task_display.task.name, max_chars.max(10));
717 let label_color = if is_collapsed {
718 "#9ca3af" } else {
720 &self.theme.text_color
721 };
722 svg.push_str(&format!(
723 r#" <text x="{x}" y="{y}" font-size="12" fill="{color}">{label}</text>"#,
724 x = label_x,
725 y = y + self.row_height / 2 + 4,
726 color = label_color,
727 label = html_escape(&label)
728 ));
729 svg.push('\n');
730
731 if let Some(scheduled) = task_display.scheduled {
733 let x_start = self.date_to_x(scheduled.start, project_start, px_per_day);
734 let x_end = self.date_to_x(scheduled.finish, project_start, px_per_day);
735 let bar_width = (x_end - x_start).max(4.0);
736
737 let is_milestone = scheduled.duration.minutes == 0;
738
739 if is_milestone && !is_collapsed {
740 let cx = x_start;
742 let cy = (bar_y + bar_height / 2) as f64;
743 let size = (bar_height as f64) / 2.0;
744
745 svg.push_str(&format!(
746 r#" <polygon points="{p1},{p2} {p3},{p4} {p5},{p6} {p7},{p8}" fill="{color}" class="task-bar milestone" data-task="{id}"/>"#,
747 p1 = cx, p2 = cy - size,
748 p3 = cx + size, p4 = cy,
749 p5 = cx, p6 = cy + size,
750 p7 = cx - size, p8 = cy,
751 color = self.theme.milestone_color,
752 id = task_display.qualified_id
753 ));
754 svg.push('\n');
755 } else if is_collapsed {
756 let collapsed_color = "#b8c0cc"; let collapsed_bar_height = (bar_height as f64 * 0.7) as u32;
759 let collapsed_bar_y = y + (self.row_height - collapsed_bar_height) / 2;
760
761 svg.push_str(&format!(
762 r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="2" fill="{color}" opacity="0.7" class="task-bar collapsed" data-task="{id}"/>"#,
763 x = x_start,
764 y = collapsed_bar_y,
765 w = bar_width,
766 h = collapsed_bar_height,
767 color = collapsed_color,
768 id = task_display.qualified_id
769 ));
770 svg.push('\n');
771 } else if task_display.is_container {
772 let bracket_height = 6.0;
774 svg.push_str(&format!(
775 r#" <path d="M{x1},{y1} L{x1},{y2} L{x2},{y2} L{x2},{y1}" fill="none" stroke="{color}" stroke-width="3" class="task-bar container" data-task="{id}"/>"#,
776 x1 = x_start,
777 y1 = bar_y as f64 + bracket_height,
778 y2 = bar_y as f64,
779 x2 = x_start + bar_width,
780 color = self.theme.container_color,
781 id = task_display.qualified_id
782 ));
783 svg.push('\n');
784 } else {
785 let color = if scheduled.is_critical {
787 &self.theme.critical_color
788 } else {
789 &self.theme.normal_color
790 };
791
792 svg.push_str(&format!(
793 r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="3" fill="{color}" class="task-bar" data-task="{id}"/>"#,
794 x = x_start,
795 y = bar_y,
796 w = bar_width,
797 h = bar_height,
798 color = color,
799 id = task_display.qualified_id
800 ));
801 svg.push('\n');
802
803 if let Some(complete) = task_display.task.complete {
805 let progress_width = bar_width * (complete as f64 / 100.0);
806 svg.push_str(&format!(
807 r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="3" fill="rgba(255,255,255,0.3)"/>"#,
808 x = x_start,
809 y = bar_y,
810 w = progress_width,
811 h = bar_height
812 ));
813 svg.push('\n');
814 }
815 }
816 }
817
818 svg
819 }
820
821 fn render_dependencies(
823 &self,
824 _project: &Project,
825 _schedule: &Schedule,
826 tasks: &[TaskDisplay],
827 project_start: NaiveDate,
828 px_per_day: f64,
829 ) -> String {
830 let mut svg = String::new();
831 svg.push_str(r#" <g class="dependencies">"#);
832 svg.push('\n');
833
834 let mut task_positions: HashMap<&str, (usize, &ScheduledTask)> = HashMap::new();
836 for (row, task_display) in tasks.iter().enumerate() {
837 if let Some(scheduled) = task_display.scheduled {
838 task_positions.insert(&task_display.qualified_id, (row, scheduled));
839 }
840 }
841
842 for task_display in tasks {
844 if let Some(to_scheduled) = task_display.scheduled {
845 for dep in &task_display.task.depends {
846 let pred_id = self.resolve_dependency(
848 &dep.predecessor,
849 &task_display.qualified_id,
850 &task_positions,
851 );
852
853 if let Some((from_row, from_scheduled)) =
854 pred_id.and_then(|id| task_positions.get(id.as_str()))
855 {
856 let to_row = task_positions
857 .get(task_display.qualified_id.as_str())
858 .map(|(r, _)| *r)
859 .unwrap_or(0);
860
861 let arrow = self.render_arrow(
862 *from_row,
863 from_scheduled,
864 to_row,
865 to_scheduled,
866 &dep.dep_type,
867 project_start,
868 px_per_day,
869 );
870 svg.push_str(&arrow);
871 }
872 }
873 }
874 }
875
876 svg.push_str(" </g>\n");
877 svg
878 }
879
880 fn resolve_dependency(
882 &self,
883 dep_path: &str,
884 from_id: &str,
885 positions: &HashMap<&str, (usize, &ScheduledTask)>,
886 ) -> Option<String> {
887 if positions.contains_key(dep_path) {
889 return Some(dep_path.to_string());
890 }
891
892 if let Some(dot_pos) = from_id.rfind('.') {
894 let parent = &from_id[..dot_pos];
895 let qualified = format!("{}.{}", parent, dep_path);
896 if positions.contains_key(qualified.as_str()) {
897 return Some(qualified);
898 }
899 }
900
901 None
902 }
903
904 fn render_arrow(
906 &self,
907 from_row: usize,
908 from_task: &ScheduledTask,
909 to_row: usize,
910 to_task: &ScheduledTask,
911 dep_type: &utf8proj_core::DependencyType,
912 project_start: NaiveDate,
913 px_per_day: f64,
914 ) -> String {
915 let bar_height = (self.row_height as f64 * 0.6) as f64;
916 let bar_y_offset = (self.row_height as f64 - bar_height) / 2.0;
917
918 let from_y = self.padding as f64
919 + self.header_height as f64
920 + (from_row as f64 * self.row_height as f64)
921 + bar_y_offset
922 + bar_height / 2.0;
923 let to_y = self.padding as f64
924 + self.header_height as f64
925 + (to_row as f64 * self.row_height as f64)
926 + bar_y_offset
927 + bar_height / 2.0;
928
929 let (from_x, to_x) = match dep_type {
930 utf8proj_core::DependencyType::FinishToStart => {
931 let fx = self.date_to_x(from_task.finish, project_start, px_per_day) + 2.0;
932 let tx = self.date_to_x(to_task.start, project_start, px_per_day) - 2.0;
933 (fx, tx)
934 }
935 utf8proj_core::DependencyType::StartToStart => {
936 let fx = self.date_to_x(from_task.start, project_start, px_per_day) - 2.0;
937 let tx = self.date_to_x(to_task.start, project_start, px_per_day) - 2.0;
938 (fx, tx)
939 }
940 utf8proj_core::DependencyType::FinishToFinish => {
941 let fx = self.date_to_x(from_task.finish, project_start, px_per_day) + 2.0;
942 let tx = self.date_to_x(to_task.finish, project_start, px_per_day) + 2.0;
943 (fx, tx)
944 }
945 utf8proj_core::DependencyType::StartToFinish => {
946 let fx = self.date_to_x(from_task.start, project_start, px_per_day) - 2.0;
947 let tx = self.date_to_x(to_task.finish, project_start, px_per_day) + 2.0;
948 (fx, tx)
949 }
950 };
951
952 let mid_x = (from_x + to_x) / 2.0;
954 let path = if (to_row as i32 - from_row as i32).abs() <= 1 {
955 format!(
957 "M{},{} C{},{} {},{} {},{}",
958 from_x, from_y, mid_x, from_y, mid_x, to_y, to_x, to_y
959 )
960 } else {
961 let offset = 15.0;
963 format!(
964 "M{},{} L{},{} L{},{} L{},{}",
965 from_x,
966 from_y,
967 from_x + offset,
968 from_y,
969 from_x + offset,
970 to_y,
971 to_x,
972 to_y
973 )
974 };
975
976 format!(
977 r#" <path d="{path}" fill="none" stroke="{color}" stroke-width="1.5" marker-end="url(#arrowhead)" class="dep-arrow"/>
978"#,
979 path = path,
980 color = self.theme.arrow_color
981 )
982 }
983
984 fn generate_css(&self) -> String {
986 format!(
987 r#" :root {{
988 --critical-color: {critical};
989 --normal-color: {normal};
990 --milestone-color: {milestone};
991 --container-color: {container};
992 --bg-color: {bg};
993 --text-color: {text};
994 --highlight-color: {highlight};
995 }}
996 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
997 body {{
998 font-family: system-ui, -apple-system, sans-serif;
999 background: var(--bg-color);
1000 color: var(--text-color);
1001 padding: 20px;
1002 }}
1003 .gantt-container {{
1004 max-width: 100%;
1005 overflow-x: auto;
1006 }}
1007 .gantt-header {{
1008 display: flex;
1009 justify-content: space-between;
1010 align-items: center;
1011 margin-bottom: 16px;
1012 }}
1013 .gantt-header h1 {{
1014 font-size: 1.5rem;
1015 font-weight: 600;
1016 }}
1017 .gantt-controls button {{
1018 padding: 8px 16px;
1019 margin-left: 8px;
1020 border: 1px solid var(--text-color);
1021 background: transparent;
1022 color: var(--text-color);
1023 cursor: pointer;
1024 border-radius: 4px;
1025 font-size: 14px;
1026 }}
1027 .gantt-controls button:hover {{
1028 background: rgba(128,128,128,0.2);
1029 }}
1030 .gantt-wrapper {{
1031 overflow-x: auto;
1032 border: 1px solid rgba(128,128,128,0.3);
1033 border-radius: 8px;
1034 }}
1035 .gantt-legend {{
1036 display: flex;
1037 gap: 24px;
1038 margin-top: 16px;
1039 font-size: 13px;
1040 }}
1041 .legend-item {{
1042 display: flex;
1043 align-items: center;
1044 gap: 6px;
1045 }}
1046 .legend-box {{
1047 width: 16px;
1048 height: 12px;
1049 border-radius: 2px;
1050 }}
1051 .legend-box.critical {{ background: var(--critical-color); }}
1052 .legend-box.normal {{ background: var(--normal-color); }}
1053 .legend-box.container {{ background: var(--container-color); }}
1054 .legend-diamond {{
1055 width: 10px;
1056 height: 10px;
1057 background: var(--milestone-color);
1058 transform: rotate(45deg);
1059 }}
1060 .task-bar {{
1061 cursor: pointer;
1062 transition: opacity 0.2s;
1063 }}
1064 .task-bar:hover {{
1065 opacity: 0.8;
1066 }}
1067 .task-bar.highlighted {{
1068 stroke: var(--highlight-color);
1069 stroke-width: 3;
1070 }}
1071 .dep-arrow {{
1072 opacity: 0.6;
1073 transition: opacity 0.2s;
1074 }}
1075 .dep-arrow.highlighted {{
1076 opacity: 1;
1077 stroke: var(--highlight-color);
1078 stroke-width: 2;
1079 }}
1080 .tooltip {{
1081 position: fixed;
1082 background: rgba(0,0,0,0.9);
1083 color: white;
1084 padding: 12px;
1085 border-radius: 6px;
1086 font-size: 13px;
1087 pointer-events: none;
1088 opacity: 0;
1089 transition: opacity 0.2s;
1090 z-index: 1000;
1091 max-width: 300px;
1092 }}
1093 .tooltip.visible {{
1094 opacity: 1;
1095 }}
1096 .tooltip .task-name {{
1097 font-weight: 600;
1098 margin-bottom: 8px;
1099 }}
1100 .tooltip .task-dates {{
1101 color: #aaa;
1102 }}"#,
1103 critical = self.theme.critical_color,
1104 normal = self.theme.normal_color,
1105 milestone = self.theme.milestone_color,
1106 container = self.theme.container_color,
1107 bg = self.theme.background_color,
1108 text = self.theme.text_color,
1109 highlight = self.theme.highlight_color,
1110 )
1111 }
1112
1113 fn generate_js(&self, tasks: &[TaskDisplay]) -> String {
1115 let mut task_data = String::from("const taskData = {\n");
1117 for task_display in tasks {
1118 if let Some(scheduled) = task_display.scheduled {
1119 task_data.push_str(&format!(
1120 r#" "{}": {{ name: "{}", start: "{}", finish: "{}", duration: "{} days", critical: {}, deps: [{}] }},
1121"#,
1122 task_display.qualified_id,
1123 html_escape(&task_display.task.name),
1124 scheduled.start,
1125 scheduled.finish,
1126 scheduled.duration.as_days() as i64,
1127 scheduled.is_critical,
1128 task_display.task.depends.iter()
1129 .map(|d| format!("\"{}\"", d.predecessor))
1130 .collect::<Vec<_>>()
1131 .join(", ")
1132 ));
1133 }
1134 }
1135 task_data.push_str(" };\n");
1136
1137 format!(
1138 r#" {task_data}
1139
1140 // Zoom functionality
1141 let currentZoom = 1;
1142 const wrapper = document.getElementById('gantt-wrapper');
1143 const svg = document.getElementById('gantt-svg');
1144
1145 function zoomIn() {{
1146 currentZoom = Math.min(currentZoom * 1.2, 3);
1147 applyZoom();
1148 }}
1149
1150 function zoomOut() {{
1151 currentZoom = Math.max(currentZoom / 1.2, 0.5);
1152 applyZoom();
1153 }}
1154
1155 function resetZoom() {{
1156 currentZoom = 1;
1157 applyZoom();
1158 }}
1159
1160 function applyZoom() {{
1161 svg.style.transform = `scale(${{currentZoom}})`;
1162 svg.style.transformOrigin = 'top left';
1163 }}
1164
1165 // Tooltip functionality
1166 const tooltip = document.getElementById('tooltip');
1167
1168 document.querySelectorAll('.task-bar').forEach(bar => {{
1169 bar.addEventListener('mouseenter', (e) => {{
1170 const taskId = bar.getAttribute('data-task');
1171 const data = taskData[taskId];
1172 if (data) {{
1173 tooltip.innerHTML = `
1174 <div class="task-name">${{data.name}}</div>
1175 <div class="task-dates">${{data.start}} → ${{data.finish}}</div>
1176 <div>Duration: ${{data.duration}}</div>
1177 ${{data.critical ? '<div style="color:#e74c3c">Critical Path</div>' : ''}}
1178 `;
1179 tooltip.classList.add('visible');
1180 }}
1181 }});
1182
1183 bar.addEventListener('mousemove', (e) => {{
1184 tooltip.style.left = (e.clientX + 15) + 'px';
1185 tooltip.style.top = (e.clientY + 15) + 'px';
1186 }});
1187
1188 bar.addEventListener('mouseleave', () => {{
1189 tooltip.classList.remove('visible');
1190 }});
1191
1192 // Click to highlight dependencies
1193 bar.addEventListener('click', () => {{
1194 const taskId = bar.getAttribute('data-task');
1195 highlightDependencies(taskId);
1196 }});
1197 }});
1198
1199 function highlightDependencies(taskId) {{
1200 // Clear previous highlights
1201 document.querySelectorAll('.highlighted').forEach(el => {{
1202 el.classList.remove('highlighted');
1203 }});
1204
1205 // Highlight selected task
1206 const taskBar = document.querySelector(`[data-task="${{taskId}}"]`);
1207 if (taskBar) taskBar.classList.add('highlighted');
1208
1209 // Highlight dependencies (simplified - would need full dep graph)
1210 const data = taskData[taskId];
1211 if (data && data.deps) {{
1212 data.deps.forEach(depId => {{
1213 const depBar = document.querySelector(`[data-task="${{depId}}"]`);
1214 if (depBar) depBar.classList.add('highlighted');
1215 }});
1216 }}
1217 }}
1218
1219 // Arrow marker definition
1220 const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
1221 defs.innerHTML = `
1222 <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
1223 <polygon points="0 0, 10 3.5, 0 7" fill="{arrow_color}" />
1224 </marker>
1225 `;
1226 svg.insertBefore(defs, svg.firstChild);"#,
1227 task_data = task_data,
1228 arrow_color = self.theme.arrow_color
1229 )
1230 }
1231}
1232
1233struct TaskDisplay<'a> {
1235 task: &'a Task,
1236 qualified_id: String,
1237 scheduled: Option<&'a ScheduledTask>,
1238 depth: usize,
1239 is_container: bool,
1240 #[allow(dead_code)]
1241 child_count: usize,
1242 visibility: TaskVisibility,
1244}
1245
1246impl Renderer for HtmlGanttRenderer {
1247 type Output = String;
1248
1249 fn render(&self, project: &Project, schedule: &Schedule) -> Result<String, RenderError> {
1250 let tasks = self.flatten_tasks_for_display(project, schedule);
1251
1252 if tasks.is_empty() {
1253 return Err(RenderError::InvalidData("No tasks to render".into()));
1254 }
1255
1256 Ok(self.generate_html(project, schedule, &tasks))
1257 }
1258}
1259
1260fn html_escape(s: &str) -> String {
1262 s.replace('&', "&")
1263 .replace('<', "<")
1264 .replace('>', ">")
1265 .replace('"', """)
1266}
1267
1268fn truncate(s: &str, max: usize) -> String {
1270 if s.chars().count() <= max {
1271 s.to_string()
1272 } else {
1273 format!(
1274 "{}…",
1275 s.chars().take(max.saturating_sub(1)).collect::<String>()
1276 )
1277 }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282 use super::*;
1283 use chrono::NaiveDate;
1284 use std::collections::HashMap;
1285 use utf8proj_core::{Duration, Schedule, ScheduledTask, TaskStatus};
1286
1287 fn create_test_project() -> Project {
1288 let mut project = Project::new("Test Project");
1289 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1290 project.tasks.push(
1291 Task::new("design")
1292 .name("Design Phase")
1293 .effort(Duration::days(5)),
1294 );
1295 project.tasks.push(
1296 Task::new("implement")
1297 .name("Implementation")
1298 .effort(Duration::days(10))
1299 .depends_on("design"),
1300 );
1301 project.tasks.push(
1302 Task::new("test")
1303 .name("Testing")
1304 .effort(Duration::days(3))
1305 .depends_on("implement"),
1306 );
1307 project
1308 }
1309
1310 fn create_test_schedule() -> Schedule {
1311 let mut tasks = HashMap::new();
1312
1313 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1314 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
1315 tasks.insert(
1316 "design".to_string(),
1317 ScheduledTask {
1318 task_id: "design".to_string(),
1319 start: start1,
1320 finish: finish1,
1321 duration: Duration::days(5),
1322 assignments: vec![],
1323 slack: Duration::zero(),
1324 is_critical: true,
1325 early_start: start1,
1326 early_finish: finish1,
1327 late_start: start1,
1328 late_finish: finish1,
1329 forecast_start: start1,
1330 forecast_finish: finish1,
1331 remaining_duration: Duration::days(5),
1332 percent_complete: 0,
1333 status: TaskStatus::NotStarted,
1334 cost_range: None,
1335 has_abstract_assignments: false,
1336 baseline_start: start1,
1337 baseline_finish: finish1,
1338 start_variance_days: 0,
1339 finish_variance_days: 0,
1340 },
1341 );
1342
1343 let start2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
1344 let finish2 = NaiveDate::from_ymd_opt(2025, 1, 24).unwrap();
1345 tasks.insert(
1346 "implement".to_string(),
1347 ScheduledTask {
1348 task_id: "implement".to_string(),
1349 start: start2,
1350 finish: finish2,
1351 duration: Duration::days(10),
1352 assignments: vec![],
1353 slack: Duration::zero(),
1354 is_critical: true,
1355 early_start: start2,
1356 early_finish: finish2,
1357 late_start: start2,
1358 late_finish: finish2,
1359 forecast_start: start2,
1360 forecast_finish: finish2,
1361 remaining_duration: Duration::days(10),
1362 percent_complete: 0,
1363 status: TaskStatus::NotStarted,
1364 cost_range: None,
1365 has_abstract_assignments: false,
1366 baseline_start: start2,
1367 baseline_finish: finish2,
1368 start_variance_days: 0,
1369 finish_variance_days: 0,
1370 },
1371 );
1372
1373 let start3 = NaiveDate::from_ymd_opt(2025, 1, 27).unwrap();
1374 let finish3 = NaiveDate::from_ymd_opt(2025, 1, 29).unwrap();
1375 tasks.insert(
1376 "test".to_string(),
1377 ScheduledTask {
1378 task_id: "test".to_string(),
1379 start: start3,
1380 finish: finish3,
1381 duration: Duration::days(3),
1382 assignments: vec![],
1383 slack: Duration::zero(),
1384 is_critical: true,
1385 early_start: start3,
1386 early_finish: finish3,
1387 late_start: start3,
1388 late_finish: finish3,
1389 forecast_start: start3,
1390 forecast_finish: finish3,
1391 remaining_duration: Duration::days(3),
1392 percent_complete: 0,
1393 status: TaskStatus::NotStarted,
1394 cost_range: None,
1395 has_abstract_assignments: false,
1396 baseline_start: start3,
1397 baseline_finish: finish3,
1398 start_variance_days: 0,
1399 finish_variance_days: 0,
1400 },
1401 );
1402
1403 let project_end = NaiveDate::from_ymd_opt(2025, 1, 29).unwrap();
1404 Schedule {
1405 tasks,
1406 critical_path: vec![
1407 "design".to_string(),
1408 "implement".to_string(),
1409 "test".to_string(),
1410 ],
1411 project_duration: Duration::days(18),
1412 project_end,
1413 total_cost: None,
1414 total_cost_range: None,
1415 project_progress: 0,
1416 project_baseline_finish: project_end,
1417 project_forecast_finish: project_end,
1418 project_variance_days: 0,
1419 planned_value: 0,
1420 earned_value: 0,
1421 spi: 1.0,
1422 }
1423 }
1424
1425 #[test]
1426 fn html_gantt_renderer_creation() {
1427 let renderer = HtmlGanttRenderer::new();
1428 assert_eq!(renderer.chart_width, 900);
1429 assert_eq!(renderer.row_height, 32);
1430 assert!(renderer.interactive);
1431 }
1432
1433 #[test]
1434 fn html_gantt_with_dark_theme() {
1435 let renderer = HtmlGanttRenderer::new().dark_theme();
1436 assert_eq!(renderer.theme.background_color, "#1a1a2e");
1437 }
1438
1439 #[test]
1440 fn html_gantt_produces_valid_html() {
1441 let renderer = HtmlGanttRenderer::new();
1442 let project = create_test_project();
1443 let schedule = create_test_schedule();
1444
1445 let result = renderer.render(&project, &schedule);
1446 assert!(result.is_ok());
1447
1448 let html = result.unwrap();
1449 assert!(html.starts_with("<!DOCTYPE html>"));
1450 assert!(html.contains("</html>"));
1451 assert!(html.contains("Test Project"));
1452 assert!(html.contains("Design Phase"));
1453 }
1454
1455 #[test]
1456 fn html_gantt_includes_svg() {
1457 let renderer = HtmlGanttRenderer::new();
1458 let project = create_test_project();
1459 let schedule = create_test_schedule();
1460
1461 let html = renderer.render(&project, &schedule).unwrap();
1462 assert!(html.contains("<svg"));
1463 assert!(html.contains("</svg>"));
1464 }
1465
1466 #[test]
1467 fn html_gantt_includes_interactivity() {
1468 let renderer = HtmlGanttRenderer::new();
1469 let project = create_test_project();
1470 let schedule = create_test_schedule();
1471
1472 let html = renderer.render(&project, &schedule).unwrap();
1473 assert!(html.contains("zoomIn()"));
1474 assert!(html.contains("tooltip"));
1475 assert!(html.contains("taskData"));
1476 }
1477
1478 #[test]
1479 fn html_gantt_static_mode() {
1480 let renderer = HtmlGanttRenderer::new().static_chart();
1481 let project = create_test_project();
1482 let schedule = create_test_schedule();
1483
1484 let html = renderer.render(&project, &schedule).unwrap();
1485 assert!(!html.contains("taskData"));
1487 }
1488
1489 #[test]
1490 fn html_gantt_empty_schedule_fails() {
1491 let renderer = HtmlGanttRenderer::new();
1492 let project = Project::new("Empty");
1493 let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
1494 let schedule = Schedule {
1495 tasks: HashMap::new(),
1496 critical_path: vec![],
1497 project_duration: Duration::zero(),
1498 project_end,
1499 total_cost: None,
1500 total_cost_range: None,
1501 project_progress: 0,
1502 project_baseline_finish: project_end,
1503 project_forecast_finish: project_end,
1504 project_variance_days: 0,
1505 planned_value: 0,
1506 earned_value: 0,
1507 spi: 1.0,
1508 };
1509
1510 let result = renderer.render(&project, &schedule);
1511 assert!(result.is_err());
1512 }
1513
1514 #[test]
1515 fn html_escape_works() {
1516 assert_eq!(html_escape("<script>"), "<script>");
1517 assert_eq!(html_escape("a & b"), "a & b");
1518 }
1519
1520 #[test]
1521 fn truncate_works() {
1522 assert_eq!(truncate("Short", 20), "Short");
1523 assert_eq!(truncate("This is a very long name", 10), "This is a…");
1524 }
1525
1526 #[test]
1527 fn html_gantt_row_height_option() {
1528 let renderer = HtmlGanttRenderer::new().row_height(48);
1530 assert_eq!(renderer.row_height, 48);
1531 }
1532
1533 #[test]
1534 fn html_gantt_with_ss_dependency() {
1535 let mut project = Project::new("SS Deps");
1537 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1538 project
1539 .tasks
1540 .push(Task::new("a").name("Task A").effort(Duration::days(5)));
1541 let mut task_b = Task::new("b").name("Task B").effort(Duration::days(3));
1542 task_b.depends.push(utf8proj_core::Dependency {
1543 predecessor: "a".to_string(),
1544 dep_type: utf8proj_core::DependencyType::StartToStart,
1545 lag: None,
1546 });
1547 project.tasks.push(task_b);
1548
1549 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1550 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
1551 let finish2 = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap();
1552 let mut tasks = HashMap::new();
1553 tasks.insert(
1554 "a".to_string(),
1555 ScheduledTask {
1556 task_id: "a".to_string(),
1557 start: start1,
1558 finish: finish1,
1559 duration: Duration::days(5),
1560 assignments: vec![],
1561 slack: Duration::zero(),
1562 is_critical: true,
1563 early_start: start1,
1564 early_finish: finish1,
1565 late_start: start1,
1566 late_finish: finish1,
1567 forecast_start: start1,
1568 forecast_finish: finish1,
1569 remaining_duration: Duration::days(5),
1570 percent_complete: 0,
1571 status: TaskStatus::NotStarted,
1572 cost_range: None,
1573 has_abstract_assignments: false,
1574 baseline_start: start1,
1575 baseline_finish: finish1,
1576 start_variance_days: 0,
1577 finish_variance_days: 0,
1578 },
1579 );
1580 tasks.insert(
1581 "b".to_string(),
1582 ScheduledTask {
1583 task_id: "b".to_string(),
1584 start: start1, finish: finish2,
1586 duration: Duration::days(3),
1587 assignments: vec![],
1588 slack: Duration::zero(),
1589 is_critical: false,
1590 early_start: start1,
1591 early_finish: finish2,
1592 late_start: start1,
1593 late_finish: finish2,
1594 forecast_start: start1,
1595 forecast_finish: finish2,
1596 remaining_duration: Duration::days(3),
1597 percent_complete: 0,
1598 status: TaskStatus::NotStarted,
1599 cost_range: None,
1600 has_abstract_assignments: false,
1601 baseline_start: start1,
1602 baseline_finish: finish2,
1603 start_variance_days: 0,
1604 finish_variance_days: 0,
1605 },
1606 );
1607
1608 let schedule = Schedule {
1609 tasks,
1610 critical_path: vec!["a".to_string()],
1611 project_duration: Duration::days(5),
1612 project_end: finish1,
1613 total_cost: None,
1614 total_cost_range: None,
1615 project_progress: 0,
1616 project_baseline_finish: finish1,
1617 project_forecast_finish: finish1,
1618 project_variance_days: 0,
1619 planned_value: 0,
1620 earned_value: 0,
1621 spi: 1.0,
1622 };
1623
1624 let renderer = HtmlGanttRenderer::new();
1625 let result = renderer.render(&project, &schedule);
1626 assert!(result.is_ok());
1627 let html = result.unwrap();
1628 assert!(html.contains("dep-arrow"));
1630 }
1631
1632 #[test]
1633 fn html_gantt_with_ff_dependency() {
1634 let mut project = Project::new("FF Deps");
1636 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1637 project
1638 .tasks
1639 .push(Task::new("a").name("Task A").effort(Duration::days(5)));
1640 let mut task_b = Task::new("b").name("Task B").effort(Duration::days(3));
1641 task_b.depends.push(utf8proj_core::Dependency {
1642 predecessor: "a".to_string(),
1643 dep_type: utf8proj_core::DependencyType::FinishToFinish,
1644 lag: None,
1645 });
1646 project.tasks.push(task_b);
1647
1648 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1649 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
1650 let start2 = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap();
1651 let mut tasks = HashMap::new();
1652 tasks.insert(
1653 "a".to_string(),
1654 ScheduledTask {
1655 task_id: "a".to_string(),
1656 start: start1,
1657 finish: finish1,
1658 duration: Duration::days(5),
1659 assignments: vec![],
1660 slack: Duration::zero(),
1661 is_critical: true,
1662 early_start: start1,
1663 early_finish: finish1,
1664 late_start: start1,
1665 late_finish: finish1,
1666 forecast_start: start1,
1667 forecast_finish: finish1,
1668 remaining_duration: Duration::days(5),
1669 percent_complete: 0,
1670 status: TaskStatus::NotStarted,
1671 cost_range: None,
1672 has_abstract_assignments: false,
1673 baseline_start: start1,
1674 baseline_finish: finish1,
1675 start_variance_days: 0,
1676 finish_variance_days: 0,
1677 },
1678 );
1679 tasks.insert(
1680 "b".to_string(),
1681 ScheduledTask {
1682 task_id: "b".to_string(),
1683 start: start2,
1684 finish: finish1, duration: Duration::days(3),
1686 assignments: vec![],
1687 slack: Duration::zero(),
1688 is_critical: false,
1689 early_start: start2,
1690 early_finish: finish1,
1691 late_start: start2,
1692 late_finish: finish1,
1693 forecast_start: start2,
1694 forecast_finish: finish1,
1695 remaining_duration: Duration::days(3),
1696 percent_complete: 0,
1697 status: TaskStatus::NotStarted,
1698 cost_range: None,
1699 has_abstract_assignments: false,
1700 baseline_start: start2,
1701 baseline_finish: finish1,
1702 start_variance_days: 0,
1703 finish_variance_days: 0,
1704 },
1705 );
1706
1707 let schedule = Schedule {
1708 tasks,
1709 critical_path: vec!["a".to_string()],
1710 project_duration: Duration::days(5),
1711 project_end: finish1,
1712 total_cost: None,
1713 total_cost_range: None,
1714 project_progress: 0,
1715 project_baseline_finish: finish1,
1716 project_forecast_finish: finish1,
1717 project_variance_days: 0,
1718 planned_value: 0,
1719 earned_value: 0,
1720 spi: 1.0,
1721 };
1722
1723 let renderer = HtmlGanttRenderer::new();
1724 let result = renderer.render(&project, &schedule);
1725 assert!(result.is_ok());
1726 }
1727
1728 #[test]
1729 fn html_gantt_with_sf_dependency() {
1730 let mut project = Project::new("SF Deps");
1732 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1733 project
1734 .tasks
1735 .push(Task::new("a").name("Task A").effort(Duration::days(5)));
1736 let mut task_b = Task::new("b").name("Task B").effort(Duration::days(3));
1737 task_b.depends.push(utf8proj_core::Dependency {
1738 predecessor: "a".to_string(),
1739 dep_type: utf8proj_core::DependencyType::StartToFinish,
1740 lag: None,
1741 });
1742 project.tasks.push(task_b);
1743
1744 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1745 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
1746 let start2 = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
1747 let mut tasks = HashMap::new();
1748 tasks.insert(
1749 "a".to_string(),
1750 ScheduledTask {
1751 task_id: "a".to_string(),
1752 start: start1,
1753 finish: finish1,
1754 duration: Duration::days(5),
1755 assignments: vec![],
1756 slack: Duration::zero(),
1757 is_critical: true,
1758 early_start: start1,
1759 early_finish: finish1,
1760 late_start: start1,
1761 late_finish: finish1,
1762 forecast_start: start1,
1763 forecast_finish: finish1,
1764 remaining_duration: Duration::days(5),
1765 percent_complete: 0,
1766 status: TaskStatus::NotStarted,
1767 cost_range: None,
1768 has_abstract_assignments: false,
1769 baseline_start: start1,
1770 baseline_finish: finish1,
1771 start_variance_days: 0,
1772 finish_variance_days: 0,
1773 },
1774 );
1775 tasks.insert(
1776 "b".to_string(),
1777 ScheduledTask {
1778 task_id: "b".to_string(),
1779 start: start2,
1780 finish: start1, duration: Duration::days(3),
1782 assignments: vec![],
1783 slack: Duration::zero(),
1784 is_critical: false,
1785 early_start: start2,
1786 early_finish: start1,
1787 late_start: start2,
1788 late_finish: start1,
1789 forecast_start: start2,
1790 forecast_finish: start1,
1791 remaining_duration: Duration::days(3),
1792 percent_complete: 0,
1793 status: TaskStatus::NotStarted,
1794 cost_range: None,
1795 has_abstract_assignments: false,
1796 baseline_start: start2,
1797 baseline_finish: start1,
1798 start_variance_days: 0,
1799 finish_variance_days: 0,
1800 },
1801 );
1802
1803 let schedule = Schedule {
1804 tasks,
1805 critical_path: vec!["a".to_string()],
1806 project_duration: Duration::days(5),
1807 project_end: finish1,
1808 total_cost: None,
1809 total_cost_range: None,
1810 project_progress: 0,
1811 project_baseline_finish: finish1,
1812 project_forecast_finish: finish1,
1813 project_variance_days: 0,
1814 planned_value: 0,
1815 earned_value: 0,
1816 spi: 1.0,
1817 };
1818
1819 let renderer = HtmlGanttRenderer::new();
1820 let result = renderer.render(&project, &schedule);
1821 assert!(result.is_ok());
1822 }
1823
1824 #[test]
1829 fn focus_config_empty_patterns_matches_all() {
1830 let config = FocusConfig::new(vec![], 1);
1831 assert!(config.matches_focus("any_task", "Any Task Name"));
1832 assert!(config.matches_focus("6.3.2.1", "[6.3.2.1] GNU Validation"));
1833 }
1834
1835 #[test]
1836 fn focus_config_prefix_matching() {
1837 let config = FocusConfig::new(vec!["6.3.2".to_string()], 1);
1838
1839 assert!(config.matches_focus("6.3.2", "Container"));
1841 assert!(config.matches_focus("6.3.2.1", "Child 1"));
1842 assert!(config.matches_focus("6.3.2.5.1", "Grandchild"));
1843
1844 assert!(!config.matches_focus("6.3.1", "Different stream"));
1846 assert!(!config.matches_focus("7.1", "Different section"));
1847 }
1848
1849 #[test]
1850 fn focus_config_name_matching() {
1851 let config = FocusConfig::new(vec!["[6.3.2]".to_string()], 1);
1852
1853 assert!(config.matches_focus("task_2259", "[6.3.2] OS Script Migration"));
1855
1856 assert!(!config.matches_focus("task_2258", "[6.3.1] Something Else"));
1858 }
1859
1860 #[test]
1861 fn focus_config_glob_matching() {
1862 let config = FocusConfig::new(vec!["*.3.2.*".to_string()], 1);
1863
1864 assert!(config.matches_focus("6.3.2.1", "Task"));
1866 assert!(config.matches_focus("7.3.2.5", "Task"));
1867
1868 assert!(!config.matches_focus("6.4.2.1", "Task"));
1870 }
1871
1872 #[test]
1873 fn focus_config_multiple_patterns() {
1874 let config = FocusConfig::new(vec!["6.3.2".to_string(), "8.6".to_string()], 1);
1875
1876 assert!(config.matches_focus("6.3.2.1", "Task"));
1877 assert!(config.matches_focus("8.6.2", "Task"));
1878 assert!(!config.matches_focus("7.1", "Task"));
1879 }
1880
1881 #[test]
1882 fn focus_visibility_direct_match() {
1883 let config = FocusConfig::new(vec!["6.3.2".to_string()], 1);
1884
1885 let vis = config.get_visibility("6.3.2.1", "Task", 2, false, false);
1886 assert_eq!(vis, TaskVisibility::Expanded);
1887 }
1888
1889 #[test]
1890 fn focus_visibility_ancestor_of_focused() {
1891 let config = FocusConfig::new(vec!["6.3.2".to_string()], 1);
1892
1893 let vis = config.get_visibility("6", "Section 6", 0, true, false);
1895 assert_eq!(vis, TaskVisibility::Expanded);
1896 }
1897
1898 #[test]
1899 fn focus_visibility_descendant_of_focused() {
1900 let config = FocusConfig::new(vec!["6.3".to_string()], 1);
1901
1902 let vis = config.get_visibility("6.3.2.1", "Task", 3, false, true);
1904 assert_eq!(vis, TaskVisibility::Expanded);
1905 }
1906
1907 #[test]
1908 fn focus_visibility_context_depth() {
1909 let config = FocusConfig::new(vec!["6.3.2".to_string()], 1);
1910
1911 let vis = config.get_visibility("7", "Other Section", 0, false, false);
1913 assert_eq!(vis, TaskVisibility::Collapsed);
1914
1915 let vis = config.get_visibility("7.1", "Subsection", 1, false, false);
1917 assert_eq!(vis, TaskVisibility::Hidden);
1918 }
1919
1920 #[test]
1921 fn focus_visibility_context_depth_zero_hides_all() {
1922 let config = FocusConfig::new(vec!["6.3.2".to_string()], 0);
1923
1924 let vis = config.get_visibility("7", "Other Section", 0, false, false);
1926 assert_eq!(vis, TaskVisibility::Hidden);
1927 }
1928
1929 #[test]
1930 fn focus_visibility_context_depth_two() {
1931 let config = FocusConfig::new(vec!["6.3.2".to_string()], 2);
1932
1933 let vis = config.get_visibility("7", "Other", 0, false, false);
1935 assert_eq!(vis, TaskVisibility::Collapsed);
1936
1937 let vis = config.get_visibility("7.1", "Subsection", 1, false, false);
1939 assert_eq!(vis, TaskVisibility::Collapsed);
1940
1941 let vis = config.get_visibility("7.1.1", "Task", 2, false, false);
1943 assert_eq!(vis, TaskVisibility::Hidden);
1944 }
1945
1946 #[test]
1947 fn focus_config_with_renderer() {
1948 let mut renderer = HtmlGanttRenderer::new();
1949 renderer.focus = Some(FocusConfig::new(vec!["6.3.2".to_string()], 1));
1950
1951 assert!(renderer.focus.is_some());
1952 assert_eq!(renderer.focus.as_ref().unwrap().focus_patterns.len(), 1);
1953 assert_eq!(renderer.focus.as_ref().unwrap().context_depth, 1);
1954 }
1955}