aboutsummaryrefslogtreecommitdiff
path: root/apps/cassiopeia/src
diff options
context:
space:
mode:
authorMx Kookie <kookie@spacekookie.de>2020-12-19 15:15:20 +0000
committerMx Kookie <kookie@spacekookie.de>2020-12-21 05:19:49 +0100
commit69eaad1c9f934bccaf7e28529a6b1657345f0184 (patch)
tree4e7517a16f7303f5f6efbd02e4aae85bdc5aec29 /apps/cassiopeia/src
parentb9c988f42504c2e4cfa0715ac8f2d2a0db591cad (diff)
cassiopeia: changing internal data representation to timeline module
What this allows us to do is much better relationship tracking between sessions and invoices. The CASS file already has all the structure we need, and it would be silly to replicate it via complicated time association algorithms. This approach uses the linear nature of the data file to track the position relative to other entries. The timeline module is then responsible for making changes to the internal representation (in case it is being used as a library for multi-query commands), and emitting a `Delta` type that can be used to easily patch the IR in question, because the mapping between the timeline and IR representations is linear.
Diffstat (limited to 'apps/cassiopeia/src')
-rw-r--r--apps/cassiopeia/src/bin/cass.rs8
-rw-r--r--apps/cassiopeia/src/data.rs142
-rw-r--r--apps/cassiopeia/src/error.rs72
-rw-r--r--apps/cassiopeia/src/lib.rs19
-rw-r--r--apps/cassiopeia/src/meta.rs3
-rw-r--r--apps/cassiopeia/src/time.rs9
-rw-r--r--apps/cassiopeia/src/timeline.rs132
7 files changed, 273 insertions, 112 deletions
diff --git a/apps/cassiopeia/src/bin/cass.rs b/apps/cassiopeia/src/bin/cass.rs
index 90f84661ae0c..8bceddc911a4 100644
--- a/apps/cassiopeia/src/bin/cass.rs
+++ b/apps/cassiopeia/src/bin/cass.rs
@@ -64,6 +64,7 @@ If you want to report a bug, please do so on my mailing list: lists.sr.ht/~space
.help(meta::ARG_CLIENT_DB_ABOUT),
)
)
+ .subcommand(SubCommand::with_name(meta::CMD_STAT).about(meta::CMD_STAT_ABOUT))
.get_matches();
let cass_file = cli.value_of(meta::ARG_FILE).unwrap();
@@ -119,6 +120,13 @@ If you want to report a bug, please do so on my mailing list: lists.sr.ht/~space
std::process::exit(1);
}
},
+ (meta::CMD_STAT, _) => match cass.stat() {
+ Some(s) => println!("{}", s),
+ None => {
+ eprintln!("Failed to collect time statistics...");
+ std::process::exit(1);
+ }
+ },
(_, _) => todo!(),
}
}
diff --git a/apps/cassiopeia/src/data.rs b/apps/cassiopeia/src/data.rs
index 188c0255203d..3034d020b1e9 100644
--- a/apps/cassiopeia/src/data.rs
+++ b/apps/cassiopeia/src/data.rs
@@ -5,7 +5,9 @@
//! analysis tasks.
use crate::{
+ error::{ParseError, ParseResult, UserResult},
format::ir::{IrItem, IrType, MakeIr},
+ timeline::{Entry, Timeline},
Date, Time,
};
use chrono::{DateTime, Duration, FixedOffset as Offset, Local, NaiveDate};
@@ -14,116 +16,42 @@ use std::collections::BTreeMap;
#[derive(Debug, Default)]
pub struct TimeFile {
/// A parsed header structure
- header: BTreeMap<String, String>,
- /// A parsed session structure
- sessions: Vec<Session>,
- /// A parsed invoice list
- invoices: Vec<Invoice>,
+ pub(crate) header: BTreeMap<String, String>,
+ /// A parsed timeline of events
+ pub(crate) timeline: Timeline,
}
impl TimeFile {
- pub(crate) fn append(&mut self, line: IrItem) {
+ /// Append entries to the timeline from the parsed IR
+ ///
+ /// Report any errors that occur back to the parser, that will
+ /// print a message to the user and terminate the program.
+ pub(crate) fn append(&mut self, line: IrItem) -> ParseResult<()> {
match line {
IrItem {
tt: IrType::Header(ref header),
..
- } => self.header = header.clone(),
+ } => Ok(header.iter().for_each(|(k, v)| {
+ self.header.insert(k.clone(), v.clone());
+ })),
IrItem {
tt: IrType::Start(time),
lo,
- } => self.sessions.push(Session::start(time.into())),
+ } => Ok(self.timeline.start(time).map(|_| ())?),
IrItem {
tt: IrType::Stop(time),
lo,
- } => self.get_last_session().unwrap().stop(time.into()),
+ } => Ok(self.timeline.stop(time).map(|_| ())?),
IrItem {
tt: IrType::Invoice(date),
lo,
- } => self.invoices.push(Invoice::new(date.into())),
- _ => {}
- }
- }
-
- fn get_last_session(&mut self) -> Option<&mut Session> {
- self.sessions.last_mut()
- }
-
- fn get_last_invoice(&mut self) -> Option<&mut Invoice> {
- self.invoices.last_mut()
- }
-
- /// Start a new session (optionally 15-minute rounded)
- ///
- /// This function returns the new session object that will have to
- /// be turned into an IR line to be written back into the file
- pub(crate) fn start(&mut self, round: bool) -> Option<Session> {
- // Check if the last session was closed
- match self.get_last_session() {
- Some(s) if !s.finished() => return None,
- _ => {}
- }
-
- // Create a new time
- let now = if round {
- Time::now().round()
- } else {
- Time::now()
- };
-
- Some(Session::start(now))
- }
-
- /// Stop the last session that was started, returning a completed
- /// session
- pub(crate) fn stop(&mut self, round: bool) -> Option<Session> {
- match self.get_last_session() {
- Some(s) if s.finished() => return None,
- None => return None,
- _ => {}
- }
-
- // Create a new time
- let now = if round {
- Time::now().round()
- } else {
- Time::now()
- };
-
- self.get_last_session().cloned().map(|mut s| {
- s.stop(now);
- s
- })
- }
-
- /// Add a new invoice block to the time file
- pub(crate) fn invoice(&mut self) -> Option<Invoice> {
- let today = Date::today();
-
- let last_sess = self.get_last_session().cloned();
-
- match self.get_last_invoice() {
- // Check if _today_ there has been already an invoice
- Some(i) if i.date == today => return None,
-
- // Check if since the last invoice there has been at least
- // _one_ terminated session.
- Some(i)
- if !last_sess
- .map(|s| !s.stop.map(|s| s.after(&i.date)).unwrap_or(false))
- .unwrap_or(false) =>
- {
- return None
- }
-
- // Otherwise, we create an invoice
- _ => {}
+ } => Ok(self.timeline.invoice(date).map(|_| ())?),
+ _ => Err(ParseError::Unknown),
}
-
- Some(Invoice::new(today))
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Session {
start: Time,
stop: Option<Time>,
@@ -131,12 +59,12 @@ pub struct Session {
impl Session {
/// Create a new session with a start time
- fn start(start: Time) -> Self {
+ pub(crate) fn start(start: Time) -> Self {
Self { start, stop: None }
}
/// Finalise a session with a stop time
- fn stop(&mut self, stop: Time) {
+ pub(crate) fn stop(&mut self, stop: Time) {
self.stop = Some(stop);
}
@@ -151,28 +79,30 @@ impl Session {
}
}
-impl MakeIr for Session {
- fn make_ir(&self) -> IrType {
- match self.stop {
- Some(ref time) => IrType::Stop(time.clone()),
- None => IrType::Start(self.start.clone()),
- }
- }
-}
-
-#[derive(Debug)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Invoice {
- date: Date,
+ pub(crate) date: Date,
}
impl Invoice {
- fn new(date: Date) -> Self {
+ pub(crate) fn new(date: Date) -> Self {
Self { date }
}
}
-impl MakeIr for Invoice {
+/// Changes to the timeline are encoded in a delta
+pub(crate) enum Delta {
+ Start(Time),
+ Stop(Time),
+ Invoice(Date),
+}
+
+impl MakeIr for Delta {
fn make_ir(&self) -> IrType {
- IrType::Invoice(self.date.clone())
+ match self {
+ Self::Start(ref time) => IrType::Start(time.clone()),
+ Self::Stop(ref time) => IrType::Stop(time.clone()),
+ Self::Invoice(ref date) => IrType::Invoice(date.clone()),
+ }
}
}
diff --git a/apps/cassiopeia/src/error.rs b/apps/cassiopeia/src/error.rs
new file mode 100644
index 000000000000..31f5414c4f86
--- /dev/null
+++ b/apps/cassiopeia/src/error.rs
@@ -0,0 +1,72 @@
+//! A set of error types for cassiopeia
+
+use std::error::Error;
+use std::fmt::{self, Display, Formatter};
+
+/// User errors that can occur when using cassiopeia
+///
+/// None of these errors are the fault of the program, but rather
+/// fault of the user for giving invalid commands. They must never
+/// make the program crash, but instead need to print human friendly
+/// error messages.
+#[derive(Debug)]
+pub enum UserError {
+ /// Trying to start a session when one exists
+ ActiveSessionExists,
+ /// Trying to stop a session when none exists
+ NoActiveSession,
+ /// Trying to create a second invoice on the same day
+ SameDayInvoice,
+ /// No work was done since the last invoice
+ NoWorkInvoice,
+}
+
+impl Display for UserError {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "You're doing it wrong!")
+ }
+}
+
+impl Error for UserError {}
+
+pub type UserResult<T> = Result<T, UserError>;
+
+/// Errors that occur when parsing a file
+///
+/// These errors can pre-maturely terminate the run of the program,
+/// but must print a detailed error about what is wrong. Also,
+/// because they are technically a superset of
+/// [`UserError`](self::UserError), one of the variants is an embedded
+/// user error.
+#[derive(Debug)]
+pub enum ParseError {
+ /// An embedded user error
+ ///
+ /// This error means that the structure of the parsed file is
+ /// wrong, with an invalid sequence of events expressed
+ User(UserError),
+ /// An invalid keyword was found
+ BadKeyword { line: usize, tokn: String },
+ /// A bad timestamp was found
+ BadTimestamp { line: usize, tokn: String },
+ /// A bad date was found
+ BadDate { line: usize, tokn: String },
+ /// An unknown parse error occured
+ Unknown,
+}
+
+impl Display for ParseError {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ write!(f, "The parsed file was bad :(")
+ }
+}
+
+impl Error for ParseError {}
+
+pub type ParseResult<T> = Result<T, ParseError>;
+
+impl From<UserError> for ParseError {
+ fn from(user: UserError) -> Self {
+ ParseError::User(user)
+ }
+}
diff --git a/apps/cassiopeia/src/lib.rs b/apps/cassiopeia/src/lib.rs
index e391c695237e..895110fe218d 100644
--- a/apps/cassiopeia/src/lib.rs
+++ b/apps/cassiopeia/src/lib.rs
@@ -10,9 +10,11 @@
mod data;
mod date;
+mod error;
mod format;
pub mod meta;
mod time;
+mod timeline;
pub use date::Date;
pub use time::Time;
@@ -46,17 +48,17 @@ impl Cassiopeia {
/// Start a new work session (with optional 15 minute rounding)
pub fn start(&mut self, round: bool) -> Option<()> {
- let s = self.tf.start(round)?;
+ let delta = self.tf.timeline.start(Time::rounded(round)).ok()?;
clean_ir(&mut self.ir);
- append_ir(&mut self.ir, s.make_ir());
+ append_ir(&mut self.ir, delta.make_ir());
format::write_file(self.path.as_str(), &mut self.ir)
}
/// Stop the existing work session (with optional 15 minute rounding)
pub fn stop(&mut self, round: bool) -> Option<()> {
- let s = self.tf.stop(round)?;
+ let delta = self.tf.timeline.stop(Time::rounded(round)).ok()?;
clean_ir(&mut self.ir);
- append_ir(&mut self.ir, s.make_ir());
+ append_ir(&mut self.ir, delta.make_ir());
format::write_file(self.path.as_str(), &mut self.ir)
}
@@ -70,6 +72,11 @@ impl Cassiopeia {
clean_ir(&mut self.ir);
format::write_file(self.path.as_str(), &mut self.ir)
}
+
+ /// Collect statistics on previous work sessions
+ pub fn stat(&self) -> Option<String> {
+ None
+ }
}
/// An invoice generator builder
@@ -142,9 +149,9 @@ impl<'cass> Invoicer<'cass> {
return None;
}
- let inv = self.tf.tf.invoice()?;
+ let delta = self.tf.tf.timeline.invoice(Date::today()).ok()?;
clean_ir(&mut self.tf.ir);
- append_ir(&mut self.tf.ir, inv.make_ir());
+ append_ir(&mut self.tf.ir, delta.make_ir());
format::write_file(self.tf.path.as_str(), &mut self.tf.ir)
}
}
diff --git a/apps/cassiopeia/src/meta.rs b/apps/cassiopeia/src/meta.rs
index d29f8b9b30e0..ec71498986e3 100644
--- a/apps/cassiopeia/src/meta.rs
+++ b/apps/cassiopeia/src/meta.rs
@@ -41,3 +41,6 @@ pub const ARG_GEN_YAML_ABOUT: &'static str =
pub const ARG_CLIENT_DB: &'static str = "CLIENT_DB";
pub const ARG_CLIENT_DB_ABOUT: &'static str =
"Provide your client database file (.yml format) used by invoice(1)";
+
+pub const CMD_STAT: &'static str = "stat";
+pub const CMD_STAT_ABOUT: &'static str = "Get statistics of previous work sessions";
diff --git a/apps/cassiopeia/src/time.rs b/apps/cassiopeia/src/time.rs
index 0161742a0c92..a5147b93aaac 100644
--- a/apps/cassiopeia/src/time.rs
+++ b/apps/cassiopeia/src/time.rs
@@ -47,6 +47,15 @@ impl Time {
}
}
+ /// Get the time that might be rounded to the next 15 minutes
+ pub(crate) fn rounded(r: bool) -> Self {
+ if r {
+ Time::now().round()
+ } else {
+ Time::now()
+ }
+ }
+
pub(crate) fn date(&self) -> chrono::Date<Offset> {
self.inner.date()
}
diff --git a/apps/cassiopeia/src/timeline.rs b/apps/cassiopeia/src/timeline.rs
new file mode 100644
index 000000000000..70119b30a725
--- /dev/null
+++ b/apps/cassiopeia/src/timeline.rs
@@ -0,0 +1,132 @@
+use crate::{
+ data::{Delta, Invoice, Session},
+ error::{UserError, UserResult},
+ Date, Time,
+};
+
+/// A timeline entry of sessions and invoices
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Entry {
+ Session(Session),
+ Invoice(Invoice),
+}
+
+impl From<Session> for Entry {
+ fn from(s: Session) -> Self {
+ Self::Session(s)
+ }
+}
+
+impl From<Invoice> for Entry {
+ fn from(i: Invoice) -> Self {
+ Self::Invoice(i)
+ }
+}
+
+/// A timeline of sessions and invoices, ordered chronologically
+#[derive(Debug, Default, Clone)]
+pub(crate) struct Timeline {
+ inner: Vec<Entry>,
+}
+
+impl Timeline {
+ /// Take a set of sessions and invoices to sort into a timeline
+ pub(crate) fn build(s: Vec<Session>, i: Vec<Invoice>) -> Self {
+ let mut inner: Vec<_> = s.into_iter().map(|s| Entry::Session(s)).collect();
+ inner.append(&mut i.into_iter().map(|i| Entry::Invoice(i)).collect());
+ Self { inner }
+ }
+
+ /// Utility function to get the last session in the timeline
+ fn last_session(&mut self) -> Option<&mut Session> {
+ self.inner
+ .iter_mut()
+ .find(|e| match e {
+ Entry::Session(_) => true,
+ _ => false,
+ })
+ .map(|e| match e {
+ Entry::Session(ref mut s) => s,
+ _ => unreachable!(),
+ })
+ }
+
+ /// Utility function to get the last invoice in the timeline
+ fn last_invoice(&self) -> Option<&Invoice> {
+ self.inner
+ .iter()
+ .find(|e| match e {
+ Entry::Invoice(_) => true,
+ _ => false,
+ })
+ .map(|e| match e {
+ Entry::Invoice(ref s) => s,
+ _ => unreachable!(),
+ })
+ }
+
+ /// Get a list of sessions that happened up to a certain invoice date
+ ///
+ /// **WARNING** If there is no invoice with the given date, this
+ /// function will return garbage data, so don't call it with
+ /// invoice dates that don't exist.
+ ///
+ /// Because: if the date passes other invoices on the way, the accumulator
+ /// will be discarded and a new count will be started.
+ pub(crate) fn session_iter(&self, date: &Date) -> Vec<&Session> {
+ self.inner
+ .iter()
+ .fold((false, vec![]), |(mut done, mut acc), entry| {
+ match (done, entry) {
+ // Put sessions into the accumulator
+ (false, Entry::Session(ref s)) => acc.push(s),
+ // When we reach the target invoice, terminate the iterator
+ (false, Entry::Invoice(ref i)) if &i.date == date => done = true,
+ // When we hit another invoice, empty accumulator
+ (false, Entry::Invoice(_)) => acc.clear(),
+ // When we are ever "done", skip all other entries
+ (true, _) => {}
+ }
+
+ (done, acc)
+ })
+ .1
+ }
+
+ /// Start a new session, if no active session is already in progress
+ pub(crate) fn start(&mut self, time: Time) -> UserResult<Delta> {
+ match self.last_session() {
+ Some(s) if !s.finished() => Err(UserError::ActiveSessionExists),
+ _ => Ok(()),
+ }?;
+
+ self.inner.push(Session::start(time.clone()).into());
+ Ok(Delta::Start(time))
+ }
+
+ /// Stop an ongoing session, if one exists
+ pub(crate) fn stop(&mut self, time: Time) -> UserResult<Delta> {
+ match self.last_session() {
+ Some(s) if s.finished() => Err(UserError::NoActiveSession),
+ _ => Ok(()),
+ }?;
+
+ self.last_session().unwrap().stop(time.clone());
+ Ok(Delta::Stop(time))
+ }
+
+ /// Create a new invoice on the given day
+ pub(crate) fn invoice(&mut self, date: Date) -> UserResult<Delta> {
+ match self.last_invoice() {
+ // If an invoice on the same day exists already
+ Some(i) if i.date == date => Err(UserError::SameDayInvoice),
+ // If there was no work since the last invoice
+ Some(ref i) if self.session_iter(&i.date).len() == 0 => Err(UserError::NoWorkInvoice),
+ // Otherwise everything is coolio
+ _ => Ok(()),
+ }?;
+
+ self.inner.push(Invoice::new(date.clone()).into());
+ Ok(Delta::Invoice(date))
+ }
+}