Skip to main content

kasl/libs/export/
mod.rs

1//! Exports reports, tasks and summaries to CSV, JSON and Excel,
2//! including the hourly (SiServer-style) Excel layout.
3//!
4//! ```rust,no_run
5//! # async fn f() -> anyhow::Result<()> {
6//! use kasl::libs::export::{Exporter, ExportFormat, ExportData};
7//! use chrono::NaiveDate;
8//!
9//! let exporter = Exporter::new(ExportFormat::Csv, None);
10//! exporter.export(ExportData::Report, NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()).await?;
11//! # Ok(())
12//! # }
13//! ```
14
15use 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/// Output formats for exports.
40#[derive(Debug, Clone, Copy, clap::ValueEnum)]
41pub enum ExportFormat {
42    Csv,
43    /// Pretty-printed JSON.
44    Json,
45    /// One worksheet per export, headers and autofit applied.
46    Excel,
47}
48
49/// What gets exported.
50#[derive(Debug, Clone, Copy, clap::ValueEnum)]
51pub enum ExportData {
52    /// The daily report: intervals, tasks, productivity.
53    Report,
54    /// The date's tasks.
55    Tasks,
56    /// The month's totals and per-day hours.
57    Summary,
58    /// Report + tasks + summary: one JSON file, or suffixed files for CSV/Excel.
59    All,
60}
61
62/// A daily report as exported; fields are pre-formatted strings.
63#[derive(Debug, Serialize, Deserialize)]
64pub struct ExportReport {
65    /// Date of the work report in YYYY-MM-DD format
66    pub date: String,
67    /// Work start time in HH:MM format
68    pub start_time: String,
69    /// Work end time in HH:MM format
70    pub end_time: String,
71    /// Total working hours formatted as human-readable duration
72    pub total_hours: String,
73    /// Productivity percentage (0.0-100.0) with one decimal place
74    pub productivity: f64,
75    /// List of work intervals with timing details
76    pub intervals: Vec<ExportInterval>,
77    /// List of tasks associated with this date
78    pub tasks: Vec<ExportTask>,
79}
80
81/// One work interval row in the exported report.
82#[derive(Debug, Serialize, Deserialize)]
83pub struct ExportInterval {
84    /// Sequential index of the interval (1-based)
85    pub index: usize,
86    /// Interval start time in HH:MM format
87    pub start: String,
88    /// Interval end time in HH:MM format
89    pub end: String,
90    /// Interval duration formatted as human-readable duration
91    pub duration: String,
92}
93
94/// One task row in the exported report.
95#[derive(Debug, Serialize, Deserialize)]
96pub struct ExportTask {
97    /// Unique task identifier from the database
98    pub id: i32,
99    /// Human-readable task name or title
100    pub name: String,
101    /// Optional task description or comments
102    pub comment: String,
103    /// Task completion percentage (0-100)
104    pub completeness: i32,
105}
106
107/// A monthly summary as exported.
108#[derive(Debug, Serialize, Deserialize)]
109pub struct ExportSummary {
110    /// Month and year in "Month YYYY" format (e.g., "January 2025")
111    pub month: String,
112    /// List of daily work hour summaries
113    pub days: Vec<ExportDaySum>,
114    /// Total working hours for the month formatted as duration
115    pub total_hours: String,
116    /// Average daily working hours formatted as duration
117    pub average_hours: String,
118    /// Total number of working days in the month
119    pub total_days: usize,
120}
121
122/// One day's line in the exported monthly summary.
123#[derive(Debug, Serialize, Deserialize)]
124pub struct ExportDaySum {
125    /// Date in YYYY-MM-DD format
126    pub date: String,
127    /// Working hours for this date formatted as duration
128    pub hours: String,
129    /// Whether this was a working day (true) or rest day (false)
130    pub is_workday: bool,
131}
132
133/// Gathers data and writes it in the chosen format.
134pub struct Exporter {
135    format: ExportFormat,
136    output_path: PathBuf,
137    /// Whether to render the daily report as an hourly (SiServer-style) breakdown.
138    ///
139    /// When enabled (and the format is Excel), the report is rendered as a
140    /// per-hour grid where each row represents one hour of the workday with a
141    /// description of the work performed, and "Перерыв" is written for hours
142    /// (or parts of hours) that fall within a break/pause.
143    hourly: bool,
144}
145
146impl Exporter {
147    /// Builds an exporter; without a path the file is named
148    /// `kasl_export_{YYYYMMDD_HHMMSS}.{ext}` in the current directory.
149    ///
150    /// ```rust,no_run
151    /// use kasl::libs::export::{Exporter, ExportFormat};
152    /// use std::path::PathBuf;
153    ///
154    /// // Create exporter with default filename
155    /// let exporter = Exporter::new(ExportFormat::Csv, None);
156    ///
157    /// // Create exporter with custom path
158    /// let custom_path = PathBuf::from("reports/daily_report.xlsx");
159    /// let exporter = Exporter::new(ExportFormat::Excel, Some(custom_path));
160    /// ```
161    pub fn new(format: ExportFormat, output_path: Option<PathBuf>) -> Self {
162        // Generate default filename with timestamp for uniqueness
163        let default_name = format!("kasl_export_{}", Local::now().format("%Y%m%d_%H%M%S"));
164
165        // Determine appropriate file extension based on format
166        let extension = match format {
167            ExportFormat::Csv => "csv",
168            ExportFormat::Json => "json",
169            ExportFormat::Excel => "xlsx",
170        };
171
172        // Use custom path or generate default with appropriate extension
173        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    /// Toggles the hourly (SiServer-style) layout; only Excel report
183    /// exports honor it.
184    pub fn hourly(mut self, hourly: bool) -> Self {
185        self.hourly = hourly;
186        self
187    }
188
189    /// Runs the export for the requested data type.
190    ///
191    /// ```rust,no_run
192    /// # async fn f() -> anyhow::Result<()> {
193    /// use kasl::libs::export::{Exporter, ExportFormat, ExportData};
194    /// use chrono::NaiveDate;
195    ///
196    /// let exporter = Exporter::new(ExportFormat::Json, None);
197    /// let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
198    /// exporter.export(ExportData::Report, date).await?;
199    /// # Ok(())
200    /// # }
201    /// ```
202    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    /// Exports the daily report (hourly Excel layout when requested).
212    async fn export_report(&self, date: NaiveDate) -> Result<()> {
213        // Hourly (SiServer-style) layout is only meaningful for Excel output.
214        // When requested, delegate to the dedicated renderer and skip the
215        // generic report layout entirely.
216        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        // Gather comprehensive report data from multiple database sources
225        let report_data = self.gather_report_data(date)?;
226
227        // Apply format-specific processing and generate output file
228        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        // Provide user feedback about successful export completion
235        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
236        Ok(())
237    }
238
239    /// Exports the date's tasks.
240    async fn export_tasks(&self, date: NaiveDate) -> Result<()> {
241        // Retrieve tasks for the specified date from the database
242        let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
243
244        // Transform database task records into export-friendly format
245        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        // Apply format-specific processing and generate output file
256        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        // Provide user feedback about successful export completion
266        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
267        Ok(())
268    }
269
270    /// Exports the monthly summary.
271    async fn export_summary(&self, date: NaiveDate) -> Result<()> {
272        // Gather and aggregate monthly data from workday records
273        let summary_data = self.gather_summary_data(date)?;
274
275        // Apply format-specific processing and generate output file
276        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        // Provide user feedback about successful export completion
286        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
287        Ok(())
288    }
289
290    /// Exports everything: one nested JSON file, or three suffixed files
291    /// (`_report`, `_tasks`, `_summary`) for CSV/Excel.
292    async fn export_all(&self, date: NaiveDate) -> Result<()> {
293        msg_info!(Message::ExportingAllData);
294
295        // Handle JSON format with combined data structure
296        if let ExportFormat::Json = self.format {
297            // Gather all data types, allowing for optional failures
298            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            // Create comprehensive JSON structure with metadata
312            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            // Write the combined JSON data to file
320            let json = serde_json::to_string_pretty(&all_data)?;
321            File::create(&self.output_path)?.write_all(json.as_bytes())?;
322        } else {
323            // Handle CSV and Excel formats with multiple files
324            let base = self.output_path.file_stem().unwrap().to_string_lossy();
325            let ext = self.output_path.extension().unwrap().to_string_lossy();
326
327            // Generate separate file paths with descriptive suffixes
328            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            // Create separate exporters for each data type
333            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            // Execute all export operations
338            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        // Provide user feedback about successful export completion
346        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
347        Ok(())
348    }
349
350    /// Assembles the daily report from workday, tasks and pauses.
351    ///
352    /// The productivity here is the simplified `net/gross` ratio, not the
353    /// full [`crate::libs::productivity::Productivity`] calculation - an
354    /// export must not differ depending on which thresholds are configured.
355    fn gather_report_data(&self, date: NaiveDate) -> Result<ExportReport> {
356        // Retrieve the primary workday record or fail if none exists
357        let workday = Workdays::new()?
358            .fetch(date)?
359            .ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
360
361        // Collect associated tasks and pause data
362        let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
363        let pauses = Pauses::new()?.get_workday_pauses(&workday)?;
364
365        // Determine end time (use current time if workday is still active)
366        let end_time = report::workday_end_time(&workday, &pauses);
367
368        // Calculate work intervals by analyzing workday and pause data
369        let intervals = report::calculate_work_intervals(&workday, &pauses);
370
371        // Calculate total pause duration for productivity metrics
372        let total_pause_duration = pauses.iter().filter_map(|p| p.duration).fold(Duration::zero(), |acc, d| acc + d);
373
374        // Calculate gross and net work durations
375        let gross_duration = end_time - workday.start;
376        let net_duration = gross_duration - total_pause_duration;
377
378        // Calculate simplified productivity percentage for export
379        // Note: This is a simplified calculation for export purposes only
380        // For comprehensive productivity analysis, use libs::productivity::Productivity
381        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        // Construct the comprehensive export report structure
388        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, // Round to 1 decimal place
394            intervals: intervals
395                .iter()
396                .enumerate()
397                .map(|(i, interval)| ExportInterval {
398                    index: i + 1, // 1-based indexing for user friendliness
399                    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    /// Aggregates the month's workdays into totals and per-day rows.
417    fn gather_summary_data(&self, date: NaiveDate) -> Result<ExportSummary> {
418        // Retrieve all workday records for the month containing the specified date
419        let workdays = Workdays::new()?.fetch_month(date)?;
420
421        // Initialize aggregation variables
422        let mut days = Vec::new();
423        let mut total_duration = Duration::zero();
424
425        // Process each workday to calculate duration and accumulate statistics
426        for workday in &workdays {
427            // Determine end time (now while the day is still today, otherwise
428            // the last observed activity - see report::workday_end_time).
429            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            // Add daily summary record
435            days.push(ExportDaySum {
436                date: workday.date.format("%Y-%m-%d").to_string(),
437                hours: format_duration(&duration),
438                is_workday: true, // All records in workdays table are work days
439            });
440        }
441
442        // Calculate average duration with division by zero protection
443        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        // Construct the monthly summary structure
450        Ok(ExportSummary {
451            month: date.format("%B %Y").to_string(), // "January 2025" format
452            days,
453            total_hours: format_duration(&total_duration),
454            average_hours: format_duration(&avg_duration),
455            total_days: workdays.len(),
456        })
457    }
458
459    /// Writes the report as three labelled CSV sections (intervals,
460    /// summary, tasks) separated by blank rows.
461    fn export_report_csv(&self, report: &ExportReport) -> Result<()> {
462        let mut wtr = csv::Writer::from_path(&self.output_path)?;
463
464        // Write work intervals section with headers
465        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        // Add spacing and summary section
477        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        // Add spacing and tasks section
484        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        // Write title and daily breakdown
511        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        // Add summary statistics
523        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    /// Writes the report worksheet: the same three sections as the CSV.
539    fn export_report_excel(&self, report: &ExportReport) -> Result<()> {
540        let mut workbook = Workbook::new();
541        let worksheet = workbook.add_worksheet();
542
543        // Create formatting styles for headers and content
544        let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
545
546        // Write work intervals section
547        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        // Add summary section with spacing
563        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        // Add tasks section with spacing
576        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        // Apply auto-sizing for optimal column widths
594        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        // Write headers
607        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        // Write task data
613        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        // Create formatting styles
631        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        // Write title and daily breakdown
635        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        // Add summary statistics
649        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    /// Gathers the data required to render an hourly (SiServer-style) daily report.
665    ///
666    /// Unlike [`Exporter::gather_report_data`], this method combines both manual
667    /// breaks and automatic pauses (respecting the configured minimum pause
668    /// duration) so that the resulting hourly grid accurately reflects every
669    /// interruption. Tasks are distributed one-per-hour across work hour slots
670    /// (not across work intervals): fewer tasks span contiguous hour blocks;
671    /// surplus tasks are appended only to hours without a break.
672    ///
673    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        // Respect the same interruption sources and thresholds as report submission.
679        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        // End of the workday (now while it is still today, otherwise the last
689        // observed activity - see report::workday_end_time).
690        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        // Total net worked time is the sum of all work intervals.
697        let worked = intervals.iter().fold(Duration::zero(), |acc, i| acc + i.duration);
698
699        // Localized weekday/month names (Monday = index 0, January = index 0).
700        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    /// Renders the hourly daily report to an Excel workbook mirroring the SiServer layout.
714    ///
715    /// The generated sheet contains a header block (title, date, weekday, workday
716    /// length), an hourly table with start/end times and per-hour descriptions,
717    /// a total worked-hours row, and an empty comment area.
718    ///
719    fn export_report_excel_hourly(&self, date: NaiveDate) -> Result<()> {
720        // Resolve localization and design template from the report config.
721        let config = Config::read()?;
722        let report_config = config.report.clone().unwrap_or_default();
723        // English unless the config asks otherwise; `from_code` defaults to it too.
724        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        // Palette parsed from the template (hex → xlsx Color).
734        let border_color = template.border();
735        let header_fill = template.fill();
736
737        // Builds a base format carrying the given font specification.
738        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        // Title / header-block formats.
747        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        // Table formats.
761        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        // Footer formats.
786        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        // Column widths (columns B..F, i.e. 1..5) from the template.
798        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        // Header block.
809        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        // Table header (two rows: day span over start/end, plus per-column headers).
820        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        // Hourly data rows.
832        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        // Total worked-hours row (two blank rows below the table, as in the template).
849        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        // Comment label and empty comment box.
854        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}