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, populate_table_todays_timelogs,
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_today_table = create_table_todays_timelogs(time_logs);
115    let timelogs_by_label_table =
116        create_table_total_time_by_label(time_logs, label_filter, label_others);
117    let timelogs_by_milestone_table = create_table_timelogs_by_milestone(time_logs);
118
119    // Replace the today's time log table with text if there are no time logs.
120    let timelogs_today_table = match timelogs_today_table {
121        Some(table) => table.to_html_string(),
122        None => "<p class='table-no-data'>No time logs for today.</p>".to_string(),
123    };
124
125    // Extract the JS from the generated chart HTML files.
126    let mut chart_files = fs::read_dir(charts_dir)?
127        // Filter out unreadable files
128        .filter_map(Result::ok)
129        // Get the path to each file
130        .map(|entry| entry.path())
131        // Filter out non-HTML files
132        .filter(|file| file.extension().is_some_and(|ext| ext == "html"))
133        .collect::<Vec<_>>();
134
135    // Sort the files by name so that the order is deterministic.
136    chart_files.sort();
137
138    let charts_js = chart_files
139        .into_iter()
140        // Get an index for each HTML file
141        .enumerate()
142        .map(|(index, html_file)| extract_js_from_html_files(index, &html_file))
143        .collect::<Result<Vec<_>, _>>()?;
144
145    let charts_divs = create_chart_divs(charts_js.len());
146
147    // Take the external script tags from the first chart and use them for all charts.
148    let chart_external_script_tags = charts_js
149        .first()
150        .map(|js| js.external_script_tags.clone())
151        .unwrap_or_default();
152
153    // Get the chart JS code from all charts and join them together.
154    let chart_js_code = charts_js
155        .into_iter()
156        .map(|js| js.chart)
157        .collect::<Vec<_>>()
158        .join("\n");
159
160    let main_title = &format!("{repository_name} Time Tracking Dashboard");
161
162    #[rustfmt::skip]
163    let html = TEMPLATE
164        .replace("$main_title", main_title)
165        .replace("$timestamp", &create_timestamp())
166        .replace("$sub_title_time_per_user", "Time Spent per User:")
167        .replace("$table_time_per_user", &timeframe_by_user_table.to_html_string())
168        .replace("$sub_title_time_logs_today", "Today's Time Logs:")
169        .replace("$table_time_logs_today", &timelogs_today_table)
170        .replace("$sub_title_time_per_label", "Time Spent per Label:")
171        .replace("$table_time_per_label", &timelogs_by_label_table.to_html_string())
172        .replace("$sub_title_time_per_milestone", "Time Spent per Milestone:")
173        .replace("$table_time_per_milestone", &timelogs_by_milestone_table.to_html_string())
174        .replace("$charts_divs", &charts_divs)
175        .replace("$external_script_tags", &chart_external_script_tags)
176        .replace("$charts_js", &chart_js_code);
177
178    Ok(html)
179}
180
181/// Creates a string with `index` number of `<div>` tags for each chart.
182fn create_chart_divs(index: usize) -> String {
183    use std::fmt::Write;
184    (0..index).fold(String::new(), |mut str, i| {
185        let _ = write!(str, r#"<div id="chart-{i}"></div>"#);
186        str
187    })
188}
189
190/// Creates the Table that shows the time spent per user in the last N days.
191fn create_table_timelogs_in_timeframes_by_user(time_logs: &[TimeLog]) -> Table {
192    let (mut table_data, table_header) = populate_table_timelogs_in_timeframes_by_user(time_logs);
193
194    let totals_row = table_data
195        .pop()
196        .expect("Table should always have at least one row");
197
198    let mut table = Table::from(table_data).with_header_row(table_header);
199    let mut footer_row = TableRow::new().with_attributes([("class", "total-row")]);
200
201    for cell_text in totals_row {
202        footer_row = footer_row.with_cell(TableCell::new(TableCellType::Data).with_raw(cell_text));
203    }
204    table.add_custom_footer_row(footer_row);
205    table
206}
207
208/// Creates a table showing the time logs from today's date. If there are no time logs,
209/// `None` is returned.
210fn create_table_todays_timelogs(time_logs: &[TimeLog]) -> Option<Table> {
211    const DATETIME_INDEX: usize = 0;
212
213    let (mut table_data, table_header) = populate_table_todays_timelogs(time_logs);
214    if table_data.is_empty() {
215        return None;
216    }
217
218    wrap_column_in_span(&mut table_data, DATETIME_INDEX);
219    Some(Table::from(table_data).with_header_row(table_header))
220}
221
222/// Creates the Table that shows the time spent per label.
223fn create_table_total_time_by_label(
224    time_logs: &[TimeLog],
225    label_filter: Option<&HashSet<String>>,
226    label_others: Option<&Label>,
227) -> Table {
228    let (table_data, table_header) =
229        populate_table_timelogs_by_label(time_logs, label_filter, label_others);
230    Table::from(table_data).with_header_row(table_header)
231}
232
233/// Creates a Table that shows the time spent per milestone.
234fn create_table_timelogs_by_milestone(time_logs: &[TimeLog]) -> Table {
235    let (table_data, table_header) = populate_table_timelogs_by_milestone(time_logs);
236    Table::from(table_data).with_header_row(table_header)
237}
238
239/// Wraps the given column index in a `<span class='timestamp'>` tag.
240/// The JS in the dashboard will then convert them to the user's locale.
241fn wrap_column_in_span(table: &mut [Vec<String>], index: usize) {
242    for row in table.iter_mut() {
243        let date = chrono::DateTime::parse_from_rfc2822(&row[index])
244            .expect("Date should be in RFC2822 format")
245            .to_rfc3339();
246        row[index] = format!("<span class='timestamp'>{date}</span>");
247    }
248}
249
250/// Extracts the content of the last `<script>` tag from an HTML file.
251fn extract_js_from_html_files(
252    index: usize,
253    entry: &PathBuf,
254) -> Result<ExtractedChartJs, HtmlError> {
255    extract_charming_chart_js(&fs::read_to_string(entry)?, &format!("chart-{index}"))
256}
257
258/// Extracts the content of the last `<script>` tag from an HTML string.
259fn extract_charming_chart_js(
260    html: &str,
261    target_div_id: &str,
262) -> Result<ExtractedChartJs, HtmlError> {
263    let document_root = Html::parse_document(html);
264    let script_tags_selector = Selector::parse("script").expect("Selector should always be valid");
265
266    // Extract the <script src=""> tags that load the ECharts JavaScript libraries.
267    let external_script_tags = document_root
268        .select(&script_tags_selector)
269        .filter(|node| node.attr("src").is_some())
270        .map(|node| node.html())
271        .collect::<Vec<_>>()
272        .join("\n");
273
274    // Extract the <script> tag that defines the chart.
275    let chart_script_tag = document_root
276        .select(&script_tags_selector)
277        .next_back()
278        .ok_or(HtmlError::ChartExtraction(
279            "No <script> tag found in chart HTML".to_string(),
280        ))?;
281
282    let script_body = chart_script_tag.text().collect::<String>()
283        .replace(
284            "document.getElementById('chart')",
285            &format!("document.getElementById('{target_div_id}')"),
286        )
287        .replace(
288            "chart.setOption(option);",
289            "if (typeof getChartBackgroundColor === 'function') { option.backgroundColor = getChartBackgroundColor(); }\n  chart.setOption(option);",
290        );
291
292    // Wrap in a function to avoid leaking vars into global scope
293    let wrapped = format!("(function() {{\n{script_body}\n}})();");
294
295    Ok(ExtractedChartJs {
296        chart: wrapped,
297        external_script_tags,
298    })
299}
300
301/// Errors that can occur during HTML file creation.
302#[derive(Debug, thiserror::Error)]
303pub enum HtmlError {
304    /// An error has occurred when reading or writing files to disk.
305    #[error("I/O error while reading/writing HTML file: {0}")]
306    Io(#[from] std::io::Error),
307
308    /// An error occurred when extracting the chart JS from the Charming-generated HTML.
309    #[error("Error extracting chart JS from Charming HTML: {0}")]
310    ChartExtraction(String),
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::model::{
317        Issue, MergeRequest, TrackableItem, TrackableItemFields, TrackableItemKind, User, UserNodes,
318    };
319    use chrono::{Duration, Local, SecondsFormat};
320    use std::sync::{Arc, Mutex};
321    use tempfile::tempdir;
322
323    const REPOSITORY_NAME: &str = "Test Repository";
324    const HTML_FILE_NAME: &str = "test-repository_dashboard.html";
325    const EXTERNAL_SCRIPTS: &str = r#"<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
326<script src="https://cdn.jsdelivr.net/npm/echarts-gl@2.0.9/dist/echarts-gl.min.js"></script>"#;
327
328    fn get_timelogs() -> Vec<TimeLog> {
329        vec![
330            TimeLog {
331                spent_at: Local::now(),
332                time_spent: Duration::seconds(3600),
333                summary: Some("Timelog 1 Summary".to_string()),
334                user: User {
335                    name: "User 1".to_string(),
336                    username: String::default(),
337                },
338                trackable_item: TrackableItem {
339                    common: TrackableItemFields {
340                        id: 1,
341                        title: "Issue Title".to_string(),
342                        time_estimate: Duration::seconds(4200),
343                        total_time_spent: Duration::seconds(3600),
344                        ..Default::default()
345                    },
346                    kind: TrackableItemKind::Issue(Issue::default()),
347                },
348            },
349            TimeLog {
350                spent_at: Local::now() - Duration::days(1),
351                time_spent: Duration::seconds(3600),
352                summary: Some("Timelog 2 Summary".to_string()),
353                user: User {
354                    name: "User 2".to_string(),
355                    username: String::default(),
356                },
357                trackable_item: TrackableItem {
358                    common: TrackableItemFields {
359                        id: 2,
360                        title: "MR Title".to_string(),
361                        time_estimate: Duration::seconds(2700),
362                        total_time_spent: Duration::seconds(3600),
363                        ..Default::default()
364                    },
365                    kind: TrackableItemKind::MergeRequest(MergeRequest {
366                        reviewers: UserNodes { users: vec![] },
367                    }),
368                },
369            },
370        ]
371    }
372
373    fn setup_charts_dir(path: &Path) {
374        let charts_dir = path.join("charts");
375        if !charts_dir.exists() {
376            fs::create_dir(path.join("charts")).unwrap();
377        }
378
379        fs::write(
380            path.join("charts/burndown-per-person.html"),
381            format!(
382                "<html><body>
383                {EXTERNAL_SCRIPTS}
384                <script>
385                var chart = echarts.init(document.getElementById('chart');
386                var option = {{
387                    title: {{ text: 'Burndown Chart per Person' }},
388                }}
389                </script></div></body></html>"
390            ),
391        )
392        .unwrap();
393
394        fs::write(
395            path.join("charts/barchart-Users.html"),
396            format!(
397                "<html><body>
398                {EXTERNAL_SCRIPTS}
399                <script>
400                var chart = echarts.init(document.getElementById('chart');
401                var option = {{
402                    title: {{ text: 'Hours spent by Users' }},
403                }}
404                </script></div></body></html>",
405            ),
406        )
407        .unwrap();
408    }
409
410    #[test]
411    fn test_create_html_mocked() {
412        let root_dir = tempdir().unwrap();
413        setup_charts_dir(root_dir.path());
414        let root_dir_path = root_dir.path().to_path_buf();
415
416        let mut mock_writer = MockHtmlWriter::new();
417        let captured_html = Arc::new(Mutex::new(String::new()));
418        let clone_for_closure = Arc::clone(&captured_html);
419        let root_dir_path_clone = root_dir_path.clone();
420
421        mock_writer
422            .expect_write_html()
423            .times(1)
424            .withf(move |_, path| path == root_dir_path_clone.join(HTML_FILE_NAME))
425            .returning(move |data, _| {
426                // Extract the HTML from the closure
427                *clone_for_closure.lock().unwrap() = data.to_string();
428                Ok(())
429            });
430        let time_logs = get_timelogs();
431
432        let charts_dir = root_dir.path().join("charts");
433        let result = create_html_with_writer(
434            &time_logs,
435            &charts_dir,
436            None,
437            None,
438            REPOSITORY_NAME,
439            &mock_writer,
440        );
441        assert!(result.is_ok());
442        assert_eq!(result.unwrap(), root_dir_path.join(HTML_FILE_NAME));
443
444        let html = captured_html.lock().unwrap();
445
446        assert!(html.contains(REPOSITORY_NAME));
447
448        assert!(html.contains("<table>"));
449        assert!(html.contains("<th>User</th>"));
450        assert!(html.contains("<th>Today</th>"));
451        assert!(html.contains("<td>User 1</td>"));
452        assert!(html.contains("<td>01h 00m</td>"));
453
454        assert!(html.contains("chart-0"));
455        assert!(html.contains("script src"));
456        assert!(html.contains("title: { text: 'Burndown Chart per Person' }"));
457    }
458
459    #[test]
460    fn test_create_html_string() {
461        let time_logs = get_timelogs();
462        let root_dir = tempdir().unwrap();
463        setup_charts_dir(root_dir.path());
464        let html = create_html_string(
465            &time_logs,
466            None,
467            None,
468            &root_dir.path().join("charts"),
469            REPOSITORY_NAME,
470        );
471        assert!(html.is_ok());
472        let html = html.unwrap();
473        assert!(html.contains("<table>"));
474        assert!(html.contains("<th>User</th>"));
475        assert!(html.contains("<th>Today</th>"));
476        assert!(html.contains("var chart = echarts.init(document.getElementById('chart-0')"));
477        assert!(html.contains("var chart = echarts.init(document.getElementById('chart-1')"));
478    }
479
480    #[test]
481    fn test_wrap_column_in_span() {
482        const DATETIME_INDEX: usize = 0;
483        const NUM_TODAY_LOGS: usize = 1;
484
485        let time_logs = get_timelogs();
486        let (mut table_data, table_header) = populate_table_todays_timelogs(&time_logs);
487        assert_eq!(table_header[DATETIME_INDEX], "Date");
488        assert_eq!(table_data.len(), NUM_TODAY_LOGS);
489
490        wrap_column_in_span(&mut table_data, DATETIME_INDEX);
491
492        let now = Local::now();
493        let formatted_now = now.to_rfc3339_opts(SecondsFormat::Secs, false);
494        assert_eq!(
495            table_data[0][DATETIME_INDEX],
496            format!("<span class='timestamp'>{formatted_now}</span>")
497        );
498    }
499
500    #[test]
501    fn test_extract_charming_chart_js_from_string() {
502        let html = EXTERNAL_SCRIPTS.to_string()
503            + r#"
504            <div id="chart"></div>
505            <script>
506                var chart = echarts.init(document.getElementById('chart'));
507            </script>"#;
508        let result = extract_charming_chart_js(&html, "chart-0").unwrap();
509
510        let js_code = result.chart;
511        assert!(js_code.contains("var chart"));
512        assert!(js_code.starts_with("(function() {"));
513        assert!(js_code.ends_with("})();"));
514        assert!(js_code.contains("document.getElementById('chart-0')"));
515        assert!(!js_code.contains("document.getElementById('chart')"));
516
517        let external_script_tags = result.external_script_tags;
518        assert_eq!(external_script_tags, EXTERNAL_SCRIPTS);
519    }
520
521    #[test]
522    fn test_extract_charming_chart_js_from_string_nonexisting_tag() {
523        let html = r#"
524            <div id="chart"></div>
525        "#;
526        let result = extract_charming_chart_js(html, "chart-0");
527        let error_msg = "No <script> tag found in chart HTML";
528        assert!(
529            matches!(result,Err(HtmlError::ChartExtraction(err_msg)) if err_msg.eq(&error_msg))
530        );
531    }
532
533    #[test]
534    fn test_create_chart_divs() {
535        let divs = create_chart_divs(3);
536        assert_eq!(
537            divs,
538            r#"<div id="chart-0"></div><div id="chart-1"></div><div id="chart-2"></div>"#
539        );
540    }
541}