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