gitlab_time_report/dashboard/
html.rs1use 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#[cfg_attr(test, automock)]
18trait HtmlWriter {
19 fn write_html(&self, data: &str, path: &Path) -> Result<(), HtmlError>;
20}
21
22struct 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
31struct ExtractedChartJs {
33 chart: String,
35 external_script_tags: String,
37}
38
39#[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
60fn 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
88fn create_timestamp() -> String {
90 chrono::Local::now().to_rfc3339()
91}
92
93fn 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 let mut chart_files = fs::read_dir(charts_dir)?
111 .filter_map(Result::ok)
113 .map(|entry| entry.path())
115 .filter(|file| file.extension().is_some_and(|ext| ext == "html"))
117 .collect::<Vec<_>>();
118
119 chart_files.sort();
121
122 let charts_js = chart_files
123 .into_iter()
124 .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 let chart_external_script_tags = charts_js
133 .first()
134 .map(|js| js.external_script_tags.clone())
135 .unwrap_or_default();
136
137 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
161fn 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
170fn 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
188fn 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
199fn 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
205fn 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
213fn 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 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 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 let wrapped = format!("(function() {{\n{script_body}\n}})();");
249
250 Ok(ExtractedChartJs {
251 chart: wrapped,
252 external_script_tags,
253 })
254}
255
256#[derive(Debug, thiserror::Error)]
258pub enum HtmlError {
259 #[error("I/O error while reading/writing HTML file: {0}")]
261 Io(#[from] std::io::Error),
262
263 #[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 *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}