1use crate::{
16 db::{pauses::Pauses, tasks::Tasks, workdays::Workdays},
17 libs::{
18 config::Config,
19 formatter::format_duration,
20 locale::{Language, Locale},
21 messages::Message,
22 report,
23 report_template::{FontSpec, ReportTemplate},
24 task::TaskFilter,
25 },
26 msg_error_anyhow, msg_info, msg_success,
27};
28use anyhow::Result;
29use chrono::{Datelike, Duration, Local, NaiveDate};
30use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook};
31use serde::{Deserialize, Serialize};
32use std::fs::File;
33use std::io::Write;
34use std::path::PathBuf;
35
36mod hourly;
37use hourly::{HourlyReport, assign_tasks_to_hour_slots, build_hourly_rows, classify_hour_slots};
38
39#[derive(Debug, Clone, Copy, clap::ValueEnum)]
41pub enum ExportFormat {
42 Csv,
43 Json,
45 Excel,
47}
48
49#[derive(Debug, Clone, Copy, clap::ValueEnum)]
51pub enum ExportData {
52 Report,
54 Tasks,
56 Summary,
58 All,
60}
61
62#[derive(Debug, Serialize, Deserialize)]
64pub struct ExportReport {
65 pub date: String,
67 pub start_time: String,
69 pub end_time: String,
71 pub total_hours: String,
73 pub productivity: f64,
75 pub intervals: Vec<ExportInterval>,
77 pub tasks: Vec<ExportTask>,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
83pub struct ExportInterval {
84 pub index: usize,
86 pub start: String,
88 pub end: String,
90 pub duration: String,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
96pub struct ExportTask {
97 pub id: i32,
99 pub name: String,
101 pub comment: String,
103 pub completeness: i32,
105}
106
107#[derive(Debug, Serialize, Deserialize)]
109pub struct ExportSummary {
110 pub month: String,
112 pub days: Vec<ExportDaySum>,
114 pub total_hours: String,
116 pub average_hours: String,
118 pub total_days: usize,
120}
121
122#[derive(Debug, Serialize, Deserialize)]
124pub struct ExportDaySum {
125 pub date: String,
127 pub hours: String,
129 pub is_workday: bool,
131}
132
133pub struct Exporter {
135 format: ExportFormat,
136 output_path: PathBuf,
137 hourly: bool,
144}
145
146impl Exporter {
147 pub fn new(format: ExportFormat, output_path: Option<PathBuf>) -> Self {
162 let default_name = format!("kasl_export_{}", Local::now().format("%Y%m%d_%H%M%S"));
164
165 let extension = match format {
167 ExportFormat::Csv => "csv",
168 ExportFormat::Json => "json",
169 ExportFormat::Excel => "xlsx",
170 };
171
172 let output_path = output_path.unwrap_or_else(|| PathBuf::from(format!("{}.{}", default_name, extension)));
174
175 Self {
176 format,
177 output_path,
178 hourly: false,
179 }
180 }
181
182 pub fn hourly(mut self, hourly: bool) -> Self {
185 self.hourly = hourly;
186 self
187 }
188
189 pub async fn export(&self, data_type: ExportData, date: NaiveDate) -> Result<()> {
203 match data_type {
204 ExportData::Report => self.export_report(date).await,
205 ExportData::Tasks => self.export_tasks(date).await,
206 ExportData::Summary => self.export_summary(date).await,
207 ExportData::All => self.export_all(date).await,
208 }
209 }
210
211 async fn export_report(&self, date: NaiveDate) -> Result<()> {
213 if self.hourly
217 && let ExportFormat::Excel = self.format
218 {
219 self.export_report_excel_hourly(date)?;
220 msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
221 return Ok(());
222 }
223
224 let report_data = self.gather_report_data(date)?;
226
227 match self.format {
229 ExportFormat::Csv => self.export_report_csv(&report_data)?,
230 ExportFormat::Json => self.export_report_json(&report_data)?,
231 ExportFormat::Excel => self.export_report_excel(&report_data)?,
232 }
233
234 msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
236 Ok(())
237 }
238
239 async fn export_tasks(&self, date: NaiveDate) -> Result<()> {
241 let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
243
244 let export_tasks: Vec<ExportTask> = tasks
246 .into_iter()
247 .map(|t| ExportTask {
248 id: t.id.unwrap_or(0),
249 name: t.name,
250 comment: t.comment,
251 completeness: t.completeness.unwrap_or(100),
252 })
253 .collect();
254
255 match self.format {
257 ExportFormat::Csv => self.export_tasks_csv(&export_tasks)?,
258 ExportFormat::Json => {
259 let json = serde_json::to_string_pretty(&export_tasks)?;
260 File::create(&self.output_path)?.write_all(json.as_bytes())?;
261 }
262 ExportFormat::Excel => self.export_tasks_excel(&export_tasks)?,
263 }
264
265 msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
267 Ok(())
268 }
269
270 async fn export_summary(&self, date: NaiveDate) -> Result<()> {
272 let summary_data = self.gather_summary_data(date)?;
274
275 match self.format {
277 ExportFormat::Csv => self.export_summary_csv(&summary_data)?,
278 ExportFormat::Json => {
279 let json = serde_json::to_string_pretty(&summary_data)?;
280 File::create(&self.output_path)?.write_all(json.as_bytes())?;
281 }
282 ExportFormat::Excel => self.export_summary_excel(&summary_data)?,
283 }
284
285 msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
287 Ok(())
288 }
289
290 async fn export_all(&self, date: NaiveDate) -> Result<()> {
293 msg_info!(Message::ExportingAllData);
294
295 if let ExportFormat::Json = self.format {
297 let report = self.gather_report_data(date).ok();
299 let tasks = Tasks::new()?
300 .fetch(TaskFilter::Date(date))?
301 .into_iter()
302 .map(|t| ExportTask {
303 id: t.id.unwrap_or(0),
304 name: t.name,
305 comment: t.comment,
306 completeness: t.completeness.unwrap_or(100),
307 })
308 .collect::<Vec<_>>();
309 let summary = self.gather_summary_data(date).ok();
310
311 let all_data = serde_json::json!({
313 "export_date": Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
314 "daily_report": report,
315 "tasks": tasks,
316 "monthly_summary": summary,
317 });
318
319 let json = serde_json::to_string_pretty(&all_data)?;
321 File::create(&self.output_path)?.write_all(json.as_bytes())?;
322 } else {
323 let base = self.output_path.file_stem().unwrap().to_string_lossy();
325 let ext = self.output_path.extension().unwrap().to_string_lossy();
326
327 let report_path = self.output_path.with_file_name(format!("{}_report.{}", base, ext));
329 let tasks_path = self.output_path.with_file_name(format!("{}_tasks.{}", base, ext));
330 let summary_path = self.output_path.with_file_name(format!("{}_summary.{}", base, ext));
331
332 let report_exporter = Exporter::new(self.format, Some(report_path));
334 let tasks_exporter = Exporter::new(self.format, Some(tasks_path));
335 let summary_exporter = Exporter::new(self.format, Some(summary_path));
336
337 report_exporter.export_report(date).await?;
339 tasks_exporter.export_tasks(date).await?;
340 summary_exporter.export_summary(date).await?;
341
342 return Ok(());
343 }
344
345 msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
347 Ok(())
348 }
349
350 fn gather_report_data(&self, date: NaiveDate) -> Result<ExportReport> {
356 let workday = Workdays::new()?
358 .fetch(date)?
359 .ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
360
361 let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
363 let pauses = Pauses::new()?.get_workday_pauses(&workday)?;
364
365 let end_time = report::workday_end_time(&workday, &pauses);
367
368 let intervals = report::calculate_work_intervals(&workday, &pauses);
370
371 let total_pause_duration = pauses.iter().filter_map(|p| p.duration).fold(Duration::zero(), |acc, d| acc + d);
373
374 let gross_duration = end_time - workday.start;
376 let net_duration = gross_duration - total_pause_duration;
377
378 let productivity = if gross_duration.num_seconds() > 0 {
382 (net_duration.num_seconds() as f64 / gross_duration.num_seconds() as f64) * 100.0
383 } else {
384 0.0
385 };
386
387 Ok(ExportReport {
389 date: date.format("%Y-%m-%d").to_string(),
390 start_time: workday.start.format("%H:%M").to_string(),
391 end_time: end_time.format("%H:%M").to_string(),
392 total_hours: format_duration(&net_duration),
393 productivity: (productivity * 10.0).round() / 10.0, intervals: intervals
395 .iter()
396 .enumerate()
397 .map(|(i, interval)| ExportInterval {
398 index: i + 1, start: interval.start.format("%H:%M").to_string(),
400 end: interval.end.format("%H:%M").to_string(),
401 duration: format_duration(&interval.duration),
402 })
403 .collect(),
404 tasks: tasks
405 .into_iter()
406 .map(|t| ExportTask {
407 id: t.id.unwrap_or(0),
408 name: t.name,
409 comment: t.comment,
410 completeness: t.completeness.unwrap_or(100),
411 })
412 .collect(),
413 })
414 }
415
416 fn gather_summary_data(&self, date: NaiveDate) -> Result<ExportSummary> {
418 let workdays = Workdays::new()?.fetch_month(date)?;
420
421 let mut days = Vec::new();
423 let mut total_duration = Duration::zero();
424
425 for workday in &workdays {
427 let day_pauses = Pauses::new()?.get_workday_pauses(workday)?;
430 let end_time = report::workday_end_time(workday, &day_pauses);
431 let duration = end_time - workday.start;
432 total_duration += duration;
433
434 days.push(ExportDaySum {
436 date: workday.date.format("%Y-%m-%d").to_string(),
437 hours: format_duration(&duration),
438 is_workday: true, });
440 }
441
442 let avg_duration = if !workdays.is_empty() {
444 Duration::seconds(total_duration.num_seconds() / workdays.len() as i64)
445 } else {
446 Duration::zero()
447 };
448
449 Ok(ExportSummary {
451 month: date.format("%B %Y").to_string(), days,
453 total_hours: format_duration(&total_duration),
454 average_hours: format_duration(&avg_duration),
455 total_days: workdays.len(),
456 })
457 }
458
459 fn export_report_csv(&self, report: &ExportReport) -> Result<()> {
462 let mut wtr = csv::Writer::from_path(&self.output_path)?;
463
464 wtr.write_record(["WORK INTERVALS", "", "", ""])?;
466 wtr.write_record(["Index", "Start", "End", "Duration"])?;
467 for interval in &report.intervals {
468 wtr.write_record(&[
469 interval.index.to_string(),
470 interval.start.clone(),
471 interval.end.clone(),
472 interval.duration.clone(),
473 ])?;
474 }
475
476 wtr.write_record(["", "", "", ""])?;
478 wtr.write_record(["SUMMARY", "", "", ""])?;
479 wtr.write_record(["Date", &report.date, "", ""])?;
480 wtr.write_record(["Total Hours", &report.total_hours, "", ""])?;
481 wtr.write_record(["Productivity", &format!("{:.1}%", report.productivity), "", ""])?;
482
483 wtr.write_record(["", "", "", ""])?;
485 wtr.write_record(["TASKS", "", "", ""])?;
486 wtr.write_record(["ID", "Name", "Comment", "Completeness"])?;
487 for task in &report.tasks {
488 wtr.write_record(&[task.id.to_string(), task.name.clone(), task.comment.clone(), format!("{}%", task.completeness)])?;
489 }
490
491 wtr.flush()?;
492 Ok(())
493 }
494
495 fn export_tasks_csv(&self, tasks: &[ExportTask]) -> Result<()> {
496 let mut wtr = csv::Writer::from_path(&self.output_path)?;
497 wtr.write_record(["ID", "Name", "Comment", "Completeness"])?;
498
499 for task in tasks {
500 wtr.write_record(&[task.id.to_string(), task.name.clone(), task.comment.clone(), format!("{}%", task.completeness)])?;
501 }
502
503 wtr.flush()?;
504 Ok(())
505 }
506
507 fn export_summary_csv(&self, summary: &ExportSummary) -> Result<()> {
508 let mut wtr = csv::Writer::from_path(&self.output_path)?;
509
510 wtr.write_record(&[format!("Monthly Summary - {}", summary.month), "".to_owned(), "".to_owned()])?;
512 wtr.write_record(["Date", "Hours", "Type"])?;
513
514 for day in &summary.days {
515 wtr.write_record(&[
516 day.date.clone(),
517 day.hours.clone(),
518 if day.is_workday { "Work".to_owned() } else { "Rest".to_owned() },
519 ])?;
520 }
521
522 wtr.write_record(["", "", ""])?;
524 wtr.write_record(["Total Hours", &summary.total_hours, ""])?;
525 wtr.write_record(["Average Hours", &summary.average_hours, ""])?;
526 wtr.write_record(["Total Days", &summary.total_days.to_string(), ""])?;
527
528 wtr.flush()?;
529 Ok(())
530 }
531
532 fn export_report_json(&self, report: &ExportReport) -> Result<()> {
533 let json = serde_json::to_string_pretty(report)?;
534 File::create(&self.output_path)?.write_all(json.as_bytes())?;
535 Ok(())
536 }
537
538 fn export_report_excel(&self, report: &ExportReport) -> Result<()> {
540 let mut workbook = Workbook::new();
541 let worksheet = workbook.add_worksheet();
542
543 let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
545
546 worksheet.write_string_with_format(0, 0, "WORK INTERVALS", &header_format)?;
548 worksheet.write_string_with_format(1, 0, "Index", &header_format)?;
549 worksheet.write_string_with_format(1, 1, "Start", &header_format)?;
550 worksheet.write_string_with_format(1, 2, "End", &header_format)?;
551 worksheet.write_string_with_format(1, 3, "Duration", &header_format)?;
552
553 let mut row = 2;
554 for interval in &report.intervals {
555 worksheet.write_number(row, 0, interval.index as f64)?;
556 worksheet.write_string(row, 1, &interval.start)?;
557 worksheet.write_string(row, 2, &interval.end)?;
558 worksheet.write_string(row, 3, &interval.duration)?;
559 row += 1;
560 }
561
562 row += 2;
564 worksheet.write_string_with_format(row, 0, "SUMMARY", &header_format)?;
565 row += 1;
566 worksheet.write_string(row, 0, "Date")?;
567 worksheet.write_string(row, 1, &report.date)?;
568 row += 1;
569 worksheet.write_string(row, 0, "Total Hours")?;
570 worksheet.write_string(row, 1, &report.total_hours)?;
571 row += 1;
572 worksheet.write_string(row, 0, "Productivity")?;
573 worksheet.write_string(row, 1, format!("{:.1}%", report.productivity))?;
574
575 row += 2;
577 worksheet.write_string_with_format(row, 0, "TASKS", &header_format)?;
578 row += 1;
579 worksheet.write_string_with_format(row, 0, "ID", &header_format)?;
580 worksheet.write_string_with_format(row, 1, "Name", &header_format)?;
581 worksheet.write_string_with_format(row, 2, "Comment", &header_format)?;
582 worksheet.write_string_with_format(row, 3, "Completeness", &header_format)?;
583
584 row += 1;
585 for task in &report.tasks {
586 worksheet.write_number(row, 0, task.id as f64)?;
587 worksheet.write_string(row, 1, &task.name)?;
588 worksheet.write_string(row, 2, &task.comment)?;
589 worksheet.write_string(row, 3, format!("{}%", task.completeness))?;
590 row += 1;
591 }
592
593 worksheet.autofit();
595
596 workbook.save(&self.output_path)?;
597 Ok(())
598 }
599
600 fn export_tasks_excel(&self, tasks: &[ExportTask]) -> Result<()> {
601 let mut workbook = Workbook::new();
602 let worksheet = workbook.add_worksheet();
603
604 let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
605
606 worksheet.write_string_with_format(0, 0, "ID", &header_format)?;
608 worksheet.write_string_with_format(0, 1, "Name", &header_format)?;
609 worksheet.write_string_with_format(0, 2, "Comment", &header_format)?;
610 worksheet.write_string_with_format(0, 3, "Completeness", &header_format)?;
611
612 for (i, task) in tasks.iter().enumerate() {
614 let row = i as u32 + 1;
615 worksheet.write_number(row, 0, task.id as f64)?;
616 worksheet.write_string(row, 1, &task.name)?;
617 worksheet.write_string(row, 2, &task.comment)?;
618 worksheet.write_string(row, 3, format!("{}%", task.completeness))?;
619 }
620
621 worksheet.autofit();
622 workbook.save(&self.output_path)?;
623 Ok(())
624 }
625
626 fn export_summary_excel(&self, summary: &ExportSummary) -> Result<()> {
627 let mut workbook = Workbook::new();
628 let worksheet = workbook.add_worksheet();
629
630 let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
632 let title_format = Format::new().set_bold().set_font_size(14.0);
633
634 worksheet.write_string_with_format(0, 0, format!("Monthly Summary - {}", summary.month), &title_format)?;
636 worksheet.write_string_with_format(2, 0, "Date", &header_format)?;
637 worksheet.write_string_with_format(2, 1, "Hours", &header_format)?;
638 worksheet.write_string_with_format(2, 2, "Type", &header_format)?;
639
640 let mut row = 3;
641 for day in &summary.days {
642 worksheet.write_string(row, 0, &day.date)?;
643 worksheet.write_string(row, 1, &day.hours)?;
644 worksheet.write_string(row, 2, if day.is_workday { "Work" } else { "Rest" })?;
645 row += 1;
646 }
647
648 row += 1;
650 worksheet.write_string(row, 0, "Total Hours")?;
651 worksheet.write_string(row, 1, &summary.total_hours)?;
652 row += 1;
653 worksheet.write_string(row, 0, "Average Hours")?;
654 worksheet.write_string(row, 1, &summary.average_hours)?;
655 row += 1;
656 worksheet.write_string(row, 0, "Total Days")?;
657 worksheet.write_number(row, 1, summary.total_days as f64)?;
658
659 worksheet.autofit();
660 workbook.save(&self.output_path)?;
661 Ok(())
662 }
663
664 fn gather_hourly_data(&self, date: NaiveDate, locale: &Locale) -> Result<HourlyReport> {
674 let workday = Workdays::new()?
675 .fetch(date)?
676 .ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
677
678 let config = Config::read()?;
680 let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
681 let pauses = Pauses::new()?
682 .set_min_duration(monitor_config.min_pause_duration)
683 .get_workday_pauses(&workday)?;
684
685 let intervals = report::calculate_work_intervals(&workday, &pauses);
686 let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
687
688 let end_time = report::workday_end_time(&workday, &pauses);
691
692 let slots = classify_hour_slots(workday.start, end_time, &intervals, &pauses);
693 let task_texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
694 let rows = build_hourly_rows(&slots, &task_texts, locale.break_label);
695
696 let worked = intervals.iter().fold(Duration::zero(), |acc, i| acc + i.duration);
698
699 let weekday_idx = date.weekday().num_days_from_monday() as usize;
701 let month_idx = (date.month().saturating_sub(1)) as usize;
702
703 Ok(HourlyReport {
704 date,
705 weekday: locale.weekdays[weekday_idx].to_string(),
706 month: locale.months[month_idx].to_string(),
707 day_hours: worked.num_hours().max(0),
708 worked: format_duration(&worked),
709 rows,
710 })
711 }
712
713 fn export_report_excel_hourly(&self, date: NaiveDate) -> Result<()> {
720 let config = Config::read()?;
722 let report_config = config.report.clone().unwrap_or_default();
723 let language = Language::from_code(report_config.language.as_deref().unwrap_or("en"));
725 let locale = Locale::for_language(language);
726 let template = ReportTemplate::load(report_config.template.as_deref().unwrap_or("siserver"));
727
728 let data = self.gather_hourly_data(date, locale)?;
729
730 let mut workbook = Workbook::new();
731 let worksheet = workbook.add_worksheet();
732
733 let border_color = template.border();
735 let header_fill = template.fill();
736
737 let font_base = |spec: &FontSpec| -> Format {
739 let mut fmt = Format::new().set_font_name(spec.name.as_str()).set_font_size(spec.size);
740 if spec.bold {
741 fmt = fmt.set_bold();
742 }
743 fmt
744 };
745
746 let fmt_title = font_base(&template.fonts.title)
748 .set_border(FormatBorder::Thin)
749 .set_border_color(border_color)
750 .set_align(FormatAlign::Center)
751 .set_align(FormatAlign::VerticalCenter);
752 let fmt_month = font_base(&template.fonts.month).set_align(FormatAlign::Center);
753 let fmt_date = font_base(&template.fonts.date)
754 .set_border(FormatBorder::Thin)
755 .set_border_color(border_color)
756 .set_align(FormatAlign::Center);
757 let fmt_center = Format::new().set_align(FormatAlign::Center);
758 let fmt_right = Format::new().set_align(FormatAlign::Right);
759
760 let fmt_header = font_base(&template.fonts.header)
762 .set_background_color(header_fill)
763 .set_border(FormatBorder::Thin)
764 .set_border_color(border_color)
765 .set_align(FormatAlign::Center)
766 .set_align(FormatAlign::VerticalCenter);
767 let fmt_time = font_base(&template.fonts.time)
768 .set_border(FormatBorder::Thin)
769 .set_border_color(border_color)
770 .set_align(FormatAlign::Center)
771 .set_align(FormatAlign::VerticalCenter);
772 let fmt_desc = Format::new()
773 .set_border(FormatBorder::Thin)
774 .set_border_color(border_color)
775 .set_align(FormatAlign::Center)
776 .set_align(FormatAlign::VerticalCenter)
777 .set_text_wrap();
778 let fmt_empty = Format::new()
779 .set_border(FormatBorder::Thin)
780 .set_border_color(border_color)
781 .set_align(FormatAlign::Center)
782 .set_align(FormatAlign::VerticalCenter)
783 .set_text_wrap();
784
785 let fmt_total_label = Format::new()
787 .set_background_color(header_fill)
788 .set_border(FormatBorder::Thin)
789 .set_border_color(border_color)
790 .set_align(FormatAlign::Right);
791 let fmt_comment_label = font_base(&template.fonts.header);
792 let fmt_comment_box = Format::new()
793 .set_border(FormatBorder::Thin)
794 .set_border_color(border_color)
795 .set_align(FormatAlign::VerticalCenter);
796
797 worksheet.set_column_width(1, template.col_widths[0])?;
799 worksheet.set_column_width(2, template.col_widths[1])?;
800 worksheet.set_column_width(3, template.col_widths[2])?;
801 if template.show_hours_column {
802 worksheet.set_column_width(4, template.col_widths[3])?;
803 }
804 if template.show_result_column {
805 worksheet.set_column_width(5, template.col_widths[4])?;
806 }
807
808 worksheet.set_row_height(1, template.title_row_height)?;
810 worksheet.merge_range(1, 1, 1, 2, locale.report_title, &fmt_title)?;
811 worksheet.write_string_with_format(1, 3, &data.month, &fmt_month)?;
812 worksheet.merge_range(2, 1, 2, 2, &data.date.format(locale.date_format).to_string(), &fmt_date)?;
813
814 worksheet.write_string_with_format(4, 1, &data.weekday, &fmt_center)?;
815 worksheet.write_string_with_format(4, 2, locale.day_type_working, &fmt_center)?;
816 worksheet.write_string_with_format(4, 3, locale.workday_length, &fmt_right)?;
817 worksheet.write_number(4, 4, data.day_hours as f64)?;
818
819 worksheet.merge_range(6, 1, 6, 2, locale.header_day, &fmt_header)?;
821 worksheet.write_string_with_format(7, 1, locale.header_start, &fmt_header)?;
822 worksheet.write_string_with_format(7, 2, locale.header_end, &fmt_header)?;
823 worksheet.merge_range(6, 3, 7, 3, "", &fmt_header)?;
824 if template.show_hours_column {
825 worksheet.merge_range(6, 4, 7, 4, locale.header_hours, &fmt_header)?;
826 }
827 if template.show_result_column {
828 worksheet.merge_range(6, 5, 7, 5, locale.header_result, &fmt_header)?;
829 }
830
831 let mut row: u32 = 8;
833 for item in &data.rows {
834 worksheet.set_row_height(row, template.data_row_height)?;
835 worksheet.write_string_with_format(row, 1, &item.start, &fmt_time)?;
836 worksheet.write_string_with_format(row, 2, &item.end, &fmt_time)?;
837 worksheet.write_string_with_format(row, 3, &item.description, &fmt_desc)?;
838 if template.show_hours_column {
839 worksheet.write_string_with_format(row, 4, "", &fmt_empty)?;
840 }
841 if template.show_result_column {
842 worksheet.write_string_with_format(row, 5, "", &fmt_empty)?;
843 }
844 row += 1;
845 }
846 let last_data_row = row.saturating_sub(1);
847
848 let total_row = last_data_row + 3;
850 worksheet.merge_range(total_row, 1, total_row, 3, locale.total_worked, &fmt_total_label)?;
851 worksheet.write_string_with_format(total_row, 4, &data.worked, &fmt_time)?;
852
853 if template.show_comment {
855 let comment_row = total_row + 2;
856 worksheet.write_string_with_format(comment_row, 1, locale.comment, &fmt_comment_label)?;
857 let box_top = comment_row + 1;
858 let box_bottom = box_top + template.comment_rows.saturating_sub(1);
859 worksheet.merge_range(box_top, 1, box_bottom, 5, "", &fmt_comment_box)?;
860 }
861
862 workbook.save(&self.output_path)?;
863 Ok(())
864 }
865}