Files
skyshift/src/ui/screens/insights.rs
T
skkeyeandClaude Opus 5.5 ae9e1b1f08 Show session lengths in ten categories
The Focus view grouped sessions into six buckets, too coarse to see a
rhythm. There are now ten: under 5m, 5-15m, 15-25m, 25-45m, 45m-1h,
1h-1h30m, 1h30m-2h, 2-3h, 3-4h and 4h or more. 25 minutes is a classic
pomodoro, and the long end separates 3-4h stretches from 4h+ hyperfocus.

Ten labelled columns don't fit side by side, so the chart is now drawn
with horizontal bars: every label stays readable at any width.

The buckets are one constant (analysis::focus::SESSION_BUCKETS); a new
test pins down where sessions land, including an exact boundary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:25:14 -04:00

532 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Bar> = 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<String> =
(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<Vec<Option<f64>>> = (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<String> =
(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<Vec<Option<f64>>> = data
.seconds
.iter()
.map(|row| row.iter().map(|seconds| Some(*seconds as f64 / busiest)).collect())
.collect();
let row_labels: Vec<String> = 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<String> = 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<Bar> = 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<usize>, _ctx: &Context) -> Option<Action> {
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<Action> {
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()
}