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