Skip to main content

kasl/commands/
report.rs

1//! Daily and monthly report generation and submission command.
2//!
3//! Handles the core reporting functionality of kasl including generation of detailed
4//! daily work reports, automatic filtering of short work intervals, integration with
5//! external APIs, and comprehensive productivity analysis using the centralized
6//! Productivity module.
7//!
8//! ## Productivity Integration
9//!
10//! This module leverages the centralized `libs::productivity::Productivity` module for:
11//! - Consistent productivity calculations across display and submission
12//! - Break recommendations based on productivity thresholds  
13//! - Validation before report submission to external APIs
14//! - Real-time productivity feedback during report generation
15
16use crate::{
17    api::si::Si,
18    db::{
19        pauses::Pauses,
20        tasks::Tasks,
21        workdays::{Workday, Workdays},
22    },
23    libs::{
24        config::Config,
25        formatter::format_duration,
26        messages::Message,
27        productivity::Productivity,
28        report,
29        task::{FormatTasks, Task, TaskFilter},
30        view::View,
31    },
32    msg_error, msg_error_anyhow, msg_info, msg_print,
33};
34use anyhow::Result;
35use chrono::{DateTime, Duration, Local};
36use clap::Args;
37use serde_json::json;
38
39/// Command-line arguments for the report command.
40///
41/// The report command supports multiple operational modes for different
42/// reporting scenarios and organizational requirements.
43#[derive(Debug, Args)]
44pub struct ReportArgs {
45    /// Submit the generated daily report to configured API
46    ///
47    /// When specified, the report will be automatically submitted to the
48    /// configured reporting service (typically SiServer) after generation.
49    /// This enables integration with organizational time tracking systems.
50    #[arg(long, help = "Submit daily report")]
51    send: bool,
52
53    /// Generate report for the previous day instead of today
54    ///
55    /// Useful for:
56    /// - Submitting yesterday's report in the morning
57    /// - Reviewing completed work sessions
58    /// - Batch processing of historical reports
59    #[arg(long, short, help = "Generate report for the last day")]
60    last: bool,
61
62    /// Submit monthly summary report to configured API
63    ///
64    /// Generates and submits an aggregate monthly report containing
65    /// summary statistics and total work hours. Typically used for
66    /// organizational reporting requirements at month-end.
67    #[arg(long, help = "Submit monthly report")]
68    month: bool,
69}
70
71/// Main entry point for the report command.
72///
73/// Acts as a dispatcher based on the provided arguments, determining the target
74/// date and delegating to the appropriate handler for daily, monthly, display,
75/// or send actions.
76///
77/// # Arguments
78///
79/// * `args` - Parsed command-line arguments specifying report options
80///
81/// # Returns
82///
83/// Returns `Ok(())` on successful report generation or processing,
84/// or an error if data retrieval or submission fails.
85///
86/// ```bash
87/// # Display today's report
88/// kasl report
89///
90/// # Submit today's report to API
91/// kasl report --send
92///
93/// # Generate yesterday's report
94/// kasl report --last
95///
96/// # Submit monthly summary
97/// kasl report --month
98///
99/// ```
100pub async fn cmd(args: ReportArgs) -> Result<()> {
101    let date = determine_report_date(args.last);
102
103    if args.month {
104        handle_monthly_report(date).await
105    } else {
106        handle_daily_report(args.send, date).await
107    }
108}
109
110/// Determines the target date for report generation.
111///
112/// Calculates whether to generate a report for today or yesterday
113/// based on user preferences. This allows flexible reporting timing
114/// to accommodate different organizational workflows.
115///
116/// # Arguments
117///
118/// * `is_last_day` - Whether to generate report for yesterday
119///
120/// # Returns
121///
122/// Returns the target date with timezone information for report generation.
123fn determine_report_date(is_last_day: bool) -> DateTime<Local> {
124    if is_last_day { Local::now() - Duration::days(1) } else { Local::now() }
125}
126
127/// Handles the logic for daily reports.
128///
129/// Routes to either display or submission mode based on user preferences.
130/// This separation allows for different handling of local viewing versus
131/// API integration scenarios.
132///
133/// # Arguments
134///
135/// * `should_send` - Whether to submit the report to external API
136/// * `date` - Target date for report generation
137async fn handle_daily_report(should_send: bool, date: DateTime<Local>) -> Result<()> {
138    if should_send {
139        send_daily_report(date).await
140    } else {
141        display_daily_report(date).await
142    }
143}
144
145/// Handles the submission of monthly summary reports.
146///
147/// Generates and submits aggregate monthly statistics to the configured
148/// reporting API. This is typically used for organizational reporting
149/// requirements and payroll integration.
150///
151/// ## Monthly Report Contents
152///
153/// - Total hours worked in the month
154/// - Number of working days
155/// - Average daily hours
156/// - Productivity trends (if available)
157///
158/// # Arguments
159///
160/// * `date` - Date within the target month for report generation
161///
162/// # Error Handling
163///
164/// Network errors are handled gracefully with user-friendly messages
165/// rather than application crashes, allowing continued local operation
166/// even when API services are unavailable.
167async fn handle_monthly_report(date: DateTime<Local>) -> Result<()> {
168    let mut si = get_si_service()?;
169    let naive_date = date.date_naive();
170
171    match si.send_monthly(&naive_date).await {
172        Ok(status) => {
173            if status.is_success() {
174                msg_info!(Message::MonthlyReportSent(date.format("%B %-d, %Y").to_string()));
175            } else {
176                msg_error!(Message::MonthlyReportSendFailed(status.to_string()));
177            }
178        }
179        Err(e) => msg_error!(Message::ErrorSendingMonthlyReport(e.to_string())),
180    }
181
182    Ok(())
183}
184
185/// Fetches data and displays a formatted daily report in the terminal.
186///
187/// This function generates a comprehensive daily work report including:
188/// - Work intervals with start/end times and durations (filtered by min_work_interval)
189/// - Productivity calculations based on actual work vs. presence time
190/// - Task completion summary
191/// - Break analysis and total pause time
192/// - Information about filtered short intervals
193///
194/// ## Report Components
195///
196/// 1. **Work Intervals Table**: Shows continuous work periods with breaks (short intervals filtered out)
197/// 2. **Summary Statistics**: Total hours, productivity percentage
198/// 3. **Task List**: Completed tasks with progress indicators
199/// 4. **Filter Information**: Details about intervals filtered due to being too short
200///
201/// ## Interval Filtering
202///
203/// Short intervals are automatically filtered from display based on the
204/// `min_work_interval` configuration setting. Users are informed about
205/// the number and total duration of filtered intervals.
206///
207/// ## Productivity Calculation
208///
209/// Productivity is calculated as:
210/// ```text
211/// Productivity = (Net Work Time / Available Work Time) * 100%
212/// Where Available Work Time = Gross Work Time - Manual Breaks - Long Pauses
213/// ```
214///
215/// This provides insight into work efficiency while accounting for
216/// legitimate breaks and focusing on actual productive activity.
217///
218/// # Arguments
219///
220/// * `date` - Target date for report generation
221///
222/// # Data Sources
223///
224/// The report integrates multiple data sources:
225/// - **Workdays**: Start and end times for the work session
226/// - **Pauses**: Automatically detected breaks and manual pauses
227/// - **Tasks**: Completed work items and progress tracking
228/// - **Configuration**: Thresholds for filtering and analysis
229async fn display_daily_report(date: DateTime<Local>) -> Result<()> {
230    let naive_date = date.date_naive();
231    let workday = match Workdays::new()?.fetch(naive_date)? {
232        Some(wd) => wd,
233        None => {
234            msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
235            return Ok(());
236        }
237    };
238
239    let tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
240    let config = Config::read()?;
241    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
242
243    // Load interruptions: detected pauses above the threshold plus any manual
244    // pauses the user recorded (protected records bypass the threshold).
245    let long_pauses = Pauses::new()?
246        .set_min_duration(monitor_config.min_pause_duration)
247        .get_workday_pauses(&workday)?;
248
249    // Calculate work intervals and apply filtering
250    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
251    let (filtered_intervals, filtered_info) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
252
253    // Use the report module to process the data
254    let (filtered_duration, productivity) = report::report_with_intervals(&workday, &intervals)?;
255
256    // Display the formatted report with filtered intervals
257    View::report(&workday, &filtered_intervals, &filtered_duration, &productivity, &tasks)?;
258
259    // Display information about filtered short intervals
260    if let Some(info) = filtered_info {
261        msg_info!(format!(
262            "Filtered out {} short intervals (total: {})",
263            info.count,
264            format_duration(&info.total_duration)
265        ));
266    }
267
268    // Warn when productivity is below the configured threshold
269    let productivity = Productivity::new(&workday)?;
270    if productivity.is_below_threshold() {
271        msg_error!(Message::LowProductivityWarning {
272            current: productivity.calculate_productivity(),
273            threshold: productivity.config.min_productivity_threshold,
274        });
275    }
276
277    Ok(())
278}
279
280/// Handles the complete process of sending a daily report to external API.
281///
282/// This function manages the full workflow for daily report submission:
283/// 1. **Workday Finalization**: Ensures the workday is properly closed
284/// 2. **Data Validation**: Verifies required data is available
285/// 3. **Interval Filtering**: Applies min_work_interval filtering to remove short intervals
286/// 4. **Report Generation**: Creates JSON payload for API submission using filtered intervals
287/// 5. **API Submission**: Sends report to configured external service
288/// 6. **Monthly Trigger**: Automatically submits monthly report if needed
289///
290/// ## Report Payload Structure
291///
292/// The generated JSON includes:
293/// - Work intervals with start/end times and durations (short intervals filtered out)
294/// - Task assignments distributed across filtered intervals
295/// - Summary statistics and metadata
296/// - Formatted time strings for external system compatibility
297///
298/// ## Interval Filtering
299///
300/// Same filtering logic as display reports - short intervals are automatically
301/// removed based on the `min_work_interval` configuration setting before
302/// sending to the external API.
303///
304/// ## Auto-Monthly Reporting
305///
306/// If the current date is the last working day of the month,
307/// this function will automatically trigger monthly report submission
308/// after successful daily report processing.
309///
310/// # Arguments
311///
312/// * `date` - Target date for report generation and submission
313///
314/// # Error Handling
315///
316/// The function handles several error scenarios gracefully:
317/// - Missing workday data (warns user, doesn't crash)
318/// - No tasks for the day (prevents submission, shows warning)
319/// - Network connectivity issues (reports error, continues operation)
320/// - API authentication failures (provides user-friendly messages)
321async fn send_daily_report(date: DateTime<Local>) -> Result<()> {
322    let naive_date = date.date_naive();
323    let mut workdays_db = Workdays::new()?;
324
325    // Finalize the workday by recording end time
326    workdays_db.insert_end(naive_date)?;
327
328    // Load the finalized workday data
329    let workday = workdays_db
330        .fetch(naive_date)?
331        .ok_or_else(|| msg_error_anyhow!(Message::WorkdayCouldNotFindAfterFinalizing(naive_date.to_string())))?;
332
333    // Validate that tasks exist for the reporting day
334    let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
335    if tasks.is_empty() {
336        msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
337        return Ok(());
338    }
339
340    let config = Config::read()?;
341    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
342
343    // Load interruptions for the submitted intervals: detected pauses above the
344    // threshold plus any manual pauses the user recorded.
345    let long_pauses = Pauses::new()?
346        .set_min_duration(monitor_config.min_pause_duration)
347        .get_workday_pauses(&workday)?;
348
349    // Validate productivity before allowing report submission
350    // Uses centralized Productivity module for consistent threshold checking
351    let productivity = Productivity::new(&workday)?;
352    let current_productivity = productivity.calculate_productivity();
353    if current_productivity < productivity.config.min_productivity_threshold {
354        msg_error!(Message::ProductivityTooLowToSend {
355            current: current_productivity,
356            threshold: productivity.config.min_productivity_threshold,
357        });
358        return Ok(());
359    }
360
361    // Apply interval filtering for API submission
362    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
363    let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
364
365    // Generate JSON payload for API submission using filtered intervals
366    let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
367    let events_json = serde_json::to_string(&report_json)?;
368    let mut si = get_si_service()?;
369
370    // Submit the report to external API
371    match si.send(&events_json, &naive_date).await {
372        Ok(status) => {
373            if status.is_success() {
374                msg_info!(Message::DailyReportSent(date.format("%B %-d, %Y").to_string()));
375
376                // Check if monthly report should be automatically triggered
377                if si.is_last_working_day_of_month(&naive_date)? {
378                    msg_info!(Message::MonthlyReportTriggered);
379                    handle_monthly_report(date).await?;
380                }
381            } else {
382                msg_error!(Message::ReportSendFailed(status.to_string()));
383            }
384        }
385        Err(e) => msg_error!(Message::ErrorSendingEvents(e.to_string())),
386    }
387
388    Ok(())
389}
390
391/// Builds the JSON payload for API submission.
392///
393/// This function creates a structured JSON report that distributes tasks
394/// across work intervals in a logical manner. The distribution algorithm
395/// ensures that all tasks are included and work intervals are properly
396/// represented in the external reporting system.
397///
398/// ## Task Distribution Algorithm
399///
400/// The function handles two scenarios:
401///
402/// 1. **More Tasks than Intervals**: Distributes multiple tasks per interval
403///    - Calculates base tasks per interval
404///    - Distributes remainder tasks evenly
405///    - Ensures all tasks are included
406///
407/// 2. **More Intervals than Tasks**: Assigns intervals to tasks
408///    - Distributes multiple intervals per task
409///    - Creates separate entries for each interval
410///    - Maintains interval granularity
411///
412/// ## JSON Structure
413///
414/// Each report entry contains:
415/// - `from`: Start time in HH:MM format
416/// - `to`: End time in HH:MM format
417/// - `total_ts`: Formatted duration string
418/// - `task`: Formatted task description with completion percentage
419/// - `index`: Sequential numbering for external system ordering
420/// - `result`: Empty field for external system use
421/// - `time`: Empty field for external system use
422///
423/// # Arguments
424///
425/// * `tasks` - Mutable reference to tasks for modification during processing
426/// * `intervals` - Pre-calculated work intervals (potentially filtered)
427///
428/// # Returns
429///
430/// Returns a JSON value containing the structured report payload
431/// ready for API submission.
432fn build_report_payload(_workday: &Workday, tasks: &mut [Task], intervals: &[report::WorkInterval]) -> serde_json::Value {
433    let num_tasks = tasks.len();
434    let num_intervals = intervals.len();
435
436    // Handle edge case of no work intervals
437    if num_intervals == 0 {
438        return json!([]);
439    }
440
441    let mut report_items = Vec::new();
442
443    // Distribute tasks across intervals based on relative quantities
444    if num_tasks >= num_intervals {
445        // More tasks than intervals: multiple tasks per interval
446        let mut task_iter = tasks.iter();
447        let base_tasks_per_interval = num_tasks / num_intervals;
448        let mut extra_tasks = num_tasks % num_intervals;
449
450        for (i, interval) in intervals.iter().enumerate() {
451            // Calculate number of tasks for this interval
452            let count = base_tasks_per_interval + if extra_tasks > 0 { 1 } else { 0 };
453            extra_tasks = extra_tasks.saturating_sub(1);
454
455            // Collect tasks for this interval
456            let mut assigned_tasks: Vec<Task> = task_iter.by_ref().take(count).cloned().collect();
457
458            report_items.push(json!({
459                "from": interval.start.format("%H:%M").to_string(),
460                "index": i + 1,
461                "result": "",
462                "task": assigned_tasks.format(),
463                "time": "",
464                "to": interval.end.format("%H:%M").to_string(),
465                "total_ts": format_duration(&interval.duration)
466            }));
467        }
468    } else {
469        // More intervals than tasks: multiple intervals per task
470        let mut interval_iter = intervals.iter();
471        let base_intervals_per_task = num_intervals / num_tasks;
472        let mut extra_intervals = num_intervals % num_tasks;
473
474        for task in tasks.iter() {
475            // Calculate number of intervals for this task
476            let count = base_intervals_per_task + if extra_intervals > 0 { 1 } else { 0 };
477            extra_intervals = extra_intervals.saturating_sub(1);
478
479            // Create entries for each interval assigned to this task
480            for _ in 0..count {
481                if let Some(interval) = interval_iter.next() {
482                    let index = report_items.len() + 1;
483                    report_items.push(json!({
484                        "from": interval.start.format("%H:%M").to_string(),
485                        "index": index,
486                        "result": "",
487                        "task": vec![task.clone()].format(),
488                        "time": "",
489                        "to": interval.end.format("%H:%M").to_string(),
490                        "total_ts": format_duration(&interval.duration)
491                    }));
492                }
493            }
494        }
495    }
496
497    json!(report_items)
498}
499
500/// Reads configuration and returns an initialized Si service instance.
501///
502/// This helper function encapsulates the configuration loading and service
503/// initialization logic, providing proper error handling for missing or
504/// invalid SiServer configuration.
505///
506/// # Returns
507///
508/// Returns a configured Si service instance ready for API operations,
509/// or an error if SiServer configuration is missing or invalid.
510///
511/// # Error Scenarios
512///
513/// - Configuration file not found or unreadable
514/// - SiServer section missing from configuration
515/// - Invalid API credentials or URLs in configuration
516fn get_si_service() -> Result<Si> {
517    Config::read()?
518        .si
519        .map(|si_config| Si::new(&si_config))
520        .ok_or_else(|| msg_error_anyhow!(Message::SiServerConfigNotFound))
521}