kasl-cli 0.10.0

kasl is a comprehensive command-line utility 🛠️ designed to streamline the tracking of work activities 📊, including start times ⏰, pauses ⏸, and task completion
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
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! 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.
//!
//! ## Productivity Integration
//!
//! This module leverages the centralized `libs::productivity::Productivity` module for:
//! - Consistent productivity calculations across display and submission
//! - Break recommendations based on productivity thresholds  
//! - Validation before report submission to external APIs
//! - Real-time productivity feedback during report generation

use crate::{
    api::si::Si,
    db::{
        breaks::Breaks,
        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,
}

/// 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.
///
/// # Arguments
///
/// * `args` - Parsed command-line arguments specifying report options
///
/// # 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.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.
///
/// # Arguments
///
/// * `is_last_day` - Whether to generate report for yesterday
///
/// # Returns
///
/// Returns the target date with timezone information for report generation.
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.
///
/// # Arguments
///
/// * `should_send` - Whether to submit the report to external API
/// * `date` - Target date for report generation
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.
///
/// ## Monthly Report Contents
///
/// - Total hours worked in the month
/// - Number of working days
/// - Average daily hours
/// - Productivity trends (if available)
///
/// # Arguments
///
/// * `date` - Date within the target month for report generation
///
/// # Error Handling
///
/// Network errors are handled gracefully with user-friendly messages
/// rather than application crashes, allowing continued local operation
/// even when API services are unavailable.
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.
///
/// This function generates a comprehensive daily work report including:
/// - Work intervals with start/end times and durations (filtered by min_work_interval)
/// - Productivity calculations based on actual work vs. presence time
/// - Task completion summary
/// - Break analysis and total pause time
/// - Information about filtered short intervals
///
/// ## Report Components
///
/// 1. **Work Intervals Table**: Shows continuous work periods with breaks (short intervals filtered out)
/// 2. **Summary Statistics**: Total hours, productivity percentage
/// 3. **Task List**: Completed tasks with progress indicators
/// 4. **Filter Information**: Details about intervals filtered due to being too short
///
/// ## Interval Filtering
///
/// Short intervals are automatically filtered from display based on the
/// `min_work_interval` configuration setting. Users are informed about
/// the number and total duration of filtered intervals.
///
/// ## Productivity Calculation
///
/// Productivity is calculated as:
/// ```
/// 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.
///
/// # Arguments
///
/// * `date` - Target date for report generation
///
/// # Data Sources
///
/// The report integrates multiple data sources:
/// - **Workdays**: Start and end times for the work session
/// - **Pauses**: Automatically detected breaks and manual pauses
/// - **Tasks**: Completed work items and progress tracking
/// - **Configuration**: Thresholds for filtering and analysis
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 both manual breaks and automatic pauses for comprehensive work interval calculation
    let manual_breaks = Breaks::new()?.get_daily_breaks(naive_date)?;
    let long_pauses = Pauses::new()?
        .set_min_duration(monitor_config.min_pause_duration)
        .get_workday_pauses(&workday)?;

    // Combine breaks and pauses for accurate work interval calculation
    // This ensures both manual breaks and automatic pauses are considered when calculating work periods
    let combined_interruptions = report::combine_breaks_and_pauses(&manual_breaks, &long_pauses);

    // Calculate work intervals and apply filtering
    let intervals = report::calculate_work_intervals(&workday, &combined_interruptions);
    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)
        ));
    }

    // Check productivity and show break recommendations if needed
    // Uses centralized productivity module with self-contained logic
    let productivity = Productivity::new(&workday)?;
    if let Some(needed_minutes) = productivity.check_productivity_recommendations() {
        msg_error!(Message::LowProductivityWarning {
            current: productivity.calculate_productivity(),
            threshold: productivity.config.min_productivity_threshold,
            needed_break_minutes: needed_minutes,
        });
    }

    Ok(())
}

