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
8use crate::{
9    api::si::Si,
10    db::{
11        pauses::Pauses,
12        tasks::Tasks,
13        workdays::{Workday, Workdays},
14    },
15    libs::{
16        config::Config,
17        formatter::format_duration,
18        messages::Message,
19        productivity::Productivity,
20        report,
21        task::{FormatTasks, Task, TaskFilter},
22        view::View,
23    },
24    msg_error, msg_error_anyhow, msg_info, msg_print,
25};
26use anyhow::Result;
27use chrono::{DateTime, Duration, Local};
28use clap::Args;
29use serde_json::json;
30
31/// Command-line arguments for the report command.
32///
33/// The report command supports multiple operational modes for different
34/// reporting scenarios and organizational requirements.
35#[derive(Debug, Args)]
36pub struct ReportArgs {
37    /// Submit the generated daily report to configured API
38    ///
39    /// When specified, the report will be automatically submitted to the
40    /// configured reporting service (typically SiServer) after generation.
41    /// This enables integration with organizational time tracking systems.
42    #[arg(long, help = "Submit daily report")]
43    send: bool,
44
45    /// Generate report for the previous day instead of today
46    ///
47    /// Useful for:
48    /// - Submitting yesterday's report in the morning
49    /// - Reviewing completed work sessions
50    /// - Batch processing of historical reports
51    #[arg(long, short, help = "Generate report for the last day")]
52    last: bool,
53
54    /// Submit monthly summary report to configured API
55    ///
56    /// Generates and submits an aggregate monthly report containing
57    /// summary statistics and total work hours. Typically used for
58    /// organizational reporting requirements at month-end.
59    #[arg(long, help = "Submit monthly report")]
60    month: bool,
61}
62
63/// Main entry point for the report command.
64///
65/// Acts as a dispatcher based on the provided arguments, determining the target
66/// date and delegating to the appropriate handler for daily, monthly, display,
67/// or send actions.
68///
69/// # Returns
70///
71/// Returns `Ok(())` on successful report generation or processing,
72/// or an error if data retrieval or submission fails.
73///
74/// ```bash
75/// # Display today's report
76/// kasl report
77///
78/// # Submit today's report to API
79/// kasl report --send
80///
81/// # Generate yesterday's report
82/// kasl report --last
83///
84/// # Submit monthly summary
85/// kasl report --month
86///
87/// ```
88pub async fn cmd(args: ReportArgs) -> Result<()> {
89    let date = determine_report_date(args.last);
90
91    if args.month {
92        handle_monthly_report(date).await
93    } else {
94        handle_daily_report(args.send, date).await
95    }
96}
97
98/// Determines the target date for report generation.
99///
100/// Calculates whether to generate a report for today or yesterday
101/// based on user preferences. This allows flexible reporting timing
102/// to accommodate different organizational workflows.
103fn determine_report_date(is_last_day: bool) -> DateTime<Local> {
104    if is_last_day { Local::now() - Duration::days(1) } else { Local::now() }
105}
106
107/// Handles the logic for daily reports.
108///
109/// Routes to either display or submission mode based on user preferences.
110/// This separation allows for different handling of local viewing versus
111/// API integration scenarios.
112async fn handle_daily_report(should_send: bool, date: DateTime<Local>) -> Result<()> {
113    if should_send {
114        send_daily_report(date).await
115    } else {
116        display_daily_report(date).await
117    }
118}
119
120/// Handles the submission of monthly summary reports.
121///
122/// Generates and submits aggregate monthly statistics to the configured
123/// reporting API. This is typically used for organizational reporting
124/// requirements and payroll integration.
125async fn handle_monthly_report(date: DateTime<Local>) -> Result<()> {
126    let mut si = get_si_service()?;
127    let naive_date = date.date_naive();
128
129    match si.send_monthly(&naive_date).await {
130        Ok(status) => {
131            if status.is_success() {
132                msg_info!(Message::MonthlyReportSent(date.format("%B %-d, %Y").to_string()));
133            } else {
134                msg_error!(Message::MonthlyReportSendFailed(status.to_string()));
135            }
136        }
137        Err(e) => msg_error!(Message::ErrorSendingMonthlyReport(e.to_string())),
138    }
139
140    Ok(())
141}
142
143/// Fetches data and displays a formatted daily report in the terminal.
144///
145/// ## Productivity Calculation
146///
147/// Productivity is calculated as:
148/// ```text
149/// Productivity = (Net Work Time / Available Work Time) * 100%
150/// Where Available Work Time = Gross Work Time - Manual Breaks - Long Pauses
151/// ```
152///
153/// This provides insight into work efficiency while accounting for
154/// legitimate breaks and focusing on actual productive activity.
155async fn display_daily_report(date: DateTime<Local>) -> Result<()> {
156    let naive_date = date.date_naive();
157    let workday = match Workdays::new()?.fetch(naive_date)? {
158        Some(wd) => wd,
159        None => {
160            msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
161            return Ok(());
162        }
163    };
164
165    let tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
166    let config = Config::read()?;
167    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
168
169    // Load interruptions: detected pauses above the threshold plus any manual
170    // pauses the user recorded (protected records bypass the threshold).
171    let long_pauses = Pauses::new()?
172        .set_min_duration(monitor_config.min_pause_duration)
173        .get_workday_pauses(&workday)?;
174
175    // Calculate work intervals and apply filtering
176    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
177    let (filtered_intervals, filtered_info) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
178
179    // Use the report module to process the data
180    let (filtered_duration, productivity) = report::report_with_intervals(&workday, &intervals)?;
181
182    // Display the formatted report with filtered intervals
183    View::report(&workday, &filtered_intervals, &filtered_duration, &productivity, &tasks)?;
184
185    // Display information about filtered short intervals
186    if let Some(info) = filtered_info {
187        msg_info!(format!(
188            "Filtered out {} short intervals (total: {})",
189            info.count,
190            format_duration(&info.total_duration)
191        ));
192    }
193
194    // Warn when productivity is below the configured threshold
195    let productivity = Productivity::new(&workday)?;
196    if productivity.is_below_threshold() {
197        msg_error!(Message::LowProductivityWarning {
198            current: productivity.calculate_productivity(),
199            threshold: productivity.config.min_productivity_threshold,
200        });
201    }
202
203    Ok(())
204}
205
206/// Handles the complete process of sending a daily report to external API.
207async fn send_daily_report(date: DateTime<Local>) -> Result<()> {
208    let naive_date = date.date_naive();
209    let mut workdays_db = Workdays::new()?;
210
211    // Finalize the workday by recording end time
212    workdays_db.insert_end(naive_date)?;
213
214    // Load the finalized workday data
215    let workday = workdays_db
216        .fetch(naive_date)?
217        .ok_or_else(|| msg_error_anyhow!(Message::WorkdayCouldNotFindAfterFinalizing(naive_date.to_string())))?;
218
219    // Validate that tasks exist for the reporting day
220    let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
221    if tasks.is_empty() {
222        msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
223        return Ok(());
224    }
225
226    let config = Config::read()?;
227    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
228
229    // Load interruptions for the submitted intervals: detected pauses above the
230    // threshold plus any manual pauses the user recorded.
231    let long_pauses = Pauses::new()?
232        .set_min_duration(monitor_config.min_pause_duration)
233        .get_workday_pauses(&workday)?;
234
235    // Validate productivity before allowing report submission
236    // Uses centralized Productivity module for consistent threshold checking
237    let productivity = Productivity::new(&workday)?;
238    let current_productivity = productivity.calculate_productivity();
239    if current_productivity < productivity.config.min_productivity_threshold {
240        msg_error!(Message::ProductivityTooLowToSend {
241            current: current_productivity,
242            threshold: productivity.config.min_productivity_threshold,
243        });
244        return Ok(());
245    }
246
247    // Apply interval filtering for API submission
248    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
249    let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
250
251    // Generate JSON payload for API submission using filtered intervals
252    let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
253    let events_json = serde_json::to_string(&report_json)?;
254    let mut si = get_si_service()?;
255
256    // Submit the report to external API
257    match si.send(&events_json, &naive_date).await {
258        Ok(status) => {
259            if status.is_success() {
260                msg_info!(Message::DailyReportSent(date.format("%B %-d, %Y").to_string()));
261
262                // Check if monthly report should be automatically triggered
263                if si.is_last_working_day_of_month(&naive_date)? {
264                    msg_info!(Message::MonthlyReportTriggered);
265                    handle_monthly_report(date).await?;
266                }
267            } else {
268                msg_error!(Message::ReportSendFailed(status.to_string()));
269            }
270        }
271        Err(e) => msg_error!(Message::ErrorSendingEvents(e.to_string())),
272    }
273
274    Ok(())
275}
276
277/// Builds the JSON payload for API submission.
278fn build_report_payload(_workday: &Workday, tasks: &mut [Task], intervals: &[report::WorkInterval]) -> serde_json::Value {
279    let num_tasks = tasks.len();
280    let num_intervals = intervals.len();
281
282    // Handle edge case of no work intervals
283    if num_intervals == 0 {
284        return json!([]);
285    }
286
287    let mut report_items = Vec::new();
288
289    // Distribute tasks across intervals based on relative quantities
290    if num_tasks >= num_intervals {
291        // More tasks than intervals: multiple tasks per interval
292        let mut task_iter = tasks.iter();
293        let base_tasks_per_interval = num_tasks / num_intervals;
294        let mut extra_tasks = num_tasks % num_intervals;
295
296        for (i, interval) in intervals.iter().enumerate() {
297            // Calculate number of tasks for this interval
298            let count = base_tasks_per_interval + if extra_tasks > 0 { 1 } else { 0 };
299            extra_tasks = extra_tasks.saturating_sub(1);
300
301            // Collect tasks for this interval
302            let mut assigned_tasks: Vec<Task> = task_iter.by_ref().take(count).cloned().collect();
303
304            report_items.push(json!({
305                "from": interval.start.format("%H:%M").to_string(),
306                "index": i + 1,
307                "result": "",
308                "task": assigned_tasks.format(),
309                "time": "",
310                "to": interval.end.format("%H:%M").to_string(),
311                "total_ts": format_duration(&interval.duration)
312            }));
313        }
314    } else {
315        // More intervals than tasks: multiple intervals per task
316        let mut interval_iter = intervals.iter();
317        let base_intervals_per_task = num_intervals / num_tasks;
318        let mut extra_intervals = num_intervals % num_tasks;
319
320        for task in tasks.iter() {
321            // Calculate number of intervals for this task
322            let count = base_intervals_per_task + if extra_intervals > 0 { 1 } else { 0 };
323            extra_intervals = extra_intervals.saturating_sub(1);
324
325            // Create entries for each interval assigned to this task
326            for _ in 0..count {
327                if let Some(interval) = interval_iter.next() {
328                    let index = report_items.len() + 1;
329                    report_items.push(json!({
330                        "from": interval.start.format("%H:%M").to_string(),
331                        "index": index,
332                        "result": "",
333                        "task": vec![task.clone()].format(),
334                        "time": "",
335                        "to": interval.end.format("%H:%M").to_string(),
336                        "total_ts": format_duration(&interval.duration)
337                    }));
338                }
339            }
340        }
341    }
342
343    json!(report_items)
344}
345
346/// Reads configuration and returns an initialized Si service instance.
347///
348/// This helper function encapsulates the configuration loading and service
349/// initialization logic, providing proper error handling for missing or
350/// invalid SiServer configuration.
351fn get_si_service() -> Result<Si> {
352    Config::read()?
353        .si
354        .map(|si_config| Si::new(&si_config))
355        .ok_or_else(|| msg_error_anyhow!(Message::SiServerConfigNotFound))
356}