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, 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#[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_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 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 let mut chart_files = fs::read_dir(charts_dir)?
127 .filter_map(Result::ok)
129 .map(|entry| entry.path())
131 .filter(|file| file.extension().is_some_and(|ext| ext == "html"))
133 .collect::<Vec<_>>();
134
135 chart_files.sort();
137
138 let charts_js = chart_files
139 .into_iter()
140 .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 let chart_external_script_tags = charts_js
149 .first()
150 .map(|js| js.external_script_tags.clone())
151 .unwrap_or_default();
152
153 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
181fn 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
190fn 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
208fn 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
222fn 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
233fn 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
239fn 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
250fn 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
258fn 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 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 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 let wrapped = format!("(function() {{\n{script_body}\n}})();");
294
295 Ok(ExtractedChartJs {
296 chart: wrapped,
297 external_script_tags,
298 })
299}
300
301#[derive(Debug, thiserror::Error)]
303pub enum HtmlError {
304 #[error("I/O error while reading/writing HTML file: {0}")]
306 Io(#[from] std::io::Error),
307
308 #[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 *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}