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}