Skip to main content

kasl/db/
workdays.rs

1//! Workday records: one row per calendar date, start and optional end.
2//!
3//! ```rust,no_run
4//! # fn main() -> anyhow::Result<()> {
5//! use kasl::db::workdays::Workdays;
6//! use chrono::Local;
7//!
8//! let mut workdays = Workdays::new()?;
9//! let today = Local::now().date_naive();
10//!
11//! workdays.insert_start(today)?;
12//! workdays.insert_end(today)?; // false when the day was never started
13//! # Ok(())
14//! # }
15//! ```
16
17use crate::{db::db::Db, libs::messages::Message, msg_error_anyhow};
18use anyhow::Result;
19use chrono::{NaiveDate, NaiveDateTime};
20use rusqlite::{Connection, OptionalExtension};
21
22const SCHEMA_WORKDAYS: &str = "CREATE TABLE IF NOT EXISTS workdays (
23    id INTEGER PRIMARY KEY,
24    date DATE NOT NULL UNIQUE,
25    start TIMESTAMP NOT NULL,
26    end TIMESTAMP
27);";
28
29const INSERT_START: &str = "INSERT INTO workdays (date, start) VALUES (?1, datetime(CURRENT_TIMESTAMP, 'localtime'))";
30const UPDATE_END: &str = "UPDATE workdays SET end = datetime(CURRENT_TIMESTAMP, 'localtime') WHERE date = ?1";
31const SELECT_BY_DATE: &str = "SELECT id, date, start, end FROM workdays WHERE date = ?1";
32const SELECT_BY_MONTH: &str = "SELECT id, date, start, end FROM workdays WHERE strftime('%Y-%m', date) = strftime('%Y-%m', ?1)";
33/// Every recorded date, oldest first, optionally bounded on either side.
34///
35/// The bounds are compared as text because `date` is stored as `YYYY-MM-DD`,
36/// which sorts and compares identically to the dates it spells. A NULL bound
37/// means "no bound on this side", so one statement answers a range, an open
38/// end, and the whole history without branching in SQL.
39const SELECT_RECORDED_DATES: &str = "SELECT date FROM workdays
40    WHERE (?1 IS NULL OR date >= ?1) AND (?2 IS NULL OR date <= ?2)
41    ORDER BY date";
42
43const UPDATE_START: &str = "UPDATE workdays SET start = ?1 WHERE date = ?2";
44const UPDATE_END_TIME: &str = "UPDATE workdays SET end = ?1 WHERE date = ?2";
45const UNSET_END_TIME: &str = "UPDATE workdays SET end = NULL WHERE date = ?1";
46
47/// One day's work session. Timestamps are local time; `end: None` means the
48/// session is still open.
49#[derive(Debug, Clone)]
50pub struct Workday {
51    /// Database primary key.
52    pub id: i32,
53
54    /// Calendar date; unique per row.
55    pub date: NaiveDate,
56
57    /// When the session began.
58    pub start: NaiveDateTime,
59
60    /// When the session ended; `None` while it is open.
61    pub end: Option<NaiveDateTime>,
62}
63
64/// Workday table access.
65pub struct Workdays {
66    pub conn: Connection,
67}
68
69impl Workdays {
70    /// Opens the database and ensures the workdays table exists.
71    ///
72    /// ```rust,no_run
73    /// # fn main() -> anyhow::Result<()> {
74    /// use kasl::db::workdays::Workdays;
75    ///
76    /// let mut workdays = Workdays::new()?;
77    /// # Ok(())
78    /// # }
79    /// ```
80    pub fn new() -> Result<Self> {
81        let db = Db::new()?;
82        db.conn.execute(SCHEMA_WORKDAYS, [])?;
83        Ok(Workdays { conn: db.conn })
84    }
85
86    /// Starts a workday at the current time; a no-op if the date already has
87    /// one, so repeated calls from the monitor are safe.
88    ///
89    /// ```rust,no_run
90    /// # use kasl::db::workdays::Workdays;
91    /// use chrono::Local;
92    ///
93    /// # fn main() -> anyhow::Result<()> {
94    /// let mut workdays = Workdays::new()?;
95    /// let today = Local::now().date_naive();
96    /// workdays.insert_start(today)?;
97    /// # Ok(())
98    /// # }
99    /// ```
100    pub fn insert_start(&mut self, date: NaiveDate) -> Result<()> {
101        let date_str = date.format("%Y-%m-%d").to_string();
102        if self.fetch(date)?.is_none() {
103            self.conn.execute(INSERT_START, [&date_str])?;
104        }
105        Ok(())
106    }
107
108    /// Stamps the current time as the day's end; calling again re-stamps.
109    ///
110    /// Returns whether a day was there to close. The UPDATE matches no row
111    /// when the date has no workday, and for a long time that came back as
112    /// plain `Ok` - so `kasl end` announced a day it had not written. The
113    /// answer belongs in the return value rather than in the data: ending an
114    /// unstarted day must still create nothing.
115    ///
116    /// ```rust,no_run
117    /// # use kasl::db::workdays::Workdays;
118    /// use chrono::Local;
119    ///
120    /// # fn main() -> anyhow::Result<()> {
121    /// let mut workdays = Workdays::new()?;
122    /// let today = Local::now().date_naive();
123    ///
124    /// workdays.insert_start(today)?;
125    /// assert!(workdays.insert_end(today)?);
126    /// # Ok(())
127    /// # }
128    /// ```
129    pub fn insert_end(&mut self, date: NaiveDate) -> Result<bool> {
130        let date_str = date.format("%Y-%m-%d").to_string();
131        Ok(self.conn.execute(UPDATE_END, [&date_str])? > 0)
132    }
133
134    /// Fetches the workday for a date, or `None` if the date has none.
135    ///
136    /// ```rust,no_run
137    /// # use kasl::db::workdays::Workdays;
138    /// use chrono::Local;
139    ///
140    /// # fn main() -> anyhow::Result<()> {
141    /// let mut workdays = Workdays::new()?;
142    /// let today = Local::now().date_naive();
143    ///
144    /// if let Some(workday) = workdays.fetch(today)? {
145    ///     println!("Work started at: {}", workday.start);
146    ///     if let Some(end_time) = workday.end {
147    ///         println!("Work ended at: {}", end_time);
148    ///     } else {
149    ///         println!("Work session is still active");
150    ///     }
151    /// } else {
152    ///     println!("No work session recorded for today");
153    /// }
154    /// # Ok(())
155    /// # }
156    /// ```
157    pub fn fetch(&mut self, date: NaiveDate) -> Result<Option<Workday>> {
158        let date_str = date.format("%Y-%m-%d").to_string();
159
160        let workday = self
161            .conn
162            .query_row(SELECT_BY_DATE, [&date_str], |row| {
163                Ok(Workday {
164                    id: row.get(0)?,
165                    date: NaiveDate::parse_from_str(&row.get::<_, String>(1)?, "%Y-%m-%d").unwrap(),
166                    start: NaiveDateTime::parse_from_str(&row.get::<_, String>(2)?, "%Y-%m-%d %H:%M:%S").unwrap(),
167                    end: row
168                        .get::<_, Option<String>>(3)?
169                        .map(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").unwrap()),
170                })
171            })
172            .optional()?;
173
174        Ok(workday)
175    }
176
177    /// Fetches every workday in the calendar month containing `date`.
178    ///
179    /// ```rust,no_run
180    /// # use kasl::db::workdays::Workdays;
181    /// use chrono::Local;
182    ///
183    /// # fn main() -> anyhow::Result<()> {
184    /// let mut workdays = Workdays::new()?;
185    /// let current_month = Local::now().date_naive();
186    ///
187    /// let monthly_workdays = workdays.fetch_month(current_month)?;
188    /// println!("Found {} workdays this month", monthly_workdays.len());
189    ///
190    /// for workday in monthly_workdays {
191    ///     if let Some(end_time) = workday.end {
192    ///         let duration = end_time - workday.start;
193    ///         println!("Date: {}, Duration: {:?}", workday.date, duration);
194    ///     }
195    /// }
196    /// # Ok(())
197    /// # }
198    /// ```
199    pub fn fetch_month(&mut self, date: NaiveDate) -> Result<Vec<Workday>> {
200        let date_str = date.format("%Y-%m-%d").to_string();
201
202        let mut stmt = self.conn.prepare(SELECT_BY_MONTH)?;
203        let workday_iter = stmt.query_map([&date_str], |row| {
204            Ok(Workday {
205                id: row.get(0)?,
206                date: NaiveDate::parse_from_str(&row.get::<_, String>(1)?, "%Y-%m-%d").unwrap(),
207                start: NaiveDateTime::parse_from_str(&row.get::<_, String>(2)?, "%Y-%m-%d %H:%M:%S").unwrap(),
208                end: row
209                    .get::<_, Option<String>>(3)?
210                    .map(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").unwrap()),
211            })
212        })?;
213
214        let mut workdays = Vec::new();
215        for workday in workday_iter {
216            workdays.push(workday?);
217        }
218
219        Ok(workdays)
220    }
221
222    /// Sets the day's start to a specific timestamp; errors if the date has
223    /// no workday.
224    ///
225    /// ```rust,no_run
226    /// # use kasl::db::workdays::Workdays;
227    /// use chrono::{Local, NaiveDateTime};
228    ///
229    /// # fn main() -> anyhow::Result<()> {
230    /// let mut workdays = Workdays::new()?;
231    /// let today = Local::now().date_naive();
232    ///
233    /// let corrected_start = NaiveDateTime::parse_from_str(
234    ///     &format!("{} 09:00:00", today.format("%Y-%m-%d")),
235    ///     "%Y-%m-%d %H:%M:%S"
236    /// )?;
237    ///
238    /// workdays.update_start(today, corrected_start)?;
239    /// # Ok(())
240    /// # }
241    /// ```
242    /// The dates that actually have a workday, oldest first.
243    ///
244    /// `from` and `to` are inclusive, and either may be left open: with both
245    /// open this is the whole history the database holds.
246    ///
247    /// One query rather than a `fetch` per calendar day, because the caller
248    /// that wants this is backfill, and walking a year of calendar to find
249    /// two hundred workdays asks the database three hundred and sixty five
250    /// questions to which it already knows the whole answer.
251    ///
252    /// ```rust,no_run
253    /// # use kasl::db::workdays::Workdays;
254    /// # fn main() -> anyhow::Result<()> {
255    /// let mut workdays = Workdays::new()?;
256    ///
257    /// // Everything ever recorded on this machine.
258    /// let all = workdays.recorded_dates(None, None)?;
259    /// println!("{} days recorded", all.len());
260    /// # Ok(())
261    /// # }
262    /// ```
263    pub fn recorded_dates(&mut self, from: Option<NaiveDate>, to: Option<NaiveDate>) -> Result<Vec<NaiveDate>> {
264        let from = from.map(|date| date.format("%Y-%m-%d").to_string());
265        let to = to.map(|date| date.format("%Y-%m-%d").to_string());
266
267        let mut stmt = self.conn.prepare(SELECT_RECORDED_DATES)?;
268        let dates = stmt
269            .query_map(rusqlite::params![from, to], |row| row.get::<_, String>(0))?
270            .collect::<std::result::Result<Vec<String>, _>>()?;
271
272        dates
273            .into_iter()
274            .map(|text| {
275                NaiveDate::parse_from_str(&text, "%Y-%m-%d")
276                    .map_err(|error| anyhow::anyhow!("the workdays table holds '{}', which is not a date: {}", text, error))
277            })
278            .collect()
279    }
280
281    pub fn update_start(&mut self, date: NaiveDate, new_start: NaiveDateTime) -> Result<()> {
282        let date_str = date.format("%Y-%m-%d").to_string();
283        let start_str = new_start.format("%Y-%m-%d %H:%M:%S").to_string();
284
285        let affected = self.conn.execute(UPDATE_START, [&start_str, &date_str])?;
286
287        if affected == 0 {
288            return Err(msg_error_anyhow!(Message::WorkdayUpdateFailed));
289        }
290
291        Ok(())
292    }
293
294    /// Sets the day's end to a specific timestamp, or reopens the day with
295    /// `None`; errors if the date has no workday.
296    ///
297    /// ```rust,no_run
298    /// # use kasl::db::workdays::Workdays;
299    /// use chrono::{Local, NaiveDateTime};
300    ///
301    /// # fn main() -> anyhow::Result<()> {
302    /// let mut workdays = Workdays::new()?;
303    /// let today = Local::now().date_naive();
304    ///
305    /// let end_time = NaiveDateTime::parse_from_str(
306    ///     &format!("{} 17:30:00", today.format("%Y-%m-%d")),
307    ///     "%Y-%m-%d %H:%M:%S"
308    /// )?;
309    /// workdays.update_end(today, Some(end_time))?;
310    ///
311    /// // Reopen the day
312    /// workdays.update_end(today, None)?;
313    /// # Ok(())
314    /// # }
315    /// ```
316    pub fn update_end(&mut self, date: NaiveDate, new_end: Option<NaiveDateTime>) -> Result<()> {
317        let date_str = date.format("%Y-%m-%d").to_string();
318        let end_str = new_end.map(|e| e.format("%Y-%m-%d %H:%M:%S").to_string());
319
320        let affected = match end_str {
321            Some(end) => self.conn.execute(UPDATE_END_TIME, [&end, &date_str])?,
322            None => self.conn.execute(UNSET_END_TIME, [&date_str])?,
323        };
324
325        if affected == 0 {
326            return Err(msg_error_anyhow!(Message::WorkdayUpdateFailed));
327        }
328
329        Ok(())
330    }
331}