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.
wip/yesman
Katharina Fey 3 years ago
parent b9c988f425
commit 69eaad1c9f
  1. 8
      apps/cassiopeia/src/bin/cass.rs
  2. 142
      apps/cassiopeia/src/data.rs
  3. 72
      apps/cassiopeia/src/error.rs
  4. 19
      apps/cassiopeia/src/lib.rs
  5. 3
      apps/cassiopeia/src/meta.rs
  6. 9
      apps/cassiopeia/src/time.rs
  7. 132
      apps/cassiopeia/src/timeline.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!(),
}
}

@ -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()),
}
}
}

@ -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)
}
}

@ -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)
}
}

@ -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";

@ -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()
}

@ -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))
}
}
Loading…
Cancel
Save