/// Handles the complete process of sending a daily report to external API.
///
/// This function manages the full workflow for daily report submission:
/// 1. **Workday Finalization**: Ensures the workday is properly closed
/// 2. **Data Validation**: Verifies required data is available
/// 3. **Interval Filtering**: Applies min_work_interval filtering to remove short intervals
/// 4. **Report Generation**: Creates JSON payload for API submission using filtered intervals
/// 5. **API Submission**: Sends report to configured external service
/// 6. **Monthly Trigger**: Automatically submits monthly report if needed
///
/// ## Report Payload Structure
///
/// The generated JSON includes:
/// - Work intervals with start/end times and durations (short intervals filtered out)
/// - Task assignments distributed across filtered intervals
/// - Summary statistics and metadata
/// - Formatted time strings for external system compatibility
///
/// ## Interval Filtering
///
/// Same filtering logic as display reports - short intervals are automatically
/// removed based on the `min_work_interval` configuration setting before
/// sending to the external API.
///
/// ## Auto-Monthly Reporting
///
/// If the current date is the last working day of the month,
/// this function will automatically trigger monthly report submission
/// after successful daily report processing.
///
/// # Arguments
///
/// * `date` - Target date for report generation and submission
///
/// # Error Handling
///
/// The function handles several error scenarios gracefully:
/// - Missing workday data (warns user, doesn't crash)
/// - No tasks for the day (prevents submission, shows warning)
/// - Network connectivity issues (reports error, continues operation)
/// - API authentication failures (provides user-friendly messages)
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
    workdays_db.insert_end(naive_date)?;

    // 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 both manual breaks and automatic pauses for comprehensive report submission
    let manual_breaks = Breaks::new()?.get_daily_breaks(naive_date)?;
    let long_pauses = Pauses::new()?
        .set_min_duration(monitor_config.min_pause_duration)
        .get_workday_pauses(&workday)?;

    // Combine breaks and pauses for accurate work interval calculation in API submission
    let combined_interruptions = report::combine_breaks_and_pauses(&manual_breaks, &long_pauses);

    // 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 {
        // Calculate break recommendations using the same comprehensive logic
        let needed_minutes = productivity.calculate_needed_break_duration(None);

        msg_error!(Message::ProductivityTooLowToSend {
            current: current_productivity,
            threshold: productivity.config.min_productivity_threshold,
            needed_break_minutes: needed_minutes,
        });
        return Ok(());
    }

    // Apply interval filtering for API submission
    let intervals = report::calculate_work_intervals(&workday, &combined_interruptions);
    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.
///
/// This function creates a structured JSON report that distributes tasks
/// across work intervals in a logical manner. The distribution algorithm
/// ensures that all tasks are included and work intervals are properly
/// represented in the external reporting system.
///
/// ## Task Distribution Algorithm
///
/// The function handles two scenarios:
///
/// 1. **More Tasks than Intervals**: Distributes multiple tasks per interval
///    - Calculates base tasks per interval
///    - Distributes remainder tasks evenly
///    - Ensures all tasks are included
///
/// 2. **More Intervals than Tasks**: Assigns intervals to tasks
///    - Distributes multiple intervals per task
///    - Creates separate entries for each interval
///    - Maintains interval granularity
///
/// ## JSON Structure
///
/// Each report entry contains:
/// - `from`: Start time in HH:MM format
/// - `to`: End time in HH:MM format
/// - `total_ts`: Formatted duration string
/// - `task`: Formatted task description with completion percentage
/// - `index`: Sequential numbering for external system ordering
/// - `result`: Empty field for external system use
/// - `time`: Empty field for external system use
///
/// # Arguments
///
/// * `tasks` - Mutable reference to tasks for modification during processing
/// * `intervals` - Pre-calculated work intervals (potentially filtered)
///
/// # Returns
///
/// Returns a JSON value containing the structured report payload
/// ready 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.
///
/// # Returns
///
/// Returns a configured Si service instance ready for API operations,
/// or an error if SiServer configuration is missing or invalid.
///
/// # Error Scenarios
///
/// - Configuration file not found or unreadable
/// - SiServer section missing from configuration
/// - Invalid API credentials or URLs in 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))
}