The Focus view grouped sessions into six buckets, too coarse to see a rhythm. There are now ten: under 5m, 5-15m, 15-25m, 25-45m, 45m-1h, 1h-1h30m, 1h30m-2h, 2-3h, 3-4h and 4h or more. 25 minutes is a classic pomodoro, and the long end separates 3-4h stretches from 4h+ hyperfocus. Ten labelled columns don't fit side by side, so the chart is now drawn with horizontal bars: every label stays readable at any width. The buckets are one constant (analysis::focus::SESSION_BUCKETS); a new test pins down where sessions land, including an exact boundary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
532 lines
22 KiB
Rust
532 lines
22 KiB
Rust
//! The Insights screen: four ways to look at your work.
|
||
//!
|
||
//! - Overview: this week and month against your targets, your balance,
|
||
//! weekly totals, and when your days start and end.
|
||
//! - Calendar: every day as a colored square, like a wall calendar.
|
||
//! - Rhythm: which hours of which weekdays you actually work.
|
||
//! - Focus: how long you stay on, and how you take breaks.
|
||
//!
|
||
//! These are patterns to notice, not grades: no streaks and no scores.
|
||
|
||
use jiff::civil::{Date, Weekday};
|
||
use jiff::{SignedDuration, ToSpan};
|
||
use ratatui::Frame;
|
||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
|
||
use ratatui::style::{Modifier, Style};
|
||
use ratatui::symbols::Marker;
|
||
use ratatui::text::{Line, Span};
|
||
use ratatui::widgets::{Axis, Bar, BarChart, Block, BorderType, Chart, Dataset, GraphType, Paragraph};
|
||
|
||
use crate::action::Action;
|
||
use crate::analysis::calendar::calendar;
|
||
use crate::analysis::focus::{SESSION_BUCKETS, focus};
|
||
use crate::analysis::overview::{Targets, Totals, overview};
|
||
use crate::analysis::rhythm::rhythm;
|
||
use crate::analysis::{Period, days_in};
|
||
use crate::domain::WorkDay;
|
||
use crate::ui::keymap::{self, Binding, Command};
|
||
use crate::ui::widgets::heatmap::Heatmap;
|
||
use crate::ui::widgets::progress_bar;
|
||
use crate::ui::{Component, Context};
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum View {
|
||
Overview,
|
||
Calendar,
|
||
Rhythm,
|
||
Focus,
|
||
}
|
||
|
||
impl View {
|
||
const ALL: [View; 4] = [View::Overview, View::Calendar, View::Rhythm, View::Focus];
|
||
|
||
fn title(self) -> &'static str {
|
||
match self {
|
||
View::Overview => "Overview",
|
||
View::Calendar => "Calendar",
|
||
View::Rhythm => "Rhythm",
|
||
View::Focus => "Focus",
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct Insights {
|
||
view: View,
|
||
period: Period,
|
||
/// Where each view's tab was drawn, for the mouse.
|
||
view_tabs: Vec<(View, Rect)>,
|
||
}
|
||
|
||
impl Insights {
|
||
pub fn new() -> Insights {
|
||
Insights { view: View::Overview, period: Period::default(), view_tabs: Vec::new() }
|
||
}
|
||
|
||
fn render_tabs(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||
self.view_tabs.clear();
|
||
let mut x = area.x + 1;
|
||
for view in View::ALL {
|
||
let label = format!(" {} ", view.title());
|
||
let width = label.chars().count() as u16;
|
||
let style = if view == self.view { ctx.theme.selected(true) } else { ctx.theme.muted() };
|
||
let rect = Rect { x, y: area.y, width, height: 1 }.intersection(area);
|
||
frame.render_widget(Paragraph::new(Span::styled(label, style)), rect);
|
||
self.view_tabs.push((view, rect));
|
||
x += width + 1;
|
||
}
|
||
let period = Line::from(vec![
|
||
Span::styled("period ", ctx.theme.muted()),
|
||
Span::styled(self.period.label(), ctx.theme.title()),
|
||
Span::styled(" < shorter · > longer · h/l views ", ctx.theme.muted()),
|
||
]);
|
||
frame.render_widget(Paragraph::new(period).right_aligned(), area);
|
||
}
|
||
|
||
fn render_overview(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||
let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date());
|
||
let range = self.period.range(today, ctx.days);
|
||
let targets = Targets { daily: settings.daily_target, week_start: settings.week_start };
|
||
let week_count = usize::from(area.width.saturating_sub(4) / 8).clamp(4, 12);
|
||
let data = overview(ctx.days, today, range, targets, ctx.clock.zone(), week_count);
|
||
|
||
let [cards, weekly, spans] =
|
||
Layout::vertical([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||
let [this_week, last_week, this_month, balance] =
|
||
Layout::horizontal([Constraint::Fill(1); 4]).areas(cards);
|
||
|
||
let days_line = |totals: Totals| {
|
||
let average = totals.average_day().map(|average| format!(" · {} a day", fmt.duration(average)));
|
||
Line::styled(
|
||
format!("{} days{}", totals.days_worked, average.unwrap_or_default()),
|
||
ctx.theme.muted(),
|
||
)
|
||
};
|
||
let week_goal = if settings.weekly_target > SignedDuration::ZERO {
|
||
let ratio = data.this_week.worked.as_secs_f64() / settings.weekly_target.as_secs_f64();
|
||
let width = this_week.width.saturating_sub(10);
|
||
let mut spans =
|
||
progress_bar(width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()).to_vec();
|
||
spans.push(Span::styled(format!(" {:.0}%", ratio * 100.0), ctx.theme.muted()));
|
||
Line::from(spans)
|
||
} else {
|
||
days_line(data.this_week)
|
||
};
|
||
card(frame, this_week, "This week", fmt.duration(data.this_week.worked), week_goal, ctx);
|
||
card(
|
||
frame,
|
||
last_week,
|
||
"Last week",
|
||
fmt.duration(data.last_week.worked),
|
||
days_line(data.last_week),
|
||
ctx,
|
||
);
|
||
card(
|
||
frame,
|
||
this_month,
|
||
"This month",
|
||
fmt.duration(data.this_month.worked),
|
||
days_line(data.this_month),
|
||
ctx,
|
||
);
|
||
let (balance_text, balance_note) = if settings.daily_target > SignedDuration::ZERO {
|
||
let sign = if data.balance.is_negative() { "" } else { "+" };
|
||
(
|
||
format!("{sign}{}", fmt.duration(data.balance)),
|
||
format!("vs {} per day worked", fmt.duration(settings.daily_target)),
|
||
)
|
||
} else {
|
||
("-".to_string(), "set a daily target to see it".to_string())
|
||
};
|
||
let balance_title = format!("Balance · {}", self.period.label());
|
||
card(
|
||
frame,
|
||
balance,
|
||
&balance_title,
|
||
balance_text,
|
||
Line::styled(balance_note, ctx.theme.muted()),
|
||
ctx,
|
||
);
|
||
|
||
// Hours per week.
|
||
let bars: Vec<Bar> = data
|
||
.weeks
|
||
.iter()
|
||
.map(|(first, worked)| {
|
||
let reached =
|
||
settings.weekly_target > SignedDuration::ZERO && *worked >= settings.weekly_target;
|
||
let color = if reached { ctx.theme.accent } else { ctx.theme.working };
|
||
Bar::with_label(first.strftime("%b %-d").to_string(), worked.as_mins().max(0) as u64)
|
||
.text_value(format!("{}h", worked.as_hours()))
|
||
.style(Style::new().fg(color))
|
||
})
|
||
.collect();
|
||
let bar_width =
|
||
(weekly.width.saturating_sub(2) / data.weeks.len().max(1) as u16).saturating_sub(1).clamp(3, 8);
|
||
let title = match settings.weekly_target {
|
||
target if target > SignedDuration::ZERO => {
|
||
format!(" Hours per week · target {} ", fmt.duration(target))
|
||
}
|
||
_ => " Hours per week ".to_string(),
|
||
};
|
||
frame.render_widget(
|
||
BarChart::new(bars)
|
||
.block(section(&title, ctx))
|
||
.bar_width(bar_width)
|
||
.bar_gap(1)
|
||
.value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)),
|
||
weekly,
|
||
);
|
||
|
||
// When days start and end.
|
||
if data.spans.is_empty() {
|
||
frame.render_widget(empty(" When your days start and end ", self.period, ctx), spans);
|
||
return;
|
||
}
|
||
let x_of = |date: Date| date.duration_since(range.first()).as_hours() as f64 / 24.0;
|
||
let starts: Vec<(f64, f64)> =
|
||
data.spans.iter().map(|span| (x_of(span.date), span.start_minute as f64 / 60.0)).collect();
|
||
let ends: Vec<(f64, f64)> =
|
||
data.spans.iter().map(|span| (x_of(span.date), span.end_minute as f64 / 60.0)).collect();
|
||
let low = data.spans.iter().map(|span| span.start_minute).min().unwrap_or(0).div_euclid(60);
|
||
let high = (data.spans.iter().map(|span| span.end_minute).max().unwrap_or(0) + 59)
|
||
.div_euclid(60)
|
||
.max(low + 1);
|
||
let step = ((high - low) / 4).max(1);
|
||
let y_labels: Vec<String> =
|
||
(low..=high).step_by(step as usize).map(|hour| hour_label(hour, ctx)).collect();
|
||
let x_labels = vec![fmt.date_short(range.first()), fmt.date_short(range.last())];
|
||
let datasets = vec![
|
||
Dataset::default()
|
||
.name("start")
|
||
.marker(Marker::Dot)
|
||
.graph_type(GraphType::Scatter)
|
||
.style(Style::new().fg(ctx.theme.working))
|
||
.data(&starts),
|
||
Dataset::default()
|
||
.name("end")
|
||
.marker(Marker::Dot)
|
||
.graph_type(GraphType::Scatter)
|
||
.style(Style::new().fg(ctx.theme.accent))
|
||
.data(&ends),
|
||
];
|
||
let chart = Chart::new(datasets)
|
||
.block(section(" When your days start and end ", ctx))
|
||
.x_axis(
|
||
Axis::default()
|
||
.bounds([0.0, f64::from(range.day_count() - 1).max(1.0)])
|
||
.labels(x_labels)
|
||
.style(ctx.theme.muted()),
|
||
)
|
||
.y_axis(
|
||
Axis::default().bounds([low as f64, high as f64]).labels(y_labels).style(ctx.theme.muted()),
|
||
);
|
||
frame.render_widget(chart, spans);
|
||
}
|
||
|
||
fn render_calendar(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||
let (settings, today) = (ctx.settings, ctx.now.date());
|
||
let block = section(" Every day, like a wall calendar ", ctx);
|
||
let inner = block.inner(area);
|
||
frame.render_widget(block, area);
|
||
|
||
let weeks = usize::from(inner.width.saturating_sub(6) / 2).clamp(1, 53);
|
||
let heat = calendar(ctx.days, today, settings.week_start, weeks);
|
||
let full = if settings.daily_target > SignedDuration::ZERO {
|
||
settings.daily_target
|
||
} else {
|
||
SignedDuration::from_hours(8)
|
||
};
|
||
let cells: Vec<Vec<Option<f64>>> = (0..7)
|
||
.map(|row| {
|
||
heat.weeks
|
||
.iter()
|
||
.map(|week| week[row].map(|(_, worked)| worked.as_secs_f64() / full.as_secs_f64()))
|
||
.collect()
|
||
})
|
||
.collect();
|
||
let row_labels: Vec<String> =
|
||
(0..7).map(|offset| weekday_name(settings.week_start.wrapping_add(offset))).collect();
|
||
// A month's name above the week where it begins.
|
||
let column_labels: Vec<(usize, String)> = heat
|
||
.weeks
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(column, week)| {
|
||
let first = week.iter().flatten().find(|(date, _)| date.day() == 1 || column == 0)?;
|
||
Some((column, first.0.strftime("%b").to_string()))
|
||
})
|
||
.collect();
|
||
let today_row = today.weekday().since(settings.week_start) as usize;
|
||
let heatmap = Heatmap {
|
||
cells: &cells,
|
||
row_labels: &row_labels,
|
||
column_labels: &column_labels,
|
||
cell: "■ ",
|
||
highlight: Some((today_row, heat.weeks.len().saturating_sub(1))),
|
||
theme: ctx.theme,
|
||
};
|
||
let (_, height) = heatmap.size();
|
||
let [grid, _, legend] =
|
||
Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Length(2)])
|
||
.areas(inner);
|
||
frame.render_widget(heatmap, grid);
|
||
|
||
let shown: Vec<&(Date, SignedDuration)> = heat.weeks.iter().flatten().flatten().collect();
|
||
let worked_days = shown.iter().filter(|(_, worked)| *worked > SignedDuration::ZERO).count();
|
||
let mut scale = vec![Span::styled(" less ", ctx.theme.muted())];
|
||
for level in ctx.theme.heat {
|
||
scale.push(Span::styled("■ ", Style::new().fg(level)));
|
||
}
|
||
scale.push(Span::styled(
|
||
format!("more · a full square is {}", ctx.fmt.duration(full)),
|
||
ctx.theme.muted(),
|
||
));
|
||
let summary = format!(" Worked on {worked_days} of the last {} days.", shown.len());
|
||
frame.render_widget(
|
||
Paragraph::new(vec![Line::from(scale), Line::styled(summary, ctx.theme.muted())]),
|
||
legend,
|
||
);
|
||
}
|
||
|
||
fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||
let today = ctx.now.date();
|
||
let range = self.period.range(today, ctx.days);
|
||
let data = rhythm(ctx.days, range, ctx.settings.week_start, ctx.clock.zone());
|
||
let block = section(" When you work: weekday × hour of the day ", ctx);
|
||
let inner = block.inner(area);
|
||
frame.render_widget(block, area);
|
||
if data.busiest_cell() == 0 {
|
||
frame.render_widget(empty("", self.period, ctx), inner);
|
||
return;
|
||
}
|
||
|
||
let busiest = data.busiest_cell() as f64;
|
||
let cells: Vec<Vec<Option<f64>>> = data
|
||
.seconds
|
||
.iter()
|
||
.map(|row| row.iter().map(|seconds| Some(*seconds as f64 / busiest)).collect())
|
||
.collect();
|
||
let row_labels: Vec<String> = data.weekdays.iter().map(|weekday| weekday_name(*weekday)).collect();
|
||
let cell = if inner.width >= 4 + 24 * 3 { "██ " } else { "██" };
|
||
let column_labels: Vec<(usize, String)> =
|
||
(0..24).step_by(3).map(|hour| (hour, ctx.fmt.hour_mark(hour as i8))).collect();
|
||
let heatmap = Heatmap {
|
||
cells: &cells,
|
||
row_labels: &row_labels,
|
||
column_labels: &column_labels,
|
||
cell,
|
||
highlight: None,
|
||
theme: ctx.theme,
|
||
};
|
||
let (_, height) = heatmap.size();
|
||
let [grid, _, notes] =
|
||
Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Fill(1)])
|
||
.areas(inner);
|
||
frame.render_widget(heatmap, grid);
|
||
|
||
let peaks: Vec<String> = data
|
||
.peak_hours(3)
|
||
.into_iter()
|
||
.map(|hour| format!("{}-{}", ctx.fmt.hour_mark(hour), ctx.fmt.hour_mark((hour + 1) % 24)))
|
||
.collect();
|
||
let total: SignedDuration = days_in(ctx.days, range).map(WorkDay::worked).sum();
|
||
let per_week = SignedDuration::from_secs((total.as_secs() as f64 / data.weeks.max(1.0)) as i64);
|
||
let lines = vec![
|
||
Line::from(vec![
|
||
Span::styled(" Busiest hours: ", ctx.theme.muted()),
|
||
Span::styled(peaks.join(", "), ctx.theme.title()),
|
||
]),
|
||
Line::styled(
|
||
format!(
|
||
" Over {}: {} worked, about {} a week.",
|
||
self.period.label(),
|
||
ctx.fmt.duration(total),
|
||
ctx.fmt.duration(per_week)
|
||
),
|
||
ctx.theme.muted(),
|
||
),
|
||
];
|
||
frame.render_widget(Paragraph::new(lines), notes);
|
||
}
|
||
|
||
fn render_focus(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||
let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date());
|
||
let range = self.period.range(today, ctx.days);
|
||
let data = focus(ctx.days, range, ctx.clock.zone(), settings.long_session);
|
||
if data.sessions == 0 {
|
||
frame.render_widget(empty(" Focus ", self.period, ctx), area);
|
||
return;
|
||
}
|
||
|
||
let wide = area.width >= 100;
|
||
let [chart_area, stats_area] = if wide {
|
||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(area)
|
||
} else {
|
||
Layout::vertical([Constraint::Fill(1), Constraint::Length(10)]).areas(area)
|
||
};
|
||
|
||
let bars: Vec<Bar> = SESSION_BUCKETS
|
||
.iter()
|
||
.zip(data.session_counts)
|
||
.map(|((label, _), count)| {
|
||
Bar::with_label(*label, count as u64)
|
||
.text_value(count.to_string())
|
||
.style(Style::new().fg(ctx.theme.working))
|
||
})
|
||
.collect();
|
||
// Horizontal bars, one row each, so every label fits; a blank row
|
||
// between bars when there's room.
|
||
let rows = chart_area.height.saturating_sub(2);
|
||
let gap = u16::from(rows >= 2 * SESSION_BUCKETS.len() as u16 - 1);
|
||
frame.render_widget(
|
||
BarChart::new(bars)
|
||
.direction(Direction::Horizontal)
|
||
.block(section(" How long your sessions last ", ctx))
|
||
.bar_width(1)
|
||
.bar_gap(gap)
|
||
.value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)),
|
||
chart_area,
|
||
);
|
||
|
||
let row = |label: &str, value: String| {
|
||
Line::from(vec![
|
||
Span::styled(format!(" {label:<17}"), ctx.theme.muted()),
|
||
Span::styled(value, Style::new()),
|
||
])
|
||
};
|
||
let typical_bucket = data
|
||
.session_counts
|
||
.iter()
|
||
.enumerate()
|
||
.max_by_key(|(_, count)| **count)
|
||
.map(|(index, _)| SESSION_BUCKETS[index].0)
|
||
.unwrap_or("");
|
||
let mut lines = vec![
|
||
row("Sessions", format!("{} · {:.1} a day", data.sessions, data.sessions_per_day())),
|
||
row(
|
||
"Typical session",
|
||
data.median_session
|
||
.map(|median| format!("{} (half are shorter)", fmt.duration(median)))
|
||
.unwrap_or_default(),
|
||
),
|
||
row(
|
||
"Longest session",
|
||
data.longest_session
|
||
.map(|(date, length)| format!("{} on {}", fmt.duration(length), fmt.date_short(date)))
|
||
.unwrap_or_default(),
|
||
),
|
||
row(
|
||
"Long sessions",
|
||
format!("{} of {} or more", data.long_sessions, fmt.duration(settings.long_session)),
|
||
),
|
||
row(
|
||
"Breaks",
|
||
match data.median_break {
|
||
Some(median) => format!("{} · typically {}", data.breaks, fmt.duration(median)),
|
||
None => "none between sessions".to_string(),
|
||
},
|
||
),
|
||
row(
|
||
"Past midnight",
|
||
format!("{} work day{}", data.past_midnight, if data.past_midnight == 1 { "" } else { "s" }),
|
||
),
|
||
Line::default(),
|
||
];
|
||
lines.push(Line::styled(
|
||
format!(" Your sessions most often last {typical_bucket}."),
|
||
ctx.theme.title(),
|
||
));
|
||
frame.render_widget(
|
||
Paragraph::new(lines).block(section(&format!(" {} ", self.period.label()), ctx)),
|
||
stats_area,
|
||
);
|
||
}
|
||
}
|
||
|
||
impl Component for Insights {
|
||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||
&[keymap::INSIGHTS]
|
||
}
|
||
|
||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||
let block = Block::bordered()
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(ctx.theme.border(false))
|
||
.title(Line::styled(" Insights ", ctx.theme.title()));
|
||
let inner = block.inner(area);
|
||
frame.render_widget(block, area);
|
||
let [tabs, _, body] =
|
||
Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1)])
|
||
.areas(inner);
|
||
self.render_tabs(frame, tabs, ctx);
|
||
match self.view {
|
||
View::Overview => self.render_overview(frame, body, ctx),
|
||
View::Calendar => self.render_calendar(frame, body, ctx),
|
||
View::Rhythm => self.render_rhythm(frame, body, ctx),
|
||
View::Focus => self.render_focus(frame, body, ctx),
|
||
}
|
||
}
|
||
|
||
fn command(&mut self, command: Command, _count: Option<usize>, _ctx: &Context) -> Option<Action> {
|
||
let index = View::ALL.iter().position(|view| *view == self.view).unwrap_or(0);
|
||
match command {
|
||
Command::Right => self.view = View::ALL[(index + 1) % View::ALL.len()],
|
||
Command::Left => self.view = View::ALL[(index + View::ALL.len() - 1) % View::ALL.len()],
|
||
Command::LongerPeriod => self.period = self.period.longer(),
|
||
Command::ShorterPeriod => self.period = self.period.shorter(),
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option<Action> {
|
||
if event.kind == MouseEventKind::Down(MouseButton::Left) {
|
||
let position = Position::new(event.column, event.row);
|
||
if let Some((view, _)) = self.view_tabs.iter().find(|(_, rect)| rect.contains(position)) {
|
||
self.view = *view;
|
||
}
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
/// A small box with a title, a big value and one line below it.
|
||
fn card(frame: &mut Frame, area: Rect, title: &str, value: String, detail: Line<'static>, ctx: &Context) {
|
||
let block = section(&format!(" {title} "), ctx);
|
||
let value = Line::styled(format!(" {value}"), Style::new().add_modifier(Modifier::BOLD));
|
||
let detail = Line::from([vec![Span::raw(" ")], detail.spans].concat());
|
||
frame.render_widget(Paragraph::new(vec![value, detail]).block(block), area);
|
||
}
|
||
|
||
/// The frame of a section: a rounded, muted border with a title.
|
||
fn section<'a>(title: &str, ctx: &Context) -> Block<'a> {
|
||
Block::bordered()
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(ctx.theme.muted())
|
||
.title(Line::styled(title.to_string(), Style::new().add_modifier(Modifier::BOLD)))
|
||
}
|
||
|
||
/// Shown when there's nothing to analyze yet.
|
||
fn empty<'a>(title: &str, period: Period, ctx: &Context) -> Paragraph<'a> {
|
||
let text =
|
||
format!(" Nothing recorded over {} yet: this fills up as you track your work.", period.label());
|
||
let paragraph = Paragraph::new(Span::styled(text, ctx.theme.muted()));
|
||
if title.is_empty() { paragraph } else { paragraph.block(section(title, ctx)) }
|
||
}
|
||
|
||
/// An hour on the chart's axis: hours past 24 are the next morning ("02+1").
|
||
fn hour_label(hour: i64, ctx: &Context) -> String {
|
||
let mark = ctx.fmt.hour_mark(hour.rem_euclid(24) as i8);
|
||
match hour.div_euclid(24) {
|
||
0 => mark,
|
||
days => format!("{mark}+{days}"),
|
||
}
|
||
}
|
||
|
||
fn weekday_name(weekday: Weekday) -> String {
|
||
// Any date with that weekday gives its short name.
|
||
let monday = jiff::civil::date(2024, 1, 1);
|
||
monday.saturating_add((weekday.to_monday_zero_offset() as i64).days()).strftime("%a").to_string()
|
||
}
|