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

+1
View File
@@ -14,6 +14,7 @@
pub mod component;
pub mod keymap;
pub mod popups;
pub mod screens;
pub mod terminal;
pub mod theme;
pub mod widgets;
+406
View File
@@ -0,0 +1,406 @@
//! The Days screen: every work day (newest first, grouped by week) on the
//! left; the selected day's details and segments on the right. This is
//! where you fix, add, delete and copy.
use jiff::SignedDuration;
use jiff::civil::Date;
use ratatui::Frame;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, List, ListItem, ListState, Paragraph, Row, Table, TableState};
use super::copy_action;
use crate::action::Action;
use crate::domain::{Segment, ShiftState, WorkDay};
use crate::time::{DateRange, DurationStyle};
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::popups::copy_menu::CopyMenu;
use crate::ui::popups::segment_editor::{SegmentEditor, Target};
use crate::ui::widgets::timeline::{self, Timeline, window_for};
use crate::ui::{Component, Context, popup};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pane {
Days,
Segments,
}
/// A row of the day list: a week heading, or a day.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Entry {
Week { first: Date, worked: SignedDuration },
Day(Date),
}
pub struct Days {
/// The selected work day (a date, so it survives changes to the list).
selected: Option<Date>,
/// The selected segment of that day.
segment: usize,
pane: Pane,
list_state: ListState,
table_state: TableState,
/// What was drawn where, for the mouse and for half-page moves.
entries: Vec<Entry>,
list_area: Rect,
table_area: Rect,
}
impl Days {
pub fn new() -> Days {
Days {
selected: None,
segment: 0,
pane: Pane::Days,
list_state: ListState::default(),
table_state: TableState::default(),
entries: Vec::new(),
list_area: Rect::default(),
table_area: Rect::default(),
}
}
fn select(&mut self, date: Date) {
self.selected = Some(date);
self.segment = 0;
}
/// The work days to list, newest first.
fn dates(ctx: &Context) -> Vec<Date> {
let open_day = ctx.journal.state().open_day();
ctx.days
.iter()
.rev()
.filter(|day| !day.is_empty() || Some(day.date()) == open_day)
.map(WorkDay::date)
.collect()
}
/// The rows of the list: each day, with a heading where a week starts.
fn entries(dates: &[Date], ctx: &Context) -> Vec<Entry> {
let mut entries = Vec::new();
for date in dates {
let week = DateRange::week_of(*date, ctx.settings.week_start);
let same_week = matches!(entries.last(), Some(Entry::Day(previous)) if week.contains(*previous));
if !same_week {
let worked =
ctx.days.iter().filter(|day| week.contains(day.date())).map(WorkDay::worked).sum();
entries.push(Entry::Week { first: week.first(), worked });
}
entries.push(Entry::Day(*date));
}
entries
}
/// The selected day, falling back to the newest one.
fn current(&self, dates: &[Date]) -> Option<(usize, Date)> {
let index = self.selected.and_then(|date| dates.iter().position(|d| *d == date)).unwrap_or(0);
dates.get(index).map(|date| (index, *date))
}
/// Moves the selection: `to` gets the current index and the last index.
fn move_day(&mut self, dates: &[Date], to: impl FnOnce(usize, usize) -> usize) {
if let Some((index, _)) = self.current(dates) {
let last = dates.len() - 1;
self.select(dates[to(index, last).min(last)]);
}
}
/// Jumps to the first day of the week below (`down`) or above.
fn move_week(&mut self, dates: &[Date], down: bool, ctx: &Context) {
let Some((index, date)) = self.current(dates) else { return };
let week_of = |date: Date| DateRange::week_of(date, ctx.settings.week_start);
// The index of the first listed day of the week containing dates[at].
let top_of_week = |at: usize| {
let week = week_of(dates[at]);
dates[..at].iter().rposition(|d| week_of(*d) != week).map_or(0, |i| i + 1)
};
let target = if down {
let week = week_of(date);
dates.iter().skip(index).position(|d| week_of(*d) != week).map(|offset| index + offset)
} else if top_of_week(index) < index {
Some(top_of_week(index))
} else {
index.checked_sub(1).map(top_of_week)
};
if let Some(target) = target {
self.select(dates[target]);
}
}
/// Whether `segment` of `day` is the session in progress.
fn is_running(day: Date, segment: &Segment, ctx: &Context) -> bool {
matches!(ctx.journal.state(), ShiftState::Working { day: open, since } if open == day && segment.start() == since)
}
fn edit(&self, day: &WorkDay, ctx: &Context) -> Action {
let Some(segment) = day.segments().get(self.segment) else {
return Action::Notify("This day has no segments (o adds one)".into());
};
let target = if Days::is_running(day.date(), segment, ctx) {
Target::Running { day: day.date(), since: segment.start() }
} else {
Target::Existing { day: day.date(), index: self.segment, original: *segment }
};
popup(SegmentEditor::new(target, ctx))
}
fn delete(&self, day: &WorkDay, ctx: &Context) -> Action {
match day.segments().get(self.segment) {
None => Action::Notify("Nothing to delete here".into()),
Some(segment) if Days::is_running(day.date(), segment, ctx) => {
Action::Notify("That's the session in progress: pause first (p), or fix it (i)".into())
}
Some(_) => Action::DeleteSegment { day: day.date(), index: self.segment },
}
}
fn render_list(&mut self, frame: &mut Frame, area: Rect, dates: &[Date], ctx: &Context) {
let fmt = &ctx.fmt;
self.entries = Days::entries(dates, ctx);
let items: Vec<ListItem> = self
.entries
.iter()
.map(|entry| match entry {
Entry::Week { first, worked } => ListItem::new(Line::from(vec![
Span::styled(
format!("Week of {:<18}", fmt.date_short(*first)),
ctx.theme.muted().add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:>9}", fmt.duration(*worked)), ctx.theme.muted()),
])),
Entry::Day(date) => {
let day = ctx.day(*date);
let span = match (day.and_then(WorkDay::first_start), day.and_then(WorkDay::last_end)) {
(Some(start), Some(end)) => format!("{}-{}", ctx.time(start), ctx.time(end)),
_ => String::new(),
};
let worked = day.map_or(SignedDuration::ZERO, WorkDay::worked);
ListItem::new(format!(
" {:<11} {:<17} {:>8}",
fmt.date_short(*date),
span,
fmt.duration(worked)
))
}
})
.collect();
let selected_row = self
.current(dates)
.and_then(|(_, date)| self.entries.iter().position(|entry| *entry == Entry::Day(date)));
self.list_state.select(selected_row);
let focused = self.pane == Pane::Days;
let list = List::new(items)
.block(
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(ctx.theme.border(focused))
.title(Line::styled(" Work days ", ctx.theme.title())),
)
.highlight_style(ctx.theme.selected(focused));
self.list_area = area;
frame.render_stateful_widget(list, area, &mut self.list_state);
}
fn render_details(&mut self, frame: &mut Frame, area: Rect, day: Option<&WorkDay>, ctx: &Context) {
let focused = self.pane == Pane::Segments;
let title = day.map_or("No work recorded yet".to_string(), |day| ctx.fmt.date_long(day.date()));
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(ctx.theme.border(focused))
.title(Line::styled(format!(" {title} "), ctx.theme.title()))
.title_bottom(
Line::styled(
" i edit · o add · dd delete · yy copy · <Space>c copy as... ",
ctx.theme.muted(),
)
.right_aligned(),
);
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(day) = day else {
let hint = " Press s to start your first shift, or o to add work you already did.";
frame.render_widget(Paragraph::new(Span::styled(hint, ctx.theme.muted())), inner);
return;
};
let [summary, _, timeline_area, _, table_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(timeline::HEIGHT),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner);
let fmt = &ctx.fmt;
let date = day.date();
let bold = Style::new().add_modifier(Modifier::BOLD);
let working_on_it =
matches!(ctx.journal.state(), ShiftState::Working { day: open, .. } if open == date);
let out = match day.last_end() {
_ if working_on_it => "now".to_string(),
Some(end) => ctx.time_on(end, date),
None => String::new(),
};
let summary_lines = vec![
Line::from(vec![
Span::styled(" In ", ctx.theme.muted()),
Span::styled(
day.first_start().map(|start| ctx.time_on(start, date)).unwrap_or_default(),
bold,
),
Span::styled(" Out ", ctx.theme.muted()),
Span::styled(out, bold),
Span::styled(" Break ", ctx.theme.muted()),
Span::styled(fmt.duration(day.break_time()), bold),
]),
Line::from(vec![
Span::styled(" Worked ", ctx.theme.muted()),
Span::styled(fmt.duration(day.worked()), bold),
Span::styled(
format!(" {} h", fmt.duration_as(DurationStyle::Decimal, day.worked())),
ctx.theme.muted(),
),
]),
];
frame.render_widget(Paragraph::new(summary_lines), summary);
let pieces: Vec<_> = day.segments().iter().map(|segment| (segment.start(), segment.end())).collect();
let state = ctx.journal.state();
let now = (state.open_day() == Some(date)).then(|| ctx.now.timestamp());
let running_since = match state {
ShiftState::Working { day: open, since } if open == date => Some(since),
_ => None,
};
let inset =
Rect { x: timeline_area.x + 1, width: timeline_area.width.saturating_sub(2), ..timeline_area };
frame.render_widget(
Timeline {
pieces: &pieces,
running_since,
now,
window: window_for(&pieces, now, ctx.clock.zone(), 6),
zone: ctx.clock.zone(),
fmt,
theme: ctx.theme,
},
inset,
);
let rows: Vec<Row> = day
.segments()
.iter()
.enumerate()
.map(|(index, segment)| {
let end = if Days::is_running(date, segment, ctx) {
Span::styled("now ●", ctx.theme.accent())
} else {
Span::raw(ctx.time_on(segment.end(), date))
};
Row::new(vec![
Span::raw(format!(" {}", index + 1)),
Span::raw(ctx.time_on(segment.start(), date)),
end,
Span::raw(fmt.duration(segment.duration())),
])
})
.collect();
self.segment = self.segment.min(rows.len().saturating_sub(1));
self.table_state.select((!rows.is_empty()).then_some(self.segment));
let widths =
[Constraint::Length(4), Constraint::Length(13), Constraint::Length(13), Constraint::Length(10)];
let table = Table::new(rows, widths)
.header(Row::new([" #", "Start", "End", "Length"]).style(ctx.theme.muted()))
.row_highlight_style(if focused { ctx.theme.selected(true) } else { bold });
self.table_area = table_area;
frame.render_stateful_widget(table, table_area, &mut self.table_state);
}
}
impl Component for Days {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::DAYS, keymap::LIST]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let dates = Days::dates(ctx);
let [list_area, details_area] =
Layout::horizontal([Constraint::Length(44), Constraint::Fill(1)]).areas(area);
self.render_list(frame, list_area, &dates, ctx);
let day = self.current(&dates).and_then(|(_, date)| ctx.day(date));
self.render_details(frame, details_area, day, ctx);
}
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
let dates = Days::dates(ctx);
let steps = count.unwrap_or(1);
let half_page = usize::from(self.list_area.height.saturating_sub(2) / 2).max(1);
let day = self.current(&dates).and_then(|(_, date)| ctx.day(date));
let segments = day.map_or(0, |day| day.segments().len());
match (command, self.pane) {
(Command::Down, Pane::Days) => self.move_day(&dates, |index, _| index + steps),
(Command::Up, Pane::Days) => self.move_day(&dates, |index, _| index.saturating_sub(steps)),
(Command::HalfPageDown, _) => self.move_day(&dates, |index, _| index + half_page),
(Command::HalfPageUp, _) => self.move_day(&dates, |index, _| index.saturating_sub(half_page)),
(Command::Top, Pane::Days) => self.move_day(&dates, |_, _| 0),
(Command::Bottom, Pane::Days) => self.move_day(&dates, |_, last| last),
(Command::Down, Pane::Segments) => {
self.segment = (self.segment + steps).min(segments.saturating_sub(1))
}
(Command::Up, Pane::Segments) => self.segment = self.segment.saturating_sub(steps),
(Command::Top, Pane::Segments) => self.segment = 0,
(Command::Bottom, Pane::Segments) => self.segment = segments.saturating_sub(1),
(Command::NextWeek, _) => self.move_week(&dates, true, ctx),
(Command::PreviousWeek, _) => self.move_week(&dates, false, ctx),
(Command::Right, _) if segments > 0 => self.pane = Pane::Segments,
(Command::Left, _) => self.pane = Pane::Days,
(Command::Edit, Pane::Days) if segments > 0 => self.pane = Pane::Segments,
(Command::Edit, _) => return day.map(|day| self.edit(day, ctx)),
(Command::Delete, Pane::Segments) => return day.map(|day| self.delete(day, ctx)),
(Command::Delete, Pane::Days) => {
return Some(Action::Notify("Choose a segment first (l), then dd deletes it".into()));
}
(Command::AddAfter | Command::AddBefore, _) => {
let date = day.map_or(ctx.current_day(), WorkDay::date);
let after = command == Command::AddAfter;
return Some(popup(SegmentEditor::new(Target::New { day: date, after }, ctx)));
}
(Command::Copy(target, preset), _) => {
return day.map(|day| copy_action(target, preset, day.date(), ctx));
}
(Command::CopyMenu, _) => return day.map(|day| popup(CopyMenu::new(day.date(), ctx))),
_ => {}
}
None
}
fn mouse(&mut self, event: MouseEvent, ctx: &Context) -> Option<Action> {
let position = Position::new(event.column, event.row);
match event.kind {
MouseEventKind::ScrollDown => return self.command(Command::Down, None, ctx),
MouseEventKind::ScrollUp => return self.command(Command::Up, None, ctx),
MouseEventKind::Down(MouseButton::Left) => {}
_ => return None,
}
// Rows start below the top border (and below the header row for the
// table); both may be scrolled.
if self.list_area.contains(position) {
let row = usize::from(position.y.saturating_sub(self.list_area.y + 1)) + self.list_state.offset();
if let Some(Entry::Day(date)) = self.entries.get(row) {
self.select(*date);
self.pane = Pane::Days;
}
} else if self.table_area.contains(position) {
self.segment =
usize::from(position.y.saturating_sub(self.table_area.y + 1)) + self.table_state.offset();
self.pane = Pane::Segments;
}
None
}
}
+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()
}
+23
View File
@@ -0,0 +1,23 @@
//! The four screens, one per tab.
pub mod days;
pub mod insights;
pub mod settings;
pub mod tracker;
use jiff::civil::Date;
use super::Context;
use super::keymap::CopyTarget;
use crate::action::Action;
use crate::export::timesheet::CopyPreset;
/// Copying from a screen: the day in focus, or its whole week. Without a
/// preset, the one chosen in the settings is used.
pub fn copy_action(target: CopyTarget, preset: Option<CopyPreset>, day: Date, ctx: &Context) -> Action {
let days = match target {
CopyTarget::Day => vec![day],
CopyTarget::Week => ctx.week_days(day),
};
Action::Copy { days, preset: preset.unwrap_or(ctx.settings.copy_preset) }
}
+297
View File
@@ -0,0 +1,297 @@
//! The Settings screen, built from the settings table (`crate::settings::FIELDS`).
//! Choices and on/off settings change with h/l; the others are typed (i).
//! Expert settings are folded away until you open them with `za`.
use ratatui::Frame;
use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, List, ListItem, ListState, Paragraph, Wrap};
use crate::action::Action;
use crate::settings::{FIELDS, Field, Kind, SECTIONS};
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::widgets::text_input::{InputEvent, TextInput};
use crate::ui::{Component, Context};
/// A row of the list: a section heading, or a setting (by its index in FIELDS).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Entry {
Section(&'static str),
Setting(usize),
}
pub struct SettingsScreen {
/// Index in FIELDS of the selected setting.
selected: usize,
show_expert: bool,
/// The value being typed, while editing (vim insert mode).
editing: Option<TextInput>,
error: Option<String>,
list_state: ListState,
entries: Vec<Entry>,
list_area: Rect,
}
impl SettingsScreen {
pub fn new() -> SettingsScreen {
SettingsScreen {
selected: 0,
show_expert: false,
editing: None,
error: None,
list_state: ListState::default(),
entries: Vec::new(),
list_area: Rect::default(),
}
}
/// The settings shown, as indexes in FIELDS, in section order.
fn visible(&self) -> Vec<usize> {
SECTIONS
.iter()
.flat_map(|(section, _)| {
FIELDS
.iter()
.enumerate()
.filter(move |(_, field)| {
field.section == *section && (self.show_expert || !field.expert)
})
.map(|(index, _)| index)
})
.collect()
}
fn entries(&self) -> Vec<Entry> {
let mut entries = Vec::new();
for index in self.visible() {
let section = FIELDS[index].section;
let description =
SECTIONS.iter().find(|(name, _)| *name == section).map_or(section, |(_, text)| *text);
if !entries.contains(&Entry::Section(description)) {
entries.push(Entry::Section(description));
}
entries.push(Entry::Setting(index));
}
entries
}
fn field(&self) -> &'static Field {
&FIELDS[self.selected]
}
/// Moves the selection by `steps` settings (negative is up).
fn move_by(&mut self, steps: isize) {
let visible = self.visible();
let position = visible.iter().position(|index| *index == self.selected).unwrap_or(0);
let target = position.saturating_add_signed(steps).min(visible.len().saturating_sub(1));
self.selected = visible[target];
self.error = None;
}
/// The next (or previous) value of a choice or on/off setting.
fn cycle(&self, forward: bool, ctx: &Context) -> Option<Action> {
let field = self.field();
let current = (field.get)(ctx.settings);
let value = match &field.kind {
Kind::Toggle => (current != "true").to_string(),
Kind::Choice(names) => {
let names = names();
let index = names.iter().position(|name| *name == current).unwrap_or(0);
let next =
if forward { (index + 1) % names.len() } else { (index + names.len() - 1) % names.len() };
names[next].to_string()
}
_ => return Some(Action::Notify("Press i to type a new value".into())),
};
Some(Action::SetSetting { key: field.key, value })
}
/// Starts typing a new value (`keep` keeps the current one to edit).
fn start_editing(&mut self, keep: bool, ctx: &Context) -> Option<Action> {
match self.field().kind {
Kind::Toggle | Kind::Choice(_) => self.cycle(true, ctx),
_ => {
let current = if keep { (self.field().get)(ctx.settings) } else { String::new() };
self.editing = Some(TextInput::new(current));
None
}
}
}
fn render_list(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
self.entries = self.entries();
let items: Vec<ListItem> = self
.entries
.iter()
.map(|entry| match entry {
Entry::Section(description) => {
ListItem::new(Line::styled(format!(" {description}"), ctx.theme.title()))
}
Entry::Setting(index) => {
let field = &FIELDS[*index];
let label = format!(" {:<28}", field.label);
let value = match &self.editing {
Some(input) if *index == self.selected => input.line(true, Style::new()),
_ => Line::styled(display_value(field, ctx), ctx.theme.accent()),
};
let mut spans = vec![Span::raw(label)];
spans.extend(value.spans);
if field.expert {
spans.push(Span::styled(" expert", ctx.theme.muted()));
}
ListItem::new(Line::from(spans))
}
})
.collect();
let selected_row = self.entries.iter().position(|entry| *entry == Entry::Setting(self.selected));
self.list_state.select(selected_row);
let fold = if self.show_expert { "za hide expert settings" } else { "za show expert settings" };
let list = List::new(items)
.block(
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(ctx.theme.border(true))
.title(Line::styled(" Settings ", ctx.theme.title()))
.title_bottom(
Line::styled(
format!(" h/l change · i type · x default · {fold} "),
ctx.theme.muted(),
)
.right_aligned(),
),
)
.highlight_style(if self.editing.is_some() {
Style::new().add_modifier(Modifier::BOLD)
} else {
ctx.theme.selected(true)
});
self.list_area = area;
frame.render_stateful_widget(list, area, &mut self.list_state);
}
fn render_help(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
let field = self.field();
let mut lines = vec![Line::from(field.help)];
if let Some(error) = &self.error {
lines.push(Line::styled(error.clone(), Style::new().fg(ctx.theme.error)));
} else if self.editing.is_some() {
lines.push(Line::styled("Enter to save · Esc to cancel", ctx.theme.muted()));
}
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(ctx.theme.muted())
.title(Line::styled(format!(" {} ", field.label), Style::new().add_modifier(Modifier::BOLD)))
.title_bottom(
Line::styled(format!(" [{}] {} ", field.section, field.key), ctx.theme.muted())
.right_aligned(),
);
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area);
}
}
/// How a value is shown: empty paths say what empty means.
/// How a value is shown: on/off instead of true/false, and empty paths say
/// what empty means.
fn display_value(field: &Field, ctx: &Context) -> String {
let value = (field.get)(ctx.settings);
match (field.key, value.as_str()) {
(_, "true") if matches!(field.kind, Kind::Toggle) => "on".to_string(),
(_, "false") if matches!(field.kind, Kind::Toggle) => "off".to_string(),
("folder", "") => format!("(Documents) {}", ctx.export_folder.display()),
("data_file", "") => "(default place)".to_string(),
("clipboard_command", "") => "(none)".to_string(),
_ => value,
}
}
impl Component for SettingsScreen {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::SETTINGS, keymap::LIST]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let [list, help] = Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(area);
self.render_list(frame, list, ctx);
self.render_help(frame, help, ctx);
}
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
let steps = count.unwrap_or(1) as isize;
let half_page = (self.list_area.height / 2).max(1) as isize;
match command {
Command::Down => self.move_by(steps),
Command::Up => self.move_by(-steps),
Command::HalfPageDown => self.move_by(half_page),
Command::HalfPageUp => self.move_by(-half_page),
Command::Top => self.move_by(isize::MIN / 2),
Command::Bottom => self.move_by(isize::MAX / 2),
Command::Left => return self.cycle(false, ctx),
Command::Right => return self.cycle(true, ctx),
Command::Edit | Command::Confirm => return self.start_editing(true, ctx),
Command::Retype => return self.start_editing(false, ctx),
Command::ResetSetting => return Some(Action::ResetSetting { key: self.field().key }),
Command::ToggleExpert => {
self.show_expert = !self.show_expert;
// If the selected setting was just hidden, select a visible one.
self.move_by(0);
}
_ => {}
}
None
}
fn mouse(&mut self, event: MouseEvent, ctx: &Context) -> Option<Action> {
let position = Position::new(event.column, event.row);
match event.kind {
MouseEventKind::ScrollDown => self.move_by(1),
MouseEventKind::ScrollUp => self.move_by(-1),
MouseEventKind::Down(MouseButton::Left) if self.list_area.contains(position) => {
let row =
usize::from(position.y.saturating_sub(self.list_area.y + 1)) + self.list_state.offset();
if let Some(Entry::Setting(index)) = self.entries.get(row) {
if *index == self.selected {
return self.cycle(true, ctx);
}
self.selected = *index;
}
}
_ => {}
}
None
}
fn inserting(&self) -> bool {
self.editing.is_some()
}
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
let input = self.editing.as_mut()?;
match input.insert_key(key) {
InputEvent::Submit => {
let value = input.text().to_string();
// Check it now, to keep the field open while it's wrong.
let mut trial = ctx.settings.clone();
match (self.field().set)(&mut trial, &value) {
Ok(()) => {
self.editing = None;
self.error = None;
Some(Action::SetSetting { key: self.field().key, value })
}
Err(error) => {
self.error = Some(error);
None
}
}
}
InputEvent::Exit => {
self.editing = None;
self.error = None;
None
}
_ => None,
}
}
}
+407
View File
@@ -0,0 +1,407 @@
//! The Tracker screen: what's happening right now, big and clear.
//!
//! ```text
//! ● Working since 21:02 · work day Thu Sep 24
//!
//! █ ███ ███ ███ ███ ███
//! ... (the running timer)
//!
//! Focus 18m / 25m ●●○○
//!
//! ╭───────────╮ ╭───────────╮ ╭─────────────╮
//! │ ▶ Start s │ │ ‖ Pause p │ │ ■ End day e │
//! ╰───────────╯ ╰───────────╯ ╰─────────────╯
//!
//! Today 5h 40m ██████████████░░░░░░ 71% of 8h
//! In 22:00 · 1 break (30m) · 2 sessions
//! 22 00 02 04
//! ██████████████░░██████▌
//!
//! This week 32h 10m ████████████████░░░░ 80% of 40h
//! ```
use jiff::SignedDuration;
use ratatui::Frame;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Paragraph};
use super::copy_action;
use crate::action::Action;
use crate::domain::{ShiftState, WorkDay};
use crate::reminders::Phase;
use crate::time::DateRange;
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::popups::copy_menu::CopyMenu;
use crate::ui::popups::segment_editor::{SegmentEditor, Target};
use crate::ui::widgets::big_digits::{self, BigDigits};
use crate::ui::widgets::button::{self, Button, Look};
use crate::ui::widgets::timeline::{self, Timeline, window_for};
use crate::ui::widgets::{centered, progress_bar};
use crate::ui::{Component, Context, popup};
#[derive(Default)]
pub struct Tracker {
/// Where each clickable button was drawn, for the mouse.
buttons: Vec<(Rect, ButtonAction)>,
}
#[derive(Debug, Clone, Copy)]
enum ButtonAction {
Start,
Pause,
EndDay,
}
impl Tracker {
pub fn new() -> Tracker {
Tracker::default()
}
/// `i`/Enter: fix the session in progress, or else today's last segment.
fn edit(&self, ctx: &Context) -> Action {
if let ShiftState::Working { day, since } = ctx.journal.state() {
return popup(SegmentEditor::new(Target::Running { day, since }, ctx));
}
let day = ctx.current_day();
let last = ctx.journal.day(day).and_then(|work_day| {
let index = work_day.segments().len().checked_sub(1)?;
Some((index, work_day.segments()[index]))
});
match last {
Some((index, original)) => {
popup(SegmentEditor::new(Target::Existing { day, index, original }, ctx))
}
None => Action::Notify("Nothing recorded today yet (o adds work you forgot to track)".into()),
}
}
fn render_status(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
let state = ctx.journal.state();
let (symbol, text) = match state {
ShiftState::Working { day, since } => {
("●", format!("Working since {} · work day {}", ctx.time(since), ctx.fmt.date_short(day)))
}
ShiftState::OnBreak { day } => {
("◐", format!("On a break · work day {}", ctx.fmt.date_short(day)))
}
ShiftState::Idle => ("○", "Not working · press s to start".to_string()),
};
let color = ctx.theme.state(state);
let line = Line::from(vec![
Span::styled(format!("{symbol} "), Style::new().fg(color)),
Span::styled(text, Style::new().add_modifier(Modifier::BOLD)),
]);
frame.render_widget(Paragraph::new(line).centered(), area);
}
/// The big number: the running session, the break, or the time of day.
fn render_timer(&self, frame: &mut Frame, area: Rect, big: bool, ctx: &Context) {
let now = ctx.now.timestamp();
let show_seconds = ctx.settings.show_seconds;
let state = ctx.journal.state();
let (text, color) = match state {
ShiftState::Working { .. } => {
(ctx.fmt.stopwatch(ctx.journal.running_for(now), show_seconds), ctx.theme.working)
}
ShiftState::OnBreak { .. } => {
let on_break = ctx.journal.on_break_for(now).unwrap_or_default();
(ctx.fmt.stopwatch(on_break, show_seconds), ctx.theme.on_break)
}
ShiftState::Idle => {
let clock = if show_seconds {
ctx.fmt.time_with_seconds(ctx.now.time())
} else {
ctx.fmt.time(ctx.now.time())
};
(clock, ctx.theme.idle)
}
};
let style = Style::new().fg(color).add_modifier(Modifier::BOLD);
// Big digits only know digits and colons; "10:05 PM" keeps its "PM"
// as small text below.
let (digits, suffix) = match text.split_once(' ') {
Some((digits, suffix)) => (digits.to_string(), suffix.to_string()),
None => (text, String::new()),
};
if big {
let big_digits = BigDigits::new(&digits, style);
let width = big_digits.width();
frame.render_widget(big_digits, area);
if !suffix.is_empty() {
// "PM" in small letters after the last digit, on the bottom row.
let x = area.x + area.width.saturating_sub(width) / 2 + width + 1;
let label =
Rect { x, y: area.bottom().saturating_sub(1), width: 2, height: 1 }.intersection(area);
frame.render_widget(Paragraph::new(Span::styled(suffix, style)), label);
}
} else {
let text = format!("{digits} {suffix}");
frame.render_widget(
Paragraph::new(Span::styled(text.trim_end().to_string(), style)).centered(),
area,
);
}
}
/// The pomodoro line: how far into focus or break you are, or a nudge.
fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
let pomodoro = &ctx.pomodoro;
let fmt = &ctx.fmt;
let dots: String = (0..pomodoro.long_break_every)
.map(|index| if index < pomodoro.cycle_position() { '●' } else { '○' })
.collect();
let snoozed = ctx.snoozed_until.filter(|until| *until > ctx.now.timestamp());
let settings = ctx.settings;
let line = match pomodoro.phase {
Phase::Off => Line::default(),
Phase::Focus { focused, goal, .. } if focused < goal => Line::from(vec![
Span::styled("Focus ", ctx.theme.muted()),
Span::raw(format!("{} / {} ", fmt.duration(focused), fmt.duration(goal))),
Span::styled(dots, ctx.theme.accent()),
]),
Phase::Focus { focused, .. } => match snoozed {
Some(until) => Line::styled(
format!("Break suggestion snoozed until {}", ctx.time(until)),
ctx.theme.muted(),
),
None => {
let every = pomodoro.long_break_every.max(1);
let long = pomodoro.completed.is_multiple_of(every);
let length = if long { settings.long_break } else { settings.short_break };
Line::from(vec![
Span::styled(
format!(
"Break time: {} of focus. A {} break would fit.",
fmt.duration(focused),
fmt.duration(length)
),
Style::new().fg(ctx.theme.warning).add_modifier(Modifier::BOLD),
),
Span::styled(" p pause · zz snooze", ctx.theme.muted()),
])
}
},
Phase::Break { taken, goal, long, .. } if taken < goal => Line::from(vec![
Span::styled(if long { "Long break " } else { "Break " }, ctx.theme.muted()),
Span::raw(format!("{} / {} ", fmt.duration(taken), fmt.duration(goal))),
Span::styled(dots, ctx.theme.accent()),
]),
Phase::Break { goal, .. } => Line::from(vec![
Span::styled(
format!("Break's over ({}). Back when you're ready.", fmt.duration(goal)),
ctx.theme.title(),
),
Span::styled(" s start", ctx.theme.muted()),
]),
};
frame.render_widget(Paragraph::new(line).centered(), area);
}
fn render_buttons(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let state = ctx.journal.state();
let working = state.is_working();
let start_label =
if matches!(state, ShiftState::OnBreak { .. }) { "▶ Resume" } else { "▶ Start" };
let buttons = [
(
Button::new(
start_label,
"s",
if working { Look::Disabled } else { Look::Primary },
ctx.theme,
),
ButtonAction::Start,
),
(
Button::new("‖ Pause", "p", if working { Look::Primary } else { Look::Disabled }, ctx.theme),
ButtonAction::Pause,
),
(
Button::new(
"■ End day",
"e",
if state.open_day().is_some() { Look::Normal } else { Look::Disabled },
ctx.theme,
),
ButtonAction::EndDay,
),
];
let gap = 2;
let total: u16 = buttons.iter().map(|(button, _)| button.width() + gap).sum::<u16>() - gap;
let mut x = area.x + area.width.saturating_sub(total) / 2;
self.buttons.clear();
for (button, action) in buttons {
let rect =
Rect { x, y: area.y, width: button.width(), height: button::HEIGHT }.intersection(area);
x += button.width() + gap;
if button.look() != Look::Disabled {
self.buttons.push((rect, action));
}
frame.render_widget(button, rect);
}
}
fn render_today(&self, frame: &mut Frame, areas: [Rect; 3], ctx: &Context) {
let [progress, details, timeline_area] = areas;
let fmt = &ctx.fmt;
let day = ctx.day(ctx.current_day());
let worked = day.map_or(SignedDuration::ZERO, WorkDay::worked);
frame.render_widget(
Paragraph::new(goal_line("Today", worked, ctx.settings.daily_target, progress.width, ctx)),
progress,
);
let details_text = match day.filter(|day| !day.is_empty()) {
None => "Nothing recorded yet today.".to_string(),
Some(day) => {
let sessions = day.segments().len();
let breaks = match day.breaks().count() {
0 => "no breaks yet".to_string(),
1 => format!("1 break ({})", fmt.duration(day.break_time())),
count => format!("{count} breaks ({})", fmt.duration(day.break_time())),
};
format!(
"In {} · {breaks} · {sessions} session{}",
day.first_start().map(|start| ctx.time(start)).unwrap_or_default(),
if sessions == 1 { "" } else { "s" },
)
}
};
frame.render_widget(Paragraph::new(Span::styled(details_text, ctx.theme.muted())), details);
let pieces: Vec<_> =
day.map(|day| day.segments().iter().map(|s| (s.start(), s.end())).collect()).unwrap_or_default();
let state = ctx.journal.state();
let now = (state.open_day() == Some(ctx.current_day())).then(|| ctx.now.timestamp());
let running_since = match state {
ShiftState::Working { since, .. } => Some(since),
_ => None,
};
frame.render_widget(
Timeline {
pieces: &pieces,
running_since,
now,
window: window_for(&pieces, now, ctx.clock.zone(), 8),
zone: ctx.clock.zone(),
fmt: &ctx.fmt,
theme: ctx.theme,
},
timeline_area,
);
}
fn render_week(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
let week = DateRange::week_of(ctx.now.date(), ctx.settings.week_start);
let worked: SignedDuration =
ctx.days.iter().filter(|day| week.contains(day.date())).map(WorkDay::worked).sum();
frame.render_widget(
Paragraph::new(goal_line("This week", worked, ctx.settings.weekly_target, area.width, ctx)),
area,
);
}
}
impl Component for Tracker {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::TRACKER]
}
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));
let inner = block.inner(area);
frame.render_widget(block, area);
let column = centered(inner, inner.width.min(76), inner.height);
let big = ctx.settings.big_timer && column.height >= 24;
let space = u16::from(column.height >= 20);
let [status, timer, rhythm, buttons, progress, details, timeline_area, week] = Layout::vertical([
Constraint::Length(1 + space),
Constraint::Length(if big { big_digits::HEIGHT + 2 * space } else { 1 + space }),
Constraint::Length(1 + space),
Constraint::Length(button::HEIGHT + space),
Constraint::Length(1),
Constraint::Length(1 + space),
Constraint::Length(timeline::HEIGHT + space),
Constraint::Length(1),
])
.flex(Flex::Center)
.areas(column);
let inset = |area: Rect| Rect { x: area.x + 2, width: area.width.saturating_sub(4), ..area };
let first_row = |area: Rect, height: u16| Rect { height: height.min(area.height), ..area };
self.render_status(frame, first_row(status, 1), ctx);
let timer_area = if big {
Rect { y: timer.y + space, height: big_digits::HEIGHT, ..timer }
} else {
first_row(timer, 1)
};
self.render_timer(frame, timer_area, big, ctx);
self.render_rhythm(frame, first_row(rhythm, 1), ctx);
self.render_buttons(frame, first_row(buttons, button::HEIGHT), ctx);
self.render_today(
frame,
[
inset(progress),
inset(first_row(details, 1)),
inset(first_row(timeline_area, timeline::HEIGHT)),
],
ctx,
);
self.render_week(frame, inset(week), ctx);
}
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
let day = ctx.current_day();
match command {
Command::Edit => Some(self.edit(ctx)),
Command::AddAfter | Command::AddBefore => {
Some(popup(SegmentEditor::new(Target::New { day, after: true }, ctx)))
}
Command::Copy(target, preset) => Some(copy_action(target, preset, day, ctx)),
Command::CopyMenu => Some(popup(CopyMenu::new(day, ctx))),
_ => None,
}
}
fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option<Action> {
if event.kind != MouseEventKind::Down(MouseButton::Left) {
return None;
}
let position = Position::new(event.column, event.row);
let (_, action) = self.buttons.iter().find(|(rect, _)| rect.contains(position))?;
Some(match action {
ButtonAction::Start => Action::Start,
ButtonAction::Pause => Action::Pause,
ButtonAction::EndDay => Action::EndDay,
})
}
}
/// "Today 5h 40m ██████░░░░ 71% of 8h", or just "Today 5h 40m" without a target.
fn goal_line(
label: &str,
worked: SignedDuration,
target: SignedDuration,
width: u16,
ctx: &Context,
) -> Line<'static> {
let mut spans = vec![
Span::styled(format!("{label:<10}"), ctx.theme.muted()),
Span::styled(format!("{:<9}", ctx.fmt.duration(worked)), Style::new().add_modifier(Modifier::BOLD)),
];
if target > SignedDuration::ZERO {
let ratio = worked.as_secs_f64() / target.as_secs_f64();
let tail = format!(" {:>3.0}% of {}", ratio * 100.0, ctx.fmt.duration(target));
let bar_width = width.saturating_sub(20 + tail.chars().count() as u16).min(40);
spans.extend(progress_bar(bar_width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()));
spans.push(Span::styled(tail, ctx.theme.muted()));
}
Line::from(spans)
}