kasl-cli 1.13.1

Work activity tracker CLI: automatic workday and break detection, task management with Jira/GitLab integration, productivity reports and exports
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
//! Daily and monthly report generation and submission command.
//!
//! Handles the core reporting functionality of kasl including generation of detailed
//! daily work reports, automatic filtering of short work intervals, integration with
//! external APIs, and comprehensive productivity analysis using the centralized
//! Productivity module.

use crate::{
    api::si::{Si, daily_report_fields},
    db::{
        jira_inbox::JiraInbox,
        pauses::Pauses,
        tasks::Tasks,
        workdays::{Workday, Workdays},
    },
    libs::{
        config::Config,
        formatter::format_duration,
        messages::Message,
        productivity::Productivity,
        report,
        task::{FormatTasks, Task, TaskFilter},
        view::View,
    },
    msg_error, msg_error_anyhow, msg_info, msg_print,
};
use anyhow::Result;
use chrono::{DateTime, Duration, Local};
use clap::Args;
use serde_json::json;

/// Command-line arguments for the report command.
///
/// The report command supports multiple operational modes for different
/// reporting scenarios and organizational requirements.
#[derive(Debug, Args)]
pub struct ReportArgs {
    /// Submit the generated daily report to configured API
    ///
    /// When specified, the report will be automatically submitted to the
    /// configured reporting service (typically SiServer) after generation.
    /// This enables integration with organizational time tracking systems.
    #[arg(long, help = "Submit daily report")]
    send: bool,

    /// Generate report for the previous day instead of today
    ///
    /// Useful for:
    /// - Submitting yesterday's report in the morning
    /// - Reviewing completed work sessions
    /// - Batch processing of historical reports
    #[arg(long, short, help = "Generate report for the last day")]
    last: bool,

    /// Submit monthly summary report to configured API
    ///
    /// Generates and submits an aggregate monthly report containing
    /// summary statistics and total work hours. Typically used for
    /// organizational reporting requirements at month-end.
    #[arg(long, help = "Submit monthly report")]
    month: bool,

    /// Print the payload before it is sent, and send nothing
    ///
    /// The same transparency `kasl server manifest` gives for the team
    /// server, for the corporate channel: what leaves this machine, in full,
    /// before it does. Requires `--send`, because what it describes is the
    /// sending.
    ///
    /// Conflicts with `--month` rather than growing a monthly preview: the
    /// monthly report sends a date and nothing else, so there is no payload
    /// to inspect, and accepting the flag there would answer a question about
    /// the daily payload with silence.
    #[arg(
        long,
        requires = "send",
        conflicts_with = "month",
        help = "Show the payload that --send would post, and post nothing"
    )]
    show: bool,
}

/// Main entry point for the report command.
///
/// Acts as a dispatcher based on the provided arguments, determining the target
/// date and delegating to the appropriate handler for daily, monthly, display,
/// or send actions.
///
/// # Returns
///
/// Returns `Ok(())` on successful report generation or processing,
/// or an error if data retrieval or submission fails.
///
/// ```bash
/// # Display today's report
/// kasl report
///
/// # Submit today's report to API
/// kasl report --send
///
/// # Generate yesterday's report
/// kasl report --last
///
/// # Submit monthly summary
/// kasl report --month
///
/// ```
pub async fn cmd(args: ReportArgs) -> Result<()> {
    let date = determine_report_date(args.last);

    if args.show {
        show_daily_payload(date).await
    } else if args.month {
        handle_monthly_report(date).await
    } else {
        handle_daily_report(args.send, date).await
    }
}

/// Determines the target date for report generation.
///
/// Calculates whether to generate a report for today or yesterday
/// based on user preferences. This allows flexible reporting timing
/// to accommodate different organizational workflows.
fn determine_report_date(is_last_day: bool) -> DateTime<Local> {
    if is_last_day { Local::now() - Duration::days(1) } else { Local::now() }
}

