Browse Source

A simple time-tracker

The intended use of this tool is to track how long I am at work.
Besides, it was a really nice exercise in doing Rust.
master
Andreas Linz 2 years ago
commit
fd45eb4d81
Signed by: alinz GPG Key ID: 9BF39809C9DA580C
4 changed files with 487 additions and 0 deletions
  1. +2
    -0
      .gitignore
  2. +147
    -0
      Cargo.lock
  3. +9
    -0
      Cargo.toml
  4. +329
    -0
      src/main.rs

+ 2
- 0
.gitignore View File

@ -0,0 +1,2 @@
/target
**/*.rs.bk

+ 147
- 0
Cargo.lock View File

@ -0,0 +1,147 @@
[[package]]
name = "bitflags"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "chrono"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"num-integer 0.1.39 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)",
"time 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "dirs"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "libc"
version = "0.2.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "libsqlite3-sys"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)",
"vcpkg 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "linked-hash-map"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "lru-cache"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"linked-hash-map 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-integer"
version = "0.1.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"num-traits 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-traits"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "pkg-config"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "redox_syscall"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "rusqlite"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
"chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
"libsqlite3-sys 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)",
"lru-cache 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
"time 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "time"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)",
"redox_syscall 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "tt"
version = "0.1.0"
dependencies = [
"chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
"dirs 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
"rusqlite 0.14.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "vcpkg"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[metadata]
"checksum bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "228047a76f468627ca71776ecdebd732a3423081fcf5125585bcd7c49886ce12"
"checksum chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)" = "45912881121cb26fad7c38c17ba7daa18764771836b34fab7d3fbd93ed633878"
"checksum dirs 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "f679c09c1cf5428702cc10f6846c56e4e23420d3a88bcc9335b17c630a7b710b"
"checksum libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)" = "76e3a3ef172f1a0b9a9ff0dd1491ae5e6c948b94479a3021819ba7d860c8645d"
"checksum libsqlite3-sys 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)" = "d3711dfd91a1081d2458ad2d06ea30a8755256e74038be2ad927d94e1c955ca8"
"checksum linked-hash-map 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7860ec297f7008ff7a1e3382d7f7e1dcd69efc94751a2284bafc3d013c2aa939"
"checksum lru-cache 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4d06ff7ff06f729ce5f4e227876cb88d10bc59cd4ae1e09fbb2bde15c850dc21"
"checksum num-integer 0.1.39 (registry+https://github.com/rust-lang/crates.io-index)" = "e83d528d2677f0518c570baf2b7abdcf0cd2d248860b68507bdcb3e91d4c0cea"
"checksum num-traits 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)" = "630de1ef5cc79d0cdd78b7e33b81f083cbfe90de0f4b2b2f07f905867c70e9fe"
"checksum pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "676e8eb2b1b4c9043511a9b7bea0915320d7e502b0a079fb03f9635a5252b18c"
"checksum redox_syscall 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)" = "c214e91d3ecf43e9a4e41e578973adeb14b474f2bee858742d127af75a0112b1"
"checksum rusqlite 0.14.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c9d9118f1ce84d8d0b67f9779936432fb42bb620cef2122409d786892cce9a3c"
"checksum time 0.1.40 (registry+https://github.com/rust-lang/crates.io-index)" = "d825be0eb33fda1a7e68012d51e9c7f451dc1a69391e7fdc197060bb8c56667b"
"checksum vcpkg 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "def296d3eb3b12371b2c7d0e83bfe1403e4db2d7a0bba324a12b21c4ee13143d"
"checksum winapi 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)" = "773ef9dcc5f24b7d850d0ff101e542ff24c3b090a9768e03ff889fdef41f00fd"
"checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
"checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

+ 9
- 0
Cargo.toml View File

@ -0,0 +1,9 @@
[package]
name = "tt"
version = "0.1.0"
authors = ["Andreas Linz <klingt.net@gmail.com>"]
[dependencies]
chrono = { version="0.4" }
rusqlite = { version="0.14", features=["chrono"] }
dirs = "1"

+ 329
- 0
src/main.rs View File

@ -0,0 +1,329 @@
extern crate chrono;
extern crate dirs;
extern crate rusqlite;
use std::collections::VecDeque;
use std::convert::From;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use chrono::prelude::*;
use chrono::Duration;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, Value, ValueRef};
#[derive(Debug, Clone, PartialEq)]
enum State {
Work,
Pause,
Unknown,
}
impl From<String> for State {
fn from(val: String) -> Self {
match val.as_ref() {
"work" => State::Work,
"pause" => State::Pause,
_ => State::Unknown,
}
}
}
impl From<State> for String {
fn from(val: State) -> Self {
match val {
State::Work => "work",
State::Pause => "pause",
State::Unknown => "unknown",
}.to_string()
}
}
impl FromSql for State {
fn column_result(value: ValueRef) -> FromSqlResult<Self> {
if let ValueRef::Text(s) = value {
Ok(s.to_string().into())
} else {
Err(FromSqlError::InvalidType)
}
}
}
impl ToSql for State {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput> {
Ok(ToSqlOutput::Owned(Value::Text(self.clone().into())))
}
}
#[derive(Debug)]
struct Record {
timestamp: DateTime<Local>,
state: State,
}
impl Record {
fn new(state: State) -> Self {
Record {
timestamp: Local::now(),
state,
}
}
fn insert(self, conn: &rusqlite::Connection) -> Result<usize, TTError> {
conn.execute(
"INSERT INTO Timestamps VALUES (?1, ?2)",
&[&self.timestamp.naive_local(), &self.state],
).map_err(TTError::RecordInsertionError)
}
fn from_row(row: &rusqlite::Row) -> Result<Self, TTError> {
Ok(Record {
timestamp: row.get_checked(0).map_err(TTError::RecordMappingError)?,
state: row.get_checked(1).map_err(TTError::RecordMappingError)?,
})
}
}
#[derive(Debug)]
enum TTError {
DataDirNotFound,
UnknownCommand,
DatabaseConnectionError(rusqlite::Error),
DatabaseCreationError(rusqlite::Error),
RecordInsertionError(rusqlite::Error),
RecordSelectionError(rusqlite::Error),
RecordMappingError(rusqlite::Error),
}
impl Display for TTError {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl Error for TTError {
fn description(&self) -> &str {
match self {
TTError::DataDirNotFound => "unable to find data directory",
TTError::UnknownCommand => "unknown command",
TTError::DatabaseConnectionError(_) => "database connection failed",
TTError::DatabaseCreationError(_) => "failed to create database",
TTError::RecordInsertionError(_) => "failed to insert record database",
TTError::RecordSelectionError(_) => "failed to select today's records",
TTError::RecordMappingError(_) => "failed to map record from database row",
}
}
fn cause(&self) -> Option<&Error> {
match self {
TTError::DatabaseConnectionError(err) => Some(err),
TTError::DatabaseCreationError(err) => Some(err),
TTError::RecordInsertionError(err) => Some(err),
TTError::RecordSelectionError(err) => Some(err),
TTError::RecordMappingError(err) => Some(err),
_ => None,
}
}
}
fn database_path() -> Result<PathBuf, TTError> {
let mut data_dir: PathBuf = dirs::data_dir().ok_or(TTError::DataDirNotFound)?;
data_dir.push("tt.sqlite");
Ok(data_dir)
}
fn prepare_database(conn: &rusqlite::Connection) -> Result<usize, TTError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS Timestamps (
timestamp DATETIME NOT NULL,
state TEXT NOT NULL
)",
&[],
).map_err(TTError::DatabaseCreationError)
}
fn todays_recs(conn: &rusqlite::Connection) -> Result<Vec<Record>, TTError> {
let today = Local::today();
let tomorrow = today.succ();
let mut stmt = conn.prepare(
"SELECT timestamp, state FROM Timestamps WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC",
).map_err(TTError::RecordSelectionError)?;
let rec_iter = stmt
.query_map(
&[&today.naive_local(), &tomorrow.naive_local()],
Record::from_row,
).map_err(TTError::RecordMappingError)?;
let recs: Vec<Record> = rec_iter
.filter_map(Result::ok)
.filter_map(Result::ok)
.collect();
Ok(recs)
}
fn usage(program: &str) -> String {
format!(
"{} <cmd>
usage | help: shows this message
begin | stop: begin or stop work
pause: starts/stops a pause
show: prints today's work time",
program
)
}
fn print_usage(program: &str) {
println!("{}", usage(&program))
}
fn main() -> Result<(), TTError> {
let db_path = database_path()?;
let conn = rusqlite::Connection::open(db_path).map_err(TTError::DatabaseConnectionError)?;
prepare_database(&conn)?;
let mut args: VecDeque<String> = std::env::args().collect();
let program = args
.pop_front()
.expect("huh, the first argument is not the application path?");
if args.is_empty() {
print_usage(&program);
return Err(TTError::UnknownCommand);
}
if let Some(subcommand) = args.pop_front() {
match subcommand.as_ref() {
"help" | "usage" => print_usage(&program),
"show" => show(&conn)?,
"begin" | "stop" => record(&conn, State::Work)?,
"pause" => record(&conn, State::Pause)?,
_ => {
print_usage(&program);
return Err(TTError::UnknownCommand);
}
};
}
Ok(())
}
fn show(conn: &rusqlite::Connection) -> Result<(), TTError> {
println!("{}", format_duration(work_dur(&todays_recs(&conn)?)));
Ok(())
}
fn record(conn: &rusqlite::Connection, state: State) -> Result<(), TTError> {
Record::new(state).insert(conn)?;
Ok(())
}
fn format_duration(dur: Option<Duration>) -> String {
if let Some(dur) = dur {
format!("{:0>#2}h {:0>#2}m", dur.num_hours(), dur.num_minutes() % 60)
} else {
"".to_owned()
}
}
fn pause_dur(chunk: &[&Record]) -> Option<Duration> {
if chunk.len() < 2 {
return None;
}
let start = chunk.first()?;
let end = chunk.last()?;
Some(end.timestamp - start.timestamp)
}
fn fold_opt_dur(acc: Duration, dur: Option<Duration>) -> Duration {
if let Some(dur) = dur {
acc + dur
} else {
acc
}
}
fn work_dur(recs: &[Record]) -> Option<Duration> {
if recs.is_empty() {
return None;
}
let work_recs: Vec<&Record> = recs.iter().filter(|rec| rec.state == State::Work).collect();
if work_recs.len() < 2 {
return None;
}
let pause_recs: Vec<&Record> = recs
.iter()
.filter(|rec| rec.state == State::Pause)
.collect();
let start = work_recs.first()?;
let end = work_recs.last()?;
let pause_time = pause_recs
.chunks(2)
.map(pause_dur)
.fold(Duration::zero(), fold_opt_dur);
Some(end.timestamp - start.timestamp - pause_time)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn new_record(timestamp: &str, state: State) -> Record {
Record {
timestamp: DateTime::parse_from_rfc3339(timestamp)
.unwrap()
.with_timezone(&Local),
state,
}
}
#[test]
fn test_work_dur() {
struct TestCase {
expected: Option<Duration>,
data: Vec<Record>,
}
let mut ts: HashMap<&str, TestCase> = HashMap::new();
ts.insert(
"valid",
TestCase {
expected: Some(Duration::hours(8) + Duration::minutes(15)),
data: vec![
new_record("2018-09-11T09:00:00+00:00", State::Work),
new_record("2018-09-11T18:00:00+00:00", State::Work),
new_record("2018-09-11T12:00:00+00:00", State::Pause),
new_record("2018-09-11T12:45:00+00:00", State::Pause),
],
},
);
ts.insert(
"only-one-pause-entry",
TestCase {
expected: Some(Duration::hours(9)),
data: vec![
new_record("2018-09-11T09:00:00+00:00", State::Work),
new_record("2018-09-11T18:00:00+00:00", State::Work),
new_record("2018-09-11T12:00:00+00:00", State::Pause),
],
},
);
ts.insert(
"only-beginning",
TestCase {
expected: None,
data: vec![
new_record("2018-09-11T09:00:00+00:00", State::Work),
new_record("2018-09-11T12:00:00+00:00", State::Pause),
new_record("2018-09-11T12:45:00+00:00", State::Pause),
],
},
);
ts.insert(
"no-work-timestamp",
TestCase {
expected: None,
data: vec![
new_record("2018-09-11T12:00:00+00:00", State::Pause),
new_record("2018-09-11T12:45:00+00:00", State::Pause),
],
},
);
for (name, t) in ts {
println!("test: {}", name);
assert_eq!(work_dur(t.data), t.expected);
}
}
}

Loading…
Cancel
Save