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