Skip to main content

utf8proj_render/
gantt.rs

1//! Interactive HTML Gantt Chart Renderer
2//!
3//! Generates standalone HTML files with embedded SVG Gantt charts.
4//! Features:
5//! - Task bars with critical path highlighting
6//! - Dependency arrows (FS, SS, FF, SF)
7//! - Hover tooltips with task details
8//! - Click to highlight dependencies
9//! - Collapsible hierarchical tasks
10//! - Responsive zoom controls
11
12use chrono::NaiveDate;
13use std::collections::HashMap;
14use utf8proj_core::{Project, RenderError, Renderer, Schedule, ScheduledTask, Task};
15
16/// HTML Gantt chart renderer configuration
17#[derive(Clone, Debug)]
18pub struct HtmlGanttRenderer {
19    /// Width of the chart area (excluding labels) in pixels
20    pub chart_width: u32,
21    /// Height per task row in pixels
22    pub row_height: u32,
23    /// Width of the label column in pixels
24    pub label_width: u32,
25    /// Header height in pixels
26    pub header_height: u32,
27    /// Padding around the chart
28    pub padding: u32,
29    /// Theme (light or dark)
30    pub theme: GanttTheme,
31    /// Show dependency arrows
32    pub show_dependencies: bool,
33    /// Enable interactivity (tooltips, click handlers)
34    pub interactive: bool,
35    /// Focus view configuration (None = show all tasks)
36    pub focus: Option<FocusConfig>,
37}
38
39/// Configuration for focus view rendering
40#[derive(Clone, Debug, Default)]
41pub struct FocusConfig {
42    /// Patterns to match for focused (expanded) tasks
43    /// Supports prefix matching (e.g., "6.3.2" matches "6.3.2.1", "6.3.2.2")
44    pub focus_patterns: Vec<String>,
45    /// Depth to show for non-focused tasks (0 = hide, 1 = top-level only)
46    pub context_depth: usize,
47}
48
49/// Visibility state for a task in focus view
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum TaskVisibility {
52    /// Show task and all descendants expanded
53    Expanded,
54    /// Show task as collapsed summary bar (children hidden)
55    Collapsed,
56    /// Do not show task at all
57    Hidden,
58}
59
60impl FocusConfig {
61    /// Create a new focus configuration
62    pub fn new(patterns: Vec<String>, context_depth: usize) -> Self {
63        Self {
64            focus_patterns: patterns,
65            context_depth,
66        }
67    }
68
69    /// Check if a task ID matches any focus pattern
70    pub fn matches_focus(&self, task_id: &str, task_name: &str) -> bool {
71        if self.focus_patterns.is_empty() {
72            return true; // No focus = everything focused
73        }
74        for pattern in &self.focus_patterns {
75            // Check if task_id starts with pattern (prefix match)
76            if task_id.starts_with(pattern) || task_id == pattern {
77                return true;
78            }
79            // Check if task_name contains pattern (for WBS codes in names)
80            if task_name.contains(pattern) {
81                return true;
82            }
83            // Check pattern as glob (simple * wildcard support)
84            if self.glob_match(pattern, task_id) {
85                return true;
86            }
87        }
88        false
89    }
90
91    /// Simple glob matching with * wildcard
92    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                    // First part must match at start (unless pattern starts with *)
108                    return false;
109                }
110                pos += found + part.len();
111            } else {
112                return false;
113            }
114        }
115        // If pattern ends with *, any suffix is OK; otherwise must match to end
116        parts
117            .last()
118            .map_or(true, |p| p.is_empty() || pos == text.len())
119    }
120
121    /// Determine visibility of a task based on focus configuration
122    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        // Direct match - always expanded
131        if self.matches_focus(task_id, task_name) {
132            return TaskVisibility::Expanded;
133        }
134
135        // Ancestor of focused task - expanded to show path
136        if is_ancestor_of_focused {
137            return TaskVisibility::Expanded;
138        }
139
140        // Descendant of focused task - expanded
141        if is_descendant_of_focused {
142            return TaskVisibility::Expanded;
143        }
144
145        // Non-focused: check context depth
146        if depth < self.context_depth {
147            return TaskVisibility::Collapsed;
148        }
149
150        TaskVisibility::Hidden
151    }
152}
153
154/// Color theme for the Gantt chart
155#[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, // Wider to fit WBS codes + task names
213            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    /// Use dark theme
229    pub fn dark_theme(mut self) -> Self {
230        self.theme = GanttTheme::dark();
231        self
232    }
233
234    /// Configure chart width
235    pub fn chart_width(mut self, width: u32) -> Self {
236        self.chart_width = width;
237        self
238    }
239
240    /// Configure row height
241    pub fn row_height(mut self, height: u32) -> Self {
242        self.row_height = height;
243        self
244    }
245
246    /// Disable dependency arrows
247    pub fn hide_dependencies(mut self) -> Self {
248        self.show_dependencies = false;
249        self
250    }
251
252    /// Disable interactivity
253    pub fn static_chart(mut self) -> Self {
254        self.interactive = false;
255        self
256    }
257
258    /// Set focus view configuration
259    ///
260    /// Focus view expands tasks matching the patterns while collapsing others.
261    ///
262    /// # Arguments
263    /// * `patterns` - Task ID prefixes or glob patterns to expand
264    ///
265    /// # Example
266    /// ```ignore
267    /// let renderer = HtmlGanttRenderer::new()
268    ///     .focus(vec!["6.3.2".into(), "8.6".into()]);
269    /// ```
270    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    /// Set context depth for non-focused tasks
277    ///
278    /// * `0` = hide all non-focused tasks
279    /// * `1` = show only top-level containers (default)
280    /// * `2` = show two levels of hierarchy
281    ///
282    /// # Example
283    /// ```ignore
284    /// let renderer = HtmlGanttRenderer::new()
285    ///     .focus(vec!["6.3.2".into()])
286    ///     .context_depth(0); // Hide all context
287    /// ```
288    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    /// Calculate pixels per day based on date range
298    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    /// Convert a date to x position
304    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    /// Build flat list of tasks with hierarchy info, respecting focus configuration
310    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        // If no focus config, return all tasks as expanded
319        let Some(ref focus) = self.focus else {
320            return all_tasks;
321        };
322
323        // First pass: identify which task IDs are directly focused
324        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        // Second pass: identify ancestors of focused tasks
331        let ancestor_ids: std::collections::HashSet<String> = all_tasks
332            .iter()
333            .filter(|t| {
334                // Check if any focused task starts with this task's ID + "."
335                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        // Third pass: compute visibility and filter
345        let mut result = Vec::new();
346        let mut skip_children_of: Option<String> = None;
347
348        for mut task_display in all_tasks {
349            // Skip children of collapsed containers
350            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                    // Show this task but skip its children
378                    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, // Default, may be changed by focus logic
420            });
421
422            if !task.children.is_empty() {
423                self.collect_tasks(&task.children, schedule, &qualified_id, depth + 1, result);
424            }
425        }
426    }
427
428    /// Generate the complete HTML document
429    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    /// Generate the SVG content (without the outer <svg> tag)
500    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        // Background
512        svg.push_str(&format!(
513            r#"                <rect width="100%" height="100%" fill="{}"/>"#,
514            self.theme.background_color
515        ));
516        svg.push('\n');
517
518        // Grid
519        svg.push_str(&self.render_grid(tasks.len(), project_start, project_end, px_per_day));
520
521        // Header
522        svg.push_str(&self.render_header(project_start, project_end, px_per_day));
523
524        // Task bars
525        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        // Dependency arrows
530        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    /// Render the timeline header
544    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        // Header background
553        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        // Calculate date interval
564        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        // Date labels
576        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            // Tick mark
581            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            // Date label
591            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        // Month/year label
610        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    /// Render grid lines
624    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        // Horizontal lines
636        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        // Vertical lines
649        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    /// Render a single task row
670    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        // Check if task is collapsed (focus view)
684        let is_collapsed = task_display.visibility == TaskVisibility::Collapsed;
685
686        // Indent for hierarchy
687        let indent = task_display.depth as u32 * 16;
688        let label_x = self.padding + 8 + indent;
689
690        // Container expand/collapse icon
691        // ▶ for collapsed, ▼ for expanded
692        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" // Muted gray for collapsed
698            } 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        // Task label - calculate max chars based on available width
713        // ~7px per char at 12px font, subtract indent (16px per level) and some margin
714        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" // Muted gray for collapsed tasks
719        } 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        // Task bar (if scheduled)
732        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                // Diamond for milestone (not shown when collapsed)
741                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                // Collapsed container: solid muted bar with distinct style
757                let collapsed_color = "#b8c0cc"; // Light gray for collapsed
758                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                // Expanded container bar (bracket style)
773                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                // Regular task bar
786                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                // Progress overlay if complete percentage is set
804                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    /// Render dependency arrows
822    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        // Build task position map
835        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        // Draw arrows
843        for task_display in tasks {
844            if let Some(to_scheduled) = task_display.scheduled {
845                for dep in &task_display.task.depends {
846                    // Try to find the predecessor
847                    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    /// Resolve dependency path
881    fn resolve_dependency(
882        &self,
883        dep_path: &str,
884        from_id: &str,
885        positions: &HashMap<&str, (usize, &ScheduledTask)>,
886    ) -> Option<String> {
887        // Try absolute path first
888        if positions.contains_key(dep_path) {
889            return Some(dep_path.to_string());
890        }
891
892        // Try relative (sibling) resolution
893        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    /// Render a dependency arrow
905    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        // Create curved path
953        let mid_x = (from_x + to_x) / 2.0;
954        let path = if (to_row as i32 - from_row as i32).abs() <= 1 {
955            // Simple curve for adjacent rows
956            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            // More complex path for distant rows
962            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    /// Generate CSS styles
985    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    /// Generate JavaScript for interactivity
1114    fn generate_js(&self, tasks: &[TaskDisplay]) -> String {
1115        // Build task data for JS
1116        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
1233/// Task display info for rendering
1234struct 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 state for focus view
1243    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
1260/// HTML-escape a string
1261fn html_escape(s: &str) -> String {
1262    s.replace('&', "&amp;")
1263        .replace('<', "&lt;")
1264        .replace('>', "&gt;")
1265        .replace('"', "&quot;")
1266}
1267
1268/// Truncate a string with ellipsis
1269fn 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        // Should not have interactive JS
1486        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>"), "&lt;script&gt;");
1517        assert_eq!(html_escape("a & b"), "a &amp; 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        // Test row_height builder method (lines 123-125)
1529        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        // Test Start-to-Start dependency rendering (lines 623-625)
1536        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, // SS: starts at same time
1585                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        // Should contain dependency arrow path
1629        assert!(html.contains("dep-arrow"));
1630    }
1631
1632    #[test]
1633    fn html_gantt_with_ff_dependency() {
1634        // Test Finish-to-Finish dependency rendering (lines 628-630)
1635        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, // FF: finishes at same time
1685                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        // Test Start-to-Finish dependency rendering (lines 633-635)
1731        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, // SF: b finishes when a starts
1781                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    // =========================================================================
1825    // Focus View Tests (TDD)
1826    // =========================================================================
1827
1828    #[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        // Should match: task IDs starting with "6.3.2"
1840        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        // Should NOT match: different prefixes
1845        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        // Should match: task names containing "[6.3.2]"
1854        assert!(config.matches_focus("task_2259", "[6.3.2] OS Script Migration"));
1855
1856        // Should NOT match
1857        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        // Should match: glob pattern
1865        assert!(config.matches_focus("6.3.2.1", "Task"));
1866        assert!(config.matches_focus("7.3.2.5", "Task"));
1867
1868        // Should NOT match
1869        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        // "6" is ancestor of "6.3.2" - should be expanded to show path
1894        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        // "6.3.2.1" is descendant of "6.3" - should be expanded
1903        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        // Depth 0 (top-level): collapsed (within context_depth)
1912        let vis = config.get_visibility("7", "Other Section", 0, false, false);
1913        assert_eq!(vis, TaskVisibility::Collapsed);
1914
1915        // Depth 1: hidden (exceeds context_depth)
1916        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        // context_depth=0 means hide all non-focused tasks
1925        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        // Depth 0: collapsed
1934        let vis = config.get_visibility("7", "Other", 0, false, false);
1935        assert_eq!(vis, TaskVisibility::Collapsed);
1936
1937        // Depth 1: collapsed (within context_depth=2)
1938        let vis = config.get_visibility("7.1", "Subsection", 1, false, false);
1939        assert_eq!(vis, TaskVisibility::Collapsed);
1940
1941        // Depth 2: hidden
1942        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}