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}