Skip to main content

kasl/libs/
export.rs

1//! Data export functionality for external analysis and backup.
2//!
3//! Provides a comprehensive data export system that enables users to extract
4//! their work tracking data in multiple formats for external analysis, backup,
5//! integration with other tools, or compliance reporting.
6//!
7//! ## Features
8//!
9//! - **Export Formats**: CSV, JSON, Excel with formatting and multiple sheets
10//! - **Data Types**: Reports, tasks, summaries, and complete data export
11//! - **File Naming**: Intelligent naming conventions with timestamp-based uniqueness
12//! - **Error Handling**: Robust validation and error recovery
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use kasl::libs::export::{Exporter, ExportFormat, ExportData};
18//! use chrono::NaiveDate;
19//!
20//! let exporter = Exporter::new(ExportFormat::Csv, None);
21//! exporter.export(ExportData::Report, NaiveDate::from_ymd(2025, 1, 15)).await?;
22//! ```
23
24use crate::{
25    db::{breaks::Breaks, pauses::Pauses, tasks::Tasks, workdays::Workdays},
26    libs::{
27        config::Config,
28        formatter::format_duration,
29        locale::{Language, Locale},
30        messages::Message,
31        pause::Pause,
32        report::{self, WorkInterval},
33        report_template::{FontSpec, ReportTemplate},
34        task::{Task, TaskFilter},
35    },
36    msg_error_anyhow, msg_info, msg_success,
37};
38use anyhow::Result;
39use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, Timelike};
40use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook};
41use serde::{Deserialize, Serialize};
42use std::fs::File;
43use std::io::Write;
44use std::path::PathBuf;
45
46/// Enumeration of supported export output formats.
47///
48/// This enum defines the available output formats for data export operations.
49/// Each format is optimized for different use cases and provides different
50/// levels of functionality and compatibility.
51#[derive(Debug, Clone, Copy, clap::ValueEnum)]
52pub enum ExportFormat {
53    /// Comma-separated values format for universal compatibility.
54    ///
55    /// CSV exports provide maximum compatibility with spreadsheet applications,
56    /// data analysis tools, and simple parsing libraries. The format uses
57    /// standard CSV conventions with proper quoting and escaping.
58    Csv,
59
60    /// JavaScript Object Notation for structured data exchange.
61    ///
62    /// JSON exports preserve data types and structure, making them ideal for
63    /// programmatic processing, API integrations, and backup/restore operations.
64    /// All exports use pretty-printing for human readability.
65    Json,
66
67    /// Microsoft Excel format with advanced formatting capabilities.
68    ///
69    /// Excel exports provide rich formatting, multiple worksheets, auto-sizing,
70    /// and professional presentation quality. Ideal for business reports and
71    /// executive presentations.
72    Excel,
73}
74
75/// Enumeration of data types available for export.
76///
77/// This enum defines the different categories of information that can be
78/// exported from the kasl application. Each data type provides different
79/// levels of detail and serves different analytical purposes.
80#[derive(Debug, Clone, Copy, clap::ValueEnum)]
81pub enum ExportData {
82    /// Export daily work report with intervals and productivity metrics.
83    ///
84    /// Includes detailed work intervals, break periods, task associations,
85    /// and calculated productivity statistics for a specific date.
86    Report,
87
88    /// Export task records with completion status and metadata.
89    ///
90    /// Includes all tasks for a specific date with their names, descriptions,
91    /// completion percentages, and associated metadata.
92    Tasks,
93
94    /// Export monthly summary with aggregated statistics.
95    ///
96    /// Includes daily work hour totals, averages, and productivity trends
97    /// for the month containing the specified date.
98    Summary,
99
100    /// Export comprehensive dataset including all available information.
101    ///
102    /// Combines reports, tasks, and summaries into a single export for
103    /// complete data backup or comprehensive analysis.
104    All,
105}
106
107/// Serializable structure representing a daily work report for export.
108///
109/// This structure contains all the information needed to represent a complete
110/// daily work report in export formats. All fields use string representations
111/// for format compatibility and consistent presentation.
112#[derive(Debug, Serialize, Deserialize)]
113pub struct ExportReport {
114    /// Date of the work report in YYYY-MM-DD format
115    pub date: String,
116    /// Work start time in HH:MM format
117    pub start_time: String,
118    /// Work end time in HH:MM format
119    pub end_time: String,
120    /// Total working hours formatted as human-readable duration
121    pub total_hours: String,
122    /// Productivity percentage (0.0-100.0) with one decimal place
123    pub productivity: f64,
124    /// List of work intervals with timing details
125    pub intervals: Vec<ExportInterval>,
126    /// List of tasks associated with this date
127    pub tasks: Vec<ExportTask>,
128}
129
130/// Serializable structure representing a work interval within a daily report.
131///
132/// Work intervals represent continuous periods of activity without breaks.
133/// They are calculated by analyzing work start/end times and pause periods.
134#[derive(Debug, Serialize, Deserialize)]
135pub struct ExportInterval {
136    /// Sequential index of the interval (1-based)
137    pub index: usize,
138    /// Interval start time in HH:MM format
139    pub start: String,
140    /// Interval end time in HH:MM format
141    pub end: String,
142    /// Interval duration formatted as human-readable duration
143    pub duration: String,
144}
145
146/// Serializable structure representing a task record for export.
147///
148/// This structure contains all relevant task information in a format
149/// suitable for external systems and analysis tools.
150#[derive(Debug, Serialize, Deserialize)]
151pub struct ExportTask {
152    /// Unique task identifier from the database
153    pub id: i32,
154    /// Human-readable task name or title
155    pub name: String,
156    /// Optional task description or comments
157    pub comment: String,
158    /// Task completion percentage (0-100)
159    pub completeness: i32,
160}
161
162/// Serializable structure representing a monthly summary for export.
163///
164/// This structure aggregates work data for an entire month, providing
165/// overview statistics and daily breakdowns for analysis purposes.
166#[derive(Debug, Serialize, Deserialize)]
167pub struct ExportSummary {
168    /// Month and year in "Month YYYY" format (e.g., "January 2025")
169    pub month: String,
170    /// List of daily work hour summaries
171    pub days: Vec<ExportDaySum>,
172    /// Total working hours for the month formatted as duration
173    pub total_hours: String,
174    /// Average daily working hours formatted as duration
175    pub average_hours: String,
176    /// Total number of working days in the month
177    pub total_days: usize,
178}
179
180/// Serializable structure representing a single day within a monthly summary.
181///
182/// This structure provides daily-level statistics within the broader
183/// monthly summary context.
184#[derive(Debug, Serialize, Deserialize)]
185pub struct ExportDaySum {
186    /// Date in YYYY-MM-DD format
187    pub date: String,
188    /// Working hours for this date formatted as duration
189    pub hours: String,
190    /// Whether this was a working day (true) or rest day (false)
191    pub is_workday: bool,
192}
193
194/// Main export handler responsible for orchestrating data export operations.
195///
196/// The Exporter struct encapsulates the export format, output destination,
197/// and provides methods for exporting different types of data. It handles
198/// the complete export pipeline from data gathering to file generation.
199///
200/// ## Design Philosophy
201///
202/// The Exporter follows a builder pattern for configuration and uses method
203/// dispatch for different export operations. This design provides flexibility
204/// while maintaining type safety and clear separation of concerns.
205pub struct Exporter {
206    /// The desired output format for the export operation
207    format: ExportFormat,
208    /// The destination path for the exported file
209    output_path: PathBuf,
210    /// Whether to render the daily report as an hourly (SiServer-style) breakdown.
211    ///
212    /// When enabled (and the format is Excel), the report is rendered as a
213    /// per-hour grid where each row represents one hour of the workday with a
214    /// description of the work performed, and "ะŸะตั€ะตั€ั‹ะฒ" is written for hours
215    /// (or parts of hours) that fall within a break/pause.
216    hourly: bool,
217}
218
219impl Exporter {
220    /// Creates a new Exporter instance with specified format and optional output path.
221    ///
222    /// This constructor sets up the export configuration and determines the output
223    /// file path. If no custom path is provided, it generates a default filename
224    /// based on the current timestamp and selected format.
225    ///
226    /// ## Default File Naming
227    ///
228    /// When no output path is specified, the constructor generates a filename using:
229    /// - **Prefix**: "kasl_export_"
230    /// - **Timestamp**: YYYYMMDD_HHMMSS format
231    /// - **Extension**: Format-appropriate extension (.csv, .json, .xlsx)
232    ///
233    /// Example default names:
234    /// - `kasl_export_20250115_143022.csv`
235    /// - `kasl_export_20250115_143022.json`
236    /// - `kasl_export_20250115_143022.xlsx`
237    ///
238    /// ## Path Validation
239    ///
240    /// The constructor validates that:
241    /// - Custom paths have appropriate file extensions
242    /// - Parent directories exist or can be created
243    /// - Write permissions are available
244    ///
245    /// # Arguments
246    ///
247    /// * `format` - The desired export format (CSV, JSON, or Excel)
248    /// * `output_path` - Optional custom output path; generates default if None
249    ///
250    /// # Returns
251    ///
252    /// Returns a configured Exporter instance ready for export operations.
253    ///
254    /// # Examples
255    ///
256    /// ```rust,no_run
257    /// use kasl::libs::export::{Exporter, ExportFormat};
258    /// use std::path::PathBuf;
259    ///
260    /// // Create exporter with default filename
261    /// let exporter = Exporter::new(ExportFormat::Csv, None);
262    ///
263    /// // Create exporter with custom path
264    /// let custom_path = PathBuf::from("reports/daily_report.xlsx");
265    /// let exporter = Exporter::new(ExportFormat::Excel, Some(custom_path));
266    /// ```
267    pub fn new(format: ExportFormat, output_path: Option<PathBuf>) -> Self {
268        // Generate default filename with timestamp for uniqueness
269        let default_name = format!("kasl_export_{}", Local::now().format("%Y%m%d_%H%M%S"));
270
271        // Determine appropriate file extension based on format
272        let extension = match format {
273            ExportFormat::Csv => "csv",
274            ExportFormat::Json => "json",
275            ExportFormat::Excel => "xlsx",
276        };
277
278        // Use custom path or generate default with appropriate extension
279        let output_path = output_path.unwrap_or_else(|| PathBuf::from(format!("{}.{}", default_name, extension)));
280
281        Self {
282            format,
283            output_path,
284            hourly: false,
285        }
286    }
287
288    /// Enables or disables the hourly (SiServer-style) daily report layout.
289    ///
290    /// This builder-style method toggles the hourly breakdown rendering for
291    /// daily reports. It only affects Excel report exports; other formats and
292    /// data types ignore this flag.
293    ///
294    /// # Arguments
295    ///
296    /// * `hourly` - Whether to render the report as an hourly grid
297    ///
298    /// # Returns
299    ///
300    /// Returns the modified `Exporter` for method chaining.
301    pub fn hourly(mut self, hourly: bool) -> Self {
302        self.hourly = hourly;
303        self
304    }
305
306    /// Main export dispatcher that routes to appropriate export handlers based on data type.
307    ///
308    /// This method serves as the primary interface for export operations, determining
309    /// which specific export handler to invoke based on the requested data type.
310    /// It provides a unified interface while delegating to specialized methods.
311    ///
312    /// ## Export Process Flow
313    ///
314    /// 1. **Data Type Analysis**: Determine which export handler to invoke
315    /// 2. **Data Gathering**: Collect relevant information from the database
316    /// 3. **Format Processing**: Apply format-specific transformations
317    /// 4. **File Generation**: Write the formatted data to the output file
318    /// 5. **Validation**: Verify export completeness and file integrity
319    ///
320    /// # Arguments
321    ///
322    /// * `data_type` - The category of data to export (Report, Tasks, Summary, All)
323    /// * `date` - The target date for data collection and filtering
324    ///
325    /// # Returns
326    ///
327    /// Returns `Ok(())` on successful export completion, or an error if any
328    /// step in the export process fails.
329    ///
330    /// # Examples
331    ///
332    /// ```rust,no_run
333    /// use kasl::libs::export::{Exporter, ExportFormat, ExportData};
334    /// use chrono::NaiveDate;
335    ///
336    /// let exporter = Exporter::new(ExportFormat::Json, None);
337    /// let date = NaiveDate::from_ymd(2025, 1, 15);
338    /// exporter.export(ExportData::Report, date).await?;
339    /// ```
340    pub async fn export(&self, data_type: ExportData, date: NaiveDate) -> Result<()> {
341        match data_type {
342            ExportData::Report => self.export_report(date).await,
343            ExportData::Tasks => self.export_tasks(date).await,
344            ExportData::Summary => self.export_summary(date).await,
345            ExportData::All => self.export_all(date).await,
346        }
347    }
348
349    /// Exports a comprehensive daily work report with intervals, tasks, and productivity metrics.
350    ///
351    /// This method generates a detailed daily report that includes all work intervals,
352    /// associated tasks, productivity calculations, and summary statistics. The report
353    /// provides a complete picture of work activity for the specified date.
354    ///
355    /// ## Report Components
356    ///
357    /// The generated report includes:
358    /// - **Work Intervals**: Detailed start/end times and durations for each work period
359    /// - **Productivity Metrics**: Calculated productivity percentage based on active work time
360    /// - **Task Information**: All tasks associated with the specified date
361    /// - **Summary Statistics**: Total hours, break time, and other key metrics
362    ///
363    /// ## Data Sources
364    ///
365    /// The report combines data from multiple database sources:
366    /// - Workday records for overall work boundaries
367    /// - Pause records for break period calculations
368    /// - Task records for work content and completion status
369    ///
370    /// # Arguments
371    ///
372    /// * `date` - The specific date for which to generate the report
373    ///
374    /// # Returns
375    ///
376    /// Returns `Ok(())` on successful report generation and file creation,
377    /// or an error if data gathering or file writing fails.
378    ///
379    /// # Error Scenarios
380    ///
381    /// - No workday record exists for the specified date
382    /// - Database connectivity issues during data gathering
383    /// - File system errors during report generation
384    /// - Data formatting or serialization errors
385    async fn export_report(&self, date: NaiveDate) -> Result<()> {
386        // Hourly (SiServer-style) layout is only meaningful for Excel output.
387        // When requested, delegate to the dedicated renderer and skip the
388        // generic report layout entirely.
389        if self.hourly {
390            if let ExportFormat::Excel = self.format {
391                self.export_report_excel_hourly(date)?;
392                msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
393                return Ok(());
394            }
395        }
396
397        // Gather comprehensive report data from multiple database sources
398        let report_data = self.gather_report_data(date)?;
399
400        // Apply format-specific processing and generate output file
401        match self.format {
402            ExportFormat::Csv => self.export_report_csv(&report_data)?,
403            ExportFormat::Json => self.export_report_json(&report_data)?,
404            ExportFormat::Excel => self.export_report_excel(&report_data)?,
405        }
406
407        // Provide user feedback about successful export completion
408        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
409        Ok(())
410    }
411
412    /// Exports task records with completion status and metadata for the specified date.
413    ///
414    /// This method extracts all tasks associated with a particular date and formats
415    /// them for export. Task exports are useful for project management integration,
416    /// productivity analysis, and task completion tracking.
417    ///
418    /// ## Task Information
419    ///
420    /// Each exported task includes:
421    /// - **Identification**: Unique database ID for reference
422    /// - **Content**: Task name and description/comments
423    /// - **Status**: Completion percentage and metadata
424    /// - **Timing**: Association with the specified date
425    ///
426    /// # Arguments
427    ///
428    /// * `date` - The specific date for which to export tasks
429    ///
430    /// # Returns
431    ///
432    /// Returns `Ok(())` on successful task export and file creation,
433    /// or an error if data retrieval or file writing fails.
434    async fn export_tasks(&self, date: NaiveDate) -> Result<()> {
435        // Retrieve tasks for the specified date from the database
436        let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
437
438        // Transform database task records into export-friendly format
439        let export_tasks: Vec<ExportTask> = tasks
440            .into_iter()
441            .map(|t| ExportTask {
442                id: t.id.unwrap_or(0),
443                name: t.name,
444                comment: t.comment,
445                completeness: t.completeness.unwrap_or(100),
446            })
447            .collect();
448
449        // Apply format-specific processing and generate output file
450        match self.format {
451            ExportFormat::Csv => self.export_tasks_csv(&export_tasks)?,
452            ExportFormat::Json => {
453                let json = serde_json::to_string_pretty(&export_tasks)?;
454                File::create(&self.output_path)?.write_all(json.as_bytes())?;
455            }
456            ExportFormat::Excel => self.export_tasks_excel(&export_tasks)?,
457        }
458
459        // Provide user feedback about successful export completion
460        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
461        Ok(())
462    }
463
464    /// Exports monthly summary with aggregated statistics and daily breakdowns.
465    ///
466    /// This method generates a comprehensive monthly overview that includes daily
467    /// work hour totals, averages, productivity trends, and other aggregate
468    /// statistics. Monthly summaries are valuable for long-term analysis and
469    /// productivity tracking.
470    ///
471    /// ## Summary Components
472    ///
473    /// The monthly summary includes:
474    /// - **Daily Breakdown**: Individual day statistics with work hours
475    /// - **Aggregate Metrics**: Total and average work hours for the month
476    /// - **Productivity Trends**: Patterns and variations in work activity
477    /// - **Calendar Context**: Work day vs. rest day classifications
478    ///
479    /// # Arguments
480    ///
481    /// * `date` - Any date within the month to summarize (month is extracted from this date)
482    ///
483    /// # Returns
484    ///
485    /// Returns `Ok(())` on successful summary generation and file creation,
486    /// or an error if data aggregation or file writing fails.
487    async fn export_summary(&self, date: NaiveDate) -> Result<()> {
488        // Gather and aggregate monthly data from workday records
489        let summary_data = self.gather_summary_data(date)?;
490
491        // Apply format-specific processing and generate output file
492        match self.format {
493            ExportFormat::Csv => self.export_summary_csv(&summary_data)?,
494            ExportFormat::Json => {
495                let json = serde_json::to_string_pretty(&summary_data)?;
496                File::create(&self.output_path)?.write_all(json.as_bytes())?;
497            }
498            ExportFormat::Excel => self.export_summary_excel(&summary_data)?,
499        }
500
501        // Provide user feedback about successful export completion
502        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
503        Ok(())
504    }
505
506    /// Exports comprehensive dataset including all available information types.
507    ///
508    /// This method provides a complete data export that combines reports, tasks,
509    /// and summaries into a single export operation. It's designed for comprehensive
510    /// backup operations, data migration, or complete analysis requirements.
511    ///
512    /// ## Export Strategy
513    ///
514    /// The method uses different strategies based on the selected format:
515    ///
516    /// ### JSON Format
517    /// Creates a single JSON file with nested structure containing:
518    /// - Export metadata (timestamp, version)
519    /// - Daily report data
520    /// - Task records
521    /// - Monthly summary
522    ///
523    /// ### CSV and Excel Formats
524    /// Creates multiple files with descriptive suffixes:
525    /// - `{base}_report.{ext}` - Daily report data
526    /// - `{base}_tasks.{ext}` - Task records
527    /// - `{base}_summary.{ext}` - Monthly summary
528    ///
529    /// # Arguments
530    ///
531    /// * `date` - The reference date for data collection and filtering
532    ///
533    /// # Returns
534    ///
535    /// Returns `Ok(())` on successful comprehensive export completion,
536    /// or an error if any component export fails.
537    async fn export_all(&self, date: NaiveDate) -> Result<()> {
538        msg_info!(Message::ExportingAllData);
539
540        // Handle JSON format with combined data structure
541        if let ExportFormat::Json = self.format {
542            // Gather all data types, allowing for optional failures
543            let report = self.gather_report_data(date).ok();
544            let tasks = Tasks::new()?
545                .fetch(TaskFilter::Date(date))?
546                .into_iter()
547                .map(|t| ExportTask {
548                    id: t.id.unwrap_or(0),
549                    name: t.name,
550                    comment: t.comment,
551                    completeness: t.completeness.unwrap_or(100),
552                })
553                .collect::<Vec<_>>();
554            let summary = self.gather_summary_data(date).ok();
555
556            // Create comprehensive JSON structure with metadata
557            let all_data = serde_json::json!({
558                "export_date": Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
559                "daily_report": report,
560                "tasks": tasks,
561                "monthly_summary": summary,
562            });
563
564            // Write the combined JSON data to file
565            let json = serde_json::to_string_pretty(&all_data)?;
566            File::create(&self.output_path)?.write_all(json.as_bytes())?;
567        } else {
568            // Handle CSV and Excel formats with multiple files
569            let base = self.output_path.file_stem().unwrap().to_string_lossy();
570            let ext = self.output_path.extension().unwrap().to_string_lossy();
571
572            // Generate separate file paths with descriptive suffixes
573            let report_path = self.output_path.with_file_name(format!("{}_report.{}", base, ext));
574            let tasks_path = self.output_path.with_file_name(format!("{}_tasks.{}", base, ext));
575            let summary_path = self.output_path.with_file_name(format!("{}_summary.{}", base, ext));
576
577            // Create separate exporters for each data type
578            let report_exporter = Exporter::new(self.format, Some(report_path));
579            let tasks_exporter = Exporter::new(self.format, Some(tasks_path));
580            let summary_exporter = Exporter::new(self.format, Some(summary_path));
581
582            // Execute all export operations
583            report_exporter.export_report(date).await?;
584            tasks_exporter.export_tasks(date).await?;
585            summary_exporter.export_summary(date).await?;
586
587            return Ok(());
588        }
589
590        // Provide user feedback about successful export completion
591        msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
592        Ok(())
593    }
594
595    /// Gathers comprehensive report data from multiple database sources and calculates metrics.
596    ///
597    /// This method orchestrates data collection from workdays, tasks, and pauses
598    /// databases to create a complete daily report. It performs calculations for
599    /// productivity metrics, work intervals, and summary statistics.
600    ///
601    /// ## Data Integration Process
602    ///
603    /// 1. **Workday Retrieval**: Fetch the primary workday record for date validation
604    /// 2. **Task Collection**: Gather all tasks associated with the specified date
605    /// 3. **Pause Analysis**: Retrieve and analyze break periods for interval calculation
606    /// 4. **Interval Calculation**: Compute work intervals by analyzing gaps and pauses
607    /// 5. **Metric Calculation**: Calculate productivity percentages and summary statistics
608    ///
609    /// ## Productivity Calculation
610    ///
611    /// For export purposes, a simplified productivity calculation is used:
612    /// ```text
613    /// Export Productivity = (Net Work Time / Gross Work Time) ร— 100
614    ///
615    /// Where:
616    /// - Net Work Time = Total Time - Pause Duration
617    /// - Gross Work Time = End Time - Start Time
618    /// ```
619    /// 
620    /// Note: This differs from the comprehensive calculation in `libs::productivity::Productivity`
621    /// which handles breaks, different pause types, and overlap scenarios.
622    ///
623    /// # Arguments
624    ///
625    /// * `date` - The specific date for which to gather report data
626    ///
627    /// # Returns
628    ///
629    /// Returns an `ExportReport` structure containing all calculated metrics
630    /// and formatted data, or an error if data retrieval or calculation fails.
631    ///
632    /// # Error Scenarios
633    ///
634    /// - No workday record exists for the specified date
635    /// - Database connectivity issues during data retrieval
636    /// - Data inconsistencies or corruption
637    fn gather_report_data(&self, date: NaiveDate) -> Result<ExportReport> {
638        // Retrieve the primary workday record or fail if none exists
639        let workday = Workdays::new()?
640            .fetch(date)?
641            .ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
642
643        // Collect associated tasks and pause data
644        let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
645        let pauses = Pauses::new()?.get_workday_pauses(&workday)?;
646
647        // Determine end time (use current time if workday is still active)
648        let end_time = workday.end.unwrap_or_else(|| Local::now().naive_local());
649
650        // Calculate work intervals by analyzing workday and pause data
651        let intervals = report::calculate_work_intervals(&workday, &pauses);
652
653        // Calculate total pause duration for productivity metrics
654        let total_pause_duration = pauses.iter().filter_map(|p| p.duration).fold(Duration::zero(), |acc, d| acc + d);
655
656        // Calculate gross and net work durations
657        let gross_duration = end_time - workday.start;
658        let net_duration = gross_duration - total_pause_duration;
659
660        // Calculate simplified productivity percentage for export
661        // Note: This is a simplified calculation for export purposes only
662        // For comprehensive productivity analysis, use libs::productivity::Productivity
663        let productivity = if gross_duration.num_seconds() > 0 {
664            (net_duration.num_seconds() as f64 / gross_duration.num_seconds() as f64) * 100.0
665        } else {
666            0.0
667        };
668
669        // Construct the comprehensive export report structure
670        Ok(ExportReport {
671            date: date.format("%Y-%m-%d").to_string(),
672            start_time: workday.start.format("%H:%M").to_string(),
673            end_time: end_time.format("%H:%M").to_string(),
674            total_hours: format_duration(&net_duration),
675            productivity: (productivity * 10.0).round() / 10.0, // Round to 1 decimal place
676            intervals: intervals
677                .iter()
678                .enumerate()
679                .map(|(i, interval)| ExportInterval {
680                    index: i + 1, // 1-based indexing for user friendliness
681                    start: interval.start.format("%H:%M").to_string(),
682                    end: interval.end.format("%H:%M").to_string(),
683                    duration: format_duration(&interval.duration),
684                })
685                .collect(),
686            tasks: tasks
687                .into_iter()
688                .map(|t| ExportTask {
689                    id: t.id.unwrap_or(0),
690                    name: t.name,
691                    comment: t.comment,
692                    completeness: t.completeness.unwrap_or(100),
693                })
694                .collect(),
695        })
696    }
697
698    /// Gathers monthly summary data by aggregating workday records and calculating statistics.
699    ///
700    /// This method processes all workday records for the month containing the specified
701    /// date and generates aggregate statistics including totals, averages, and daily
702    /// breakdowns for comprehensive monthly analysis.
703    ///
704    /// ## Aggregation Process
705    ///
706    /// 1. **Month Identification**: Extract month boundaries from the specified date
707    /// 2. **Workday Collection**: Retrieve all workday records within the month
708    /// 3. **Duration Calculation**: Calculate work duration for each day
709    /// 4. **Statistical Analysis**: Compute totals, averages, and distributions
710    /// 5. **Summary Generation**: Format results for export consumption
711    ///
712    /// ## Statistical Calculations
713    ///
714    /// - **Total Hours**: Sum of all work durations in the month
715    /// - **Average Hours**: Mean work duration per working day
716    /// - **Working Days**: Count of days with recorded work activity
717    /// - **Daily Breakdown**: Individual day statistics with classifications
718    ///
719    /// # Arguments
720    ///
721    /// * `date` - Any date within the target month (used to determine month boundaries)
722    ///
723    /// # Returns
724    ///
725    /// Returns an `ExportSummary` structure containing aggregated monthly statistics
726    /// and daily breakdowns, or an error if data retrieval or calculation fails.
727    fn gather_summary_data(&self, date: NaiveDate) -> Result<ExportSummary> {
728        // Retrieve all workday records for the month containing the specified date
729        let workdays = Workdays::new()?.fetch_month(date)?;
730
731        // Initialize aggregation variables
732        let mut days = Vec::new();
733        let mut total_duration = Duration::zero();
734
735        // Process each workday to calculate duration and accumulate statistics
736        for workday in &workdays {
737            // Determine end time (use current time if workday is still active)
738            let end_time = workday.end.unwrap_or_else(|| Local::now().naive_local());
739            let duration = end_time - workday.start;
740            total_duration = total_duration + duration;
741
742            // Add daily summary record
743            days.push(ExportDaySum {
744                date: workday.date.format("%Y-%m-%d").to_string(),
745                hours: format_duration(&duration),
746                is_workday: true, // All records in workdays table are work days
747            });
748        }
749
750        // Calculate average duration with division by zero protection
751        let avg_duration = if !workdays.is_empty() {
752            Duration::seconds(total_duration.num_seconds() / workdays.len() as i64)
753        } else {
754            Duration::zero()
755        };
756
757        // Construct the monthly summary structure
758        Ok(ExportSummary {
759            month: date.format("%B %Y").to_string(), // "January 2025" format
760            days,
761            total_hours: format_duration(&total_duration),
762            average_hours: format_duration(&avg_duration),
763            total_days: workdays.len(),
764        })
765    }
766
767    /// Exports daily report data to CSV format with structured sections and headers.
768    ///
769    /// This method creates a CSV file with multiple sections for different types of
770    /// information, using headers and empty rows to create visual separation and
771    /// improve readability in spreadsheet applications.
772    ///
773    /// ## CSV Structure
774    ///
775    /// The generated CSV includes the following sections:
776    /// 1. **Work Intervals**: Detailed timing for each work period
777    /// 2. **Summary Information**: Key metrics and totals
778    /// 3. **Task Details**: Associated tasks with completion status
779    ///
780    /// Each section is separated by empty rows and includes descriptive headers
781    /// for easy identification and processing.
782    ///
783    /// # Arguments
784    ///
785    /// * `report` - The report data structure to export
786    ///
787    /// # Returns
788    ///
789    /// Returns `Ok(())` on successful CSV generation, or an error if file
790    /// writing fails or data formatting encounters issues.
791    fn export_report_csv(&self, report: &ExportReport) -> Result<()> {
792        let mut wtr = csv::Writer::from_path(&self.output_path)?;
793
794        // Write work intervals section with headers
795        wtr.write_record(&["WORK INTERVALS", "", "", ""])?;
796        wtr.write_record(&["Index", "Start", "End", "Duration"])?;
797        for interval in &report.intervals {
798            wtr.write_record(&[
799                interval.index.to_string(),
800                interval.start.clone(),
801                interval.end.clone(),
802                interval.duration.clone(),
803            ])?;
804        }
805
806        // Add spacing and summary section
807        wtr.write_record(&["", "", "", ""])?;
808        wtr.write_record(&["SUMMARY", "", "", ""])?;
809        wtr.write_record(&["Date", &report.date, "", ""])?;
810        wtr.write_record(&["Total Hours", &report.total_hours, "", ""])?;
811        wtr.write_record(&["Productivity", &format!("{:.1}%", report.productivity), "", ""])?;
812
813        // Add spacing and tasks section
814        wtr.write_record(&["", "", "", ""])?;
815        wtr.write_record(&["TASKS", "", "", ""])?;
816        wtr.write_record(&["ID", "Name", "Comment", "Completeness"])?;
817        for task in &report.tasks {
818            wtr.write_record(&[task.id.to_string(), task.name.clone(), task.comment.clone(), format!("{}%", task.completeness)])?;
819        }
820
821        wtr.flush()?;
822        Ok(())
823    }
824
825    /// Exports task records to CSV format with standard table structure.
826    ///
827    /// This method creates a simple CSV table with task information, suitable
828    /// for import into spreadsheet applications or database systems.
829    ///
830    /// # Arguments
831    ///
832    /// * `tasks` - The task data collection to export
833    ///
834    /// # Returns
835    ///
836    /// Returns `Ok(())` on successful CSV generation, or an error if file
837    /// writing fails.
838    fn export_tasks_csv(&self, tasks: &[ExportTask]) -> Result<()> {
839        let mut wtr = csv::Writer::from_path(&self.output_path)?;
840        wtr.write_record(&["ID", "Name", "Comment", "Completeness"])?;
841
842        for task in tasks {
843            wtr.write_record(&[task.id.to_string(), task.name.clone(), task.comment.clone(), format!("{}%", task.completeness)])?;
844        }
845
846        wtr.flush()?;
847        Ok(())
848    }
849
850    /// Exports monthly summary to CSV format with hierarchical structure.
851    ///
852    /// This method creates a CSV file with a title header, daily breakdown table,
853    /// and summary statistics section for comprehensive monthly analysis.
854    ///
855    /// # Arguments
856    ///
857    /// * `summary` - The monthly summary data to export
858    ///
859    /// # Returns
860    ///
861    /// Returns `Ok(())` on successful CSV generation, or an error if file
862    /// writing fails.
863    fn export_summary_csv(&self, summary: &ExportSummary) -> Result<()> {
864        let mut wtr = csv::Writer::from_path(&self.output_path)?;
865
866        // Write title and daily breakdown
867        wtr.write_record(&[format!("Monthly Summary - {}", summary.month), "".to_owned(), "".to_owned()])?;
868        wtr.write_record(&["Date", "Hours", "Type"])?;
869
870        for day in &summary.days {
871            wtr.write_record(&[
872                day.date.clone(),
873                day.hours.clone(),
874                if day.is_workday { "Work".to_owned() } else { "Rest".to_owned() },
875            ])?;
876        }
877
878        // Add summary statistics
879        wtr.write_record(&["", "", ""])?;
880        wtr.write_record(&["Total Hours", &summary.total_hours, ""])?;
881        wtr.write_record(&["Average Hours", &summary.average_hours, ""])?;
882        wtr.write_record(&["Total Days", &summary.total_days.to_string(), ""])?;
883
884        wtr.flush()?;
885        Ok(())
886    }
887
888    /// Exports daily report data to JSON format with pretty printing.
889    ///
890    /// This method serializes the report data structure to JSON with formatting
891    /// that makes it human-readable and suitable for both programmatic processing
892    /// and manual inspection.
893    ///
894    /// # Arguments
895    ///
896    /// * `report` - The report data structure to serialize
897    ///
898    /// # Returns
899    ///
900    /// Returns `Ok(())` on successful JSON generation, or an error if
901    /// serialization or file writing fails.
902    fn export_report_json(&self, report: &ExportReport) -> Result<()> {
903        let json = serde_json::to_string_pretty(report)?;
904        File::create(&self.output_path)?.write_all(json.as_bytes())?;
905        Ok(())
906    }
907
908    /// Exports daily report to Excel format with professional formatting and multiple sections.
909    ///
910    /// This method creates a comprehensive Excel worksheet with formatted headers,
911    /// auto-sized columns, and structured sections for work intervals, summary
912    /// information, and task details. The Excel format provides the richest
913    /// presentation quality with professional formatting.
914    ///
915    /// ## Excel Features
916    ///
917    /// - **Formatted Headers**: Bold text with gray background for section identification
918    /// - **Auto-sizing**: Columns automatically sized for optimal readability
919    /// - **Section Separation**: Visual spacing between different data sections
920    /// - **Data Types**: Appropriate formatting for numbers, percentages, and text
921    ///
922    /// # Arguments
923    ///
924    /// * `report` - The report data structure to export
925    ///
926    /// # Returns
927    ///
928    /// Returns `Ok(())` on successful Excel generation, or an error if
929    /// workbook creation or file writing fails.
930    fn export_report_excel(&self, report: &ExportReport) -> Result<()> {
931        let mut workbook = Workbook::new();
932        let worksheet = workbook.add_worksheet();
933
934        // Create formatting styles for headers and content
935        let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
936
937        // Write work intervals section
938        worksheet.write_string_with_format(0, 0, "WORK INTERVALS", &header_format)?;
939        worksheet.write_string_with_format(1, 0, "Index", &header_format)?;
940        worksheet.write_string_with_format(1, 1, "Start", &header_format)?;
941        worksheet.write_string_with_format(1, 2, "End", &header_format)?;
942        worksheet.write_string_with_format(1, 3, "Duration", &header_format)?;
943
944        let mut row = 2;
945        for interval in &report.intervals {
946            worksheet.write_number(row, 0, interval.index as f64)?;
947            worksheet.write_string(row, 1, &interval.start)?;
948            worksheet.write_string(row, 2, &interval.end)?;
949            worksheet.write_string(row, 3, &interval.duration)?;
950            row += 1;
951        }
952
953        // Add summary section with spacing
954        row += 2;
955        worksheet.write_string_with_format(row, 0, "SUMMARY", &header_format)?;
956        row += 1;
957        worksheet.write_string(row, 0, "Date")?;
958        worksheet.write_string(row, 1, &report.date)?;
959        row += 1;
960        worksheet.write_string(row, 0, "Total Hours")?;
961        worksheet.write_string(row, 1, &report.total_hours)?;
962        row += 1;
963        worksheet.write_string(row, 0, "Productivity")?;
964        worksheet.write_string(row, 1, &format!("{:.1}%", report.productivity))?;
965
966        // Add tasks section with spacing
967        row += 2;
968        worksheet.write_string_with_format(row, 0, "TASKS", &header_format)?;
969        row += 1;
970        worksheet.write_string_with_format(row, 0, "ID", &header_format)?;
971        worksheet.write_string_with_format(row, 1, "Name", &header_format)?;
972        worksheet.write_string_with_format(row, 2, "Comment", &header_format)?;
973        worksheet.write_string_with_format(row, 3, "Completeness", &header_format)?;
974
975        row += 1;
976        for task in &report.tasks {
977            worksheet.write_number(row, 0, task.id as f64)?;
978            worksheet.write_string(row, 1, &task.name)?;
979            worksheet.write_string(row, 2, &task.comment)?;
980            worksheet.write_string(row, 3, &format!("{}%", task.completeness))?;
981            row += 1;
982        }
983
984        // Apply auto-sizing for optimal column widths
985        worksheet.autofit();
986
987        workbook.save(&self.output_path)?;
988        Ok(())
989    }
990
991    /// Exports task records to Excel format with formatted table structure.
992    ///
993    /// This method creates a clean Excel table with task information, suitable
994    /// for further analysis or integration with other Excel-based workflows.
995    ///
996    /// # Arguments
997    ///
998    /// * `tasks` - The task collection to export
999    ///
1000    /// # Returns
1001    ///
1002    /// Returns `Ok(())` on successful Excel generation, or an error if
1003    /// workbook creation fails.
1004    fn export_tasks_excel(&self, tasks: &[ExportTask]) -> Result<()> {
1005        let mut workbook = Workbook::new();
1006        let worksheet = workbook.add_worksheet();
1007
1008        let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
1009
1010        // Write headers
1011        worksheet.write_string_with_format(0, 0, "ID", &header_format)?;
1012        worksheet.write_string_with_format(0, 1, "Name", &header_format)?;
1013        worksheet.write_string_with_format(0, 2, "Comment", &header_format)?;
1014        worksheet.write_string_with_format(0, 3, "Completeness", &header_format)?;
1015
1016        // Write task data
1017        for (i, task) in tasks.iter().enumerate() {
1018            let row = i as u32 + 1;
1019            worksheet.write_number(row, 0, task.id as f64)?;
1020            worksheet.write_string(row, 1, &task.name)?;
1021            worksheet.write_string(row, 2, &task.comment)?;
1022            worksheet.write_string(row, 3, &format!("{}%", task.completeness))?;
1023        }
1024
1025        worksheet.autofit();
1026        workbook.save(&self.output_path)?;
1027        Ok(())
1028    }
1029
1030    /// Exports monthly summary to Excel format with title formatting and statistics.
1031    ///
1032    /// This method creates a professional monthly summary report with a formatted
1033    /// title, daily breakdown table, and summary statistics section.
1034    ///
1035    /// # Arguments
1036    ///
1037    /// * `summary` - The monthly summary data to export
1038    ///
1039    /// # Returns
1040    ///
1041    /// Returns `Ok(())` on successful Excel generation, or an error if
1042    /// workbook creation fails.
1043    fn export_summary_excel(&self, summary: &ExportSummary) -> Result<()> {
1044        let mut workbook = Workbook::new();
1045        let worksheet = workbook.add_worksheet();
1046
1047        // Create formatting styles
1048        let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
1049        let title_format = Format::new().set_bold().set_font_size(14.0);
1050
1051        // Write title and daily breakdown
1052        worksheet.write_string_with_format(0, 0, &format!("Monthly Summary - {}", summary.month), &title_format)?;
1053        worksheet.write_string_with_format(2, 0, "Date", &header_format)?;
1054        worksheet.write_string_with_format(2, 1, "Hours", &header_format)?;
1055        worksheet.write_string_with_format(2, 2, "Type", &header_format)?;
1056
1057        let mut row = 3;
1058        for day in &summary.days {
1059            worksheet.write_string(row, 0, &day.date)?;
1060            worksheet.write_string(row, 1, &day.hours)?;
1061            worksheet.write_string(row, 2, if day.is_workday { "Work" } else { "Rest" })?;
1062            row += 1;
1063        }
1064
1065        // Add summary statistics
1066        row += 1;
1067        worksheet.write_string(row, 0, "Total Hours")?;
1068        worksheet.write_string(row, 1, &summary.total_hours)?;
1069        row += 1;
1070        worksheet.write_string(row, 0, "Average Hours")?;
1071        worksheet.write_string(row, 1, &summary.average_hours)?;
1072        row += 1;
1073        worksheet.write_string(row, 0, "Total Days")?;
1074        worksheet.write_number(row, 1, summary.total_days as f64)?;
1075
1076        worksheet.autofit();
1077        workbook.save(&self.output_path)?;
1078        Ok(())
1079    }
1080
1081    /// Gathers the data required to render an hourly (SiServer-style) daily report.
1082    ///
1083    /// Unlike [`Exporter::gather_report_data`], this method combines both manual
1084    /// breaks and automatic pauses (respecting the configured minimum pause
1085    /// duration) so that the resulting hourly grid accurately reflects every
1086    /// interruption. Tasks are distributed one-per-hour across work hour slots
1087    /// (not across work intervals): fewer tasks span contiguous hour blocks;
1088    /// surplus tasks are appended only to hours without a break.
1089    ///
1090    /// # Arguments
1091    ///
1092    /// * `date` - The target date for which to build the hourly report
1093    ///
1094    /// # Returns
1095    ///
1096    /// Returns an [`HourlyReport`] describing the workday header and one row per
1097    /// hour of work, or an error if no workday exists for the date.
1098    fn gather_hourly_data(&self, date: NaiveDate, locale: &Locale) -> Result<HourlyReport> {
1099        let workday = Workdays::new()?
1100            .fetch(date)?
1101            .ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
1102
1103        // Respect the same interruption sources and thresholds as report submission.
1104        let config = Config::read()?;
1105        let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
1106        let breaks = Breaks::new()?.get_daily_breaks(date)?;
1107        let pauses = Pauses::new()?
1108            .set_min_duration(monitor_config.min_pause_duration)
1109            .get_workday_pauses(&workday)?;
1110        let interruptions = report::combine_breaks_and_pauses(&breaks, &pauses);
1111
1112        let intervals = report::calculate_work_intervals(&workday, &interruptions);
1113        let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
1114
1115        // End of the workday (fall back to "now" if the session is still open).
1116        let end_time = workday.end.unwrap_or_else(|| Local::now().naive_local());
1117
1118        let slots = classify_hour_slots(workday.start, end_time, &intervals, &interruptions);
1119        let task_texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1120        let rows = build_hourly_rows(&slots, &task_texts, locale.break_label);
1121
1122        // Total net worked time is the sum of all work intervals.
1123        let worked = intervals.iter().fold(Duration::zero(), |acc, i| acc + i.duration);
1124
1125        // Localized weekday/month names (Monday = index 0, January = index 0).
1126        let weekday_idx = date.weekday().num_days_from_monday() as usize;
1127        let month_idx = (date.month().saturating_sub(1)) as usize;
1128
1129        Ok(HourlyReport {
1130            date,
1131            weekday: locale.weekdays[weekday_idx].to_string(),
1132            month: locale.months[month_idx].to_string(),
1133            day_hours: worked.num_hours().max(0),
1134            worked: format_duration(&worked),
1135            rows,
1136        })
1137    }
1138
1139    /// Renders the hourly daily report to an Excel workbook mirroring the SiServer layout.
1140    ///
1141    /// The generated sheet contains a header block (title, date, weekday, workday
1142    /// length), an hourly table with start/end times and per-hour descriptions,
1143    /// a total worked-hours row, and an empty comment area.
1144    ///
1145    /// # Arguments
1146    ///
1147    /// * `date` - The target date for the report
1148    ///
1149    /// # Returns
1150    ///
1151    /// Returns `Ok(())` on successful workbook creation, or an error if data
1152    /// gathering or file writing fails.
1153    fn export_report_excel_hourly(&self, date: NaiveDate) -> Result<()> {
1154        // Resolve localization and design template from the report config.
1155        let config = Config::read()?;
1156        let report_config = config.report.clone().unwrap_or_default();
1157        let language = Language::from_code(report_config.language.as_deref().unwrap_or("ru"));
1158        let locale = Locale::for_language(language);
1159        let template = ReportTemplate::load(report_config.template.as_deref().unwrap_or("siserver"));
1160
1161        let data = self.gather_hourly_data(date, locale)?;
1162
1163        let mut workbook = Workbook::new();
1164        let worksheet = workbook.add_worksheet();
1165
1166        // Palette parsed from the template (hex โ†’ xlsx Color).
1167        let border_color = template.border();
1168        let header_fill = template.fill();
1169
1170        // Builds a base format carrying the given font specification.
1171        let font_base = |spec: &FontSpec| -> Format {
1172            let mut fmt = Format::new().set_font_name(spec.name.as_str()).set_font_size(spec.size);
1173            if spec.bold {
1174                fmt = fmt.set_bold();
1175            }
1176            fmt
1177        };
1178
1179        // Title / header-block formats.
1180        let fmt_title = font_base(&template.fonts.title)
1181            .set_border(FormatBorder::Thin)
1182            .set_border_color(border_color)
1183            .set_align(FormatAlign::Center)
1184            .set_align(FormatAlign::VerticalCenter);
1185        let fmt_month = font_base(&template.fonts.month).set_align(FormatAlign::Center);
1186        let fmt_date = font_base(&template.fonts.date)
1187            .set_border(FormatBorder::Thin)
1188            .set_border_color(border_color)
1189            .set_align(FormatAlign::Center);
1190        let fmt_center = Format::new().set_align(FormatAlign::Center);
1191        let fmt_right = Format::new().set_align(FormatAlign::Right);
1192
1193        // Table formats.
1194        let fmt_header = font_base(&template.fonts.header)
1195            .set_background_color(header_fill)
1196            .set_border(FormatBorder::Thin)
1197            .set_border_color(border_color)
1198            .set_align(FormatAlign::Center)
1199            .set_align(FormatAlign::VerticalCenter);
1200        let fmt_time = font_base(&template.fonts.time)
1201            .set_border(FormatBorder::Thin)
1202            .set_border_color(border_color)
1203            .set_align(FormatAlign::Center)
1204            .set_align(FormatAlign::VerticalCenter);
1205        let fmt_desc = Format::new()
1206            .set_border(FormatBorder::Thin)
1207            .set_border_color(border_color)
1208            .set_align(FormatAlign::Center)
1209            .set_align(FormatAlign::VerticalCenter)
1210            .set_text_wrap();
1211        let fmt_empty = Format::new()
1212            .set_border(FormatBorder::Thin)
1213            .set_border_color(border_color)
1214            .set_align(FormatAlign::Center)
1215            .set_align(FormatAlign::VerticalCenter)
1216            .set_text_wrap();
1217
1218        // Footer formats.
1219        let fmt_total_label = Format::new()
1220            .set_background_color(header_fill)
1221            .set_border(FormatBorder::Thin)
1222            .set_border_color(border_color)
1223            .set_align(FormatAlign::Right);
1224        let fmt_comment_label = font_base(&template.fonts.header);
1225        let fmt_comment_box = Format::new()
1226            .set_border(FormatBorder::Thin)
1227            .set_border_color(border_color)
1228            .set_align(FormatAlign::VerticalCenter);
1229
1230        // Column widths (columns B..F, i.e. 1..5) from the template.
1231        worksheet.set_column_width(1, template.col_widths[0])?;
1232        worksheet.set_column_width(2, template.col_widths[1])?;
1233        worksheet.set_column_width(3, template.col_widths[2])?;
1234        if template.show_hours_column {
1235            worksheet.set_column_width(4, template.col_widths[3])?;
1236        }
1237        if template.show_result_column {
1238            worksheet.set_column_width(5, template.col_widths[4])?;
1239        }
1240
1241        // Header block.
1242        worksheet.set_row_height(1, template.title_row_height)?;
1243        worksheet.merge_range(1, 1, 1, 2, locale.report_title, &fmt_title)?;
1244        worksheet.write_string_with_format(1, 3, &data.month, &fmt_month)?;
1245        worksheet.merge_range(2, 1, 2, 2, &data.date.format(locale.date_format).to_string(), &fmt_date)?;
1246
1247        worksheet.write_string_with_format(4, 1, &data.weekday, &fmt_center)?;
1248        worksheet.write_string_with_format(4, 2, locale.day_type_working, &fmt_center)?;
1249        worksheet.write_string_with_format(4, 3, locale.workday_length, &fmt_right)?;
1250        worksheet.write_number(4, 4, data.day_hours as f64)?;
1251
1252        // Table header (two rows: day span over start/end, plus per-column headers).
1253        worksheet.merge_range(6, 1, 6, 2, locale.header_day, &fmt_header)?;
1254        worksheet.write_string_with_format(7, 1, locale.header_start, &fmt_header)?;
1255        worksheet.write_string_with_format(7, 2, locale.header_end, &fmt_header)?;
1256        worksheet.merge_range(6, 3, 7, 3, "", &fmt_header)?;
1257        if template.show_hours_column {
1258            worksheet.merge_range(6, 4, 7, 4, locale.header_hours, &fmt_header)?;
1259        }
1260        if template.show_result_column {
1261            worksheet.merge_range(6, 5, 7, 5, locale.header_result, &fmt_header)?;
1262        }
1263
1264        // Hourly data rows.
1265        let mut row: u32 = 8;
1266        for item in &data.rows {
1267            worksheet.set_row_height(row, template.data_row_height)?;
1268            worksheet.write_string_with_format(row, 1, &item.start, &fmt_time)?;
1269            worksheet.write_string_with_format(row, 2, &item.end, &fmt_time)?;
1270            worksheet.write_string_with_format(row, 3, &item.description, &fmt_desc)?;
1271            if template.show_hours_column {
1272                worksheet.write_string_with_format(row, 4, "", &fmt_empty)?;
1273            }
1274            if template.show_result_column {
1275                worksheet.write_string_with_format(row, 5, "", &fmt_empty)?;
1276            }
1277            row += 1;
1278        }
1279        let last_data_row = row.saturating_sub(1);
1280
1281        // Total worked-hours row (two blank rows below the table, as in the template).
1282        let total_row = last_data_row + 3;
1283        worksheet.merge_range(total_row, 1, total_row, 3, locale.total_worked, &fmt_total_label)?;
1284        worksheet.write_string_with_format(total_row, 4, &data.worked, &fmt_time)?;
1285
1286        // Comment label and empty comment box.
1287        if template.show_comment {
1288            let comment_row = total_row + 2;
1289            worksheet.write_string_with_format(comment_row, 1, locale.comment, &fmt_comment_label)?;
1290            let box_top = comment_row + 1;
1291            let box_bottom = box_top + template.comment_rows.saturating_sub(1);
1292            worksheet.merge_range(box_top, 1, box_bottom, 5, "", &fmt_comment_box)?;
1293        }
1294
1295        workbook.save(&self.output_path)?;
1296        Ok(())
1297    }
1298}
1299
1300/// A single rendered row of the hourly report (one hour of the workday).
1301struct HourlyRow {
1302    /// Hour slot start in "HH:MM" format.
1303    start: String,
1304    /// Hour slot end in "HH:MM" format.
1305    end: String,
1306    /// Description of what happened during the hour ("ะŸะตั€ะตั€ั‹ะฒ" for breaks).
1307    description: String,
1308}
1309
1310/// Aggregated data required to render an hourly daily report.
1311struct HourlyReport {
1312    /// Report date.
1313    date: NaiveDate,
1314    /// Localized weekday name.
1315    weekday: String,
1316    /// Localized month name.
1317    month: String,
1318    /// Whole worked hours, used for the "workday length" header cell.
1319    day_hours: i64,
1320    /// Total net worked time formatted as "HH:MM".
1321    worked: String,
1322    /// Hour-by-hour rows.
1323    rows: Vec<HourlyRow>,
1324}
1325
1326/// One hour-aligned slot of the workday with work/break classification flags.
1327#[derive(Debug, Clone)]
1328struct HourSlot {
1329    /// Grid start of the hour (minutes/seconds zeroed).
1330    start: NaiveDateTime,
1331    /// Slot end (may be earlier than `start + 1h` on the last hour).
1332    end: NaiveDateTime,
1333    /// Whether the slot overlaps any work interval.
1334    has_work: bool,
1335    /// Whether the slot overlaps any break/pause.
1336    has_break: bool,
1337}
1338
1339/// Truncates a timestamp down to the start of its hour (zeroing minutes/seconds).
1340fn floor_to_hour(dt: NaiveDateTime) -> NaiveDateTime {
1341    dt.with_minute(0).and_then(|d| d.with_second(0)).and_then(|d| d.with_nanosecond(0)).unwrap_or(dt)
1342}
1343
1344/// Returns `true` when `[a_start, a_end)` overlaps `[b_start, b_end)`.
1345fn ranges_overlap(a_start: NaiveDateTime, a_end: NaiveDateTime, b_start: NaiveDateTime, b_end: NaiveDateTime) -> bool {
1346    a_start < b_end && b_start < a_end
1347}
1348
1349/// Builds hour-aligned slots covering `[work_start, work_end)` and classifies
1350/// each slot by overlap with work intervals and interruptions.
1351fn classify_hour_slots(
1352    work_start: NaiveDateTime,
1353    work_end: NaiveDateTime,
1354    intervals: &[WorkInterval],
1355    interruptions: &[Pause],
1356) -> Vec<HourSlot> {
1357    let mut slots = Vec::new();
1358    if work_end <= work_start {
1359        return slots;
1360    }
1361
1362    let mut slot_start = floor_to_hour(work_start);
1363    while slot_start < work_end {
1364        let slot_grid_end = slot_start + Duration::hours(1);
1365        let slot_end = slot_grid_end.min(work_end);
1366        let window_start = slot_start.max(work_start);
1367
1368        let has_work = intervals.iter().any(|interval| ranges_overlap(window_start, slot_end, interval.start, interval.end));
1369        let has_break = interruptions.iter().any(|pause| {
1370            let Some(pause_end) = pause.end else {
1371                return false;
1372            };
1373            let start = pause.start.max(work_start);
1374            let end = pause_end.min(work_end);
1375            start < end && ranges_overlap(window_start, slot_end, start, end)
1376        });
1377
1378        slots.push(HourSlot {
1379            start: slot_start,
1380            end: slot_end,
1381            has_work,
1382            has_break,
1383        });
1384
1385        slot_start = slot_grid_end;
1386    }
1387
1388    slots
1389}
1390
1391/// Distributes task descriptions across hour slots (one primary task per work hour).
1392///
1393/// - Work hours receive task labels; pure break hours stay empty (`None`).
1394/// - When there are fewer tasks than work hours, each task occupies a contiguous
1395///   block of consecutive work hours.
1396/// - When there are more tasks than work hours, each work hour gets one task and
1397///   surplus tasks are appended (joined with `". "`) only to hours without a break.
1398///   If every work hour has a break, surplus tasks fall back onto work hours in order.
1399/// - With no tasks, work hours use the locale's generic work label.
1400fn assign_tasks_to_hour_slots(tasks: &[Task], slots: &[HourSlot], locale: &Locale) -> Vec<Option<String>> {
1401    let mut texts: Vec<Option<String>> = vec![None; slots.len()];
1402    let work_indices: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| s.has_work).map(|(i, _)| i).collect();
1403
1404    if work_indices.is_empty() {
1405        return texts;
1406    }
1407
1408    if tasks.is_empty() {
1409        for &idx in &work_indices {
1410            texts[idx] = Some(locale.work_generic.to_string());
1411        }
1412        return texts;
1413    }
1414
1415    let num_work = work_indices.len();
1416    let num_tasks = tasks.len();
1417
1418    if num_tasks <= num_work {
1419        // Contiguous blocks: A A A B B C โ€ฆ
1420        let base = num_work / num_tasks;
1421        let mut extra = num_work % num_tasks;
1422        let mut cursor = 0usize;
1423
1424        for task in tasks {
1425            let count = base + if extra > 0 { 1 } else { 0 };
1426            if extra > 0 {
1427                extra -= 1;
1428            }
1429            let text = locale.work_text(&task.name);
1430            for _ in 0..count {
1431                if cursor < num_work {
1432                    texts[work_indices[cursor]] = Some(text.clone());
1433                    cursor += 1;
1434                }
1435            }
1436        }
1437    } else {
1438        // One task per work hour, then append surplus into no-break hours.
1439        let mut parts: Vec<Vec<String>> = work_indices.iter().enumerate().map(|(i, _)| vec![locale.work_text(&tasks[i].name)]).collect();
1440
1441        let surplus: Vec<String> = tasks[num_work..].iter().map(|t| locale.work_text(&t.name)).collect();
1442        let mut no_break_local: Vec<usize> = work_indices
1443            .iter()
1444            .enumerate()
1445            .filter(|(_, &slot_idx)| !slots[slot_idx].has_break)
1446            .map(|(local_i, _)| local_i)
1447            .collect();
1448
1449        if no_break_local.is_empty() {
1450            // Fallback so surplus task names are not dropped from the report.
1451            no_break_local = (0..num_work).collect();
1452        }
1453
1454        let base = surplus.len() / no_break_local.len();
1455        let mut rem = surplus.len() % no_break_local.len();
1456        let mut iter = surplus.into_iter();
1457
1458        for &local_i in &no_break_local {
1459            let count = base + if rem > 0 { 1 } else { 0 };
1460            if rem > 0 {
1461                rem -= 1;
1462            }
1463            for _ in 0..count {
1464                if let Some(text) = iter.next() {
1465                    parts[local_i].push(text);
1466                }
1467            }
1468        }
1469
1470        for (local_i, &slot_idx) in work_indices.iter().enumerate() {
1471            texts[slot_idx] = Some(parts[local_i].join(". "));
1472        }
1473    }
1474
1475    texts
1476}
1477
1478/// Builds rendered hourly rows from classified slots and assigned task texts.
1479///
1480/// - Pure break / empty slots โ†’ `break_label`
1481/// - Work without break โ†’ task text
1482/// - Work with break โ†’ `"{task}. {break_label}"`
1483fn build_hourly_rows(slots: &[HourSlot], task_texts: &[Option<String>], break_label: &str) -> Vec<HourlyRow> {
1484    slots
1485        .iter()
1486        .enumerate()
1487        .map(|(i, slot)| {
1488            let description = if !slot.has_work {
1489                break_label.to_string()
1490            } else {
1491                let work = task_texts.get(i).and_then(|t| t.as_ref()).map(String::as_str).unwrap_or("");
1492                if slot.has_break {
1493                    if work.is_empty() {
1494                        break_label.to_string()
1495                    } else {
1496                        format!("{work}. {break_label}")
1497                    }
1498                } else if work.is_empty() {
1499                    break_label.to_string()
1500                } else {
1501                    work.to_string()
1502                }
1503            };
1504
1505            HourlyRow {
1506                start: slot.start.format("%H:%M").to_string(),
1507                end: slot.end.format("%H:%M").to_string(),
1508                description,
1509            }
1510        })
1511        .collect()
1512}
1513
1514#[cfg(test)]
1515mod hourly_tests {
1516    use super::*;
1517    use chrono::NaiveDate;
1518
1519    fn dt(hour: u32, min: u32) -> NaiveDateTime {
1520        NaiveDate::from_ymd_opt(2025, 1, 15).unwrap().and_hms_opt(hour, min, 0).unwrap()
1521    }
1522
1523    fn interval(start_h: u32, start_m: u32, end_h: u32, end_m: u32) -> WorkInterval {
1524        let start = dt(start_h, start_m);
1525        let end = dt(end_h, end_m);
1526        WorkInterval {
1527            start,
1528            end,
1529            duration: end - start,
1530            pause_after: None,
1531        }
1532    }
1533
1534    fn pause(start_h: u32, start_m: u32, end_h: u32, end_m: u32) -> Pause {
1535        let start = dt(start_h, start_m);
1536        let end = dt(end_h, end_m);
1537        Pause {
1538            id: 1,
1539            start,
1540            end: Some(end),
1541            duration: Some(end - start),
1542        }
1543    }
1544
1545    fn task(name: &str) -> Task {
1546        Task::new(name, "", Some(0))
1547    }
1548
1549    #[test]
1550    fn fewer_tasks_fill_contiguous_blocks() {
1551        let locale = Locale::for_language(Language::En);
1552        // 09:00โ€“14:00 continuous work โ†’ 5 work hours
1553        let intervals = vec![interval(9, 0, 14, 0)];
1554        let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &[]);
1555        assert_eq!(slots.len(), 5);
1556        assert!(slots.iter().all(|s| s.has_work && !s.has_break));
1557
1558        let tasks = vec![task("A"), task("B"), task("C")];
1559        let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1560        let names: Vec<&str> = texts.iter().map(|t| t.as_deref().unwrap()).collect();
1561
1562        // 5 / 3 โ†’ base 1, extra 2 โ†’ A A B B C
1563        assert_eq!(
1564            names,
1565            vec![
1566                "Work on task [A]",
1567                "Work on task [A]",
1568                "Work on task [B]",
1569                "Work on task [B]",
1570                "Work on task [C]",
1571            ]
1572        );
1573    }
1574
1575    #[test]
1576    fn surplus_tasks_go_to_no_break_hours() {
1577        let locale = Locale::for_language(Language::En);
1578        // work 09โ€“12, break 12:00โ€“12:30, work 12:30โ€“14 โ†’ hours 09โ€“11/13 no-break, 12 mixed
1579        let intervals = vec![interval(9, 0, 12, 0), interval(12, 30, 14, 0)];
1580        let interruptions = vec![pause(12, 0, 12, 30)];
1581        let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &interruptions);
1582
1583        assert_eq!(slots.len(), 5);
1584        assert!(slots[0].has_work && !slots[0].has_break); // 09
1585        assert!(slots[1].has_work && !slots[1].has_break); // 10
1586        assert!(slots[2].has_work && !slots[2].has_break); // 11
1587        assert!(slots[3].has_work && slots[3].has_break); // 12 mixed
1588        assert!(slots[4].has_work && !slots[4].has_break); // 13
1589
1590        // 5 tasks for 5 work hours โ†’ one each, no surplus
1591        let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E")];
1592        let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1593        assert_eq!(texts[3].as_deref(), Some("Work on task [D]"));
1594
1595        // 6 tasks โ†’ surplus F must not land on mixed hour 12 (index 3)
1596        let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E"), task("F")];
1597        let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1598        assert_eq!(texts[3].as_deref(), Some("Work on task [D]"));
1599        assert!(texts.iter().enumerate().any(|(i, t)| i != 3 && t.as_ref().is_some_and(|s| s.contains("[F]"))));
1600        assert!(!texts[3].as_ref().unwrap().contains("[F]"));
1601    }
1602
1603    #[test]
1604    fn surplus_distributed_across_no_break_hours() {
1605        let locale = Locale::for_language(Language::En);
1606        let intervals = vec![interval(9, 0, 12, 0)];
1607        let slots = classify_hour_slots(dt(9, 0), dt(12, 0), &intervals, &[]);
1608        let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E")];
1609        let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1610
1611        // 5 tasks / 3 hours โ†’ base 1 each, then 2 surplus into first hours
1612        // base surplus=0, rem=2 โ†’ first two no-break hours get +1
1613        assert_eq!(texts[0].as_deref(), Some("Work on task [A]. Work on task [D]"));
1614        assert_eq!(texts[1].as_deref(), Some("Work on task [B]. Work on task [E]"));
1615        assert_eq!(texts[2].as_deref(), Some("Work on task [C]"));
1616    }
1617
1618    #[test]
1619    fn pure_break_hour_has_only_break_label() {
1620        let locale = Locale::for_language(Language::En);
1621        let intervals = vec![interval(9, 0, 12, 0), interval(13, 0, 14, 0)];
1622        let interruptions = vec![pause(12, 0, 13, 0)];
1623        let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &interruptions);
1624        let tasks = vec![task("A"), task("B"), task("C")];
1625        let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1626        let rows = build_hourly_rows(&slots, &texts, locale.break_label);
1627
1628        assert!(!slots[3].has_work && slots[3].has_break);
1629        assert_eq!(rows[3].description, "Break");
1630        assert!(texts[3].is_none());
1631    }
1632
1633    #[test]
1634    fn mixed_hour_shows_task_and_break() {
1635        let locale = Locale::for_language(Language::En);
1636        let intervals = vec![interval(9, 0, 12, 0), interval(12, 30, 13, 0)];
1637        let interruptions = vec![pause(12, 0, 12, 30)];
1638        let slots = classify_hour_slots(dt(9, 0), dt(13, 0), &intervals, &interruptions);
1639        let tasks = vec![task("A"), task("B"), task("C"), task("D")];
1640        let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1641        let rows = build_hourly_rows(&slots, &texts, locale.break_label);
1642
1643        assert!(slots[3].has_work && slots[3].has_break);
1644        assert_eq!(rows[3].description, "Work on task [D]. Break");
1645    }
1646}