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, daily_report_fields},
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    /// Print the payload before it is sent, and send nothing
64    ///
65    /// The same transparency `kasl server manifest` gives for the team
66    /// server, for the corporate channel: what leaves this machine, in full,
67    /// before it does. Requires `--send`, because what it describes is the
68    /// sending.
69    ///
70    /// Conflicts with `--month` rather than growing a monthly preview: the
71    /// monthly report sends a date and nothing else, so there is no payload
72    /// to inspect, and accepting the flag there would answer a question about
73    /// the daily payload with silence.
74    #[arg(
75        long,
76        requires = "send",
77        conflicts_with = "month",
78        help = "Show the payload that --send would post, and post nothing"
79    )]
80    show: bool,
81}
82
83/// Main entry point for the report command.
84///
85/// Acts as a dispatcher based on the provided arguments, determining the target
86/// date and delegating to the appropriate handler for daily, monthly, display,
87/// or send actions.
88///
89/// # Returns
90///
91/// Returns `Ok(())` on successful report generation or processing,
92/// or an error if data retrieval or submission fails.
93///
94/// ```bash
95/// # Display today's report
96/// kasl report
97///
98/// # Submit today's report to API
99/// kasl report --send
100///
101/// # Generate yesterday's report
102/// kasl report --last
103///
104/// # Submit monthly summary
105/// kasl report --month
106///
107/// ```
108pub async fn cmd(args: ReportArgs) -> Result<()> {
109    let date = determine_report_date(args.last);
110
111    if args.show {
112        show_daily_payload(date).await
113    } else if args.month {
114        handle_monthly_report(date).await
115    } else {
116        handle_daily_report(args.send, date).await
117    }
118}
119
120/// Determines the target date for report generation.
121///
122/// Calculates whether to generate a report for today or yesterday
123/// based on user preferences. This allows flexible reporting timing
124/// to accommodate different organizational workflows.
125fn determine_report_date(is_last_day: bool) -> DateTime<Local> {
126    if is_last_day { Local::now() - Duration::days(1) } else { Local::now() }
127}
128
129/// Handles the logic for daily reports.
130///
131/// Routes to either display or submission mode based on user preferences.
132/// This separation allows for different handling of local viewing versus
133/// API integration scenarios.
134async fn handle_daily_report(should_send: bool, date: DateTime<Local>) -> Result<()> {
135    if should_send {
136        send_daily_report(date).await
137    } else {
138        display_daily_report(date).await
139    }
140}
141
142/// Handles the submission of monthly summary reports.
143///
144/// Generates and submits aggregate monthly statistics to the configured
145/// reporting API. This is typically used for organizational reporting
146/// requirements and payroll integration.
147async fn handle_monthly_report(date: DateTime<Local>) -> Result<()> {
148    let mut si = get_si_service()?;
149    let naive_date = date.date_naive();
150
151    match si.send_monthly(&naive_date).await {
152        Ok(status) => {
153            if status.is_success() {
154                msg_info!(Message::MonthlyReportSent(date.format("%B %-d, %Y").to_string()));
155            } else {
156                msg_error!(Message::MonthlyReportSendFailed(status.to_string()));
157            }
158        }
159        Err(e) => msg_error!(Message::ErrorSendingMonthlyReport(e.to_string())),
160    }
161
162    Ok(())
163}
164
165/// Fetches data and displays a formatted daily report in the terminal.
166///
167/// ## Productivity Calculation
168///
169/// Productivity is calculated as:
170/// ```text
171/// Productivity = (Net Work Time / Available Work Time) * 100%
172/// Where Available Work Time = Gross Work Time - Manual Breaks - Long Pauses
173/// ```
174///
175/// This provides insight into work efficiency while accounting for
176/// legitimate breaks and focusing on actual productive activity.
177async fn display_daily_report(date: DateTime<Local>) -> Result<()> {
178    let naive_date = date.date_naive();
179    let workday = match Workdays::new()?.fetch(naive_date)? {
180        Some(wd) => wd,
181        None => {
182            msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
183            return Ok(());
184        }
185    };
186
187    let tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
188    let config = Config::read()?;
189    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
190
191    // Load interruptions: detected pauses above the threshold plus any manual
192    // pauses the user recorded (protected records bypass the threshold).
193    let long_pauses = Pauses::new()?
194        .set_min_duration(monitor_config.min_pause_duration)
195        .get_workday_pauses(&workday)?;
196
197    // Calculate work intervals and apply filtering
198    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
199    let (filtered_intervals, filtered_info) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
200
201    // Use the report module to process the data
202    let (filtered_duration, productivity) = report::report_with_intervals(&workday, &intervals)?;
203
204    // Display the formatted report with filtered intervals
205    View::report(&workday, &filtered_intervals, &filtered_duration, &productivity, &tasks)?;
206
207    // Display information about filtered short intervals
208    if let Some(info) = filtered_info {
209        msg_info!(format!(
210            "Filtered out {} short intervals (total: {})",
211            info.count,
212            format_duration(&info.total_duration)
213        ));
214    }
215
216    // What is still waiting in the inbox - the day is not only what was done.
217    let counts = JiraInbox::new()?.counts()?;
218    if !counts.is_empty() {
219        msg_info!(Message::JiraInboxSummary {
220            total: counts.total,
221            fresh: counts.fresh,
222            taken: counts.taken,
223        });
224    }
225
226    // Warn when productivity is below the configured threshold
227    let productivity = Productivity::new(&workday)?;
228    if productivity.is_below_threshold() {
229        msg_error!(Message::LowProductivityWarning {
230            current: productivity.calculate_productivity(),
231            threshold: productivity.config.min_productivity_threshold,
232        });
233    }
234
235    Ok(())
236}
237
238/// Prints the payload `--send` would post, and posts nothing.
239///
240/// The point is being able to read what leaves this machine before it does.
241/// That only means something if the preview is the real payload, so this
242/// builds it through the same functions the send path uses - the same
243/// interval filtering, the same task distribution, the same form fields from
244/// [`daily_report_fields`] - and stops one step short of the request.
245///
246/// Three things the send path does are deliberately *not* done here.
247///
248/// The day is not finalized. `--send` writes an end timestamp before it
249/// assembles anything, and a preview that quietly ended someone's working day
250/// would be the opposite of a command you run to find out what would happen.
251/// The day is read as it stands, open end and all.
252///
253/// The productivity threshold is not enforced. It decides whether a report
254/// may be submitted, not what the submission contains, and refusing to show
255/// the payload of a day that is below it would hide exactly the day someone
256/// wants to look at.
257///
258/// Nothing is authenticated. No session is opened, no credential is read: the
259/// address comes from the config, and a preview that logged in would reach
260/// the network to tell you what it would do if it reached the network.
261async fn show_daily_payload(date: DateTime<Local>) -> Result<()> {
262    let naive_date = date.date_naive();
263
264    let workday = match Workdays::new()?.fetch(naive_date)? {
265        Some(workday) => workday,
266        None => {
267            msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
268            return Ok(());
269        }
270    };
271
272    let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
273    if tasks.is_empty() {
274        // The same stop `--send` makes, and for the same reason: there is no
275        // report to describe. Reported rather than shown as an empty payload,
276        // which would read as "this day sends nothing" when what it means is
277        // "this day cannot be sent".
278        msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
279        return Ok(());
280    }
281
282    let config = Config::read()?;
283    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
284
285    let long_pauses = Pauses::new()?
286        .set_min_duration(monitor_config.min_pause_duration)
287        .get_workday_pauses(&workday)?;
288
289    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
290    let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
291
292    let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
293    let events_json = serde_json::to_string(&report_json)?;
294
295    // The destination is half the answer to "what leaves this machine", so it
296    // is named from the configured client rather than described in prose.
297    let si = get_si_service()?;
298    msg_info!(Message::ReportPayloadHeading {
299        url: si.daily_report_url(),
300        date: naive_date.to_string(),
301    });
302
303    // Every field of the form, not only the interesting one. `tasks` is where
304    // the work is described, but a person asking what is sent about them is
305    // owed the whole request - a field they were not shown is a field they
306    // were not told about.
307    for (name, value) in daily_report_fields(&naive_date.format("%Y-%m-%d").to_string(), &events_json) {
308        // The tasks field holds the report as JSON; pretty-printing it is the
309        // difference between a payload someone can read and one they can only
310        // confirm exists.
311        if name == "tasks" {
312            msg_print!(format!("  {}:", name));
313            // Indented one step further than the field lines. The JSON is the
314            // only multi-line value here, and without a shift its own braces
315            // sit in the same column as a field name - readable enough to a
316            // person, and indistinguishable to anything reading the preview
317            // back, which the parity check against the real request does.
318            for line in serde_json::to_string_pretty(&report_json)?.lines() {
319                msg_print!(format!("    {}", line));
320            }
321        } else if value.is_empty() {
322            // Shown as empty rather than skipped: a field sent empty is still
323            // a field sent, and leaving it out would describe a smaller
324            // request than the one made.
325            msg_print!(format!("  {}: (empty)", name));
326        } else {
327            msg_print!(format!("  {}: {}", name, value));
328        }
329    }
330
331    Ok(())
332}
333
334/// Handles the complete process of sending a daily report to external API.
335async fn send_daily_report(date: DateTime<Local>) -> Result<()> {
336    let naive_date = date.date_naive();
337    let mut workdays_db = Workdays::new()?;
338
339    // Finalize the workday by recording end time. A day that was never
340    // started cannot be sent, and saying so here beats the fetch below
341    // failing with "could not find after finalizing".
342    if !workdays_db.insert_end(naive_date)? {
343        return Err(msg_error_anyhow!(Message::WorkdayNeverStarted(naive_date.to_string())));
344    }
345
346    // Load the finalized workday data
347    let workday = workdays_db
348        .fetch(naive_date)?
349        .ok_or_else(|| msg_error_anyhow!(Message::WorkdayCouldNotFindAfterFinalizing(naive_date.to_string())))?;
350
351    // Validate that tasks exist for the reporting day
352    let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
353    if tasks.is_empty() {
354        msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
355        return Ok(());
356    }
357
358    let config = Config::read()?;
359    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
360
361    // Load interruptions for the submitted intervals: detected pauses above the
362    // threshold plus any manual pauses the user recorded.
363    let long_pauses = Pauses::new()?
364        .set_min_duration(monitor_config.min_pause_duration)
365        .get_workday_pauses(&workday)?;
366
367    // Validate productivity before allowing report submission
368    // Uses centralized Productivity module for consistent threshold checking
369    let productivity = Productivity::new(&workday)?;
370    let current_productivity = productivity.calculate_productivity();
371    if current_productivity < productivity.config.min_productivity_threshold {
372        msg_error!(Message::ProductivityTooLowToSend {
373            current: current_productivity,
374            threshold: productivity.config.min_productivity_threshold,
375        });
376        return Ok(());
377    }
378
379    // Apply interval filtering for API submission
380    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
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.
410fn build_report_payload(_workday: &Workday, tasks: &mut [Task], intervals: &[report::WorkInterval]) -> serde_json::Value {
411    let num_tasks = tasks.len();
412    let num_intervals = intervals.len();
413
414    // Handle edge case of no work intervals
415    if num_intervals == 0 {
416        return json!([]);
417    }
418
419    let mut report_items = Vec::new();
420
421    // Distribute tasks across intervals based on relative quantities
422    if num_tasks >= num_intervals {
423        // More tasks than intervals: multiple tasks per interval
424        let mut task_iter = tasks.iter();
425        let base_tasks_per_interval = num_tasks / num_intervals;
426        let mut extra_tasks = num_tasks % num_intervals;
427
428        for (i, interval) in intervals.iter().enumerate() {
429            // Calculate number of tasks for this interval
430            let count = base_tasks_per_interval + if extra_tasks > 0 { 1 } else { 0 };
431            extra_tasks = extra_tasks.saturating_sub(1);
432
433            // Collect tasks for this interval
434            let mut assigned_tasks: Vec<Task> = task_iter.by_ref().take(count).cloned().collect();
435
436            report_items.push(json!({
437                "from": interval.start.format("%H:%M").to_string(),
438                "index": i + 1,
439                "result": "",
440                "task": assigned_tasks.format(),
441                "time": "",
442                "to": interval.end.format("%H:%M").to_string(),
443                "total_ts": format_duration(&interval.duration)
444            }));
445        }
446    } else {
447        // More intervals than tasks: multiple intervals per task
448        let mut interval_iter = intervals.iter();
449        let base_intervals_per_task = num_intervals / num_tasks;
450        let mut extra_intervals = num_intervals % num_tasks;
451
452        for task in tasks.iter() {
453            // Calculate number of intervals for this task
454            let count = base_intervals_per_task + if extra_intervals > 0 { 1 } else { 0 };
455            extra_intervals = extra_intervals.saturating_sub(1);
456
457            // Create entries for each interval assigned to this task
458            for _ in 0..count {
459                if let Some(interval) = interval_iter.next() {
460                    let index = report_items.len() + 1;
461                    report_items.push(json!({
462                        "from": interval.start.format("%H:%M").to_string(),
463                        "index": index,
464                        "result": "",
465                        "task": vec![task.clone()].format(),
466                        "time": "",
467                        "to": interval.end.format("%H:%M").to_string(),
468                        "total_ts": format_duration(&interval.duration)
469                    }));
470                }
471            }
472        }
473    }
474
475    json!(report_items)
476}
477
478/// Reads configuration and returns an initialized Si service instance.
479///
480/// This helper function encapsulates the configuration loading and service
481/// initialization logic, providing proper error handling for missing or
482/// invalid SiServer configuration.
483fn get_si_service() -> Result<Si> {
484    Config::read()?
485        .si
486        .map(|si_config| Si::new(&si_config))
487        .ok_or_else(|| msg_error_anyhow!(Message::SiServerConfigNotFound))
488}