/// Handles the logic for daily reports.
///
/// Routes to either display or submission mode based on user preferences.
/// This separation allows for different handling of local viewing versus
/// API integration scenarios.
async fn handle_daily_report(should_send: bool, date: DateTime<Local>) -> Result<()> {
    if should_send {
        send_daily_report(date).await
    } else {
        display_daily_report(date).await
    }
}

/// Handles the submission of monthly summary reports.
///
/// Generates and submits aggregate monthly statistics to the configured
/// reporting API. This is typically used for organizational reporting
/// requirements and payroll integration.
async fn handle_monthly_report(date: DateTime<Local>) -> Result<()> {
    let mut si = get_si_service()?;
    let naive_date = date.date_naive();

    match si.send_monthly(&naive_date).await {
        Ok(status) => {
            if status.is_success() {
                msg_info!(Message::MonthlyReportSent(date.format("%B %-d, %Y").to_string()));
            } else {
                msg_error!(Message::MonthlyReportSendFailed(status.to_string()));
            }
        }
        Err(e) => msg_error!(Message::ErrorSendingMonthlyReport(e.to_string())),
    }

    Ok(())
}

/// Fetches data and displays a formatted daily report in the terminal.
///
/// ## Productivity Calculation
///
/// Productivity is calculated as:
/// ```text
/// Productivity = (Net Work Time / Available Work Time) * 100%
/// Where Available Work Time = Gross Work Time - Manual Breaks - Long Pauses
/// ```
///
/// This provides insight into work efficiency while accounting for
/// legitimate breaks and focusing on actual productive activity.
async fn display_daily_report(date: DateTime<Local>) -> Result<()> {
    let naive_date = date.date_naive();
    let workday = match Workdays::new()?.fetch(naive_date)? {
        Some(wd) => wd,
        None => {
            msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
            return Ok(());
        }
    };

    let tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
    let config = Config::read()?;
    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();

    // Load interruptions: detected pauses above the threshold plus any manual
    // pauses the user recorded (protected records bypass the threshold).
    let long_pauses = Pauses::new()?
        .set_min_duration(monitor_config.min_pause_duration)
        .get_workday_pauses(&workday)?;

    // Calculate work intervals and apply filtering
    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
    let (filtered_intervals, filtered_info) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);

    // Use the report module to process the data
    let (filtered_duration, productivity) = report::report_with_intervals(&workday, &intervals)?;

    // Display the formatted report with filtered intervals
    View::report(&workday, &filtered_intervals, &filtered_duration, &productivity, &tasks)?;

    // Display information about filtered short intervals
    if let Some(info) = filtered_info {
        msg_info!(format!(
            "Filtered out {} short intervals (total: {})",
            info.count,
            format_duration(&info.total_duration)
        ));
    }

    // What is still waiting in the inbox - the day is not only what was done.
    let counts = JiraInbox::new()?.counts()?;
    if !counts.is_empty() {
        msg_info!(Message::JiraInboxSummary {
            total: counts.total,
            fresh: counts.fresh,
            taken: counts.taken,
        });
    }

    // Warn when productivity is below the configured threshold
    let productivity = Productivity::new(&workday)?;
    if productivity.is_below_threshold() {
        msg_error!(Message::LowProductivityWarning {
            current: productivity.calculate_productivity(),
            threshold: productivity.config.min_productivity_threshold,
        });
    }

    Ok(())
}

