archive-by-hours mostly completely shaped
This commit is contained in:
parent
2b8cc6f94a
commit
0392e91985
|
@ -8,19 +8,20 @@ use regex::Regex;
|
|||
use crate::database::Database;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::commands::{Command, Facts};
|
||||
use crate::timeparse::{parse_time, parse_duration};
|
||||
use crate::timeparse::{parse_time, parse_hours};
|
||||
use crate::old::{entries_or_warning, time_or_warning};
|
||||
use crate::formatters::text;
|
||||
use crate::regex::parse_regex;
|
||||
use crate::interactive::ask;
|
||||
use crate::io::Streams;
|
||||
use crate::models::Entry;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Args {
|
||||
start: Option<DateTime<Utc>>,
|
||||
end: Option<DateTime<Utc>>,
|
||||
grep: Option<Regex>,
|
||||
time: Option<Duration>,
|
||||
hours: Option<u16>,
|
||||
fake: bool,
|
||||
sheet: Option<String>,
|
||||
}
|
||||
|
@ -33,13 +34,29 @@ impl<'a> TryFrom<&'a ArgMatches<'a>> for Args {
|
|||
start: matches.value_of("start").map(parse_time).transpose()?,
|
||||
end: matches.value_of("end").map(parse_time).transpose()?,
|
||||
grep: matches.value_of("grep").map(parse_regex).transpose()?,
|
||||
time: matches.value_of("time").map(parse_duration).transpose()?,
|
||||
hours: matches.value_of("time").map(parse_hours).transpose()?,
|
||||
fake: matches.is_present("fake"),
|
||||
sheet: matches.value_of("sheet").map(|s| s.to_owned()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify the given Entry such that it only lasts the given `time`, and return
|
||||
/// the data needed to create a new entry with mostly the same attributes such
|
||||
/// that it accounts for the time substracted from the original.
|
||||
fn split_entry(entry: &mut Entry, time: Duration) -> (DateTime<Utc>, Option<DateTime<Utc>>, Option<String>, String) {
|
||||
let Entry {
|
||||
id: _, note, start, end, sheet,
|
||||
} = entry.clone();
|
||||
|
||||
let old_entry_end = start + time;
|
||||
let new_entry_start = old_entry_end;
|
||||
|
||||
entry.end = Some(old_entry_end);
|
||||
|
||||
(new_entry_start, end, note, sheet)
|
||||
}
|
||||
|
||||
pub struct ArchiveCommand {}
|
||||
|
||||
impl<'a> Command<'a> for ArchiveCommand {
|
||||
|
@ -52,23 +69,76 @@ impl<'a> Command<'a> for ArchiveCommand {
|
|||
O: Write,
|
||||
E: Write,
|
||||
{
|
||||
let mut entries = {
|
||||
let start = args.start.map(|s| time_or_warning(s, &streams.db)).transpose()?.map(|s| s.0);
|
||||
let end = args.end.map(|e| time_or_warning(e, &streams.db)).transpose()?.map(|e| e.0);
|
||||
let current_sheet = streams.db.current_sheet()?;
|
||||
let sheet = args.sheet.unwrap_or(current_sheet);
|
||||
// Get all entries from the database that match the filter criteria
|
||||
// given from the command line: start time, end time and sheet.
|
||||
let entries = {
|
||||
let started_after = args.start.map(|s| time_or_warning(s, &streams.db)).transpose()?.map(|s| s.0);
|
||||
let started_before = args.end.map(|e| time_or_warning(e, &streams.db)).transpose()?.map(|e| e.0);
|
||||
let sheet = args.sheet.map(Ok).unwrap_or_else(|| streams.db.current_sheet())?;
|
||||
|
||||
streams.db.entries_by_sheet(&sheet, start, end)?
|
||||
let mut entries = streams.db.entries_by_sheet(&sheet, started_after, started_before)?;
|
||||
|
||||
// only archive those entries that are finished.
|
||||
entries.retain(|e| e.end.is_some());
|
||||
|
||||
if let Some(re) = args.grep {
|
||||
entries.retain(|e| re.is_match(&e.note.clone().unwrap_or_default()));
|
||||
}
|
||||
|
||||
entries
|
||||
};
|
||||
|
||||
if let Some(re) = args.grep {
|
||||
entries.retain(|e| re.is_match(&e.note.clone().unwrap_or_default()));
|
||||
}
|
||||
|
||||
if let Some(time) = args.time {
|
||||
// If the user requested to archive entries by a total time then not all
|
||||
// entries will be archived, and instead just those oldest ones that
|
||||
// accumulate the given time will be. If the total time of the filtered
|
||||
// entries is more than the requested time the last one will be split
|
||||
// into two pieces.
|
||||
let (time, entries, new, extra_msg) = if let Some(hours) = args.hours {
|
||||
let requested_time = Duration::hours(hours as i64);
|
||||
// archive the maximum amount of consecutive entries whose
|
||||
// accumulated time is not bigger that `time`.
|
||||
}
|
||||
let mut selected_entries = Vec::with_capacity(entries.len());
|
||||
let mut accumulated_time = Duration::seconds(0);
|
||||
let mut new = None;
|
||||
|
||||
for entry in entries {
|
||||
// Can unwrap because only entries with an end time get this far
|
||||
let timespan = entry.timespan().unwrap();
|
||||
|
||||
if accumulated_time < requested_time {
|
||||
if accumulated_time + timespan > requested_time {
|
||||
// should split the last entry
|
||||
let missing_time = requested_time - accumulated_time;
|
||||
let mut entry = entry;
|
||||
let parts = split_entry(&mut entry, missing_time);
|
||||
new.replace(parts);
|
||||
selected_entries.push(entry);
|
||||
accumulated_time = accumulated_time + missing_time;
|
||||
} else {
|
||||
// fits perfectly, just add it
|
||||
selected_entries.push(entry);
|
||||
accumulated_time = accumulated_time + timespan;
|
||||
}
|
||||
} else {
|
||||
// accumulated_time is equal or higher than requested_time,
|
||||
// no more entries are admitted
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(text::format_hours(accumulated_time), selected_entries, new, String::from("\nAdditionally an entry will be split so that the total archived time is exact."))
|
||||
} else {
|
||||
(text::format_hours(
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|e| e.end.map(|end| end - e.start))
|
||||
.fold(Duration::seconds(0), |acc, new| {
|
||||
acc + new
|
||||
})
|
||||
), entries, None, String::from(""))
|
||||
};
|
||||
|
||||
let n = entries.len();
|
||||
|
||||
if args.fake {
|
||||
let (entries, _) = entries_or_warning(entries, &streams.db)?;
|
||||
|
@ -79,10 +149,14 @@ impl<'a> Command<'a> for ArchiveCommand {
|
|||
facts,
|
||||
true,
|
||||
)?;
|
||||
} else if ask(streams, &format!("Archive {} entries?", entries.len()))? {
|
||||
} else if ask(streams, &format!("A total of {n} entries accounting for {time} will be archived.{extra_msg}\nProceed?"))? {
|
||||
for entry in entries {
|
||||
streams.db.entry_update(entry.id, entry.start, entry.end, entry.note, &format!("_{}", entry.sheet))?;
|
||||
}
|
||||
|
||||
if let Some((start, end, note, sheet)) = new {
|
||||
streams.db.entry_insert(start, end, note, &sheet)?;
|
||||
}
|
||||
} else {
|
||||
writeln!(streams.out, "Ok, they're still there")?;
|
||||
}
|
||||
|
@ -93,25 +167,177 @@ impl<'a> Command<'a> for ArchiveCommand {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
use chrono::TimeZone;
|
||||
|
||||
use crate::models::Entry;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn archive_by_hours() {
|
||||
let args = Args {
|
||||
time: Some(Duration::hours(2)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut streams = Streams::fake(b"");
|
||||
fn archive_archives() {
|
||||
let args: Args = Default::default();
|
||||
let mut streams = Streams::fake(b"y\n");
|
||||
let facts = Facts::new();
|
||||
|
||||
streams.db.set_current_sheet("foo").unwrap();
|
||||
streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap();
|
||||
streams.db.entry_insert(facts.now - Duration::minutes(90), Some(facts.now), Some("second".into()), "foo").unwrap();
|
||||
|
||||
ArchiveCommand::handle(args, &mut streams, &facts).unwrap();
|
||||
|
||||
let remaining = streams.db.entries_by_sheet("default", None, None).unwrap();
|
||||
let archived = streams.db.entries_by_sheet("_default", None, None).unwrap();
|
||||
let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap();
|
||||
let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap();
|
||||
|
||||
assert_eq!(String::from_utf8_lossy(&streams.out), "A total of 2 entries accounting for 3h will be archived.\nProceed? [y/N] ");
|
||||
|
||||
// First entry gets archived whole, second entry gets split in two:
|
||||
// - a 30 minute piece to complete the requested 2 hour span
|
||||
// - a 1 hour piece that remains unarchived
|
||||
assert_eq!(archived, vec![
|
||||
Entry {
|
||||
id: 1,
|
||||
note: Some("first".into()),
|
||||
start: facts.now - Duration::hours(3),
|
||||
end: Some(facts.now - Duration::minutes(90)),
|
||||
sheet: "_foo".into(),
|
||||
},
|
||||
Entry {
|
||||
id: 2,
|
||||
note: Some("second".into()),
|
||||
start: facts.now - Duration::minutes(90),
|
||||
end: Some(facts.now),
|
||||
sheet: "_foo".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(remaining, vec![]);
|
||||
assert_eq!(archived, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_running_entry_is_archived() {
|
||||
let args: Args = Default::default();
|
||||
let mut streams = Streams::fake(b"y\n");
|
||||
let facts = Facts::new();
|
||||
|
||||
streams.db.set_current_sheet("foo").unwrap();
|
||||
streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap();
|
||||
streams.db.entry_insert(facts.now - Duration::minutes(90), None, Some("running".into()), "foo").unwrap();
|
||||
|
||||
ArchiveCommand::handle(args, &mut streams, &facts).unwrap();
|
||||
|
||||
let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap();
|
||||
let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap();
|
||||
|
||||
assert_eq!(String::from_utf8_lossy(&streams.out), "A total of 1 entries accounting for 1h 30m will be archived.\nProceed? [y/N] ");
|
||||
|
||||
// First entry gets archived whole, second entry gets split in two:
|
||||
// - a 30 minute piece to complete the requested 2 hour span
|
||||
// - a 1 hour piece that remains unarchived
|
||||
assert_eq!(archived, vec![
|
||||
Entry {
|
||||
id: 1,
|
||||
note: Some("first".into()),
|
||||
start: facts.now - Duration::hours(3),
|
||||
end: Some(facts.now - Duration::minutes(90)),
|
||||
sheet: "_foo".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(remaining, vec![
|
||||
Entry {
|
||||
id: 2,
|
||||
note: Some("running".into()),
|
||||
start: facts.now - Duration::minutes(90),
|
||||
end: None,
|
||||
sheet: "foo".into(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_split_properly() {
|
||||
let mut old_entry = Entry {
|
||||
id: 1,
|
||||
start: Utc.ymd(2022, 7, 29).and_hms(10, 0, 0),
|
||||
end: Some(Utc.ymd(2022, 7, 29).and_hms(11, 0, 0)),
|
||||
note: Some("an entry".to_string()),
|
||||
sheet: "foo".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(split_entry(&mut old_entry, Duration::minutes(25)), (
|
||||
Utc.ymd(2022, 7, 29).and_hms(10, 25, 0),
|
||||
Some(Utc.ymd(2022, 7, 29).and_hms(11, 0, 0)),
|
||||
Some("an entry".to_string()),
|
||||
"foo".to_string(),
|
||||
));
|
||||
assert_eq!(old_entry, Entry {
|
||||
id: 1,
|
||||
start: Utc.ymd(2022, 7, 29).and_hms(10, 0, 0),
|
||||
end: Some(Utc.ymd(2022, 7, 29).and_hms(10, 25, 0)),
|
||||
note: Some("an entry".to_string()),
|
||||
sheet: "foo".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_by_hours() {
|
||||
let args = Args {
|
||||
hours: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let mut streams = Streams::fake(b"y\n");
|
||||
let facts = Facts::new();
|
||||
|
||||
streams.db.set_current_sheet("foo").unwrap();
|
||||
streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap();
|
||||
streams.db.entry_insert(facts.now - Duration::minutes(90), Some(facts.now), Some("second".into()), "foo").unwrap();
|
||||
|
||||
ArchiveCommand::handle(args, &mut streams, &facts).unwrap();
|
||||
|
||||
let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap();
|
||||
let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap();
|
||||
|
||||
assert_str_eq!(String::from_utf8_lossy(&streams.out), "A total of 2 entries accounting for 2h will be archived.
|
||||
Additionally an entry will be split so that the total archived time is exact.
|
||||
Proceed? [y/N] ");
|
||||
|
||||
// First entry gets archived whole, second entry gets split in two:
|
||||
// - a 30 minute piece to complete the requested 2 hour span
|
||||
// - a 1 hour piece that remains unarchived
|
||||
assert_eq!(archived, vec![
|
||||
Entry {
|
||||
id: 1,
|
||||
note: Some("first".into()),
|
||||
start: facts.now - Duration::hours(3),
|
||||
end: Some(facts.now - Duration::minutes(90)),
|
||||
sheet: "_foo".into(),
|
||||
},
|
||||
Entry {
|
||||
id: 2,
|
||||
note: Some("second".into()),
|
||||
start: facts.now - Duration::minutes(90),
|
||||
end: Some(facts.now - Duration::hours(1)),
|
||||
sheet: "_foo".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(remaining, vec![
|
||||
Entry {
|
||||
id: 3,
|
||||
note: Some("second".into()),
|
||||
start: facts.now - Duration::hours(1),
|
||||
end: Some(facts.now),
|
||||
sheet: "foo".into(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_enough_entries_to_archive_time() {
|
||||
assert!(false, "Like above, but there are no enough entries and the message should be accurate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_and_split_work_well_together() {
|
||||
assert!(false, "When using --fake expect to have the same output but the database must not be touched");
|
||||
assert!(false, "Also a nice explanation of what would happen is shown");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,6 +115,9 @@ query it using t backend or the sqlite3 command provided by your system.")]
|
|||
#[error("Could not understand '{0}' as a week day. Try 'monday' or 'TuesDay'")]
|
||||
InvalidWeekDaySpec(String),
|
||||
|
||||
#[error("Could not understand '{0}' as a number of hours")]
|
||||
InvalidHours(String),
|
||||
|
||||
#[error("An error ocurred while trying to read entries from the database.
|
||||
|
||||
In the row with id {id} the data at column '{col}' has a value that is not a
|
||||
|
|
|
@ -12,6 +12,24 @@ pub fn format_duration(dur: Duration) -> String {
|
|||
format!("{}:{:02}:{:02}", dur.num_hours(), dur.num_minutes() % 60, dur.num_seconds() % 60)
|
||||
}
|
||||
|
||||
pub fn format_hours(dur: Duration) -> String {
|
||||
let hours = dur.num_hours();
|
||||
let minutes = dur.num_minutes() % 60;
|
||||
let seconds = dur.num_seconds() % 60;
|
||||
|
||||
let time = [
|
||||
if hours > 0 { Some(format!("{hours}h")) } else { None },
|
||||
if minutes > 0 { Some(format!("{minutes}m")) } else { None },
|
||||
if seconds > 0 { Some(format!("{seconds}s")) } else { None },
|
||||
].into_iter().flatten().collect::<Vec<_>>().join(" ");
|
||||
|
||||
if time.is_empty() {
|
||||
String::from("less than one second")
|
||||
} else {
|
||||
time
|
||||
}
|
||||
}
|
||||
|
||||
fn format_start(t: NaiveTime) -> String {
|
||||
format!("{:02}:{:02}:{:02}", t.hour(), t.minute(), t.second())
|
||||
}
|
||||
|
@ -376,4 +394,13 @@ Timesheet: sheet2
|
|||
Grand total 4:00:00
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hour_formatting() {
|
||||
assert_eq!(format_hours(Duration::hours(3) + Duration::minutes(30)), "3h 30m");
|
||||
assert_eq!(format_hours(Duration::hours(3) + Duration::minutes(1)), "3h 1m");
|
||||
assert_eq!(format_hours(Duration::hours(3) + Duration::minutes(5) + Duration::seconds(59)), "3h 5m 59s");
|
||||
assert_eq!(format_hours(Duration::hours(3)), "3h");
|
||||
assert_eq!(format_hours(Duration::milliseconds(543)), "less than one second")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Entry {
|
||||
pub id: u64,
|
||||
pub note: Option<String>,
|
||||
|
@ -27,4 +27,8 @@ impl Entry {
|
|||
sheet: "default".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timespan(&self) -> Option<Duration> {
|
||||
self.end.map(|e| e - self.start)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -157,8 +157,8 @@ pub fn parse_time(input: &str) -> Result<DateTime<Utc>> {
|
|||
Err(Error::DateTimeParseError(input.into()))
|
||||
}
|
||||
|
||||
pub fn parse_duration(input: &str) -> Result<Duration> {
|
||||
todo!()
|
||||
pub fn parse_hours(input: &str) -> Result<u16> {
|
||||
input.parse().map_err(|_| Error::InvalidHours(input.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
Loading…
Reference in New Issue