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)?;
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)";
33const UPDATE_START: &str = "UPDATE workdays SET start = ?1 WHERE date = ?2";
34const UPDATE_END_TIME: &str = "UPDATE workdays SET end = ?1 WHERE date = ?2";
35const UNSET_END_TIME: &str = "UPDATE workdays SET end = NULL WHERE date = ?1";
36
37/// One day's work session. Timestamps are local time; `end: None` means the
38/// session is still open.
39#[derive(Debug, Clone)]
40pub struct Workday {
41    /// Database primary key.
42    pub id: i32,
43
44    /// Calendar date; unique per row.
45    pub date: NaiveDate,
46
47    /// When the session began.
48    pub start: NaiveDateTime,
49
50    /// When the session ended; `None` while it is open.
51    pub end: Option<NaiveDateTime>,
52}
53
54/// Workday table access.
55pub struct Workdays {
56    pub conn: Connection,
57}
58
59impl Workdays {
60    /// Opens the database and ensures the workdays table exists.
61    ///
62    /// ```rust,no_run
63    /// # fn main() -> anyhow::Result<()> {
64    /// use kasl::db::workdays::Workdays;
65    ///
66    /// let mut workdays = Workdays::new()?;
67    /// # Ok(())
68    /// # }
69    /// ```
70    pub fn new() -> Result<Self> {
71        let db = Db::new()?;
72        db.conn.execute(SCHEMA_WORKDAYS, [])?;
73        Ok(Workdays { conn: db.conn })
74    }
75
76    /// Starts a workday at the current time; a no-op if the date already has
77    /// one, so repeated calls from the monitor are safe.
78    ///
79    /// ```rust,no_run
80    /// # use kasl::db::workdays::Workdays;
81    /// use chrono::Local;
82    ///
83    /// # fn main() -> anyhow::Result<()> {
84    /// let mut workdays = Workdays::new()?;
85    /// let today = Local::now().date_naive();
86    /// workdays.insert_start(today)?;
87    /// # Ok(())
88    /// # }
89    /// ```
90    pub fn insert_start(&mut self, date: NaiveDate) -> Result<()> {
91        let date_str = date.format("%Y-%m-%d").to_string();
92        if self.fetch(date)?.is_none() {
93            self.conn.execute(INSERT_START, [&date_str])?;
94        }
95        Ok(())
96    }
97
98    /// Stamps the current time as the day's end; calling again re-stamps.
99    ///
100    /// Known gap: when no workday exists for the date, the UPDATE matches
101    /// nothing and this still returns `Ok` - `kasl end` then reports success
102    /// without having written anything (tracked for the doctor stage).
103    ///
104    /// ```rust,no_run
105    /// # use kasl::db::workdays::Workdays;
106    /// use chrono::Local;
107    ///
108    /// # fn main() -> anyhow::Result<()> {
109    /// let mut workdays = Workdays::new()?;
110    /// let today = Local::now().date_naive();
111    ///
112    /// workdays.insert_start(today)?;
113    /// workdays.insert_end(today)?;
114    /// # Ok(())
115    /// # }
116    /// ```
117    pub fn insert_end(&mut self, date: NaiveDate) -> Result<()> {
118        let date_str = date.format("%Y-%m-%d").to_string();
119        self.conn.execute(UPDATE_END, [&date_str])?;
120        Ok(())
121    }
122
123    /// Fetches the workday for a date, or `None` if the date has none.
124    ///
125    /// ```rust,no_run
126    /// # use kasl::db::workdays::Workdays;
127    /// use chrono::Local;
128    ///
129    /// # fn main() -> anyhow::Result<()> {
130    /// let mut workdays = Workdays::new()?;
131    /// let today = Local::now().date_naive();
132    ///
133    /// if let Some(workday) = workdays.fetch(today)? {
134    ///     println!("Work started at: {}", workday.start);
135    ///     if let Some(end_time) = workday.end {
136    ///         println!("Work ended at: {}", end_time);
137    ///     } else {
138    ///         println!("Work session is still active");
139    ///     }
140    /// } else {
141    ///     println!("No work session recorded for today");
142    /// }
143    /// # Ok(())
144    /// # }
145    /// ```
146    pub fn fetch(&mut self, date: NaiveDate) -> Result<Option<Workday>> {
147        let date_str = date.format("%Y-%m-%d").to_string();
148
149        let workday = self
150            .conn
151            .query_row(SELECT_BY_DATE, [&date_str], |row| {
152                Ok(Workday {
153                    id: row.get(0)?,
154                    date: NaiveDate::parse_from_str(&row.get::<_, String>(1)?, "%Y-%m-%d").unwrap(),
155                    start: NaiveDateTime::parse_from_str(&row.get::<_, String>(2)?, "%Y-%m-%d %H:%M:%S").unwrap(),
156                    end: row
157                        .get::<_, Option<String>>(3)?
158                        .map(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").unwrap()),
159                })
160            })
161            .optional()?;
162
163        Ok(workday)
164    }
165
166    /// Fetches every workday in the calendar month containing `date`.
167    ///
168    /// ```rust,no_run
169    /// # use kasl::db::workdays::Workdays;
170    /// use chrono::Local;
171    ///
172    /// # fn main() -> anyhow::Result<()> {
173    /// let mut workdays = Workdays::new()?;
174    /// let current_month = Local::now().date_naive();
175    ///
176    /// let monthly_workdays = workdays.fetch_month(current_month)?;
177    /// println!("Found {} workdays this month", monthly_workdays.len());
178    ///
179    /// for workday in monthly_workdays {
180    ///     if let Some(end_time) = workday.end {
181    ///         let duration = end_time - workday.start;
182    ///         println!("Date: {}, Duration: {:?}", workday.date, duration);
183    ///     }
184    /// }
185    /// # Ok(())
186    /// # }
187    /// ```
188    pub fn fetch_month(&mut self, date: NaiveDate) -> Result<Vec<Workday>> {
189        let date_str = date.format("%Y-%m-%d").to_string();
190
191        let mut stmt = self.conn.prepare(SELECT_BY_MONTH)?;
192        let workday_iter = stmt.query_map([&date_str], |row| {
193            Ok(Workday {
194                id: row.get(0)?,
195                date: NaiveDate::parse_from_str(&row.get::<_, String>(1)?, "%Y-%m-%d").unwrap(),
196                start: NaiveDateTime::parse_from_str(&row.get::<_, String>(2)?, "%Y-%m-%d %H:%M:%S").unwrap(),
197                end: row
198                    .get::<_, Option<String>>(3)?
199                    .map(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").unwrap()),
200            })
201        })?;
202
203        let mut workdays = Vec::new();
204        for workday in workday_iter {
205            workdays.push(workday?);
206        }
207
208        Ok(workdays)
209    }
210
211    /// Sets the day's start to a specific timestamp; errors if the date has
212    /// no workday.
213    ///
214    /// ```rust,no_run
215    /// # use kasl::db::workdays::Workdays;
216    /// use chrono::{Local, NaiveDateTime};
217    ///
218    /// # fn main() -> anyhow::Result<()> {
219    /// let mut workdays = Workdays::new()?;
220    /// let today = Local::now().date_naive();
221    ///
222    /// let corrected_start = NaiveDateTime::parse_from_str(
223    ///     &format!("{} 09:00:00", today.format("%Y-%m-%d")),
224    ///     "%Y-%m-%d %H:%M:%S"
225    /// )?;
226    ///
227    /// workdays.update_start(today, corrected_start)?;
228    /// # Ok(())
229    /// # }
230    /// ```
231    pub fn update_start(&mut self, date: NaiveDate, new_start: NaiveDateTime) -> Result<()> {
232        let date_str = date.format("%Y-%m-%d").to_string();
233        let start_str = new_start.format("%Y-%m-%d %H:%M:%S").to_string();
234
235        let affected = self.conn.execute(UPDATE_START, [&start_str, &date_str])?;
236
237        if affected == 0 {
238            return Err(msg_error_anyhow!(Message::WorkdayUpdateFailed));
239        }
240
241        Ok(())
242    }
243
244    /// Sets the day's end to a specific timestamp, or reopens the day with
245    /// `None`; errors if the date has no workday.
246    ///
247    /// ```rust,no_run
248    /// # use kasl::db::workdays::Workdays;
249    /// use chrono::{Local, NaiveDateTime};
250    ///
251    /// # fn main() -> anyhow::Result<()> {
252    /// let mut workdays = Workdays::new()?;
253    /// let today = Local::now().date_naive();
254    ///
255    /// let end_time = NaiveDateTime::parse_from_str(
256    ///     &format!("{} 17:30:00", today.format("%Y-%m-%d")),
257    ///     "%Y-%m-%d %H:%M:%S"
258    /// )?;
259    /// workdays.update_end(today, Some(end_time))?;
260    ///
261    /// // Reopen the day
262    /// workdays.update_end(today, None)?;
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub fn update_end(&mut self, date: NaiveDate, new_end: Option<NaiveDateTime>) -> Result<()> {
267        let date_str = date.format("%Y-%m-%d").to_string();
268        let end_str = new_end.map(|e| e.format("%Y-%m-%d %H:%M:%S").to_string());
269
270        let affected = match end_str {
271            Some(end) => self.conn.execute(UPDATE_END_TIME, [&end, &date_str])?,
272            None => self.conn.execute(UNSET_END_TIME, [&date_str])?,
273        };
274
275        if affected == 0 {
276            return Err(msg_error_anyhow!(Message::WorkdayUpdateFailed));
277        }
278
279        Ok(())
280    }
281}