diff --git a/Cargo.toml b/Cargo.toml index 137f9b3..465d42b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,5 @@ tui = "0.8.0" termion = "1.5" serde_json = "1" serde = { version = "1", features = ["derive"] } -time = { version = "0.2", features = ["serde"] } +time = { version = "0.2.9", features = ["serde"] } +itertools = "0.9" diff --git a/src/main.rs b/src/main.rs index 5513c93..d0d606e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,9 @@ use std::io; use termion::raw::IntoRawMode; use tui::backend::TermionBackend; use tui::Terminal; +mod timesheet; mod todolist; mod tracc; -mod timesheet; use tracc::Tracc; fn main() -> Result<(), io::Error> { diff --git a/src/timesheet.rs b/src/timesheet.rs index 5a61e04..0a66755 100644 --- a/src/timesheet.rs +++ b/src/timesheet.rs @@ -1,104 +1,150 @@ +use super::tracc::ListView; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::from_reader; use std::fmt; use std::fs::File; use std::io::BufReader; -use time::Time; +use time::OffsetDateTime; pub struct TimeSheet { pub times: Vec, pub selected: usize, pub register: Option, + pub editing_time: bool, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct TimePoint { text: String, - time: Time, + time: OffsetDateTime, } impl TimePoint { pub fn new(text: &str) -> Self { Self { text: String::from(text), - time: Time::now(), + time: OffsetDateTime::now_local(), } } } impl fmt::Display for TimePoint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "[{}] {}", self.time.format("%H:%M"), self.text) + write!( + f, + "[{}] {}", + self.time + .to_offset(time::UtcOffset::current_local_offset()) + .format("%H:%M"), + self.text + ) } } +fn read_times(path: &str) -> Option> { + File::open(path) + .ok() + .map(|f| BufReader::new(f)) + .and_then(|r| from_reader(r).ok()) +} + impl TimeSheet { - pub fn new() -> Self { + pub fn open_or_create(path: &str) -> Self { Self { - times: vec![ - TimePoint::new("A test value"), - TimePoint::new("A second test value"), - ], + times: read_times(path).unwrap_or(vec![TimePoint::new("Did something")]), selected: 0, register: None, + editing_time: false, } } pub fn printable(&self) -> Vec { self.times.iter().map(TimePoint::to_string).collect() } + + fn current(&self) -> &TimePoint { + &self.times[self.selected] + } + + pub fn time_by_tasks(&self) -> String { + let mut time_by_task = std::collections::HashMap::new(); + let durations = self + .times + //.iter() + //.tuple_windows() + .windows(2) + .map(|ts| { + let prev = &ts[0]; + let next = &ts[1]; + let diff = next.time - prev.time; + (prev.text.clone(), diff) + }); + //.fold( + //std::collections::HashMap::new(), + //|mut map, (text, duration)| { + // *map.entry(text).or_insert(time::Duration::zero()) += duration; + // map + //}, + //); + for (text, duration) in durations { + *time_by_task.entry(text).or_insert(time::Duration::zero()) += duration; + } + let mut times: Vec<_> = time_by_task + .into_iter() + .map(|(text, duration)| format!("{}: {}", text, format_duration(&duration))) + .collect(); + times.sort(); + times.join("; ") + } + + pub fn sum_as_str(&self) -> String { + let total = self + .times + .windows(2) + .fold(time::Duration::zero(), |total, ts| { + let last = ts[0].time; + let next = ts[1].time; + total + (next - last) + }); + format_duration(&total) + } } -/* -impl TimeSheet { - pub fn selection_down(&mut self) { - self.selected = (self.selected + 1).min(self.todos.len().saturating_sub(1)); + +fn format_duration(d: &time::Duration) -> String { + format!("{}:{:02}", d.whole_hours(), d.whole_minutes().max(1) % 60) +} + +impl ListView for TimeSheet { + fn selection_pointer(&mut self) -> &mut usize { + &mut self.selected } - pub fn selection_up(&mut self) { - self.selected = self.selected.saturating_sub(1); + fn list(&mut self) -> &mut Vec { + &mut self.times } - pub fn insert(&mut self, todo: Todo) { - if self.selected == self.todos.len().saturating_sub(1) { - self.todos.push(todo); - self.selected = self.todos.len() - 1; - } else { - self.todos.insert(self.selected + 1, todo); - self.selected += 1; - } + fn register(&mut self) -> &mut Option { + &mut self.register } - pub fn remove_current(&mut self) -> Option { - if self.todos.is_empty() { - return None; - } - let index = self.selected; - self.selected = index.min(self.todos.len().saturating_sub(2)); - return Some(self.todos.remove(index)); - } - - pub fn toggle_current(&mut self) { - self.todos[self.selected].done = !self.todos[self.selected].done; - } - - fn current(&self) -> &Todo { - &self.todos[self.selected] - } - - pub fn normal_mode(&mut self) { + fn normal_mode(&mut self) { if self.current().text.is_empty() { self.remove_current(); self.selected = self.selected.saturating_sub(1); } + self.times.sort_by_key(|t| t.time); } - pub fn append_to_current(&mut self, chr: char) { - self.todos[self.selected].text.push(chr); + fn toggle_current(&mut self) { + self.editing_time = !self.editing_time; } - pub fn current_pop(&mut self) { - self.todos[self.selected].text.pop(); + fn append_to_current(&mut self, chr: char) { + self.times[self.selected].text.push(chr); } + fn backspace(&mut self) { + self.times[self.selected].text.pop(); + } } -*/ diff --git a/src/todolist.rs b/src/todolist.rs index 8412f5a..866f4e8 100644 --- a/src/todolist.rs +++ b/src/todolist.rs @@ -1,9 +1,9 @@ +use crate::tracc::ListView; use serde::{Deserialize, Serialize}; use serde_json::from_reader; -use std::fs::File; use std::fmt; +use std::fs::File; use std::io::BufReader; -use crate::tracc::ListView; pub struct TodoList { pub todos: Vec, @@ -29,7 +29,7 @@ impl Todo { impl fmt::Display for Todo { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "[{}] {}", if self.done { 'x' } else { ' ' }, self.text) + write!(f, "[{}] {}", if self.done { 'x' } else { ' ' }, self.text) } } @@ -42,49 +42,29 @@ fn read_todos(path: &str) -> Option> { impl TodoList { pub fn open_or_create(path: &str) -> Self { - TodoList { + Self { todos: read_todos(path).unwrap_or(vec![Todo::new("This is a list entry")]), selected: 0, register: None, } } - pub fn toggle_current(&mut self) { - self.todos[self.selected].done = !self.todos[self.selected].done; - } - fn current(&self) -> &Todo { &self.todos[self.selected] } } impl ListView for TodoList { - fn selection_down(&mut self) { - self.selected = (self.selected + 1).min(self.todos.len().saturating_sub(1)); + fn selection_pointer(&mut self) -> &mut usize { + &mut self.selected } - fn selection_up(&mut self) { - self.selected = self.selected.saturating_sub(1); + fn list(&mut self) -> &mut Vec { + &mut self.todos } - fn insert

(&mut self, todo: Todo, position: P) where P: Into> { - let pos = position.into().unwrap_or(self.selected); - if pos == self.todos.len().saturating_sub(1) { - self.todos.push(todo); - self.selected = self.todos.len() - 1; - } else { - self.todos.insert(pos + 1, todo); - self.selected = pos + 1; - } - } - - fn remove_current(&mut self) { - if self.todos.is_empty() { - return; - } - let index = self.selected; - self.selected = index.min(self.todos.len().saturating_sub(2)); - self.register = self.todos.remove(index).into(); + fn register(&mut self) -> &mut Option { + &mut self.register } fn normal_mode(&mut self) { @@ -102,14 +82,7 @@ impl ListView for TodoList { self.todos[self.selected].text.pop(); } - fn printable(&self) -> Vec { - self.todos.iter().map(Todo::to_string).collect() - } - - fn paste(&mut self) { - if self.register.is_some() { - // Is there a better way? - self.insert(self.register.as_ref().unwrap().clone(), None); - } + fn toggle_current(&mut self) { + self.todos[self.selected].done = !self.todos[self.selected].done; } } diff --git a/src/tracc.rs b/src/tracc.rs index 213de43..ae71018 100644 --- a/src/tracc.rs +++ b/src/tracc.rs @@ -1,6 +1,7 @@ +use super::timesheet::{TimePoint, TimeSheet}; use super::todolist::TodoList; -use super::timesheet::TimeSheet; use std::default::Default; +use std::fmt; use std::io::{self, Write}; use termion::event::Key; use termion::input::TermRead; @@ -10,7 +11,8 @@ use tui::style::{Color, Style}; use tui::widgets::*; type Terminal = tui::Terminal>>; -const JSON_PATH: &str = "tracc.json"; +const JSON_PATH_TIME: &str = "tracc_time.json"; +const JSON_PATH_TODO: &str = "tracc_todo.json"; pub enum Mode { Insert, @@ -34,8 +36,8 @@ pub struct Tracc { impl Tracc { pub fn new(terminal: Terminal) -> Self { Self { - todos: TodoList::open_or_create(JSON_PATH), - times: TimeSheet::new(), + todos: TodoList::open_or_create(JSON_PATH_TODO), + times: TimeSheet::open_or_create(JSON_PATH_TIME), terminal, input_mode: Mode::Normal, focus: Focus::Top, @@ -51,21 +53,39 @@ impl Tracc { match self.input_mode { Mode::Normal => match input { Key::Char('q') => break, - Key::Char('j') => self.todos.selection_down(), - Key::Char('k') => self.todos.selection_up(), + Key::Char('j') => match self.focus { + Focus::Top => self.todos.selection_down(), + Focus::Bottom => self.times.selection_down(), + }, + Key::Char('k') => match self.focus { + Focus::Top => self.todos.selection_up(), + Focus::Bottom => self.times.selection_up(), + }, Key::Char('o') => { - self.todos.insert(Default::default(), None); + match self.focus { + Focus::Top => self.todos.insert(Default::default(), None), + Focus::Bottom => self.times.insert(TimePoint::new(""), None), + } self.set_mode(Mode::Insert)?; } Key::Char('a') | Key::Char('A') => self.set_mode(Mode::Insert)?, - Key::Char(' ') => self.todos.toggle_current(), + Key::Char(' ') => match self.focus { + Focus::Top => self.todos.toggle_current(), + Focus::Bottom => self.times.toggle_current(), + }, // dd Key::Char('d') => { if let Key::Char('d') = inputs.next().unwrap()? { - self.todos.remove_current() + match self.focus { + Focus::Top => self.todos.remove_current(), + Focus::Bottom => self.times.remove_current(), + } } } - Key::Char('p') => self.todos.paste(), + Key::Char('p') => match self.focus { + Focus::Top => self.todos.paste(), + Focus::Bottom => self.times.paste(), + }, Key::Char('\t') => { self.focus = match self.focus { Focus::Top => Focus::Bottom, @@ -76,14 +96,20 @@ impl Tracc { }, Mode::Insert => match input { Key::Char('\n') | Key::Esc => self.set_mode(Mode::Normal)?, - Key::Backspace => self.todos.backspace(), - Key::Char(x) => self.todos.append_to_current(x), + Key::Backspace => match self.focus { + Focus::Top => self.todos.backspace(), + Focus::Bottom => self.times.backspace(), + }, + Key::Char(x) => match self.focus { + Focus::Top => self.todos.append_to_current(x), + Focus::Bottom => self.times.append_to_current(x), + }, _ => (), }, }; } self.terminal.clear()?; - persist_todos(&self.todos, JSON_PATH); + persist_state(&self.todos, &self.times); Ok(()) } @@ -92,6 +118,7 @@ impl Tracc { Mode::Insert => self.terminal.show_cursor()?, Mode::Normal => { self.todos.normal_mode(); + self.times.normal_mode(); self.terminal.hide_cursor()? } }; @@ -121,6 +148,8 @@ impl Tracc { let top_focus = Some(self.todos.selected).filter(|_| self.focus == Focus::Top); let printable_times = self.times.printable(); let bottom_focus = Some(self.times.selected).filter(|_| self.focus == Focus::Bottom); + let total_time = self.times.sum_as_str(); + let times = self.times.time_by_tasks(); self.terminal.draw(|mut frame| { let size = frame.size(); @@ -137,36 +166,91 @@ impl Tracc { .split(size); selectable_list(" t r a c c ", &printable_todos, top_focus) .render(&mut frame, chunks[0]); - selectable_list(" 🕑 ", &printable_times, bottom_focus) - .render(&mut frame, chunks[1]); - Paragraph::new([Text::raw("Sum for today: 1:12")].iter()) - .block(Block::default().borders(Borders::ALL)) - .render(&mut frame, chunks[2]); + selectable_list(" 🕑 ", &printable_times, bottom_focus).render(&mut frame, chunks[1]); + Paragraph::new( + [Text::raw(format!( + "Sum for today: {}\n{}", + total_time, times + ))] + .iter(), + ) + .block(Block::default().borders(Borders::ALL)) + .render(&mut frame, chunks[2]); })?; Ok(()) } } -fn persist_todos(todos: &TodoList, path: &str) { - let string = serde_json::to_string(&todos.todos).unwrap(); - std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path) - .ok() - .or_else(|| panic!("Can’t save todos to JSON. Dumping raw data:\n{}", string)) - .map(|mut f| f.write(string.as_bytes())); +fn persist_state(todos: &TodoList, times: &TimeSheet) { + fn write(path: &str, content: String) { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .ok() + .or_else(|| panic!("Can’t save state to JSON. Dumping raw data:\n{}", content)) + .map(|mut f| f.write(content.as_bytes())); + } + let todos = serde_json::to_string(&todos.todos).unwrap(); + write(JSON_PATH_TODO, todos); + let times = serde_json::to_string(×.times).unwrap(); + write(JSON_PATH_TIME, times); } -pub trait ListView { - fn printable(&self) -> Vec; - fn selection_up(&mut self); - fn selection_down(&mut self); - fn insert

(&mut self, todo: T, position: P) where P: Into>; - fn paste(&mut self); - fn remove_current(&mut self); +pub trait ListView { + // get properties of implementations + fn selection_pointer(&mut self) -> &mut usize; + fn list(&mut self) -> &mut Vec; + fn register(&mut self) -> &mut Option; + + // specific input handling fn backspace(&mut self); fn append_to_current(&mut self, chr: char); fn normal_mode(&mut self); + fn toggle_current(&mut self); + + // selection manipulation + fn selection_up(&mut self) { + *self.selection_pointer() = self.selection_pointer().saturating_sub(1); + } + + fn selection_down(&mut self) { + *self.selection_pointer() = + (*self.selection_pointer() + 1).min(self.list().len().saturating_sub(1)); + } + + // adding/removing elements + fn insert(&mut self, item: T, position: Option) { + let pos = position.unwrap_or(*self.selection_pointer()); + if pos == self.list().len().saturating_sub(1) { + self.list().push(item); + *self.selection_pointer() = self.list().len() - 1; + } else { + self.list().insert(pos + 1, item); + *self.selection_pointer() = pos + 1; + } + } + + fn remove_current(&mut self) { + if self.list().is_empty() { + return; + } + let index = *self.selection_pointer(); + *self.selection_pointer() = index.min(self.list().len().saturating_sub(2)); + *self.register() = self.list().remove(index).into(); + } + + fn paste(&mut self) { + let register = self.register().clone(); + match register { + Some(item) => self.insert(item, None), + None => (), + } + } + + // printing + fn printable(&mut self) -> Vec { + self.list().iter().map(T::to_string).collect() + } }