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