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