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 && let ExportFormat::Excel = self.format
391 {
392 self.export_report_excel_hourly(date)?;
393 msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
394 return Ok(());
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 += 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)
1342 .and_then(|d| d.with_second(0))
1343 .and_then(|d| d.with_nanosecond(0))
1344 .unwrap_or(dt)
1345}
1346
1347/// Returns `true` when `[a_start, a_end)` overlaps `[b_start, b_end)`.
1348fn ranges_overlap(a_start: NaiveDateTime, a_end: NaiveDateTime, b_start: NaiveDateTime, b_end: NaiveDateTime) -> bool {
1349 a_start < b_end && b_start < a_end
1350}
1351
1352/// Builds hour-aligned slots covering `[work_start, work_end)` and classifies
1353/// each slot by overlap with work intervals and interruptions.
1354fn classify_hour_slots(work_start: NaiveDateTime, work_end: NaiveDateTime, intervals: &[WorkInterval], interruptions: &[Pause]) -> Vec<HourSlot> {
1355 let mut slots = Vec::new();
1356 if work_end <= work_start {
1357 return slots;
1358 }
1359
1360 let mut slot_start = floor_to_hour(work_start);
1361 while slot_start < work_end {
1362 let slot_grid_end = slot_start + Duration::hours(1);
1363 let slot_end = slot_grid_end.min(work_end);
1364 let window_start = slot_start.max(work_start);
1365
1366 let has_work = intervals
1367 .iter()
1368 .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 extra = extra.saturating_sub(1);
1427 let text = locale.work_text(&task.name);
1428 for _ in 0..count {
1429 if cursor < num_work {
1430 texts[work_indices[cursor]] = Some(text.clone());
1431 cursor += 1;
1432 }
1433 }
1434 }
1435 } else {
1436 // One task per work hour, then append surplus into no-break hours.
1437 let mut parts: Vec<Vec<String>> = work_indices.iter().enumerate().map(|(i, _)| vec![locale.work_text(&tasks[i].name)]).collect();
1438
1439 let surplus: Vec<String> = tasks[num_work..].iter().map(|t| locale.work_text(&t.name)).collect();
1440 let mut no_break_local: Vec<usize> = work_indices
1441 .iter()
1442 .enumerate()
1443 .filter(|&(_, &slot_idx)| !slots[slot_idx].has_break)
1444 .map(|(local_i, _)| local_i)
1445 .collect();
1446
1447 if no_break_local.is_empty() {
1448 // Fallback so surplus task names are not dropped from the report.
1449 no_break_local = (0..num_work).collect();
1450 }
1451
1452 let base = surplus.len() / no_break_local.len();
1453 let mut rem = surplus.len() % no_break_local.len();
1454 let mut iter = surplus.into_iter();
1455
1456 for &local_i in &no_break_local {
1457 let count = base + if rem > 0 { 1 } else { 0 };
1458 rem = rem.saturating_sub(1);
1459 for _ in 0..count {
1460 if let Some(text) = iter.next() {
1461 parts[local_i].push(text);
1462 }
1463 }
1464 }
1465
1466 for (local_i, &slot_idx) in work_indices.iter().enumerate() {
1467 texts[slot_idx] = Some(parts[local_i].join(". "));
1468 }
1469 }
1470
1471 texts
1472}
1473
1474/// Builds rendered hourly rows from classified slots and assigned task texts.
1475///
1476/// - Pure break / empty slots โ `break_label`
1477/// - Work without break โ task text
1478/// - Work with break โ `"{task}. {break_label}"`
1479fn build_hourly_rows(slots: &[HourSlot], task_texts: &[Option<String>], break_label: &str) -> Vec<HourlyRow> {
1480 slots
1481 .iter()
1482 .enumerate()
1483 .map(|(i, slot)| {
1484 let description = if !slot.has_work {
1485 break_label.to_string()
1486 } else {
1487 let work = task_texts.get(i).and_then(|t| t.as_ref()).map(String::as_str).unwrap_or("");
1488 if slot.has_break {
1489 if work.is_empty() {
1490 break_label.to_string()
1491 } else {
1492 format!("{work}. {break_label}")
1493 }
1494 } else if work.is_empty() {
1495 break_label.to_string()
1496 } else {
1497 work.to_string()
1498 }
1499 };
1500
1501 HourlyRow {
1502 start: slot.start.format("%H:%M").to_string(),
1503 end: slot.end.format("%H:%M").to_string(),
1504 description,
1505 }
1506 })
1507 .collect()
1508}
1509
1510#[cfg(test)]
1511mod hourly_tests {
1512 use super::*;
1513 use chrono::NaiveDate;
1514
1515 fn dt(hour: u32, min: u32) -> NaiveDateTime {
1516 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap().and_hms_opt(hour, min, 0).unwrap()
1517 }
1518
1519 fn interval(start_h: u32, start_m: u32, end_h: u32, end_m: u32) -> WorkInterval {
1520 let start = dt(start_h, start_m);
1521 let end = dt(end_h, end_m);
1522 WorkInterval {
1523 start,
1524 end,
1525 duration: end - start,
1526 pause_after: None,
1527 }
1528 }
1529
1530 fn pause(start_h: u32, start_m: u32, end_h: u32, end_m: u32) -> Pause {
1531 let start = dt(start_h, start_m);
1532 let end = dt(end_h, end_m);
1533 Pause {
1534 id: 1,
1535 start,
1536 end: Some(end),
1537 duration: Some(end - start),
1538 }
1539 }
1540
1541 fn task(name: &str) -> Task {
1542 Task::new(name, "", Some(0))
1543 }
1544
1545 #[test]
1546 fn fewer_tasks_fill_contiguous_blocks() {
1547 let locale = Locale::for_language(Language::En);
1548 // 09:00โ14:00 continuous work โ 5 work hours
1549 let intervals = vec![interval(9, 0, 14, 0)];
1550 let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &[]);
1551 assert_eq!(slots.len(), 5);
1552 assert!(slots.iter().all(|s| s.has_work && !s.has_break));
1553
1554 let tasks = vec![task("A"), task("B"), task("C")];
1555 let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1556 let names: Vec<&str> = texts.iter().map(|t| t.as_deref().unwrap()).collect();
1557
1558 // 5 / 3 โ base 1, extra 2 โ A A B B C
1559 assert_eq!(
1560 names,
1561 vec![
1562 "Work on task [A]",
1563 "Work on task [A]",
1564 "Work on task [B]",
1565 "Work on task [B]",
1566 "Work on task [C]",
1567 ]
1568 );
1569 }
1570
1571 #[test]
1572 fn surplus_tasks_go_to_no_break_hours() {
1573 let locale = Locale::for_language(Language::En);
1574 // work 09โ12, break 12:00โ12:30, work 12:30โ14 โ hours 09โ11/13 no-break, 12 mixed
1575 let intervals = vec![interval(9, 0, 12, 0), interval(12, 30, 14, 0)];
1576 let interruptions = vec![pause(12, 0, 12, 30)];
1577 let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &interruptions);
1578
1579 assert_eq!(slots.len(), 5);
1580 assert!(slots[0].has_work && !slots[0].has_break); // 09
1581 assert!(slots[1].has_work && !slots[1].has_break); // 10
1582 assert!(slots[2].has_work && !slots[2].has_break); // 11
1583 assert!(slots[3].has_work && slots[3].has_break); // 12 mixed
1584 assert!(slots[4].has_work && !slots[4].has_break); // 13
1585
1586 // 5 tasks for 5 work hours โ one each, no surplus
1587 let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E")];
1588 let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1589 assert_eq!(texts[3].as_deref(), Some("Work on task [D]"));
1590
1591 // 6 tasks โ surplus F must not land on mixed hour 12 (index 3)
1592 let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E"), task("F")];
1593 let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1594 assert_eq!(texts[3].as_deref(), Some("Work on task [D]"));
1595 assert!(texts.iter().enumerate().any(|(i, t)| i != 3 && t.as_ref().is_some_and(|s| s.contains("[F]"))));
1596 assert!(!texts[3].as_ref().unwrap().contains("[F]"));
1597 }
1598
1599 #[test]
1600 fn surplus_distributed_across_no_break_hours() {
1601 let locale = Locale::for_language(Language::En);
1602 let intervals = vec![interval(9, 0, 12, 0)];
1603 let slots = classify_hour_slots(dt(9, 0), dt(12, 0), &intervals, &[]);
1604 let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E")];
1605 let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1606
1607 // 5 tasks / 3 hours โ base 1 each, then 2 surplus into first hours
1608 // base surplus=0, rem=2 โ first two no-break hours get +1
1609 assert_eq!(texts[0].as_deref(), Some("Work on task [A]. Work on task [D]"));
1610 assert_eq!(texts[1].as_deref(), Some("Work on task [B]. Work on task [E]"));
1611 assert_eq!(texts[2].as_deref(), Some("Work on task [C]"));
1612 }
1613
1614 #[test]
1615 fn pure_break_hour_has_only_break_label() {
1616 let locale = Locale::for_language(Language::En);
1617 let intervals = vec![interval(9, 0, 12, 0), interval(13, 0, 14, 0)];
1618 let interruptions = vec![pause(12, 0, 13, 0)];
1619 let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &interruptions);
1620 let tasks = vec![task("A"), task("B"), task("C")];
1621 let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1622 let rows = build_hourly_rows(&slots, &texts, locale.break_label);
1623
1624 assert!(!slots[3].has_work && slots[3].has_break);
1625 assert_eq!(rows[3].description, "Break");
1626 assert!(texts[3].is_none());
1627 }
1628
1629 #[test]
1630 fn mixed_hour_shows_task_and_break() {
1631 let locale = Locale::for_language(Language::En);
1632 let intervals = vec![interval(9, 0, 12, 0), interval(12, 30, 13, 0)];
1633 let interruptions = vec![pause(12, 0, 12, 30)];
1634 let slots = classify_hour_slots(dt(9, 0), dt(13, 0), &intervals, &interruptions);
1635 let tasks = vec![task("A"), task("B"), task("C"), task("D")];
1636 let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
1637 let rows = build_hourly_rows(&slots, &texts, locale.break_label);
1638
1639 assert!(slots[3].has_work && slots[3].has_break);
1640 assert_eq!(rows[3].description, "Work on task [D]. Break");
1641 }
1642}