1use 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))]
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
61fn 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
97fn create_timestamp() -> String {
99 chrono::Local::now().to_rfc3339()
100}
101
102fn 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 let mut chart_files = fs::read_dir(charts_dir)?
120 .filter_map(Result::ok)
122 .map(|entry| entry.path())
124 .filter(|file| file.extension().is_some_and(|ext| ext == "html"))
126 .collect::<Vec<_>>();
127
128 chart_files.sort();
130
131 let charts_js = chart_files
132 .into_iter()
133 .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 let chart_external_script_tags = charts_js
142 .first()
143 .map(|js| js.external_script_tags.clone())
144 .unwrap_or_default();
145
146 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
170fn 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
179fn 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
197fn 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
208fn 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
214fn 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
222fn 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 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 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 let wrapped = format!("(function() {{\n{script_body}\n}})();");
258
259 Ok(ExtractedChartJs {
260 chart: wrapped,
261 external_script_tags,
262 })
263}
264
265#[derive(Debug, thiserror::Error)]
267pub enum HtmlError {
268 #[error("I/O error while reading/writing HTML file: {0}")]
270 Io(#[from] std::io::Error),
271
272 #[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 *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}