gitlab_time_report/dashboard/
html.rs

1//! Contains the methods to create a HTML file with statistics from a list of time logs.
2
3use crate::model::{Label, TimeLog};
4use crate::tables::{
5    populate_table_timelogs_by_label, populate_table_timelogs_by_milestone,
6    populate_table_timelogs_in_timeframes_by_user,
7};
8use build_html::{Html as HtmlBuilder, HtmlContainer, Table, TableCell, TableCellType, TableRow};
9#[cfg(test)]
10use mockall::automock;
11use scraper::{Html, Selector};
12use std::collections::HashSet;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16/// Contains the method to abstract the writing of HTML files
17#[cfg_attr(test, automock)]
18trait HtmlWriter {
19    fn write_html(&self, data: &str, path: &Path) -> Result<(), HtmlError>;
20}
21
22/// Default implementation that writes to actual files
23struct FileHtmlWriter;
24
25impl HtmlWriter for FileHtmlWriter {
26    fn write_html(&self, data: &str, path: &Path) -> Result<(), HtmlError> {
27        Ok(fs::write(path, data)?)
28    }
29}
30
31/// The JavaScript extracted from the Charming-generated HTML files.
32struct ExtractedChartJs {
33    /// The function that defines the chart.
34    chart: String,
35    /// `<script>` tags that load the `ECharts` JavaScript in the dashboard.
36    external_script_tags: String,
37}
38
39/// Creates a HTML file at `path` that contains statistics and charts about the given time logs.
40/// # Errors
41/// Possible errors can be seen in [`HtmlError`].
42#[cfg(not(tarpaulin_include))]
43pub fn create_html(
44    time_logs: &[TimeLog],
45    charts_dir: &Path,
46    label_filter: Option<&HashSet<String>>,
47    label_others: Option<&Label>,
48    repository_name: &str,
49) -> Result<(), HtmlError> {
50    create_html_with_writer(
51        time_logs,
52        charts_dir,
53        label_filter,
54        label_others,
55        repository_name,
56        &FileHtmlWriter,
57    )
58}
59
60/// Implementation of [`create_html()`] that takes a [`HtmlWriter`] to write the file.
61fn create_html_with_writer(
62    time_logs: &[TimeLog],
63    charts_dir: &Path,
64    label_filter: Option<&HashSet<String>>,
65    label_others: Option<&Label>,
66    repository_name: &str,
67    writer: &impl HtmlWriter,
68) -> Result<(), HtmlError> {
69    let parent_directory = charts_dir
70        .parent()
71        .ok_or(HtmlError::Io(std::io::Error::new(
72            std::io::ErrorKind::InvalidInput,
73            "Path does not contain parent directory",
74        )))?;
75    let html_path = parent_directory.join("dashboard.html");
76
77    let html_string = create_html_string(
78        time_logs,
79        label_filter,
80        label_others,
81        charts_dir,
82        repository_name,
83    )?;
84    writer.write_html(&html_string, &html_path)?;
85    Ok(())
86}
87
88/// Creates a string with the current timestamp in the ISO 8601 format.
89fn create_timestamp() -> String {
90    chrono::Local::now().to_rfc3339()
91}
92
93/// Creates an HTML string from the given time logs and the HTML template
94/// which then is used to create the HTML page.
95fn create_html_string(
96    time_logs: &[TimeLog],
97    label_filter: Option<&HashSet<String>>,
98    label_others: Option<&Label>,
99    charts_dir: &Path,
100    repository_name: &str,
101) -> Result<String, HtmlError> {
102    const TEMPLATE: &str = include_str!("templates/base.html");
103
104    let timeframe_by_user_table = create_table_timelogs_in_timeframes_by_user(time_logs);
105    let timelogs_by_label_table =
106        create_table_total_time_by_label(time_logs, label_filter, label_others);
107    let timelogs_by_milestone_table = create_table_timelogs_by_milestone(time_logs);
108
109    // Extract the JS from the generated chart HTML files.
110    let mut chart_files = fs::read_dir(charts_dir)?
111        // Filter out unreadable files
112        .filter_map(Result::ok)
113        // Get the path to each file
114        .map(|entry| entry.path())
115        // Filter out non-HTML files
116        .filter(|file| file.extension().is_some_and(|ext| ext == "html"))
117        .collect::<Vec<_>>();
118
119    // Sort the files by name so that the order is deterministic.
120    chart_files.sort();
121
122    let charts_js = chart_files
123        .into_iter()
124        // Get an index for each HTML file
125        .enumerate()
126        .map(|(index, html_file)| extract_js_from_html_files(index, &html_file))
127        .collect::<Result<Vec<_>, _>>()?;
128
129    let charts_divs = create_chart_divs(charts_js.len());
130
131    // Take the external script tags from the first chart and use them for all charts.
132    let chart_external_script_tags = charts_js
133        .first()
134        .map(|js| js.external_script_tags.clone())
135        .unwrap_or_default();
136
137    // Get the chart JS code from all charts and join them together.
138    let chart_js_code = charts_js
139        .into_iter()
140        .map(|js| js.chart)
141        .collect::<Vec<_>>()
142        .join("\n");
143
144    let main_title = &format!("{repository_name} Time Tracking Dashboard");
145    let html = TEMPLATE
146        .replace("$main_title", main_title)
147        .replace("$timestamp", &create_timestamp())
148        .replace("$sub_title_1", "Time Spent per User:")
149        .replace("$content_1", &timeframe_by_user_table.to_html_string())
150        .replace("$sub_title_2", "Time Spent per Label:")
151        .replace("$content_2", &timelogs_by_label_table.to_html_string())
152        .replace("$sub_title_3", "Time Spent per Milestone:")
153        .replace("$content_3", &timelogs_by_milestone_table.to_html_string())
154        .replace("$charts_divs", &charts_divs)
155        .replace("$external_script_tags", &chart_external_script_tags)
156        .replace("$charts_js", &chart_js_code);
157
158    Ok(html)
159}
160
161/// Creates a string with `index` number of `<div>` tags for each chart.
162fn create_chart_divs(index: usize) -> String {
163    use std::fmt::Write;
164    (0..index).fold(String::new(), |mut str, i| {
165        let _ = write!(str, r#"<div id="chart-{i}"></div>"#);
166        str
167    })
168}
169
170/// Creates the Table that shows the time spent per user in the last N days.
171fn create_table_timelogs_in_timeframes_by_user(time_logs: &[TimeLog]) -> Table {
172    let (mut table_data, table_header) = populate_table_timelogs_in_timeframes_by_user(time_logs);
173
174    let totals_row = table_data
175        .pop()
176        .expect("Table should always have at least one row");
177
178    let mut table = Table::from(table_data).with_header_row(table_header);
179    let mut footer_row = TableRow::new().with_attributes([("class", "total-row")]);
180
181    for cell_text in totals_row {
182        footer_row = footer_row.with_cell(TableCell::new(TableCellType::Data).with_raw(cell_text));
183    }
184    table.add_custom_footer_row(footer_row);
185    table
186}
187
188/// Creates the Table that shows the time spent per label.
189fn create_table_total_time_by_label(
190    time_logs: &[TimeLog],
191    label_filter: Option<&HashSet<String>>,
192    label_others: Option<&Label>,
193) -> Table {
194    let (table_data, table_header) =
195        populate_table_timelogs_by_label(time_logs, label_filter, label_others);
196    Table::from(table_data).with_header_row(table_header)
197}
198
199/// Creates a Table that shows the time spent per milestone.
200fn create_table_timelogs_by_milestone(time_logs: &[TimeLog]) -> Table {
201    let (table_data, table_header) = populate_table_timelogs_by_milestone(time_logs);
202    Table::from(table_data).with_header_row(table_header)
203}
204
205/// Extracts the content of the last `<script>` tag from an HTML file.
206fn extract_js_from_html_files(
207    index: usize,
208    entry: &PathBuf,
209) -> Result<ExtractedChartJs, HtmlError> {
210    extract_charming_chart_js(&fs::read_to_string(entry)?, &format!("chart-{index}"))
211}
212
213/// Extracts the content of the last `<script>` tag from an HTML string.
214fn extract_charming_chart_js(
215    html: &str,
216    target_div_id: &str,
217) -> Result<ExtractedChartJs, HtmlError> {
218    let document_root = Html::parse_document(html);
219    let script_tags_selector = Selector::parse("script").expect("Selector should always be valid");
220
221    // Extract the <script src=""> tags that load the ECharts JavaScript libraries.
222    let external_script_tags = document_root
223        .select(&script_tags_selector)
224        .filter(|node| node.attr("src").is_some())
225        .map(|node| node.html())
226        .collect::<Vec<_>>()
227        .join("\n");
228
229    // Extract the <script> tag that defines the chart.
230    let chart_script_tag = document_root
231        .select(&script_tags_selector)
232        .next_back()
233        .ok_or(HtmlError::ChartExtraction(
234            "No <script> tag found in chart HTML".to_string(),
235        ))?;
236
237    let script_body = chart_script_tag.text().collect::<String>()
238        .replace(
239        "document.getElementById('chart')",
240        &format!("document.getElementById('{target_div_id}')")
241        )
242        .replace(
243            "chart.setOption(option);",
244            "if (typeof getChartBackgroundColor === 'function') { option.backgroundColor = getChartBackgroundColor(); }\n  chart.setOption(option);",
245        );
246
247    // Wrap in a function to avoid leaking vars into global scope
248    let wrapped = format!("(function() {{\n{script_body}\n}})();");
249
250    Ok(ExtractedChartJs {
251        chart: wrapped,
252        external_script_tags,
253    })
254}
255
256/// Errors that can occur during HTML file creation.
257#[derive(Debug, thiserror::Error)]
258pub enum HtmlError {
259    /// An error has occurred when reading or writing files to disk.
260    #[error("I/O error while reading/writing HTML file: {0}")]
261    Io(#[from] std::io::Error),
262
263    /// An error occurred when extracting the chart JS from the Charming-generated HTML.
264    #[error("Error extracting chart JS from Charming HTML: {0}")]
265    ChartExtraction(String),
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::model::{
272        Issue, MergeRequest, TrackableItem, TrackableItemFields, TrackableItemKind, User, UserNodes,
273    };
274    use chrono::{Duration, Local};
275    use std::sync::{Arc, Mutex};
276    use tempfile::tempdir;
277
278    const REPOSITORY_NAME: &str = "test-repository";
279    const EXTERNAL_SCRIPTS: &str = r#"<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
280<script src="https://cdn.jsdelivr.net/npm/echarts-gl@2.0.9/dist/echarts-gl.min.js"></script>"#;
281
282    fn get_timelogs() -> Vec<TimeLog> {
283        vec![
284            TimeLog {
285                spent_at: Local::now(),
286                time_spent: Duration::seconds(3600),
287                summary: Some("Timelog 1 Summary".to_string()),
288                user: User {
289                    name: "User 1".to_string(),
290                    username: String::default(),
291                },
292                trackable_item: TrackableItem {
293                    common: TrackableItemFields {
294                        id: 1,
295                        title: "Issue Title".to_string(),
296                        time_estimate: Duration::seconds(4200),
297                        total_time_spent: Duration::seconds(3600),
298                        ..Default::default()
299                    },
300                    kind: TrackableItemKind::Issue(Issue::default()),
301                },
302            },
303            TimeLog {
304                spent_at: Local::now() - Duration::days(1),
305                time_spent: Duration::seconds(3600),
306                summary: Some("Timelog 2 Summary".to_string()),
307                user: User {
308                    name: "User 2".to_string(),
309                    username: String::default(),
310                },
311                trackable_item: TrackableItem {
312                    common: TrackableItemFields {
313                        id: 2,
314                        title: "MR Title".to_string(),
315                        time_estimate: Duration::seconds(2700),
316                        total_time_spent: Duration::seconds(3600),
317                        ..Default::default()
318                    },
319                    kind: TrackableItemKind::MergeRequest(MergeRequest {
320                        reviewers: UserNodes { users: vec![] },
321                    }),
322                },
323            },
324        ]
325    }
326
327    fn setup_charts_dir(path: &Path) {
328        let charts_dir = path.join("charts");
329        if !charts_dir.exists() {
330            fs::create_dir(path.join("charts")).unwrap();
331        }
332
333        fs::write(
334            path.join("charts/burndown-per-person.html"),
335            format!(
336                "<html><body>
337                {EXTERNAL_SCRIPTS}
338                <script>
339                var chart = echarts.init(document.getElementById('chart');
340                var option = {{
341                    title: {{ text: 'Burndown Chart per Person' }},
342                }}
343                </script></div></body></html>"
344            ),
345        )
346        .unwrap();
347
348        fs::write(
349            path.join("charts/barchart-Users.html"),
350            format!(
351                "<html><body>
352                {EXTERNAL_SCRIPTS}
353                <script>
354                var chart = echarts.init(document.getElementById('chart');
355                var option = {{
356                    title: {{ text: 'Hours spent by Users' }},
357                }}
358                </script></div></body></html>",
359            ),
360        )
361        .unwrap();
362    }
363
364    #[test]
365    fn test_create_html_mocked() {
366        let root_dir = tempdir().unwrap();
367        setup_charts_dir(root_dir.path());
368        let root_dir_path = root_dir.path().to_path_buf();
369
370        let mut mock_writer = MockHtmlWriter::new();
371        let captured_html = Arc::new(Mutex::new(String::new()));
372        let clone_for_closure = Arc::clone(&captured_html);
373
374        mock_writer
375            .expect_write_html()
376            .times(1)
377            .withf(move |_, path| path == root_dir_path.join("dashboard.html"))
378            .returning(move |data, _| {
379                // Extract the HTML from the closure
380                *clone_for_closure.lock().unwrap() = data.to_string();
381                Ok(())
382            });
383        let time_logs = get_timelogs();
384
385        let charts_dir = root_dir.path().join("charts");
386        let result = create_html_with_writer(
387            &time_logs,
388            &charts_dir,
389            None,
390            None,
391            REPOSITORY_NAME,
392            &mock_writer,
393        );
394        assert!(result.is_ok());
395        let html = captured_html.lock().unwrap();
396
397        assert!(html.contains(REPOSITORY_NAME));
398
399        assert!(html.contains("<table>"));
400        assert!(html.contains("<th>User</th>"));
401        assert!(html.contains("<th>Today</th>"));
402        assert!(html.contains("<td>User 1</td>"));
403        assert!(html.contains("<td>01h 00m</td>"));
404
405        assert!(html.contains("chart-0"));
406        assert!(html.contains("script src"));
407        assert!(html.contains("title: { text: 'Burndown Chart per Person' }"));
408    }
409
410    #[test]
411    fn test_create_html_string() {
412        let time_logs = get_timelogs();
413        let root_dir = tempdir().unwrap();
414        setup_charts_dir(root_dir.path());
415        let html = create_html_string(
416            &time_logs,
417            None,
418            None,
419            &root_dir.path().join("charts"),
420            REPOSITORY_NAME,
421        );
422        assert!(html.is_ok());
423        let html = html.unwrap();
424        assert!(html.contains("<table>"));
425        assert!(html.contains("<th>User</th>"));
426        assert!(html.contains("<th>Today</th>"));
427        assert!(html.contains("var chart = echarts.init(document.getElementById('chart-0')"));
428        assert!(html.contains("var chart = echarts.init(document.getElementById('chart-1')"));
429    }
430
431    #[test]
432    fn test_extract_charming_chart_js_from_string() {
433        let html = EXTERNAL_SCRIPTS.to_string()
434            + r#"
435            <div id="chart"></div>
436            <script>
437                var chart = echarts.init(document.getElementById('chart'));
438            </script>"#;
439        let result = extract_charming_chart_js(&html, "chart-0").unwrap();
440
441        let js_code = result.chart;
442        assert!(js_code.contains("var chart"));
443        assert!(js_code.starts_with("(function() {"));
444        assert!(js_code.ends_with("})();"));
445        assert!(js_code.contains("document.getElementById('chart-0')"));
446        assert!(!js_code.contains("document.getElementById('chart')"));
447
448        let external_script_tags = result.external_script_tags;
449        assert_eq!(external_script_tags, EXTERNAL_SCRIPTS);
450    }
451
452    #[test]
453    fn test_extract_charming_chart_js_from_string_nonexisting_tag() {
454        let html = r#"
455            <div id="chart"></div>
456        "#;
457        let result = extract_charming_chart_js(html, "chart-0");
458        let error_msg = "No <script> tag found in chart HTML";
459        assert!(
460            matches!(result,Err(HtmlError::ChartExtraction(err_msg)) if err_msg.eq(&error_msg))
461        );
462    }
463
464    #[test]
465    fn test_create_chart_divs() {
466        let divs = create_chart_divs(3);
467        assert_eq!(
468            divs,
469            r#"<div id="chart-0"></div><div id="chart-1"></div><div id="chart-2"></div>"#
470        );
471    }
472}