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