punch_clock/
sheet.rs

1//! Working with recorded timesheets (lists of events).
2
3use std::{
4    fs::File,
5    io::{Read, Write},
6    path::{Path, PathBuf},
7};
8
9use chrono::{DateTime, Duration, Utc};
10use directories::ProjectDirs;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::Event;
15
16/// List of events, together comprising a log of work from which totals can be calculated for
17/// various periods of time.
18#[derive(Clone, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
19pub struct Sheet {
20    pub events: Vec<Event>,
21}
22
23impl Sheet {
24    /// Attempt to load a sheet from the file at the default location, as determined by
25    /// [`default_loc()`][default].
26    ///
27    /// [default]: #method.default_loc
28    pub fn load_default() -> Result<Sheet, SheetError> {
29        Self::load(Self::default_loc()?)
30    }
31
32    /// Attempt to load a sheet from the file at the given path.
33    pub fn load<P>(path: P) -> Result<Sheet, SheetError>
34    where
35        P: AsRef<Path>,
36    {
37        let mut sheet_json = String::new();
38
39        {
40            let mut sheet_file = File::open(&path).map_err(SheetError::OpenSheet)?;
41
42            sheet_file
43                .read_to_string(&mut sheet_json)
44                .map_err(SheetError::ReadSheet)?;
45        }
46
47        if sheet_json.is_empty() {
48            Ok(Sheet::default())
49        } else {
50            serde_json::from_str(&sheet_json).map_err(SheetError::ParseSheet)
51        }
52    }
53
54    /// Get the default directory in which sheets are stored.
55    ///
56    /// The directory is determined using the [directories][directories] crate by platform as
57    /// follows:
58    ///
59    /// + Linux: `$XDG_CONFIG_HOME/punchclock/sheet.json`
60    /// + macOS: `$HOME/Library/Application Support/dev.neros.PunchClock/sheet.json`
61    /// + Windows: `%APPDATA%\Local\Neros\PunchClock\sheet.json`
62    ///
63    /// [directories]: https://crates.io/crates/directories
64    pub fn default_dir() -> Result<PathBuf, SheetError> {
65        ProjectDirs::from("dev", "neros", "PunchClock")
66            .ok_or(SheetError::FindSheet)
67            .map(|dirs| dirs.data_dir().to_owned())
68    }
69
70    /// Get the path to the file the default sheet is stored in.
71    ///
72    /// This is the file `sheet.json` inside the directory returned from
73    /// [`default_dir()`][default].
74    ///
75    /// [default]: #method.default_dir
76    pub fn default_loc() -> Result<PathBuf, SheetError> {
77        Self::default_dir().map(|mut dir| {
78            dir.push("sheet.json");
79            dir
80        })
81    }
82
83    /// Attempt to write a sheet to the file at the default location, as determined by
84    /// [`default_loc()`][default].
85    ///
86    /// [default]: #method.default_loc
87    pub fn write_default(&self) -> Result<(), SheetError> {
88        self.write(Self::default_loc()?)
89    }
90
91    /// Attempt to write a sheet to the file at the given path.
92    pub fn write<P>(&self, path: P) -> Result<(), SheetError>
93    where
94        P: AsRef<Path>,
95    {
96        let new_sheet_json = serde_json::to_string(self).unwrap();
97
98        match File::create(&path) {
99            Ok(mut sheet_file) => {
100                write!(&mut sheet_file, "{}", new_sheet_json).map_err(SheetError::WriteSheet)
101            }
102            Err(e) => Err(SheetError::WriteSheet(e)),
103        }
104    }
105
106    /// Record a punch-in (start of a time-tracking period) at the current time.
107    pub fn punch_in(&mut self) -> Result<DateTime<Utc>, SheetError> {
108        self.punch_in_at(Utc::now())
109    }
110
111    /// Record a punch-in (start of a time-tracking period) at the given time.
112    pub fn punch_in_at(&mut self, time: DateTime<Utc>) -> Result<DateTime<Utc>, SheetError> {
113        match self.events.last() {
114            Some(Event { stop: Some(_), .. }) | None => {
115                let event = Event::new(time);
116                self.events.push(event);
117                Ok(time)
118            }
119            Some(Event {
120                start: start_time, ..
121            }) => Err(SheetError::PunchedIn(*start_time)),
122        }
123    }
124
125    /// Record a punch-out (end of a time-tracking period) at the current time.
126    pub fn punch_out(&mut self) -> Result<DateTime<Utc>, SheetError> {
127        self.punch_out_at(Utc::now())
128    }
129
130    /// Record a punch-out (end of a time-tracking period) at the given time.
131    pub fn punch_out_at(&mut self, time: DateTime<Utc>) -> Result<DateTime<Utc>, SheetError> {
132        match self.events.last_mut() {
133            Some(ref mut event @ Event { stop: None, .. }) => {
134                event.stop = Some(time);
135                Ok(time)
136            }
137            Some(Event {
138                stop: Some(stop_time),
139                ..
140            }) => Err(SheetError::PunchedOut(*stop_time)),
141            None => Err(SheetError::NoPunches),
142        }
143    }
144
145    /// Get the current status of time-tracking, including the time at which the status last
146    /// changed.
147    pub fn status(&self) -> SheetStatus {
148        match self.events.last() {
149            Some(Event {
150                stop: Some(stop), ..
151            }) => SheetStatus::PunchedOut(*stop),
152            Some(Event { start, .. }) => SheetStatus::PunchedIn(*start),
153            None => SheetStatus::Empty,
154        }
155    }
156
157    /// Count the amount of time for which there was recorded work between the two given instants,
158    /// including an ongoing time-tracking period if there is one.
159    pub fn count_range(&self, begin: DateTime<Utc>, end: DateTime<Utc>) -> Duration {
160        self.events
161            .iter()
162            .map(|e| (e.start, e.stop.unwrap_or_else(Utc::now)))
163            .filter(|(start, stop)| {
164                let entirely_before = start < &begin && stop < &begin;
165                let entirely_after = start > &end && stop > &end;
166
167                !(entirely_before || entirely_after)
168            })
169            .map(|(start, stop)| {
170                let real_begin = std::cmp::max(begin, start);
171                let real_end = std::cmp::min(end, stop);
172
173                real_end - real_begin
174            })
175            .fold(Duration::zero(), |acc, next| acc + next)
176    }
177}
178
179/// Whether or not time is currently being tracked.
180#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
181pub enum SheetStatus {
182    /// Time is currently being tracked, and has been since the given instant.
183    PunchedIn(DateTime<Utc>),
184    /// Time is not currently being tracked, as of the given instant.
185    PunchedOut(DateTime<Utc>),
186    /// No time has ever been tracked.
187    Empty,
188}
189
190/// Errors arising through the use of [`Sheet`][sheet].
191///
192/// [sheet]: ./struct.Sheet.html
193#[derive(Error, Debug)]
194pub enum SheetError {
195    #[error("already punched in at {0}")]
196    PunchedIn(DateTime<Utc>),
197    #[error("not punched in, last punched out at {0}")]
198    PunchedOut(DateTime<Utc>),
199    #[error("not punched in, no punch-ins recorded")]
200    NoPunches,
201    #[error("unable to find sheet file")]
202    FindSheet,
203    #[error("unable to open sheet file")]
204    OpenSheet(#[source] std::io::Error),
205    #[error("unable to read sheet file")]
206    ReadSheet(#[source] std::io::Error),
207    #[error("unable to parse sheet")]
208    ParseSheet(#[source] serde_json::Error),
209    #[error("unable to write sheet to file")]
210    WriteSheet(#[source] std::io::Error),
211}