/// Prints the payload `--send` would post, and posts nothing.
///
/// The point is being able to read what leaves this machine before it does.
/// That only means something if the preview is the real payload, so this
/// builds it through the same functions the send path uses - the same
/// interval filtering, the same task distribution, the same form fields from
/// [`daily_report_fields`] - and stops one step short of the request.
///
/// Three things the send path does are deliberately *not* done here.
///
/// The day is not finalized. `--send` writes an end timestamp before it
/// assembles anything, and a preview that quietly ended someone's working day
/// would be the opposite of a command you run to find out what would happen.
/// The day is read as it stands, open end and all.
///
/// The productivity threshold is not enforced. It decides whether a report
/// may be submitted, not what the submission contains, and refusing to show
/// the payload of a day that is below it would hide exactly the day someone
/// wants to look at.
///
/// Nothing is authenticated. No session is opened, no credential is read: the
/// address comes from the config, and a preview that logged in would reach
/// the network to tell you what it would do if it reached the network.
async fn show_daily_payload(date: DateTime<Local>) -> Result<()> {
    let naive_date = date.date_naive();

    let workday = match Workdays::new()?.fetch(naive_date)? {
        Some(workday) => workday,
        None => {
            msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
            return Ok(());
        }
    };

    let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
    if tasks.is_empty() {
        // The same stop `--send` makes, and for the same reason: there is no
        // report to describe. Reported rather than shown as an empty payload,
        // which would read as "this day sends nothing" when what it means is
        // "this day cannot be sent".
        msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
        return Ok(());
    }

    let config = Config::read()?;
    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();

    let long_pauses = Pauses::new()?
        .set_min_duration(monitor_config.min_pause_duration)
        .get_workday_pauses(&workday)?;

    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
    let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);

    let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
    let events_json = serde_json::to_string(&report_json)?;

    // The destination is half the answer to "what leaves this machine", so it
    // is named from the configured client rather than described in prose.
    let si = get_si_service()?;
    msg_info!(Message::ReportPayloadHeading {
        url: si.daily_report_url(),
        date: naive_date.to_string(),
    });

    // Every field of the form, not only the interesting one. `tasks` is where
    // the work is described, but a person asking what is sent about them is
    // owed the whole request - a field they were not shown is a field they
    // were not told about.
    for (name, value) in daily_report_fields(&naive_date.format("%Y-%m-%d").to_string(), &events_json) {
        // The tasks field holds the report as JSON; pretty-printing it is the
        // difference between a payload someone can read and one they can only
        // confirm exists.
        if name == "tasks" {
            msg_print!(format!("  {}:", name));
            // Indented one step further than the field lines. The JSON is the
            // only multi-line value here, and without a shift its own braces
            // sit in the same column as a field name - readable enough to a
            // person, and indistinguishable to anything reading the preview
            // back, which the parity check against the real request does.
            for line in serde_json::to_string_pretty(&report_json)?.lines() {
                msg_print!(format!("    {}", line));
            }
        } else if value.is_empty() {
            // Shown as empty rather than skipped: a field sent empty is still
            // a field sent, and leaving it out would describe a smaller
            // request than the one made.
            msg_print!(format!("  {}: (empty)", name));
        } else {
            msg_print!(format!("  {}: {}", name, value));
        }
    }

    Ok(())
}

/// Handles the complete process of sending a daily report to external API.
async fn send_daily_report(date: DateTime<Local>) -> Result<()> {
    let naive_date = date.date_naive();
    let mut workdays_db = Workdays::new()?;

    // Finalize the workday by recording end time. A day that was never
    // started cannot be sent, and saying so here beats the fetch below
    // failing with "could not find after finalizing".
    if !workdays_db.insert_end(naive_date)? {
        return Err(msg_error_anyhow!(Message::WorkdayNeverStarted(naive_date.to_string())));
    }

    // Load the finalized workday data
    let workday = workdays_db
        .fetch(naive_date)?
        .ok_or_else(|| msg_error_anyhow!(Message::WorkdayCouldNotFindAfterFinalizing(naive_date.to_string())))?;

    // Validate that tasks exist for the reporting day
    let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
    if tasks.is_empty() {
        msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
        return Ok(());
    }

    let config = Config::read()?;
    let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();

    // Load interruptions for the submitted intervals: detected pauses above the
    // threshold plus any manual pauses the user recorded.
    let long_pauses = Pauses::new()?
        .set_min_duration(monitor_config.min_pause_duration)
        .get_workday_pauses(&workday)?;

    // Validate productivity before allowing report submission
    // Uses centralized Productivity module for consistent threshold checking
    let productivity = Productivity::new(&workday)?;
    let current_productivity = productivity.calculate_productivity();
    if current_productivity < productivity.config.min_productivity_threshold {
        msg_error!(Message::ProductivityTooLowToSend {
            current: current_productivity,
            threshold: productivity.config.min_productivity_threshold,
        });
        return Ok(());
    }

    // Apply interval filtering for API submission
    let intervals = report::calculate_work_intervals(&workday, &long_pauses);
    let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);

    // Generate JSON payload for API submission using filtered intervals
    let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
    let events_json = serde_json::to_string(&report_json)?;
    let mut si = get_si_service()?;

    // Submit the report to external API
    match si.send(&events_json, &naive_date).await {
        Ok(status) => {
            if status.is_success() {
                msg_info!(Message::DailyReportSent(date.format("%B %-d, %Y").to_string()));

                // Check if monthly report should be automatically triggered
                if si.is_last_working_day_of_month(&naive_date)? {
                    msg_info!(Message::MonthlyReportTriggered);
                    handle_monthly_report(date).await?;
                }
            } else {
                msg_error!(Message::ReportSendFailed(status.to_string()));
            }
        }
        Err(e) => msg_error!(Message::ErrorSendingEvents(e.to_string())),
    }

    Ok(())
}

