tiempo 1.6.0

A command line time tracker
Documentation
use std::io::{self, BufRead, Write};
use std::collections::HashMap;
use std::cmp::Reverse;

use chrono::{DateTime, Utc, Duration};

use crate::io::Streams;
use crate::database::Database;
use crate::error::Result;
use crate::commands::Facts;
use crate::models::Entry;
use crate::tabulate::{Tabulate, Col, Align};
use crate::formatters::text::format_duration;
use crate::old::entries_or_warning;

fn read_line<I: BufRead>(mut r#in: I) -> io::Result<String> {
    let mut pre_n = String::new();
    r#in.read_line(&mut pre_n)?;
    Ok(pre_n)
}

pub fn ask<D, I, O, E>(streams: &mut Streams<D, I, O, E>, question: &str) -> io::Result<bool>
where
    D: Database,
    I: BufRead,
    O: Write,
    E: Write,
{
    write!(streams.out, "{} [y/N] ", question)?;
    streams.out.flush()?;

    Ok(read_line(&mut streams.r#in)?.to_lowercase().starts_with('y'))
}

enum Choice {
    Number(usize),
    Quit,
    CtrlD,
    Whatever,
}

fn to_choice(s: String) -> Choice {
    let s = s.trim();

    if let Ok(n) = s.parse::<usize>() {
        if n == 0 {
            Choice::Whatever
        } else {
            Choice::Number(n)
        }
    } else if s.is_empty() {
        Choice::CtrlD
    } else if s.to_lowercase() == "q" {
        Choice::Quit
    } else {
        Choice::Whatever
    }
}

/// Offers the last N entries (configurable) to the user and waits for a choice.
pub fn note_from_last_entries<D, I, O, E>(streams: &mut Streams<D, I, O, E>, facts: &Facts, current_sheet: &str) -> Result<Option<String>>
where
    D: Database,
    I: BufRead,
    O: Write,
    E: Write,
{
    let entries = streams.db.entries_by_sheet(current_sheet, None, None)?;
    let entries = entries_or_warning(entries, &streams.db)?.0;
    let mut uniques = HashMap::new();

    struct GroupedEntry {
        note: String,
        last_start: DateTime<Utc>,
        accumulated_time: Duration,
    }

    // From all possible entries belonging to this sheet keep only those with a
    // note
    let entries_with_notes = entries
        .into_iter()
        .filter_map(|e| e.note.map(|n| GroupedEntry {
            note: n,
            last_start: e.start,
            accumulated_time: e.end.unwrap_or(facts.now) - e.start,
        }));

    // iterate over the entries with a note and group them into `uniques`
    // accumulating their elapsed times and recording the last time it was
    // started
    for entry in entries_with_notes {
        let mut e = uniques.entry(entry.note.clone()).or_insert(GroupedEntry {
            accumulated_time: Duration::seconds(0),
            ..entry
        });

        if entry.last_start > e.last_start {
            e.last_start = entry.last_start;
        }

        e.accumulated_time = e.accumulated_time + entry.accumulated_time;
    }

    // turn uniques into a vector and sort it by the time it was last started
    let mut uniques: Vec<_> = uniques.into_values().collect();
    uniques.sort_unstable_by_key(|e| Reverse(e.last_start));

    writeln!(streams.out, "Latest entries of sheet '{current_sheet}':\n")?;

    let formatter = timeago::Formatter::new();

    // Create a table for nicer output
    let mut table = Tabulate::with_columns(vec![
        Col::new().min_width(3).and_alignment(Align::Right), // option number
        Col::new(), // note
        Col::new().and_alignment(Align::Right), // acumulated time
        Col::new().min_width(13).and_alignment(Align::Right), // last started
    ]);

    table.feed(vec!["#", "Note", "Total time", "Last started"]);
    table.separator(' ');

    for (i, entry) in uniques.iter().take(facts.config.interactive_entries).enumerate().rev() {
        let i = i + 1;
        let ago = formatter.convert_chrono(entry.last_start, facts.now);

        table.feed(vec![
            i.to_string(),
            entry.note.clone(),
            format_duration(entry.accumulated_time),
            ago,
        ]);

    }
    write!(streams.out, "{}", table.print(false))?;

    writeln!(streams.out, "\nenter number or q to cancel")?;

    loop {
        write!(streams.out, ">> ")?;
        streams.out.flush()?;

        let choice = to_choice(read_line(&mut streams.r#in)?);

        match choice {
            Choice::Number(i) => if let Some(e) = uniques.get(i - 1) {
                return Ok(Some(e.note.clone()));
            } else {
                writeln!(streams.out, "Not an option")?;
            }
            Choice::Quit => return Ok(None),
            Choice::CtrlD => {
                writeln!(streams.out)?;
                return Ok(None);
            }
            Choice::Whatever => writeln!(streams.out, "Not an option")?,
        }
    };
}

pub fn confirm_deletion<D, I, O, E>(streams: &mut Streams<D, I, O, E>, entry: Entry, now: DateTime<Utc>) -> Result<()>
where
    D: Database,
    I: BufRead,
    O: Write,
    E: Write,
{
    let id = entry.id;
    let note = entry.note.unwrap_or_else(|| "-empty note-".into());
    let formatter = {
        let mut formatter = timeago::Formatter::new();
        formatter.ago("");
        formatter
    };
    let duration = if let Some(end) = entry.end {
        let span = formatter.convert_chrono(entry.start, end);
        format!("finished with a timespan of {span}")
    } else {
        let span = formatter.convert_chrono(entry.start, now);
        format!("unfinished and running for {span}")
    };

    if ask(streams, &format!("\
are you sure you want to delete entry {id} with note

\"{note}\"

{duration}?"))? {
        streams.db.delete_entry_by_id(entry.id)?;
        writeln!(streams.out, "Gone")?;
    } else {
        writeln!(streams.out, "Don't worry, it's still there")?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use chrono::Duration;
    use pretty_assertions::assert_str_eq;

    use crate::config::Config;

    use super::*;

    #[test]
    fn interactive_choice_of_tasks() {
        let mut streams = Streams::fake(b"1\n");
        let facts = Facts::new();
        let one_hour_ago = facts.now - Duration::hours(1);
        let two_hours_ago = facts.now - Duration::hours(2);

        // insert some entries to pick from
        streams.db.entry_insert(two_hours_ago, Some(one_hour_ago), Some("first task".into()), "default").unwrap();
        streams.db.entry_insert(one_hour_ago, Some(facts.now), Some("second task".into()), "default").unwrap();

        // call the command interactively
        assert_eq!(note_from_last_entries(&mut streams, &facts, "default").unwrap().unwrap(), "second task");

        // check the output
        assert_str_eq!(&String::from_utf8_lossy(&streams.out), "Latest entries of sheet 'default':

  # Note        Total time  Last started

  2 first task     1:00:00   2 hours ago
  1 second task    1:00:00    1 hour ago

enter number or q to cancel
>> ");
        assert_str_eq!(&String::from_utf8_lossy(&streams.err), "");
    }

    /// only the most recently started N items (from settings) are shown and
    /// they are ordered by start date descending
    #[test]
    fn list_is_limited_to_n() {
        let config = Config {
            interactive_entries: 4,
            ..Default::default()
        };
        let mut streams = Streams::fake(b"1\n");
        let facts = Facts::new().with_config(config);

        // insert some entries to pick from
        streams.db.entry_insert(facts.now - Duration::minutes(9), Some(facts.now - Duration::minutes(8)), Some("task 1".into()), "default").unwrap();
        streams.db.entry_insert(facts.now - Duration::minutes(8), Some(facts.now - Duration::minutes(7)), Some("task 2".into()), "default").unwrap();
        streams.db.entry_insert(facts.now - Duration::minutes(7), Some(facts.now - Duration::minutes(6)), Some("task 3".into()), "default").unwrap();
        streams.db.entry_insert(facts.now - Duration::minutes(6), Some(facts.now - Duration::minutes(5)), Some("task 4".into()), "default").unwrap();
        streams.db.entry_insert(facts.now - Duration::minutes(5), Some(facts.now - Duration::minutes(4)), Some("task 5".into()), "default").unwrap();
        streams.db.entry_insert(facts.now - Duration::minutes(4), Some(facts.now - Duration::minutes(3)), Some("task 6".into()), "default").unwrap();

        // call the command interactively
        assert_eq!(note_from_last_entries(&mut streams, &facts, "default").unwrap().unwrap(), "task 6");

        // check the output
        assert_str_eq!(&String::from_utf8_lossy(&streams.out), "Latest entries of sheet 'default':

  # Note   Total time  Last started

  4 task 3    0:01:00 7 minutes ago
  3 task 4    0:01:00 6 minutes ago
  2 task 5    0:01:00 5 minutes ago
  1 task 6    0:01:00 4 minutes ago

enter number or q to cancel
>> ");
        assert_str_eq!(&String::from_utf8_lossy(&streams.err), "");
    }
}