diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 332f3dd..dbe639e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/screens/days.rs b/src/ui/screens/days.rs new file mode 100644 index 0000000..0aeda74 --- /dev/null +++ b/src/ui/screens/days.rs @@ -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, + /// 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, + 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 { + 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 { + 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 = 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 · 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 = 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, ctx: &Context) -> Option { + 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 { + 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 + } +} diff --git a/src/ui/screens/insights.rs b/src/ui/screens/insights.rs new file mode 100644 index 0000000..7976a56 --- /dev/null +++ b/src/ui/screens/insights.rs @@ -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 = 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(); + 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, _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() +} diff --git a/src/ui/screens/mod.rs b/src/ui/screens/mod.rs new file mode 100644 index 0000000..f1a77af --- /dev/null +++ b/src/ui/screens/mod.rs @@ -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, 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) } +} diff --git a/src/ui/screens/settings.rs b/src/ui/screens/settings.rs new file mode 100644 index 0000000..d72d94b --- /dev/null +++ b/src/ui/screens/settings.rs @@ -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, + error: Option, + list_state: ListState, + entries: Vec, + 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 { + 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 { + 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 { + 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 { + 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 = 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, ctx: &Context) -> Option { + 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 { + 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 { + 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, + } + } +} diff --git a/src/ui/screens/tracker.rs b/src/ui/screens/tracker.rs new file mode 100644 index 0000000..7fb0478 --- /dev/null +++ b/src/ui/screens/tracker.rs @@ -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::() - 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, ctx: &Context) -> Option { + 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 { + 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) +}