Add the Tracker, Days, Insights and Settings screens

- Tracker: big running timer, pomodoro line, clickable buttons, today's
  progress and timeline, this week.
- Days: work days grouped by week, the selected day's details, timeline
  and segments to edit, add, delete and copy.
- Insights: overview cards and charts, calendar heatmap, weekday x hour
  rhythm, session lengths and breaks.
- Settings: generated from the settings table; expert settings are
  folded away (za).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:22 -04:00
1 parent 255dcd1392
commit 57ea9403b6
6 files changed
+1663

No files matched your search

+529
View File
@@ -0,0 +1,529 @@
//! 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, 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();
let bar_width = (chart_area.width.saturating_sub(2) / SESSION_BUCKETS.len() as u16)
.saturating_sub(2)
.clamp(3, 12);
frame.render_widget(
BarChart::new(bars)
.block(section(" How long your sessions last ", ctx))
.bar_width(bar_width)
.bar_gap(2)
.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()
}