/// Builds the JSON payload for API submission.
fn build_report_payload(_workday: &Workday, tasks: &mut [Task], intervals: &[report::WorkInterval]) -> serde_json::Value {
    let num_tasks = tasks.len();
    let num_intervals = intervals.len();

    // Handle edge case of no work intervals
    if num_intervals == 0 {
        return json!([]);
    }

    let mut report_items = Vec::new();

    // Distribute tasks across intervals based on relative quantities
    if num_tasks >= num_intervals {
        // More tasks than intervals: multiple tasks per interval
        let mut task_iter = tasks.iter();
        let base_tasks_per_interval = num_tasks / num_intervals;
        let mut extra_tasks = num_tasks % num_intervals;

        for (i, interval) in intervals.iter().enumerate() {
            // Calculate number of tasks for this interval
            let count = base_tasks_per_interval + if extra_tasks > 0 { 1 } else { 0 };
            extra_tasks = extra_tasks.saturating_sub(1);

            // Collect tasks for this interval
            let mut assigned_tasks: Vec<Task> = task_iter.by_ref().take(count).cloned().collect();

            report_items.push(json!({
                "from": interval.start.format("%H:%M").to_string(),
                "index": i + 1,
                "result": "",
                "task": assigned_tasks.format(),
                "time": "",
                "to": interval.end.format("%H:%M").to_string(),
                "total_ts": format_duration(&interval.duration)
            }));
        }
    } else {
        // More intervals than tasks: multiple intervals per task
        let mut interval_iter = intervals.iter();
        let base_intervals_per_task = num_intervals / num_tasks;
        let mut extra_intervals = num_intervals % num_tasks;

        for task in tasks.iter() {
            // Calculate number of intervals for this task
            let count = base_intervals_per_task + if extra_intervals > 0 { 1 } else { 0 };
            extra_intervals = extra_intervals.saturating_sub(1);

            // Create entries for each interval assigned to this task
            for _ in 0..count {
                if let Some(interval) = interval_iter.next() {
                    let index = report_items.len() + 1;
                    report_items.push(json!({
                        "from": interval.start.format("%H:%M").to_string(),
                        "index": index,
                        "result": "",
                        "task": vec![task.clone()].format(),
                        "time": "",
                        "to": interval.end.format("%H:%M").to_string(),
                        "total_ts": format_duration(&interval.duration)
                    }));
                }
            }
        }
    }

    json!(report_items)
}

/// Reads configuration and returns an initialized Si service instance.
///
/// This helper function encapsulates the configuration loading and service
/// initialization logic, providing proper error handling for missing or
/// invalid SiServer configuration.
fn get_si_service() -> Result<Si> {
    Config::read()?
        .si
        .map(|si_config| Si::new(&si_config))
        .ok_or_else(|| msg_error_anyhow!(Message::SiServerConfigNotFound))
}