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}