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