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:
1 parent
255dcd1392
commit
57ea9403b6
6 files changed
+1663
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user