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
8use crate::{
9 api::si::{Si, daily_report_fields},
10 db::{
11 jira_inbox::JiraInbox,
12 pauses::Pauses,
13 tasks::Tasks,
14 workdays::{Workday, Workdays},
15 },
16 libs::{
17 config::Config,
18 formatter::format_duration,
19 messages::Message,
20 productivity::Productivity,
21 report,
22 task::{FormatTasks, Task, TaskFilter},
23 view::View,
24 },
25 msg_error, msg_error_anyhow, msg_info, msg_print,
26};
27use anyhow::Result;
28use chrono::{DateTime, Duration, Local};
29use clap::Args;
30use serde_json::json;
31
32/// Command-line arguments for the report command.
33///
34/// The report command supports multiple operational modes for different
35/// reporting scenarios and organizational requirements.
36#[derive(Debug, Args)]
37pub struct ReportArgs {
38 /// Submit the generated daily report to configured API
39 ///
40 /// When specified, the report will be automatically submitted to the
41 /// configured reporting service (typically SiServer) after generation.
42 /// This enables integration with organizational time tracking systems.
43 #[arg(long, help = "Submit daily report")]
44 send: bool,
45
46 /// Generate report for the previous day instead of today
47 ///
48 /// Useful for:
49 /// - Submitting yesterday's report in the morning
50 /// - Reviewing completed work sessions
51 /// - Batch processing of historical reports
52 #[arg(long, short, help = "Generate report for the last day")]
53 last: bool,
54
55 /// Submit monthly summary report to configured API
56 ///
57 /// Generates and submits an aggregate monthly report containing
58 /// summary statistics and total work hours. Typically used for
59 /// organizational reporting requirements at month-end.
60 #[arg(long, help = "Submit monthly report")]
61 month: bool,
62
63 /// Print the payload before it is sent, and send nothing
64 ///
65 /// The same transparency `kasl server manifest` gives for the team
66 /// server, for the corporate channel: what leaves this machine, in full,
67 /// before it does. Requires `--send`, because what it describes is the
68 /// sending.
69 ///
70 /// Conflicts with `--month` rather than growing a monthly preview: the
71 /// monthly report sends a date and nothing else, so there is no payload
72 /// to inspect, and accepting the flag there would answer a question about
73 /// the daily payload with silence.
74 #[arg(
75 long,
76 requires = "send",
77 conflicts_with = "month",
78 help = "Show the payload that --send would post, and post nothing"
79 )]
80 show: bool,
81}
82
83/// Main entry point for the report command.
84///
85/// Acts as a dispatcher based on the provided arguments, determining the target
86/// date and delegating to the appropriate handler for daily, monthly, display,
87/// or send actions.
88///
89/// # Returns
90///
91/// Returns `Ok(())` on successful report generation or processing,
92/// or an error if data retrieval or submission fails.
93///
94/// ```bash
95/// # Display today's report
96/// kasl report
97///
98/// # Submit today's report to API
99/// kasl report --send
100///
101/// # Generate yesterday's report
102/// kasl report --last
103///
104/// # Submit monthly summary
105/// kasl report --month
106///
107/// ```
108pub async fn cmd(args: ReportArgs) -> Result<()> {
109 let date = determine_report_date(args.last);
110
111 if args.show {
112 show_daily_payload(date).await
113 } else if args.month {
114 handle_monthly_report(date).await
115 } else {
116 handle_daily_report(args.send, date).await
117 }
118}
119
120/// Determines the target date for report generation.
121///
122/// Calculates whether to generate a report for today or yesterday
123/// based on user preferences. This allows flexible reporting timing
124/// to accommodate different organizational workflows.
125fn determine_report_date(is_last_day: bool) -> DateTime<Local> {
126 if is_last_day { Local::now() - Duration::days(1) } else { Local::now() }
127}
128
129/// Handles the logic for daily reports.
130///
131/// Routes to either display or submission mode based on user preferences.
132/// This separation allows for different handling of local viewing versus
133/// API integration scenarios.
134async fn handle_daily_report(should_send: bool, date: DateTime<Local>) -> Result<()> {
135 if should_send {
136 send_daily_report(date).await
137 } else {
138 display_daily_report(date).await
139 }
140}
141
142/// Handles the submission of monthly summary reports.
143///
144/// Generates and submits aggregate monthly statistics to the configured
145/// reporting API. This is typically used for organizational reporting
146/// requirements and payroll integration.
147async fn handle_monthly_report(date: DateTime<Local>) -> Result<()> {
148 let mut si = get_si_service()?;
149 let naive_date = date.date_naive();
150
151 match si.send_monthly(&naive_date).await {
152 Ok(status) => {
153 if status.is_success() {
154 msg_info!(Message::MonthlyReportSent(date.format("%B %-d, %Y").to_string()));
155 } else {
156 msg_error!(Message::MonthlyReportSendFailed(status.to_string()));
157 }
158 }
159 Err(e) => msg_error!(Message::ErrorSendingMonthlyReport(e.to_string())),
160 }
161
162 Ok(())
163}
164
165/// Fetches data and displays a formatted daily report in the terminal.
166///
167/// ## Productivity Calculation
168///
169/// Productivity is calculated as:
170/// ```text
171/// Productivity = (Net Work Time / Available Work Time) * 100%
172/// Where Available Work Time = Gross Work Time - Manual Breaks - Long Pauses
173/// ```
174///
175/// This provides insight into work efficiency while accounting for
176/// legitimate breaks and focusing on actual productive activity.
177async fn display_daily_report(date: DateTime<Local>) -> Result<()> {
178 let naive_date = date.date_naive();
179 let workday = match Workdays::new()?.fetch(naive_date)? {
180 Some(wd) => wd,
181 None => {
182 msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
183 return Ok(());
184 }
185 };
186
187 let tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
188 let config = Config::read()?;
189 let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
190
191 // Load interruptions: detected pauses above the threshold plus any manual
192 // pauses the user recorded (protected records bypass the threshold).
193 let long_pauses = Pauses::new()?
194 .set_min_duration(monitor_config.min_pause_duration)
195 .get_workday_pauses(&workday)?;
196
197 // Calculate work intervals and apply filtering
198 let intervals = report::calculate_work_intervals(&workday, &long_pauses);
199 let (filtered_intervals, filtered_info) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
200
201 // Use the report module to process the data
202 let (filtered_duration, productivity) = report::report_with_intervals(&workday, &intervals)?;
203
204 // Display the formatted report with filtered intervals
205 View::report(&workday, &filtered_intervals, &filtered_duration, &productivity, &tasks)?;
206
207 // Display information about filtered short intervals
208 if let Some(info) = filtered_info {
209 msg_info!(format!(
210 "Filtered out {} short intervals (total: {})",
211 info.count,
212 format_duration(&info.total_duration)
213 ));
214 }
215
216 // What is still waiting in the inbox - the day is not only what was done.
217 let counts = JiraInbox::new()?.counts()?;
218 if !counts.is_empty() {
219 msg_info!(Message::JiraInboxSummary {
220 total: counts.total,
221 fresh: counts.fresh,
222 taken: counts.taken,
223 });
224 }
225
226 // Warn when productivity is below the configured threshold
227 let productivity = Productivity::new(&workday)?;
228 if productivity.is_below_threshold() {
229 msg_error!(Message::LowProductivityWarning {
230 current: productivity.calculate_productivity(),
231 threshold: productivity.config.min_productivity_threshold,
232 });
233 }
234
235 Ok(())
236}
237
238/// Prints the payload `--send` would post, and posts nothing.
239///
240/// The point is being able to read what leaves this machine before it does.
241/// That only means something if the preview is the real payload, so this
242/// builds it through the same functions the send path uses - the same
243/// interval filtering, the same task distribution, the same form fields from
244/// [`daily_report_fields`] - and stops one step short of the request.
245///
246/// Three things the send path does are deliberately *not* done here.
247///
248/// The day is not finalized. `--send` writes an end timestamp before it
249/// assembles anything, and a preview that quietly ended someone's working day
250/// would be the opposite of a command you run to find out what would happen.
251/// The day is read as it stands, open end and all.
252///
253/// The productivity threshold is not enforced. It decides whether a report
254/// may be submitted, not what the submission contains, and refusing to show
255/// the payload of a day that is below it would hide exactly the day someone
256/// wants to look at.
257///
258/// Nothing is authenticated. No session is opened, no credential is read: the
259/// address comes from the config, and a preview that logged in would reach
260/// the network to tell you what it would do if it reached the network.
261async fn show_daily_payload(date: DateTime<Local>) -> Result<()> {
262 let naive_date = date.date_naive();
263
264 let workday = match Workdays::new()?.fetch(naive_date)? {
265 Some(workday) => workday,
266 None => {
267 msg_print!(Message::WorkdayNotFoundForDate(date.format("%B %-d, %Y").to_string()), true);
268 return Ok(());
269 }
270 };
271
272 let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
273 if tasks.is_empty() {
274 // The same stop `--send` makes, and for the same reason: there is no
275 // report to describe. Reported rather than shown as an empty payload,
276 // which would read as "this day sends nothing" when what it means is
277 // "this day cannot be sent".
278 msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
279 return Ok(());
280 }
281
282 let config = Config::read()?;
283 let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
284
285 let long_pauses = Pauses::new()?
286 .set_min_duration(monitor_config.min_pause_duration)
287 .get_workday_pauses(&workday)?;
288
289 let intervals = report::calculate_work_intervals(&workday, &long_pauses);
290 let (filtered_intervals, _) = report::filter_short_intervals(&intervals, monitor_config.min_work_interval);
291
292 let report_json = build_report_payload(&workday, &mut tasks, &filtered_intervals);
293 let events_json = serde_json::to_string(&report_json)?;
294
295 // The destination is half the answer to "what leaves this machine", so it
296 // is named from the configured client rather than described in prose.
297 let si = get_si_service()?;
298 msg_info!(Message::ReportPayloadHeading {
299 url: si.daily_report_url(),
300 date: naive_date.to_string(),
301 });
302
303 // Every field of the form, not only the interesting one. `tasks` is where
304 // the work is described, but a person asking what is sent about them is
305 // owed the whole request - a field they were not shown is a field they
306 // were not told about.
307 for (name, value) in daily_report_fields(&naive_date.format("%Y-%m-%d").to_string(), &events_json) {
308 // The tasks field holds the report as JSON; pretty-printing it is the
309 // difference between a payload someone can read and one they can only
310 // confirm exists.
311 if name == "tasks" {
312 msg_print!(format!(" {}:", name));
313 // Indented one step further than the field lines. The JSON is the
314 // only multi-line value here, and without a shift its own braces
315 // sit in the same column as a field name - readable enough to a
316 // person, and indistinguishable to anything reading the preview
317 // back, which the parity check against the real request does.
318 for line in serde_json::to_string_pretty(&report_json)?.lines() {
319 msg_print!(format!(" {}", line));
320 }
321 } else if value.is_empty() {
322 // Shown as empty rather than skipped: a field sent empty is still
323 // a field sent, and leaving it out would describe a smaller
324 // request than the one made.
325 msg_print!(format!(" {}: (empty)", name));
326 } else {
327 msg_print!(format!(" {}: {}", name, value));
328 }
329 }
330
331 Ok(())
332}
333
334/// Handles the complete process of sending a daily report to external API.
335async fn send_daily_report(date: DateTime<Local>) -> Result<()> {
336 let naive_date = date.date_naive();
337 let mut workdays_db = Workdays::new()?;
338
339 // Finalize the workday by recording end time. A day that was never
340 // started cannot be sent, and saying so here beats the fetch below
341 // failing with "could not find after finalizing".
342 if !workdays_db.insert_end(naive_date)? {
343 return Err(msg_error_anyhow!(Message::WorkdayNeverStarted(naive_date.to_string())));
344 }
345
346 // Load the finalized workday data
347 let workday = workdays_db
348 .fetch(naive_date)?
349 .ok_or_else(|| msg_error_anyhow!(Message::WorkdayCouldNotFindAfterFinalizing(naive_date.to_string())))?;
350
351 // Validate that tasks exist for the reporting day
352 let mut tasks = Tasks::new()?.fetch(TaskFilter::Date(naive_date))?;
353 if tasks.is_empty() {
354 msg_error!(Message::TasksNotFoundForDate(date.format("%B %-d, %Y").to_string()));
355 return Ok(());
356 }
357
358 let config = Config::read()?;
359 let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
360
361 // Load interruptions for the submitted intervals: detected pauses above the
362 // threshold plus any manual pauses the user recorded.
363 let long_pauses = Pauses::new()?
364 .set_min_duration(monitor_config.min_pause_duration)
365 .get_workday_pauses(&workday)?;
366
367 // Validate productivity before allowing report submission
368 // Uses centralized Productivity module for consistent threshold checking
369 let productivity = Productivity::new(&workday)?;
370 let current_productivity = productivity.calculate_productivity();
371 if current_productivity < productivity.config.min_productivity_threshold {
372 msg_error!(Message::ProductivityTooLowToSend {
373 current: current_productivity,
374 threshold: productivity.config.min_productivity_threshold,
375 });
376 return Ok(());
377 }
378
379 // Apply interval filtering for API submission
380 let intervals = report::calculate_work_intervals(&workday, &long_pauses);
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.
410fn build_report_payload(_workday: &Workday, tasks: &mut [Task], intervals: &[report::WorkInterval]) -> serde_json::Value {
411 let num_tasks = tasks.len();
412 let num_intervals = intervals.len();
413
414 // Handle edge case of no work intervals
415 if num_intervals == 0 {
416 return json!([]);
417 }
418
419 let mut report_items = Vec::new();
420
421 // Distribute tasks across intervals based on relative quantities
422 if num_tasks >= num_intervals {
423 // More tasks than intervals: multiple tasks per interval
424 let mut task_iter = tasks.iter();
425 let base_tasks_per_interval = num_tasks / num_intervals;
426 let mut extra_tasks = num_tasks % num_intervals;
427
428 for (i, interval) in intervals.iter().enumerate() {
429 // Calculate number of tasks for this interval
430 let count = base_tasks_per_interval + if extra_tasks > 0 { 1 } else { 0 };
431 extra_tasks = extra_tasks.saturating_sub(1);
432
433 // Collect tasks for this interval
434 let mut assigned_tasks: Vec<Task> = task_iter.by_ref().take(count).cloned().collect();
435
436 report_items.push(json!({
437 "from": interval.start.format("%H:%M").to_string(),
438 "index": i + 1,
439 "result": "",
440 "task": assigned_tasks.format(),
441 "time": "",
442 "to": interval.end.format("%H:%M").to_string(),
443 "total_ts": format_duration(&interval.duration)
444 }));
445 }
446 } else {
447 // More intervals than tasks: multiple intervals per task
448 let mut interval_iter = intervals.iter();
449 let base_intervals_per_task = num_intervals / num_tasks;
450 let mut extra_intervals = num_intervals % num_tasks;
451
452 for task in tasks.iter() {
453 // Calculate number of intervals for this task
454 let count = base_intervals_per_task + if extra_intervals > 0 { 1 } else { 0 };
455 extra_intervals = extra_intervals.saturating_sub(1);
456
457 // Create entries for each interval assigned to this task
458 for _ in 0..count {
459 if let Some(interval) = interval_iter.next() {
460 let index = report_items.len() + 1;
461 report_items.push(json!({
462 "from": interval.start.format("%H:%M").to_string(),
463 "index": index,
464 "result": "",
465 "task": vec![task.clone()].format(),
466 "time": "",
467 "to": interval.end.format("%H:%M").to_string(),
468 "total_ts": format_duration(&interval.duration)
469 }));
470 }
471 }
472 }
473 }
474
475 json!(report_items)
476}
477
478/// Reads configuration and returns an initialized Si service instance.
479///
480/// This helper function encapsulates the configuration loading and service
481/// initialization logic, providing proper error handling for missing or
482/// invalid SiServer configuration.
483fn get_si_service() -> Result<Si> {
484 Config::read()?
485 .si
486 .map(|si_config| Si::new(&si_config))
487 .ok_or_else(|| msg_error_anyhow!(Message::SiServerConfigNotFound))
488}