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//!
8//! ## Productivity Integration
9//!
10//! This module leverages the centralized `libs::productivity::Productivity` module for:
11//! - Consistent productivity calculations across display and submission
12//! - Break recommendations based on productivity thresholds
13//! - Validation before report submission to external APIs
14//! - Real-time productivity feedback during report generation
15
16use crate::{
17 api::si::Si,
18 db::{
19 pauses::Pauses,
20 tasks::Tasks,
21 workdays::{Workday, Workdays},
22 },
23 libs::{
24 config::Config,
25 formatter::format_duration,
26 messages::Message,
27 productivity::Productivity,
28 report,
29 task::{FormatTasks, Task, TaskFilter},
30 view::View,
31 },
32 msg_error, msg_error_anyhow, msg_info, msg_print,
33};
34use anyhow::Result;
35use chrono::{DateTime, Duration, Local};
36use clap::Args;
37use serde_json::json;
38
39/// Command-line arguments for the report command.
40///
41/// The report command supports multiple operational modes for different
42/// reporting scenarios and organizational requirements.
43#[derive(Debug, Args)]
44pub struct ReportArgs {
45 /// Submit the generated daily report to configured API
46 ///
47 /// When specified, the report will be automatically submitted to the
48 /// configured reporting service (typically SiServer) after generation.
49 /// This enables integration with organizational time tracking systems.
50 #[arg(long, help = "Submit daily report")]
51 send: bool,
52
53 /// Generate report for the previous day instead of today
54 ///
55 /// Useful for:
56 /// - Submitting yesterday's report in the morning
57 /// - Reviewing completed work sessions
58 /// - Batch processing of historical reports
59 #[arg(long, short, help = "Generate report for the last day")]
60 last: bool,
61
62 /// Submit monthly summary report to configured API
63 ///
64 /// Generates and submits an aggregate monthly report containing
65 /// summary statistics and total work hours. Typically used for
66 /// organizational reporting requirements at month-end.
67 #[arg(long, help = "Submit monthly report")]
68 month: bool,
69}
70
71/// Main entry point for the report command.
72///
73/// Acts as a dispatcher based on the provided arguments, determining the target
74/// date and delegating to the appropriate handler for daily, monthly, display,
75/// or send actions.
76///
77/// # Arguments
78///
79/// * `args` - Parsed command-line arguments specifying report options
80///
81/// # Returns
82///
83/// Returns `Ok(())` on successful report generation or processing,
84/// or an error if data retrieval or submission fails.
85///
86/// ```bash
87/// # Display today's report
88/// kasl report
89///
90/// # Submit today's report to API
91/// kasl report --send
92///
93/// # Generate yesterday's report
94/// kasl report --last
95///
96/// # Submit monthly summary
97/// kasl report --month
98///
99/// ```
100pub async fn cmd(args: ReportArgs) -> Result<()> {
101 let date = determine_report_date(args.last);
102
103 if args.month {
104 handle_monthly_report(date).await
105 } else {
106 handle_daily_report(args.send, date).await
107 }
108}
109
110/// Determines the target date for report generation.
111///
112/// Calculates whether to generate a report for today or yesterday
113/// based on user preferences. This allows flexible reporting timing
114/// to accommodate different organizational workflows.
115///
116/// # Arguments
117///
118/// * `is_last_day` - Whether to generate report for yesterday
119///
120/// # Returns
121///
122/// Returns the target date with timezone information for report generation.
123fn determine_report_date(is_last_day: bool) -> DateTime<Local> {
124 if is_last_day { Local::now() - Duration::days(1) } else { Local::now() }
125}
126
127/// Handles the logic for daily reports.
128///
129/// Routes to either display or submission mode based on user preferences.
130/// This separation allows for different handling of local viewing versus
131/// API integration scenarios.
132///
133/// # Arguments
134///
135/// * `should_send` - Whether to submit the report to external API
136/// * `date` - Target date for report generation
137async fn handle_daily_report(should_send: bool, date: DateTime<Local>) -> Result<()> {
138 if should_send {
139 send_daily_report(date).await
140 } else {
141 display_daily_report(date).await
142 }
143}
144
145/// Handles the submission of monthly summary reports.
146///
147/// Generates and submits aggregate monthly statistics to the configured
148/// reporting API. This is typically used for organizational reporting
149/// requirements and payroll integration.
150///
151/// ## Monthly Report Contents
152///
153/// - Total hours worked in the month
154/// - Number of working days
155/// - Average daily hours
156/// - Productivity trends (if available)
157///
158/// # Arguments
159///
160/// * `date` - Date within the target month for report generation
161///
162/// # Error Handling
163///
164/// Network errors are handled gracefully with user-friendly messages
165/// rather than application crashes, allowing continued local operation
166/// even when API services are unavailable.
167async fn handle_monthly_report(date: DateTime<Local>) -> Result<()> {
168 let mut si = get_si_service()?;
169 let naive_date = date.date_naive();
170
171 match si.send_monthly(&naive_date).await {
172 Ok(status) => {
173 if status.is_success() {
174 msg_info!(Message::MonthlyReportSent(date.format("%B %-d, %Y").to_string()));
175 } else {
176 msg_error!(Message::MonthlyReportSendFailed(status.to_string()));
177 }
178 }
179 Err(e) => msg_error!(Message::ErrorSendingMonthlyReport(e.to_string())),
180 }
181
182 Ok(())
183}
184
185/// Fetches data and displays a formatted daily report in the terminal.
186///
187/// This function generates a comprehensive daily work report including:
188/// - Work intervals with start/end times and durations (filtered by min_work_interval)
189/// - Productivity calculations based on actual work vs. presence time
190/// - Task completion summary
191/// - Break analysis and total pause time
192/// - Information about filtered short intervals
193///
194/// ## Report Components
195///
196/// 1. **Work Intervals Table**: Shows continuous work periods with breaks (short intervals filtered out)
197/// 2. **Summary Statistics**: Total hours, productivity percentage
198/// 3. **Task List**: Completed tasks with progress indicators
199/// 4. **Filter Information**: Details about intervals filtered due to being too short
200///
201/// ## Interval Filtering
202///
203/// Short intervals are automatically filtered from display based on the
204/// `min_work_interval` configuration setting. Users are informed about
205/// the number and total duration of filtered intervals.
206///
207/// ## Productivity Calculation
208///
209/// Productivity is calculated as:
210/// ```text
211/// Productivity = (Net Work Time / Available Work Time) * 100%
212/// Where Available Work Time = Gross Work Time - Manual Breaks - Long Pauses
213/// ```
214///
215/// This provides insight into work efficiency while accounting for
216/// legitimate breaks and focusing on actual productive activity.
217///
218/// # Arguments
219///
220/// * `date` - Target date for report generation
221///
222/// # Data Sources
223///
224/// The report integrates multiple data sources:
225/// - **Workdays**: Start and end times for the work session
226/// - **Pauses**: Automatically detected breaks and manual pauses
227/// - **Tasks**: Completed work items and progress tracking
228/// - **Configuration**: Thresholds for filtering and analysis
229async fn display_daily_report(date: DateTime<Local>) -> Result<()> {
230 let naive_date = date.date_naive();
231 let workday = match Workdays::new()?.fetch(naive_date)? {
232 Some(wd) => wd,
233 None => {
234 msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
235 return Ok(());
236 }
237 };
238
239 let tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
240 let config = Config::read()?;
241 let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
242
243 // Load interruptions: detected pauses above the threshold plus any manual
244 // pauses the user recorded (protected records bypass the threshold).
245 let long_pauses = Pauses::new()?
246 .set_min_duration(monitor_config.min_pause_duration)
247 .get_workday_pauses(&workday)?;
248
249 // Calculate work intervals and apply filtering
250 let intervals = report::calculate_work_intervals(&workday, &long_pauses);
251 let (filtered_intervals, filtered_info) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
252
253 // Use the report module to process the data
254 let (filtered_duration, productivity) = report::report_with_intervals(&workday, &intervals)?;
255
256 // Display the formatted report with filtered intervals
257 View::report(&workday, &filtered_intervals, &filtered_duration, &productivity, &tasks)?;
258
259 // Display information about filtered short intervals
260 if let Some(info) = filtered_info {
261 msg_info!(format!(
262 "Filtered out {} short intervals (total: {})",
263 info.count,
264 format_duration(&info.total_duration)
265 ));
266 }
267
268 // Warn when productivity is below the configured threshold
269 let productivity = Productivity::new(&workday)?;
270 if productivity.is_below_threshold() {
271 msg_error!(Message::LowProductivityWarning {
272 current: productivity.calculate_productivity(),
273 threshold: productivity.config.min_productivity_threshold,
274 });
275 }
276
277 Ok(())
278}
279
280/// Handles the complete process of sending a daily report to external API.
281///
282/// This function manages the full workflow for daily report submission:
283/// 1. **Workday Finalization**: Ensures the workday is properly closed
284/// 2. **Data Validation**: Verifies required data is available
285/// 3. **Interval Filtering**: Applies min_work_interval filtering to remove short intervals
286/// 4. **Report Generation**: Creates JSON payload for API submission using filtered intervals
287/// 5. **API Submission**: Sends report to configured external service
288/// 6. **Monthly Trigger**: Automatically submits monthly report if needed
289///
290/// ## Report Payload Structure
291///
292/// The generated JSON includes:
293/// - Work intervals with start/end times and durations (short intervals filtered out)
294/// - Task assignments distributed across filtered intervals
295/// - Summary statistics and metadata
296/// - Formatted time strings for external system compatibility
297///
298/// ## Interval Filtering
299///
300/// Same filtering logic as display reports - short intervals are automatically
301/// removed based on the `min_work_interval` configuration setting before
302/// sending to the external API.
303///
304/// ## Auto-Monthly Reporting
305///
306/// If the current date is the last working day of the month,
307/// this function will automatically trigger monthly report submission
308/// after successful daily report processing.
309///
310/// # Arguments
311///
312/// * `date` - Target date for report generation and submission
313///
314/// # Error Handling
315///
316/// The function handles several error scenarios gracefully:
317/// - Missing workday data (warns user, doesn't crash)
318/// - No tasks for the day (prevents submission, shows warning)
319/// - Network connectivity issues (reports error, continues operation)
320/// - API authentication failures (provides user-friendly messages)
321async fn send_daily_report(date: DateTime<Local>) -> Result<()> {
322 let naive_date = date.date_naive();
323 let mut workdays_db = Workdays::new()?;
324
325 // Finalize the workday by recording end time
326 workdays_db.insert_end(naive_date)?;
327
328 // Load the finalized workday data
329 let workday = workdays_db
330 .fetch(naive_date)?
331 .ok_or_else(|| msg_error_anyhow!(Message::WorkdayCouldNotFindAfterFinalizing(naive_date.to_string())))?;
332
333 // Validate that tasks exist for the reporting day
334 let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
335 if tasks.is_empty() {
336 msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
337 return Ok(());
338 }
339
340 let config = Config::read()?;
341 let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
342
343 // Load interruptions for the submitted intervals: detected pauses above the
344 // threshold plus any manual pauses the user recorded.
345 let long_pauses = Pauses::new()?
346 .set_min_duration(monitor_config.min_pause_duration)
347 .get_workday_pauses(&workday)?;
348
349 // Validate productivity before allowing report submission
350 // Uses centralized Productivity module for consistent threshold checking
351 let productivity = Productivity::new(&workday)?;
352 let current_productivity = productivity.calculate_productivity();
353 if current_productivity < productivity.config.min_productivity_threshold {
354 msg_error!(Message::ProductivityTooLowToSend {
355 current: current_productivity,
356 threshold: productivity.config.min_productivity_threshold,
357 });
358 return Ok(());
359 }
360
361 // Apply interval filtering for API submission
362 let intervals = report::calculate_work_intervals(&workday, &long_pauses);
363 let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
364
365 // Generate JSON payload for API submission using filtered intervals
366 let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
367 let events_json = serde_json::to_string(&report_json)?;
368 let mut si = get_si_service()?;
369
370 // Submit the report to external API
371 match si.send(&events_json, &naive_date).await {
372 Ok(status) => {
373 if status.is_success() {
374 msg_info!(Message::DailyReportSent(date.format("%B %-d, %Y").to_string()));
375
376 // Check if monthly report should be automatically triggered
377 if si.is_last_working_day_of_month(&naive_date)? {
378 msg_info!(Message::MonthlyReportTriggered);
379 handle_monthly_report(date).await?;
380 }
381 } else {
382 msg_error!(Message::ReportSendFailed(status.to_string()));
383 }
384 }
385 Err(e) => msg_error!(Message::ErrorSendingEvents(e.to_string())),
386 }
387
388 Ok(())
389}
390
391/// Builds the JSON payload for API submission.
392///
393/// This function creates a structured JSON report that distributes tasks
394/// across work intervals in a logical manner. The distribution algorithm
395/// ensures that all tasks are included and work intervals are properly
396/// represented in the external reporting system.
397///
398/// ## Task Distribution Algorithm
399///
400/// The function handles two scenarios:
401///
402/// 1. **More Tasks than Intervals**: Distributes multiple tasks per interval
403/// - Calculates base tasks per interval
404/// - Distributes remainder tasks evenly
405/// - Ensures all tasks are included
406///
407/// 2. **More Intervals than Tasks**: Assigns intervals to tasks
408/// - Distributes multiple intervals per task
409/// - Creates separate entries for each interval
410/// - Maintains interval granularity
411///
412/// ## JSON Structure
413///
414/// Each report entry contains:
415/// - `from`: Start time in HH:MM format
416/// - `to`: End time in HH:MM format
417/// - `total_ts`: Formatted duration string
418/// - `task`: Formatted task description with completion percentage
419/// - `index`: Sequential numbering for external system ordering
420/// - `result`: Empty field for external system use
421/// - `time`: Empty field for external system use
422///
423/// # Arguments
424///
425/// * `tasks` - Mutable reference to tasks for modification during processing
426/// * `intervals` - Pre-calculated work intervals (potentially filtered)
427///
428/// # Returns
429///
430/// Returns a JSON value containing the structured report payload
431/// ready for API submission.
432fn build_report_payload(_workday: &Workday, tasks: &mut [Task], intervals: &[report::WorkInterval]) -> serde_json::Value {
433 let num_tasks = tasks.len();
434 let num_intervals = intervals.len();
435
436 // Handle edge case of no work intervals
437 if num_intervals == 0 {
438 return json!([]);
439 }
440
441 let mut report_items = Vec::new();
442
443 // Distribute tasks across intervals based on relative quantities
444 if num_tasks >= num_intervals {
445 // More tasks than intervals: multiple tasks per interval
446 let mut task_iter = tasks.iter();
447 let base_tasks_per_interval = num_tasks / num_intervals;
448 let mut extra_tasks = num_tasks % num_intervals;
449
450 for (i, interval) in intervals.iter().enumerate() {
451 // Calculate number of tasks for this interval
452 let count = base_tasks_per_interval + if extra_tasks > 0 { 1 } else { 0 };
453 extra_tasks = extra_tasks.saturating_sub(1);
454
455 // Collect tasks for this interval
456 let mut assigned_tasks: Vec<Task> = task_iter.by_ref().take(count).cloned().collect();
457
458 report_items.push(json!({
459 "from": interval.start.format("%H:%M").to_string(),
460 "index": i + 1,
461 "result": "",
462 "task": assigned_tasks.format(),
463 "time": "",
464 "to": interval.end.format("%H:%M").to_string(),
465 "total_ts": format_duration(&interval.duration)
466 }));
467 }
468 } else {
469 // More intervals than tasks: multiple intervals per task
470 let mut interval_iter = intervals.iter();
471 let base_intervals_per_task = num_intervals / num_tasks;
472 let mut extra_intervals = num_intervals % num_tasks;
473
474 for task in tasks.iter() {
475 // Calculate number of intervals for this task
476 let count = base_intervals_per_task + if extra_intervals > 0 { 1 } else { 0 };
477 extra_intervals = extra_intervals.saturating_sub(1);
478
479 // Create entries for each interval assigned to this task
480 for _ in 0..count {
481 if let Some(interval) = interval_iter.next() {
482 let index = report_items.len() + 1;
483 report_items.push(json!({
484 "from": interval.start.format("%H:%M").to_string(),
485 "index": index,
486 "result": "",
487 "task": vec![task.clone()].format(),
488 "time": "",
489 "to": interval.end.format("%H:%M").to_string(),
490 "total_ts": format_duration(&interval.duration)
491 }));
492 }
493 }
494 }
495 }
496
497 json!(report_items)
498}
499
500/// Reads configuration and returns an initialized Si service instance.
501///
502/// This helper function encapsulates the configuration loading and service
503/// initialization logic, providing proper error handling for missing or
504/// invalid SiServer configuration.
505///
506/// # Returns
507///
508/// Returns a configured Si service instance ready for API operations,
509/// or an error if SiServer configuration is missing or invalid.
510///
511/// # Error Scenarios
512///
513/// - Configuration file not found or unreadable
514/// - SiServer section missing from configuration
515/// - Invalid API credentials or URLs in configuration
516fn get_si_service() -> Result<Si> {
517 Config::read()?
518 .si
519 .map(|si_config| Si::new(&si_config))
520 .ok_or_else(|| msg_error_anyhow!(Message::SiServerConfigNotFound))
521}