diff --git a/src/analysis/focus.rs b/src/analysis/focus.rs index 9e66bbe..7755639 100644 --- a/src/analysis/focus.rs +++ b/src/analysis/focus.rs @@ -10,9 +10,21 @@ use super::{days_in, median}; use crate::domain::WorkDay; use crate::time::DateRange; -/// Session lengths are counted in these buckets: (label, upper limit in minutes). -pub const SESSION_BUCKETS: [(&str, i64); 6] = - [("<15m", 15), ("15-30m", 30), ("30m-1h", 60), ("1-2h", 120), ("2-3h", 180), ("3h+", i64::MAX)]; +/// Session lengths are counted in these buckets: (label, upper limit in +/// minutes). 25 minutes is a classic pomodoro. Change them freely: the +/// Insights screen follows. +pub const SESSION_BUCKETS: [(&str, i64); 10] = [ + ("under 5m", 5), + ("5-15m", 15), + ("15-25m", 25), + ("25-45m", 45), + ("45m-1h", 60), + ("1h-1h30m", 90), + ("1h30m-2h", 120), + ("2-3h", 180), + ("3-4h", 240), + ("4h or more", i64::MAX), +]; #[derive(Debug, Clone, PartialEq)] pub struct Focus { @@ -73,3 +85,32 @@ pub fn focus(days: &[WorkDay], range: DateRange, zone: &TimeZone, long_session: past_midnight, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Journal, Segment, ShiftState}; + use jiff::civil::date; + + #[test] + fn sessions_land_in_the_right_bucket() { + // Sessions of 3m, 20m, exactly 25m, 50m and 5h, with 10m breaks. + let lengths = [3, 20, 25, 50, 300]; + let mut start = date(2026, 9, 24).at(8, 0, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp(); + let mut segments = Vec::new(); + for minutes in lengths { + let end = start + SignedDuration::from_mins(minutes); + segments.push(Segment::new(start, end).unwrap()); + start = end + SignedDuration::from_mins(10); + } + let journal = Journal::from_parts(vec![(date(2026, 9, 24), segments)], ShiftState::Idle).unwrap(); + let days = journal.snapshot(start); + let range = DateRange::last_days(date(2026, 9, 24), 7); + + let result = focus(&days, range, &TimeZone::UTC, SignedDuration::from_hours(2)); + // under 5m, 5-15m, 15-25m, 25-45m, 45m-1h, ..., 4h or more + assert_eq!(result.session_counts, [1, 0, 1, 1, 1, 0, 0, 0, 0, 1]); + assert_eq!(result.long_sessions, 1); + assert_eq!(result.breaks, 4); + } +} diff --git a/src/ui/screens/insights.rs b/src/ui/screens/insights.rs index 7976a56..31d214a 100644 --- a/src/ui/screens/insights.rs +++ b/src/ui/screens/insights.rs @@ -12,7 +12,7 @@ use jiff::civil::{Date, Weekday}; use jiff::{SignedDuration, ToSpan}; use ratatui::Frame; use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::symbols::Marker; use ratatui::text::{Line, Span}; @@ -375,14 +375,16 @@ impl Insights { .style(Style::new().fg(ctx.theme.working)) }) .collect(); - let bar_width = (chart_area.width.saturating_sub(2) / SESSION_BUCKETS.len() as u16) - .saturating_sub(2) - .clamp(3, 12); + // Horizontal bars, one row each, so every label fits; a blank row + // between bars when there's room. + let rows = chart_area.height.saturating_sub(2); + let gap = u16::from(rows >= 2 * SESSION_BUCKETS.len() as u16 - 1); frame.render_widget( BarChart::new(bars) + .direction(Direction::Horizontal) .block(section(" How long your sessions last ", ctx)) - .bar_width(bar_width) - .bar_gap(2) + .bar_width(1) + .bar_gap(gap) .value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)), chart_area, );