//! The Insights screen: four ways to look at your work. //! //! - Overview: this week and month against your targets, your balance, //! weekly totals, and when your days start and end. //! - Calendar: every day as a colored square, like a wall calendar. //! - Rhythm: which hours of which weekdays you actually work. //! - Focus: how long you stay on, and how you take breaks. //! //! These are patterns to notice, not grades: no streaks and no scores. use jiff::civil::{Date, Weekday}; use jiff::{SignedDuration, ToSpan}; use ratatui::Frame; use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::symbols::Marker; use ratatui::text::{Line, Span}; use ratatui::widgets::{Axis, Bar, BarChart, Block, BorderType, Chart, Dataset, GraphType, Paragraph}; use crate::action::Action; use crate::analysis::calendar::calendar; use crate::analysis::focus::{SESSION_BUCKETS, focus}; use crate::analysis::overview::{Targets, Totals, overview}; use crate::analysis::rhythm::rhythm; use crate::analysis::{Period, days_in}; use crate::domain::WorkDay; use crate::ui::keymap::{self, Binding, Command}; use crate::ui::widgets::heatmap::Heatmap; use crate::ui::widgets::progress_bar; use crate::ui::{Component, Context}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum View { Overview, Calendar, Rhythm, Focus, } impl View { const ALL: [View; 4] = [View::Overview, View::Calendar, View::Rhythm, View::Focus]; fn title(self) -> &'static str { match self { View::Overview => "Overview", View::Calendar => "Calendar", View::Rhythm => "Rhythm", View::Focus => "Focus", } } } pub struct Insights { view: View, period: Period, /// Where each view's tab was drawn, for the mouse. view_tabs: Vec<(View, Rect)>, } impl Insights { pub fn new() -> Insights { Insights { view: View::Overview, period: Period::default(), view_tabs: Vec::new() } } fn render_tabs(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) { self.view_tabs.clear(); let mut x = area.x + 1; for view in View::ALL { let label = format!(" {} ", view.title()); let width = label.chars().count() as u16; let style = if view == self.view { ctx.theme.selected(true) } else { ctx.theme.muted() }; let rect = Rect { x, y: area.y, width, height: 1 }.intersection(area); frame.render_widget(Paragraph::new(Span::styled(label, style)), rect); self.view_tabs.push((view, rect)); x += width + 1; } let period = Line::from(vec![ Span::styled("period ", ctx.theme.muted()), Span::styled(self.period.label(), ctx.theme.title()), Span::styled(" < shorter · > longer · h/l views ", ctx.theme.muted()), ]); frame.render_widget(Paragraph::new(period).right_aligned(), area); } fn render_overview(&self, frame: &mut Frame, area: Rect, ctx: &Context) { let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date()); let range = self.period.range(today, ctx.days); let targets = Targets { daily: settings.daily_target, week_start: settings.week_start }; let week_count = usize::from(area.width.saturating_sub(4) / 8).clamp(4, 12); let data = overview(ctx.days, today, range, targets, ctx.clock.zone(), week_count); let [cards, weekly, spans] = Layout::vertical([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1)]).areas(area); let [this_week, last_week, this_month, balance] = Layout::horizontal([Constraint::Fill(1); 4]).areas(cards); let days_line = |totals: Totals| { let average = totals.average_day().map(|average| format!(" · {} a day", fmt.duration(average))); Line::styled( format!("{} days{}", totals.days_worked, average.unwrap_or_default()), ctx.theme.muted(), ) }; let week_goal = if settings.weekly_target > SignedDuration::ZERO { let ratio = data.this_week.worked.as_secs_f64() / settings.weekly_target.as_secs_f64(); let width = this_week.width.saturating_sub(10); let mut spans = progress_bar(width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()).to_vec(); spans.push(Span::styled(format!(" {:.0}%", ratio * 100.0), ctx.theme.muted())); Line::from(spans) } else { days_line(data.this_week) }; card(frame, this_week, "This week", fmt.duration(data.this_week.worked), week_goal, ctx); card( frame, last_week, "Last week", fmt.duration(data.last_week.worked), days_line(data.last_week), ctx, ); card( frame, this_month, "This month", fmt.duration(data.this_month.worked), days_line(data.this_month), ctx, ); let (balance_text, balance_note) = if settings.daily_target > SignedDuration::ZERO { let sign = if data.balance.is_negative() { "" } else { "+" }; ( format!("{sign}{}", fmt.duration(data.balance)), format!("vs {} per day worked", fmt.duration(settings.daily_target)), ) } else { ("-".to_string(), "set a daily target to see it".to_string()) }; let balance_title = format!("Balance · {}", self.period.label()); card( frame, balance, &balance_title, balance_text, Line::styled(balance_note, ctx.theme.muted()), ctx, ); // Hours per week. let bars: Vec = data .weeks .iter() .map(|(first, worked)| { let reached = settings.weekly_target > SignedDuration::ZERO && *worked >= settings.weekly_target; let color = if reached { ctx.theme.accent } else { ctx.theme.working }; Bar::with_label(first.strftime("%b %-d").to_string(), worked.as_mins().max(0) as u64) .text_value(format!("{}h", worked.as_hours())) .style(Style::new().fg(color)) }) .collect(); let bar_width = (weekly.width.saturating_sub(2) / data.weeks.len().max(1) as u16).saturating_sub(1).clamp(3, 8); let title = match settings.weekly_target { target if target > SignedDuration::ZERO => { format!(" Hours per week · target {} ", fmt.duration(target)) } _ => " Hours per week ".to_string(), }; frame.render_widget( BarChart::new(bars) .block(section(&title, ctx)) .bar_width(bar_width) .bar_gap(1) .value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)), weekly, ); // When days start and end. if data.spans.is_empty() { frame.render_widget(empty(" When your days start and end ", self.period, ctx), spans); return; } let x_of = |date: Date| date.duration_since(range.first()).as_hours() as f64 / 24.0; let starts: Vec<(f64, f64)> = data.spans.iter().map(|span| (x_of(span.date), span.start_minute as f64 / 60.0)).collect(); let ends: Vec<(f64, f64)> = data.spans.iter().map(|span| (x_of(span.date), span.end_minute as f64 / 60.0)).collect(); let low = data.spans.iter().map(|span| span.start_minute).min().unwrap_or(0).div_euclid(60); let high = (data.spans.iter().map(|span| span.end_minute).max().unwrap_or(0) + 59) .div_euclid(60) .max(low + 1); let step = ((high - low) / 4).max(1); let y_labels: Vec = (low..=high).step_by(step as usize).map(|hour| hour_label(hour, ctx)).collect(); let x_labels = vec![fmt.date_short(range.first()), fmt.date_short(range.last())]; let datasets = vec![ Dataset::default() .name("start") .marker(Marker::Dot) .graph_type(GraphType::Scatter) .style(Style::new().fg(ctx.theme.working)) .data(&starts), Dataset::default() .name("end") .marker(Marker::Dot) .graph_type(GraphType::Scatter) .style(Style::new().fg(ctx.theme.accent)) .data(&ends), ]; let chart = Chart::new(datasets) .block(section(" When your days start and end ", ctx)) .x_axis( Axis::default() .bounds([0.0, f64::from(range.day_count() - 1).max(1.0)]) .labels(x_labels) .style(ctx.theme.muted()), ) .y_axis( Axis::default().bounds([low as f64, high as f64]).labels(y_labels).style(ctx.theme.muted()), ); frame.render_widget(chart, spans); } fn render_calendar(&self, frame: &mut Frame, area: Rect, ctx: &Context) { let (settings, today) = (ctx.settings, ctx.now.date()); let block = section(" Every day, like a wall calendar ", ctx); let inner = block.inner(area); frame.render_widget(block, area); let weeks = usize::from(inner.width.saturating_sub(6) / 2).clamp(1, 53); let heat = calendar(ctx.days, today, settings.week_start, weeks); let full = if settings.daily_target > SignedDuration::ZERO { settings.daily_target } else { SignedDuration::from_hours(8) }; let cells: Vec>> = (0..7) .map(|row| { heat.weeks .iter() .map(|week| week[row].map(|(_, worked)| worked.as_secs_f64() / full.as_secs_f64())) .collect() }) .collect(); let row_labels: Vec = (0..7).map(|offset| weekday_name(settings.week_start.wrapping_add(offset))).collect(); // A month's name above the week where it begins. let column_labels: Vec<(usize, String)> = heat .weeks .iter() .enumerate() .filter_map(|(column, week)| { let first = week.iter().flatten().find(|(date, _)| date.day() == 1 || column == 0)?; Some((column, first.0.strftime("%b").to_string())) }) .collect(); let today_row = today.weekday().since(settings.week_start) as usize; let heatmap = Heatmap { cells: &cells, row_labels: &row_labels, column_labels: &column_labels, cell: "■ ", highlight: Some((today_row, heat.weeks.len().saturating_sub(1))), theme: ctx.theme, }; let (_, height) = heatmap.size(); let [grid, _, legend] = Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Length(2)]) .areas(inner); frame.render_widget(heatmap, grid); let shown: Vec<&(Date, SignedDuration)> = heat.weeks.iter().flatten().flatten().collect(); let worked_days = shown.iter().filter(|(_, worked)| *worked > SignedDuration::ZERO).count(); let mut scale = vec![Span::styled(" less ", ctx.theme.muted())]; for level in ctx.theme.heat { scale.push(Span::styled("■ ", Style::new().fg(level))); } scale.push(Span::styled( format!("more · a full square is {}", ctx.fmt.duration(full)), ctx.theme.muted(), )); let summary = format!(" Worked on {worked_days} of the last {} days.", shown.len()); frame.render_widget( Paragraph::new(vec![Line::from(scale), Line::styled(summary, ctx.theme.muted())]), legend, ); } fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) { let today = ctx.now.date(); let range = self.period.range(today, ctx.days); let data = rhythm(ctx.days, range, ctx.settings.week_start, ctx.clock.zone()); let block = section(" When you work: weekday × hour of the day ", ctx); let inner = block.inner(area); frame.render_widget(block, area); if data.busiest_cell() == 0 { frame.render_widget(empty("", self.period, ctx), inner); return; } let busiest = data.busiest_cell() as f64; let cells: Vec>> = data .seconds .iter() .map(|row| row.iter().map(|seconds| Some(*seconds as f64 / busiest)).collect()) .collect(); let row_labels: Vec = data.weekdays.iter().map(|weekday| weekday_name(*weekday)).collect(); let cell = if inner.width >= 4 + 24 * 3 { "██ " } else { "██" }; let column_labels: Vec<(usize, String)> = (0..24).step_by(3).map(|hour| (hour, ctx.fmt.hour_mark(hour as i8))).collect(); let heatmap = Heatmap { cells: &cells, row_labels: &row_labels, column_labels: &column_labels, cell, highlight: None, theme: ctx.theme, }; let (_, height) = heatmap.size(); let [grid, _, notes] = Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Fill(1)]) .areas(inner); frame.render_widget(heatmap, grid); let peaks: Vec = data .peak_hours(3) .into_iter() .map(|hour| format!("{}-{}", ctx.fmt.hour_mark(hour), ctx.fmt.hour_mark((hour + 1) % 24))) .collect(); let total: SignedDuration = days_in(ctx.days, range).map(WorkDay::worked).sum(); let per_week = SignedDuration::from_secs((total.as_secs() as f64 / data.weeks.max(1.0)) as i64); let lines = vec![ Line::from(vec![ Span::styled(" Busiest hours: ", ctx.theme.muted()), Span::styled(peaks.join(", "), ctx.theme.title()), ]), Line::styled( format!( " Over {}: {} worked, about {} a week.", self.period.label(), ctx.fmt.duration(total), ctx.fmt.duration(per_week) ), ctx.theme.muted(), ), ]; frame.render_widget(Paragraph::new(lines), notes); } fn render_focus(&self, frame: &mut Frame, area: Rect, ctx: &Context) { let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date()); let range = self.period.range(today, ctx.days); let data = focus(ctx.days, range, ctx.clock.zone(), settings.long_session); if data.sessions == 0 { frame.render_widget(empty(" Focus ", self.period, ctx), area); return; } let wide = area.width >= 100; let [chart_area, stats_area] = if wide { Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(area) } else { Layout::vertical([Constraint::Fill(1), Constraint::Length(10)]).areas(area) }; let bars: Vec = SESSION_BUCKETS .iter() .zip(data.session_counts) .map(|((label, _), count)| { Bar::with_label(*label, count as u64) .text_value(count.to_string()) .style(Style::new().fg(ctx.theme.working)) }) .collect(); // 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(1) .bar_gap(gap) .value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)), chart_area, ); let row = |label: &str, value: String| { Line::from(vec![ Span::styled(format!(" {label:<17}"), ctx.theme.muted()), Span::styled(value, Style::new()), ]) }; let typical_bucket = data .session_counts .iter() .enumerate() .max_by_key(|(_, count)| **count) .map(|(index, _)| SESSION_BUCKETS[index].0) .unwrap_or(""); let mut lines = vec![ row("Sessions", format!("{} · {:.1} a day", data.sessions, data.sessions_per_day())), row( "Typical session", data.median_session .map(|median| format!("{} (half are shorter)", fmt.duration(median))) .unwrap_or_default(), ), row( "Longest session", data.longest_session .map(|(date, length)| format!("{} on {}", fmt.duration(length), fmt.date_short(date))) .unwrap_or_default(), ), row( "Long sessions", format!("{} of {} or more", data.long_sessions, fmt.duration(settings.long_session)), ), row( "Breaks", match data.median_break { Some(median) => format!("{} · typically {}", data.breaks, fmt.duration(median)), None => "none between sessions".to_string(), }, ), row( "Past midnight", format!("{} work day{}", data.past_midnight, if data.past_midnight == 1 { "" } else { "s" }), ), Line::default(), ]; lines.push(Line::styled( format!(" Your sessions most often last {typical_bucket}."), ctx.theme.title(), )); frame.render_widget( Paragraph::new(lines).block(section(&format!(" {} ", self.period.label()), ctx)), stats_area, ); } } impl Component for Insights { fn keys(&self) -> &'static [&'static [Binding]] { &[keymap::INSIGHTS] } fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) { let block = Block::bordered() .border_type(BorderType::Rounded) .border_style(ctx.theme.border(false)) .title(Line::styled(" Insights ", ctx.theme.title())); let inner = block.inner(area); frame.render_widget(block, area); let [tabs, _, body] = Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1)]) .areas(inner); self.render_tabs(frame, tabs, ctx); match self.view { View::Overview => self.render_overview(frame, body, ctx), View::Calendar => self.render_calendar(frame, body, ctx), View::Rhythm => self.render_rhythm(frame, body, ctx), View::Focus => self.render_focus(frame, body, ctx), } } fn command(&mut self, command: Command, _count: Option, _ctx: &Context) -> Option { let index = View::ALL.iter().position(|view| *view == self.view).unwrap_or(0); match command { Command::Right => self.view = View::ALL[(index + 1) % View::ALL.len()], Command::Left => self.view = View::ALL[(index + View::ALL.len() - 1) % View::ALL.len()], Command::LongerPeriod => self.period = self.period.longer(), Command::ShorterPeriod => self.period = self.period.shorter(), _ => {} } None } fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option { if event.kind == MouseEventKind::Down(MouseButton::Left) { let position = Position::new(event.column, event.row); if let Some((view, _)) = self.view_tabs.iter().find(|(_, rect)| rect.contains(position)) { self.view = *view; } } None } } /// A small box with a title, a big value and one line below it. fn card(frame: &mut Frame, area: Rect, title: &str, value: String, detail: Line<'static>, ctx: &Context) { let block = section(&format!(" {title} "), ctx); let value = Line::styled(format!(" {value}"), Style::new().add_modifier(Modifier::BOLD)); let detail = Line::from([vec![Span::raw(" ")], detail.spans].concat()); frame.render_widget(Paragraph::new(vec![value, detail]).block(block), area); } /// The frame of a section: a rounded, muted border with a title. fn section<'a>(title: &str, ctx: &Context) -> Block<'a> { Block::bordered() .border_type(BorderType::Rounded) .border_style(ctx.theme.muted()) .title(Line::styled(title.to_string(), Style::new().add_modifier(Modifier::BOLD))) } /// Shown when there's nothing to analyze yet. fn empty<'a>(title: &str, period: Period, ctx: &Context) -> Paragraph<'a> { let text = format!(" Nothing recorded over {} yet: this fills up as you track your work.", period.label()); let paragraph = Paragraph::new(Span::styled(text, ctx.theme.muted())); if title.is_empty() { paragraph } else { paragraph.block(section(title, ctx)) } } /// An hour on the chart's axis: hours past 24 are the next morning ("02+1"). fn hour_label(hour: i64, ctx: &Context) -> String { let mark = ctx.fmt.hour_mark(hour.rem_euclid(24) as i8); match hour.div_euclid(24) { 0 => mark, days => format!("{mark}+{days}"), } } fn weekday_name(weekday: Weekday) -> String { // Any date with that weekday gives its short name. let monday = jiff::civil::date(2024, 1, 1); monday.saturating_add((weekday.to_monday_zero_offset() as i64).days()).strftime("%a").to_string() }