Skip to main content

kasl/commands/
breaks.rs

1//! Manual break management command for productivity optimization.
2//!
3//! Provides functionality to add manual break periods to improve productivity calculations and help users reach minimum productivity thresholds for report submission.
4//!
5//! ## Features
6//!
7//! - **Automatic Placement**: Intelligent break placement in longest work intervals
8//! - **Interactive Mode**: Manual selection of break timing and duration
9//! - **Productivity Integration**: Immediate impact on productivity calculations
10//! - **Smart Recommendations**: Suggests optimal break durations and placement
11//! - **Validation**: Ensures breaks don't overlap with existing pauses
12//!
13//! ## Usage
14//!
15//! ```bash
16//! # Add break with automatic placement
17//! kasl breaks --minutes 30
18//!
19//! # Interactive break creation
20//! kasl breaks
21//!
22//! # Force break creation ignoring validation
23//! kasl breaks --minutes 60 --force
24//! ```
25
26use crate::{
27    db::{breaks::Breaks, pauses::Pauses, workdays::Workdays},
28    libs::{config::Config, formatter::format_duration, messages::Message, pause::Pause, productivity::Productivity, report},
29    msg_error, msg_info, msg_print, msg_success,
30};
31use anyhow::Result;
32use chrono::{Duration, Local, NaiveDate, NaiveDateTime};
33use clap::Args;
34use dialoguer::{Input, Select, theme::ColorfulTheme};
35
36/// Command-line arguments for the breaks command.
37///
38/// Supports both automatic break placement and interactive selection
39/// of break timing and placement options.
40#[derive(Debug, Args)]
41pub struct BreaksArgs {
42    /// Minutes duration for the break (automatic placement)
43    ///
44    /// When specified, the system will automatically find the optimal
45    /// placement for a break of this duration. If not specified, enters
46    /// interactive mode for manual break configuration.
47    #[arg(long, short)]
48    minutes: Option<u64>,
49
50    /// Force creation even if productivity validation fails
51    ///
52    /// Bypasses normal productivity threshold checks and creates the break
53    /// regardless of current productivity levels. Use with caution.
54    #[arg(long)]
55    force: bool,
56}
57
58/// Represents a break placement option with timing details.
59///
60/// Each option shows the user when and where a break would be placed,
61/// providing context for informed decision making.
62#[derive(Debug, Clone)]
63pub struct BreakOption {
64    /// Start time of the proposed break
65    pub start: NaiveDateTime,
66    /// End time of the proposed break
67    pub end: NaiveDateTime,
68    /// Duration of the break
69    pub duration: Duration,
70    /// Human-readable description of the placement
71    pub description: String,
72}
73
74/// Main entry point for the breaks command.
75///
76/// Routes between automatic placement mode and interactive configuration
77/// based on command line arguments provided by the user.
78///
79/// # Arguments
80///
81/// * `args` - Parsed command line arguments specifying break options
82///
83/// # Returns
84///
85/// Returns `Ok(())` on successful break creation, or an error if
86/// validation fails or database operations encounter issues.
87pub async fn cmd(args: BreaksArgs) -> Result<()> {
88    let today = Local::now().date_naive();
89
90    // Validate that we can only create breaks for today
91    let config = Config::read()?;
92    let productivity_config = config.productivity.unwrap_or_default();
93
94    if let Some(minutes) = args.minutes {
95        handle_automatic_break_placement(today, minutes, &productivity_config, args.force).await
96    } else {
97        handle_interactive_break_creation(today, &productivity_config, args.force).await
98    }
99}
100
101/// Handles automatic break placement with specified duration.
102///
103/// Finds the optimal placement for a break of the given duration and
104/// creates it without user interaction. Includes productivity validation
105/// unless forced.
106async fn handle_automatic_break_placement(
107    date: NaiveDate,
108    minutes: u64,
109    productivity_config: &crate::libs::config::ProductivityConfig,
110    _force: bool,
111) -> Result<()> {
112    // Validate break duration
113    if minutes < productivity_config.min_break_duration {
114        msg_error!(Message::BreakDurationPrompt {
115            min_duration: productivity_config.min_break_duration,
116            max_duration: productivity_config.max_break_duration,
117        });
118        return Ok(());
119    }
120
121    if minutes > productivity_config.max_break_duration {
122        msg_error!(Message::BreakDurationPrompt {
123            min_duration: productivity_config.min_break_duration,
124            max_duration: productivity_config.max_break_duration,
125        });
126        return Ok(());
127    }
128
129    // Get workday and pauses data
130    let workday = match Workdays::new()?.fetch(date)? {
131        Some(wd) => wd,
132        None => {
133            msg_error!("No workday found for today");
134            return Ok(());
135        }
136    };
137
138    let config = Config::read()?;
139    let monitor_config = config.monitor.unwrap_or_default();
140    let pauses = Pauses::new()?
141        .set_min_duration(monitor_config.min_pause_duration)
142        .get_workday_pauses(&workday)?;
143
144    // Find optimal break placement
145    let break_options = find_break_placement_options(&workday, &pauses, minutes, monitor_config.min_work_interval)?;
146
147    if break_options.is_empty() {
148        msg_error!(Message::NoValidBreakPlacement);
149        return Ok(());
150    }
151
152    // Use the first (optimal) option
153    let break_option = &break_options[0];
154
155    // Create the break record
156    let break_record = crate::db::breaks::Break {
157        id: None,
158        date,
159        start: break_option.start,
160        end: break_option.end,
161        duration: break_option.duration,
162        reason: None,
163        created_at: None,
164    };
165
166    let breaks_db = Breaks::new()?;
167    breaks_db.insert(&break_record)?;
168
169    msg_success!(Message::BreakCreated {
170        start_time: break_option.start.format("%H:%M").to_string(),
171        end_time: break_option.end.format("%H:%M").to_string(),
172        duration_minutes: minutes,
173    });
174
175    // Recalculate and show productivity
176    show_updated_productivity(date).await?;
177
178    Ok(())
179}
180
181/// Handles interactive break creation with user selection.
182///
183/// Prompts user for break duration and presents placement options
184/// for selection. Provides full control over break timing and placement.
185async fn handle_interactive_break_creation(date: NaiveDate, productivity_config: &crate::libs::config::ProductivityConfig, _force: bool) -> Result<()> {
186    msg_print!(Message::BreakInteractivePrompt);
187
188    // Prompt for break duration
189    let theme = ColorfulTheme::default();
190    let duration_input: String = Input::with_theme(&theme)
191        .with_prompt(format!(
192            "Enter break duration ({}-{} minutes)",
193            productivity_config.min_break_duration, productivity_config.max_break_duration
194        ))
195        .interact_text()?;
196
197    let minutes: u64 = match duration_input.parse() {
198        Ok(m) if m >= productivity_config.min_break_duration && m <= productivity_config.max_break_duration => m,
199        _ => {
200            msg_error!(Message::BreakDurationPrompt {
201                min_duration: productivity_config.min_break_duration,
202                max_duration: productivity_config.max_break_duration,
203            });
204            return Ok(());
205        }
206    };
207
208    // Get workday and pauses data
209    let workday = match Workdays::new()?.fetch(date)? {
210        Some(wd) => wd,
211        None => {
212            msg_error!("No workday found for today");
213            return Ok(());
214        }
215    };
216
217    let config = Config::read()?;
218    let monitor_config = config.monitor.unwrap_or_default();
219    let pauses = Pauses::new()?
220        .set_min_duration(monitor_config.min_pause_duration)
221        .get_workday_pauses(&workday)?;
222
223    // Find break placement options
224    let break_options = find_break_placement_options(&workday, &pauses, minutes, monitor_config.min_work_interval)?;
225
226    if break_options.is_empty() {
227        msg_error!(Message::NoValidBreakPlacement);
228        return Ok(());
229    }
230
231    // Present options to user
232    msg_print!(Message::BreakPlacementOptions);
233    let option_labels: Vec<String> = break_options
234        .iter()
235        .enumerate()
236        .map(|(i, opt)| {
237            format!(
238                "{}. {} - {} ({} min) - {}",
239                i + 1,
240                opt.start.format("%H:%M"),
241                opt.end.format("%H:%M"),
242                opt.duration.num_minutes(),
243                opt.description
244            )
245        })
246        .collect();
247
248    let selection = Select::with_theme(&theme)
249        .with_prompt("Select break placement")
250        .items(&option_labels)
251        .default(0)
252        .interact()?;
253
254    let chosen_option = &break_options[selection];
255
256    // Create the break record
257    let break_record = crate::db::breaks::Break {
258        id: None,
259        date,
260        start: chosen_option.start,
261        end: chosen_option.end,
262        duration: chosen_option.duration,
263        reason: None,
264        created_at: None,
265    };
266
267    let breaks_db = Breaks::new()?;
268    breaks_db.insert(&break_record)?;
269
270    msg_success!(Message::BreakCreated {
271        start_time: chosen_option.start.format("%H:%M").to_string(),
272        end_time: chosen_option.end.format("%H:%M").to_string(),
273        duration_minutes: minutes,
274    });
275
276    // Recalculate and show productivity
277    show_updated_productivity(date).await?;
278
279    Ok(())
280}
281
282/// Finds optimal placement options for a break of the given duration.
283///
284/// Analyzes the workday and existing pauses to suggest the best times
285/// to place a break, avoiding conflicts and maintaining minimum work intervals.
286fn find_break_placement_options(
287    workday: &crate::db::workdays::Workday,
288    pauses: &[Pause],
289    duration_minutes: u64,
290    min_work_interval: u64,
291) -> Result<Vec<BreakOption>> {
292    let mut options = Vec::new();
293    let current_time = Local::now().naive_local();
294    let workday_end = workday.end.unwrap_or(current_time);
295
296    // Calculate work intervals
297    let intervals = report::calculate_work_intervals(workday, pauses);
298
299    // Find gaps between pauses that can accommodate the break
300    let break_duration = Duration::minutes(duration_minutes as i64);
301
302    if intervals.is_empty() {
303        return Ok(options);
304    }
305
306    // Strategy 1: Place break in the middle of the longest interval
307    let longest_interval = intervals.iter().max_by_key(|interval| interval.duration.num_minutes());
308
309    if let Some(interval) = longest_interval {
310        // Check if the interval is long enough to accommodate the break plus minimum work time
311        let required_time = break_duration + Duration::minutes(min_work_interval as i64 * 2);
312        if interval.duration >= required_time && interval.end <= current_time {
313            let interval_mid = interval.start + (interval.duration / 2);
314            let break_start = interval_mid - (break_duration / 2);
315            let break_end = break_start + break_duration;
316
317            options.push(BreakOption {
318                start: break_start,
319                end: break_end,
320                duration: break_duration,
321                description: "Middle of longest work period".to_string(),
322            });
323        }
324    }
325
326    // Strategy 2: Place break after existing pauses (if there's room)
327    for (i, pause) in pauses.iter().enumerate() {
328        if let Some(pause_end) = pause.end {
329            // Find the next pause or end of workday
330            let next_pause_start = pauses.get(i + 1).map(|p| p.start).unwrap_or(workday_end.min(current_time));
331
332            let available_time = next_pause_start - pause_end;
333            let required_time = break_duration + Duration::minutes(min_work_interval as i64);
334
335            if available_time >= required_time && pause_end + break_duration <= current_time {
336                options.push(BreakOption {
337                    start: pause_end,
338                    end: pause_end + break_duration,
339                    duration: break_duration,
340                    description: format!("After {} pause", format_duration(&pause.duration.unwrap_or_default())),
341                });
342            }
343        }
344    }
345
346    // Strategy 3: Place break before existing pauses (if there's room)
347    for pause in pauses.iter() {
348        let work_start = workday.start;
349        let available_time = pause.start - work_start;
350        let required_time = break_duration + Duration::minutes(min_work_interval as i64);
351
352        if available_time >= required_time {
353            let break_end = pause.start - Duration::minutes(min_work_interval as i64);
354            let break_start = break_end - break_duration;
355
356            if break_start >= work_start && break_end <= current_time {
357                options.push(BreakOption {
358                    start: break_start,
359                    end: break_end,
360                    duration: break_duration,
361                    description: format!("Before {} pause", format_duration(&pause.duration.unwrap_or_default())),
362                });
363            }
364        }
365    }
366
367    // Remove duplicates and sort by start time
368    options.sort_by_key(|opt| opt.start);
369    options.dedup_by(|a, b| {
370        (a.start - b.start).num_minutes().abs() < 5 // Consider times within 5 minutes as duplicates
371    });
372
373    // Limit to top 3 options
374    options.truncate(3);
375
376    Ok(options)
377}
378
379/// Shows updated productivity after break creation.
380///
381/// Recalculates productivity using the centralized `Productivity` module and displays
382/// the updated productivity percentage to the user. This provides immediate feedback
383/// on how the newly added break affects overall productivity metrics.
384///
385/// ## Calculation Process
386///
387/// 1. **Data Loading**: Fetches the current workday record
388/// 2. **Comprehensive Analysis**: Uses `Productivity::new()` to automatically load:
389///    - All manual breaks (including the just-created break)
390///    - Short pauses (< min_pause_duration)  
391///    - Long pauses (>= min_pause_duration)
392/// 3. **Unified Calculation**: Applies the same productivity logic used throughout the app
393/// 4. **User Feedback**: Displays the recalculated productivity percentage
394///
395/// This ensures the user sees exactly how their break addition impacts the productivity
396/// metric that will be used in reports and other application features.
397async fn show_updated_productivity(date: NaiveDate) -> Result<()> {
398    // Get workday data for the specified date
399    let workday = Workdays::new()?.fetch(date)?.expect("Workday should exist");
400
401    // Use centralized productivity module for consistent calculation
402    let productivity = Productivity::new(&workday)?.calculate_productivity();
403
404    msg_info!(Message::ProductivityRecalculated(productivity));
405    Ok(())
406}