#![forbid(unsafe_code)]
use chrono::{Days, Local, NaiveDate};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Margin, Rect},
style::{Color, Modifier, Style, Stylize},
symbols,
text::{Line, Text},
widgets::{
Axis, Block, Chart, Dataset, GraphType, LegendPosition, Paragraph, Row, Table, TableState,
Wrap,
},
};
use crate::{App, count::TaskCount};
#[derive(Default, Clone, PartialEq)]
pub enum LogSubMode {
#[default]
Table,
Graph(GraphKind),
}
#[derive(Default, Clone, PartialEq)]
pub enum GraphKind {
#[default]
All,
CompleteAndIncomplete,
Complete,
Incomplete,
RecurringAndNonRecurring,
IncompleteByRecurring,
CompleteByRecurring,
}
pub enum CountKind {
Incomplete,
Complete,
RecurringIncomplete,
RecurringComplete,
NonRecurringIncomplete,
NonRecurringComplete,
}
#[derive(Default)]
pub struct LogMode {
pub submode: LogSubMode,
pub data: Vec<TaskCount>,
pub table: TableState,
}
pub fn render(
app: &mut App,
frame: &mut Frame,
mut info_block: Block,
mut main_block: Block,
top_right: Rect,
left: Rect,
controls_content: &mut Vec<Line<'_>>,
vertical: Layout,
) {
match &app.logmode.submode {
LogSubMode::Table => {
main_block = main_block
.title(" Log (table) ".bold())
.title_alignment(Alignment::Center);
let header_footer_style = Style::default().bg(Color::DarkGray);
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
let widths = Constraint::from_lengths([12, 12, 12, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]);
let mut rows: Vec<Row> = vec![];
for (i, task_count) in app.logmode.data.clone().into_iter().enumerate() {
let mut task_count = task_count.as_strings();
if i == 0 {
task_count[0] = "now".to_string();
}
let mut new_row = vec![];
for (i, each) in task_count.into_iter().enumerate() {
if i == 0 {
new_row.push(Text::from(each));
} else {
new_row.push(Text::from(each).alignment(Alignment::Right))
}
}
rows.push(Row::new(new_row));
}
let mut table = Table::new(rows, widths)
.header(Row::new(TaskCount::labels()).style(header_footer_style))
.row_highlight_style(selected_style)
.flex(ratatui::layout::Flex::SpaceBetween);
if app.logmode.data.len() > 20 {
table = table.footer(Row::new(TaskCount::labels()).style(header_footer_style));
}
frame.render_stateful_widget(table.block(main_block), left, &mut app.logmode.table);
}
LogSubMode::Graph(graph_kind) => {
main_block = main_block
.title(" Log (graph) ".bold())
.title_alignment(Alignment::Center);
let today = Local::now().date_naive();
match graph_kind {
GraphKind::All => {
let incomplete =
get_count_data(app.logmode.data.clone(), CountKind::Incomplete, today);
let complete =
get_count_data(app.logmode.data.clone(), CountKind::Complete, today);
let recurring_incomplete = get_count_data(
app.logmode.data.clone(),
CountKind::RecurringIncomplete,
today,
);
let recurring_complete = get_count_data(
app.logmode.data.clone(),
CountKind::RecurringComplete,
today,
);
let non_recurring_incomplete = get_count_data(
app.logmode.data.clone(),
CountKind::NonRecurringIncomplete,
today,
);
let non_recurring_complete = get_count_data(
app.logmode.data.clone(),
CountKind::NonRecurringComplete,
today,
);
let all_counts: Vec<_> = vec![
incomplete.clone(),
complete.clone(),
recurring_incomplete.clone(),
recurring_complete.clone(),
non_recurring_incomplete.clone(),
non_recurring_complete.clone(),
]
.into_iter()
.flatten()
.collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let incomplete = to_f64(incomplete);
let complete = to_f64(complete);
let recurring_incomplete = to_f64(recurring_incomplete);
let recurring_complete = to_f64(recurring_complete);
let non_recurring_incomplete = to_f64(non_recurring_incomplete);
let non_recurring_complete = to_f64(non_recurring_complete);
let datasets = vec![
Dataset::default()
.name("incomplete tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.magenta()
.data(&incomplete),
Dataset::default()
.name("complete tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.cyan()
.data(&complete),
Dataset::default()
.name("incomplete non-recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.red()
.data(&non_recurring_incomplete),
Dataset::default()
.name("complete non-recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.green()
.data(&non_recurring_complete),
Dataset::default()
.name("incomplete recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.yellow()
.data(&recurring_incomplete),
Dataset::default()
.name("complete recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.gray()
.data(&recurring_complete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
GraphKind::CompleteAndIncomplete => {
let incomplete =
get_count_data(app.logmode.data.clone(), CountKind::Incomplete, today);
let complete =
get_count_data(app.logmode.data.clone(), CountKind::Complete, today);
let all_counts: Vec<_> = vec![incomplete.clone(), complete.clone()]
.into_iter()
.flatten()
.collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let incomplete = to_f64(incomplete);
let complete = to_f64(complete);
let datasets = vec![
Dataset::default()
.name("incomplete tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.magenta()
.data(&incomplete),
Dataset::default()
.name("complete tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.cyan()
.data(&complete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
GraphKind::Complete => {
let complete =
get_count_data(app.logmode.data.clone(), CountKind::Complete, today);
let all_counts: Vec<_> = vec![complete.clone()].into_iter().flatten().collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let complete = to_f64(complete);
let datasets = vec![
Dataset::default()
.name("complete tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.cyan()
.data(&complete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
GraphKind::Incomplete => {
let incomplete =
get_count_data(app.logmode.data.clone(), CountKind::Incomplete, today);
let all_counts: Vec<_> =
vec![incomplete.clone()].into_iter().flatten().collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let incomplete = to_f64(incomplete);
let datasets = vec![
Dataset::default()
.name("incomplete tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.magenta()
.data(&incomplete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
GraphKind::RecurringAndNonRecurring => {
let recurring_incomplete = get_count_data(
app.logmode.data.clone(),
CountKind::RecurringIncomplete,
today,
);
let recurring_complete = get_count_data(
app.logmode.data.clone(),
CountKind::RecurringComplete,
today,
);
let non_recurring_incomplete = get_count_data(
app.logmode.data.clone(),
CountKind::NonRecurringIncomplete,
today,
);
let non_recurring_complete = get_count_data(
app.logmode.data.clone(),
CountKind::NonRecurringComplete,
today,
);
let all_counts: Vec<_> = vec![
recurring_incomplete.clone(),
recurring_complete.clone(),
non_recurring_incomplete.clone(),
non_recurring_complete.clone(),
]
.into_iter()
.flatten()
.collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let recurring_incomplete = to_f64(recurring_incomplete);
let recurring_complete = to_f64(recurring_complete);
let non_recurring_incomplete = to_f64(non_recurring_incomplete);
let non_recurring_complete = to_f64(non_recurring_complete);
let datasets = vec![
Dataset::default()
.name("incomplete non-recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.red()
.data(&non_recurring_incomplete),
Dataset::default()
.name("complete non-recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.green()
.data(&non_recurring_complete),
Dataset::default()
.name("incomplete recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.yellow()
.data(&recurring_incomplete),
Dataset::default()
.name("complete recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.gray()
.data(&recurring_complete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
GraphKind::IncompleteByRecurring => {
let recurring_incomplete = get_count_data(
app.logmode.data.clone(),
CountKind::RecurringIncomplete,
today,
);
let non_recurring_incomplete = get_count_data(
app.logmode.data.clone(),
CountKind::NonRecurringIncomplete,
today,
);
let all_counts: Vec<_> = vec![
recurring_incomplete.clone(),
non_recurring_incomplete.clone(),
]
.into_iter()
.flatten()
.collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let recurring_incomplete = to_f64(recurring_incomplete);
let non_recurring_incomplete = to_f64(non_recurring_incomplete);
let datasets = vec![
Dataset::default()
.name("incomplete non-recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.red()
.data(&non_recurring_incomplete),
Dataset::default()
.name("incomplete recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.yellow()
.data(&recurring_incomplete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
GraphKind::CompleteByRecurring => {
let recurring_complete = get_count_data(
app.logmode.data.clone(),
CountKind::RecurringComplete,
today,
);
let non_recurring_complete = get_count_data(
app.logmode.data.clone(),
CountKind::NonRecurringComplete,
today,
);
let all_counts: Vec<_> =
vec![recurring_complete.clone(), non_recurring_complete.clone()]
.into_iter()
.flatten()
.collect();
let (x_axis, y_axis) = create_axes(&all_counts, &today);
let recurring_complete = to_f64(recurring_complete);
let non_recurring_complete = to_f64(non_recurring_complete);
let datasets = vec![
Dataset::default()
.name("complete non-recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.green()
.data(&non_recurring_complete),
Dataset::default()
.name("complete recurring tasks")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.gray()
.data(&recurring_complete),
];
let chart = Chart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.legend_position(Some(LegendPosition::TopLeft));
frame.render_widget(chart.block(main_block), left);
}
}
}
}
info_block = info_block.title_top(Line::from(" Changes ").centered());
if app.logmode.data.len() < 2 {
frame.render_widget(
Paragraph::new(vec![Line::from(
"Changes will appear here when you have multiple dates of data.",
)])
.wrap(Wrap { trim: false })
.block(info_block),
top_right,
)
} else if let Some(v) = app.logmode.table.selected() {
let [mut info_top, mut info_bottom] = vertical.areas(top_right);
let margin = Margin::new(1, 1);
info_top = info_top.inner(margin);
info_bottom = info_bottom.inner(margin);
info_top.height = 9;
info_bottom.y = info_top.bottom() - 1;
let selected_record = app.logmode.data[v].clone();
let now_to_selected = app.logmode.data[0].clone() - selected_record.clone();
let to_now = Table::new(
vec![
Row::new(vec![
Text::from(format!("{} days ago", now_to_selected.days_ago))
.alignment(Alignment::Left),
Text::from("complete").alignment(Alignment::Right),
Text::from("incomplete").alignment(Alignment::Right),
])
.underlined(),
Row::new(vec![
Text::from("total").alignment(Alignment::Left),
Text::from(now_to_selected.complete).alignment(Alignment::Right),
Text::from(now_to_selected.incomplete).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("priority 1").alignment(Alignment::Left),
Text::from(now_to_selected.p1_comp).alignment(Alignment::Right),
Text::from(now_to_selected.p1_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("priority 2").alignment(Alignment::Left),
Text::from(now_to_selected.p2_comp).alignment(Alignment::Right),
Text::from(now_to_selected.p2_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("priority 3").alignment(Alignment::Left),
Text::from(now_to_selected.p3_comp).alignment(Alignment::Right),
Text::from(now_to_selected.p3_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("recurring").alignment(Alignment::Left),
Text::from(now_to_selected.recurring_comp).alignment(Alignment::Right),
Text::from(now_to_selected.recurring_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("non-recurring").alignment(Alignment::Left),
Text::from(now_to_selected.non_recurring_comp).alignment(Alignment::Right),
Text::from(now_to_selected.non_recurring_incomp).alignment(Alignment::Right),
]),
],
[
Constraint::Length(13),
Constraint::Length(10),
Constraint::Length(10),
],
);
if v != app.logmode.data.len() - 1 {
let previous = app.logmode.data[v + 1].clone();
let selected_to_previous = selected_record.clone() - previous;
let to_prev = Table::new(
vec![
Row::new(vec![
Text::from("To previous").alignment(Alignment::Left),
Text::from("complete").alignment(Alignment::Right),
Text::from("incomplete").alignment(Alignment::Right),
])
.underlined(),
Row::new(vec![
Text::from("total").alignment(Alignment::Left),
Text::from(selected_to_previous.complete).alignment(Alignment::Right),
Text::from(selected_to_previous.incomplete).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("priority 1").alignment(Alignment::Left),
Text::from(selected_to_previous.p1_comp).alignment(Alignment::Right),
Text::from(selected_to_previous.p1_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("priority 2").alignment(Alignment::Left),
Text::from(selected_to_previous.p2_comp).alignment(Alignment::Right),
Text::from(selected_to_previous.p2_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("priority 3").alignment(Alignment::Left),
Text::from(selected_to_previous.p3_comp).alignment(Alignment::Right),
Text::from(selected_to_previous.p3_incomp).alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("recurring").alignment(Alignment::Left),
Text::from(selected_to_previous.recurring_comp).alignment(Alignment::Right),
Text::from(selected_to_previous.recurring_incomp)
.alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("non-recurring").alignment(Alignment::Left),
Text::from(selected_to_previous.non_recurring_comp)
.alignment(Alignment::Right),
Text::from(selected_to_previous.non_recurring_incomp)
.alignment(Alignment::Right),
]),
Row::new(vec![
Text::from("").alignment(Alignment::Left),
Text::from("").alignment(Alignment::Left),
Text::from("").alignment(Alignment::Left),
]),
],
&[
Constraint::Length(13),
Constraint::Length(10),
Constraint::Length(10),
],
);
frame.render_widget(info_block, top_right);
frame.render_widget(to_prev, info_top);
frame.render_widget(to_now, info_bottom);
} else {
let to_prev = Paragraph::new(vec![Line::from(
"At start - no previous entry to compare to.",
)])
.wrap(Wrap { trim: false });
frame.render_widget(info_block, top_right);
frame.render_widget(to_prev, info_top);
frame.render_widget(to_now, info_bottom);
};
}
controls_content.push(Line::from(""));
controls_content.push(Line::from("Navigation - table".blue().bold().underlined()));
controls_content.push(
vec![
"↑".blue(),
" or ".into(),
"k".blue(),
" Previous row".into(),
]
.into(),
);
controls_content.push(vec!["↓".blue(), " or ".into(), "j".blue(), " Next row".into()].into());
controls_content.push(
vec![
"PgDn".blue(),
" or ".into(),
"CTRL+d".blue(),
" Go down 30 rows".into(),
]
.into(),
);
controls_content.push(
vec![
"PgUp".blue(),
" or ".into(),
"CTRL+u".blue(),
" Go up 30 rows".into(),
]
.into(),
);
controls_content.push(vec!["Esc".blue(), " Go to top of log".into()].into());
controls_content.push(Line::from(""));
controls_content.push(Line::from("Graph toggles".blue().bold().underlined()));
controls_content.push(vec!["g".blue(), " Graph".into()].into());
controls_content.push(vec!["c".blue(), " By Completion Status".into()].into());
controls_content.push(vec!["r".blue(), " By Recurring Status".into()].into());
}
fn get_count_data(
task_counts: Vec<TaskCount>,
count_kind: CountKind,
today: NaiveDate,
) -> Vec<(i64, Option<usize>)> {
match count_kind {
CountKind::Incomplete => task_counts
.into_iter()
.map(|tc| ((tc.date - today).num_days(), tc.incomplete))
.collect(),
CountKind::Complete => task_counts
.into_iter()
.map(|tc| ((tc.date - today).num_days(), tc.complete))
.collect(),
CountKind::RecurringIncomplete => task_counts
.into_iter()
.map(|tc| ((tc.date - today).num_days(), tc.recurring_incomp))
.collect(),
CountKind::RecurringComplete => task_counts
.into_iter()
.map(|tc| ((tc.date - today).num_days(), tc.recurring_comp))
.collect(),
CountKind::NonRecurringIncomplete => task_counts
.into_iter()
.map(|tc| ((tc.date - today).num_days(), tc.non_recurring_incomp))
.collect(),
CountKind::NonRecurringComplete => task_counts
.into_iter()
.map(|tc| ((tc.date - today).num_days(), tc.non_recurring_comp))
.collect(),
}
}
fn to_f64(counts: Vec<(i64, Option<usize>)>) -> Vec<(f64, f64)> {
counts
.into_iter()
.filter(|x| x.1.is_some())
.map(|x| (x.0 as f64, x.1.unwrap() as f64))
.collect()
}
struct AxisData {
x_min: f64,
x_min_label: String,
x_mid_label: String,
y_min: f64,
y_min_label: String,
y_mid_label: String,
y_max: f64,
y_max_label: String,
}
pub fn create_axes<'a>(data: &[(i64, Option<usize>)], today: &NaiveDate) -> (Axis<'a>, Axis<'a>) {
let axes = create_axes_data(data, today);
(
Axis::default()
.title("Date".light_blue())
.style(Style::default().white())
.bounds([axes.x_min, 0.0])
.labels([axes.x_min_label, axes.x_mid_label, today.to_string()])
.labels_alignment(Alignment::Center),
Axis::default()
.title("Number of tasks".light_blue())
.style(Style::default().white())
.bounds([axes.y_min, axes.y_max])
.labels([axes.y_min_label, axes.y_mid_label, axes.y_max_label]),
)
}
fn create_axes_data(data: &[(i64, Option<usize>)], today: &NaiveDate) -> AxisData {
let mut data = data.to_vec();
data.retain(|x| x.1.is_some());
let x: Vec<i64> = data.iter().map(|x| x.0).collect();
let y: Vec<usize> = data.iter().filter_map(|x| x.1).collect();
let x_min = *x.iter().min().unwrap() as f64;
let x_mid = x_min as u64 + ((0.0 - x_min).round() as u64 / 2);
let y_max = *y.iter().max().unwrap() as f64;
let y_min = *y.iter().min().unwrap() as f64;
let y_mid = y_min as u64 + ((y_max - y_min).round() as u64 / 2);
let x_min_label = today
.checked_sub_days(Days::new(x_min.abs() as u64))
.unwrap()
.to_string();
let x_mid_label = today
.checked_sub_days(Days::new(x_mid))
.unwrap()
.to_string();
let y_max_label = y_max.to_string();
let y_min_label = y_min.to_string();
let y_mid_label = y_mid.to_string();
AxisData {
x_min,
x_min_label,
x_mid_label,
y_min,
y_min_label,
y_mid_label,
y_max,
y_max_label,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_axes_data_no_gaps_correct() {
let data = [
(-10, Some(100)),
(-9, Some(101)),
(-8, Some(102)),
(-7, Some(102)),
(-6, Some(103)),
(-5, Some(102)),
(-4, Some(102)),
(-3, Some(99)),
(-2, Some(98)),
(-1, Some(102)),
(0, Some(101)),
];
let today = Local::now().date_naive();
let axes = create_axes_data(&data, &today);
let oldest_date = today.checked_sub_days(Days::new(10)).unwrap();
assert_eq!(axes.x_min, -10.0);
assert_eq!(axes.x_min_label, format!("{oldest_date}"));
assert_eq!(axes.y_min, 98.0);
assert_eq!(axes.y_max, 103.0);
}
#[test]
fn create_axes_data_with_gaps_correct() {
let data = [
(-30, Some(100)),
(-10, Some(100)),
(-9, Some(101)),
(-8, Some(102)),
(-7, Some(102)),
(-6, Some(103)),
(-5, Some(102)),
(-4, Some(102)),
(-3, Some(99)),
(-2, Some(98)),
(-1, Some(102)),
(0, Some(101)),
];
let today = Local::now().date_naive();
let axes = create_axes_data(&data, &today);
let oldest_date = today.checked_sub_days(Days::new(30)).unwrap();
assert_eq!(axes.x_min, -30.0);
assert_eq!(axes.x_min_label, format!("{oldest_date}"));
assert_eq!(axes.y_min, 98.0);
assert_eq!(axes.y_max, 103.0);
}
#[test]
fn create_axes_data_disordered_correct() {
let data = [
(-3, Some(99)),
(-10, Some(100)),
(-9, Some(101)),
(-8, Some(102)),
(-7, Some(102)),
(0, Some(101)),
(-6, Some(103)),
(-5, Some(102)),
(-1, Some(102)),
(-30, Some(100)),
(-4, Some(102)),
(-2, Some(98)),
];
let today = Local::now().date_naive();
let axes = create_axes_data(&data, &today);
let oldest_date = today.checked_sub_days(Days::new(30)).unwrap();
assert_eq!(axes.x_min, -30.0);
assert_eq!(axes.x_min_label, format!("{oldest_date}"));
assert_eq!(axes.y_min, 98.0);
assert_eq!(axes.y_max, 103.0);
}
}