Skip to main content

sioul_core/
timelog.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Time spent on tasks, and the focus session running now.
5//!
6//! A session is a stretch of time given to one task, usually through the focus
7//! timer. Sessions are kept in plain files, one per month
8//! (`$XDG_DATA_HOME/sioul/time/2026-10.toml`), not in CalDAV: they are your
9//! record, other devices need not carry them, and a task's file stays small.
10//! The session running now is kept on disk too (`time/running.toml`), so
11//! closing the window does not lose it.
12//!
13//! Time can also be noted by hand, for a task or for a project alone (a
14//! meeting, a call); billable time of a project carries, once billed, the
15//! number of its invoice, so it is never billed twice.
16//!
17//! Each session says how its minutes were known (`Kind`): measured by the
18//! timer, typed by hand, or measured then corrected by hand. A typed duration
19//! is a memory, with its known bias (Roy, Christenfeld & McKenzie 2005), so the
20//! estimate's correction counts it little (`capacity`, docs/capacity.md).
21
22use crate::config::data_dir;
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26
27/// How a session's minutes were known.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Kind {
31    /// The timer measured them.
32    Measured,
33    /// Noted by hand.
34    Typed,
35    /// Measured, then changed by hand (a break missed, a timer left running).
36    Corrected,
37}
38
39impl Kind {
40    /// As the pages say it; a session written before the kind was kept: "unknown".
41    pub fn id(kind: Option<Kind>) -> &'static str {
42        match kind {
43            Some(Kind::Measured) => "measured",
44            Some(Kind::Typed) => "typed",
45            Some(Kind::Corrected) => "corrected",
46            None => "unknown",
47        }
48    }
49
50    /// Whether its minutes came from the timer: measured, or measured and corrected.
51    pub fn timed(kind: Option<Kind>) -> bool {
52        matches!(kind, Some(Kind::Measured | Kind::Corrected))
53    }
54}
55
56/// Time given to one task, or to a project.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Session {
59    /// Its own id, given when it is recorded (`record_in`, `ids`): your
60    /// devices merge each session by it, so that one changed on two devices
61    /// stays one (docs/database.md, "The format of what travels"). "" for a
62    /// session an older Sioul recorded, which keeps none.
63    #[serde(default, skip_serializing_if = "String::is_empty")]
64    pub id: String,
65    /// The task's UID; "" for time given to a project alone.
66    #[serde(default, skip_serializing_if = "String::is_empty")]
67    pub task: String,
68    /// The project (its id) it counts for; "" for the task's own project.
69    #[serde(default, skip_serializing_if = "String::is_empty")]
70    pub project: String,
71    /// Not to be billed, though its project is billable.
72    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73    pub unbilled: bool,
74    /// The invoice it was billed on.
75    #[serde(default, skip_serializing_if = "String::is_empty")]
76    pub invoice: String,
77    /// When it began, Unix seconds.
78    pub start: i64,
79    pub minutes: u32,
80    /// The task was marked done at its end.
81    #[serde(default)]
82    pub done: bool,
83    /// A word on what was done, if you left one.
84    #[serde(default, skip_serializing_if = "String::is_empty")]
85    pub note: String,
86    /// How its minutes were known; None for a session written before this was kept.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub kind: Option<Kind>,
89}
90
91#[derive(Debug, Default, Serialize, Deserialize)]
92struct Month {
93    #[serde(default, rename = "session")]
94    sessions: Vec<Session>,
95}
96
97/// Where the sessions are kept.
98pub fn folder() -> PathBuf {
99    data_dir().join("time")
100}
101
102fn month_file(dir: &Path, start: i64) -> PathBuf {
103    let month = jiff::Timestamp::from_second(start).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m").to_string()).unwrap_or_else(|_| "unknown".into());
104    dir.join(format!("{month}.toml"))
105}
106
107fn write_atomically(path: &Path, text: &str) -> Result<(), String> {
108    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
109    if let Some(parent) = path.parent() {
110        std::fs::create_dir_all(parent).map_err(fail)?;
111    }
112    let temporary = path.with_extension("toml.new");
113    std::fs::write(&temporary, text).map_err(fail)?;
114    std::fs::rename(&temporary, path).map_err(fail)
115}
116
117/// Adds a session to its month's file, in `dir`, with an id of its own when it has none.
118pub fn record_in(dir: &Path, session: &Session) -> Result<(), String> {
119    let path = month_file(dir, session.start);
120    // Read, changed and written under the month's lock, which the sharing takes too.
121    crate::filelock::with_lock(&path, || {
122        // A month not begun yet starts empty; one there that cannot be read is left
123        // alone: written over, its time (and what was billed of it) would be lost.
124        let mut month: Month = match std::fs::read_to_string(&path) {
125            Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?,
126            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Month::default(),
127            Err(e) => return Err(format!("{}: {e}", path.display())),
128        };
129        let mut session = session.clone();
130        if session.id.is_empty() {
131            session.id = crate::ids::new();
132        }
133        month.sessions.push(session);
134        write_atomically(&path, &toml::to_string(&month).map_err(|e| e.to_string())?)
135    })
136}
137
138/// Adds a session to its month's file.
139pub fn record(session: &Session) -> Result<(), String> {
140    record_in(&folder(), session)
141}
142
143/// Every session kept in `dir`, oldest first.
144pub fn sessions_in(dir: &Path) -> Vec<Session> {
145    let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
146    let mut files: Vec<PathBuf> = entries
147        .filter_map(Result::ok)
148        .map(|e| e.path())
149        .filter(|p| p.extension().is_some_and(|x| x == "toml") && p.file_stem().is_some_and(|s| s.to_string_lossy().chars().next().is_some_and(|c| c.is_ascii_digit())))
150        .collect();
151    files.sort();
152    let mut all: Vec<Session> = files.iter().filter_map(|p| std::fs::read_to_string(p).ok()).filter_map(|t| toml::from_str::<Month>(&t).ok()).flat_map(|m| m.sessions).collect();
153    all.sort_by_key(|s| s.start);
154    all
155}
156
157/// Every session, oldest first.
158pub fn sessions() -> Vec<Session> {
159    sessions_in(&folder())
160}
161
162impl Session {
163    /// What tells it from the others: when it began, and for what.
164    pub fn key(&self) -> String {
165        format!("{}:{}:{}", self.start, if self.task.is_empty() { &self.project } else { &self.task }, self.minutes)
166    }
167
168    /// Its minutes changed by hand: a measured one becomes corrected; one
169    /// typed stays typed, one of unknown kind stays unknown.
170    pub fn set_minutes(&mut self, minutes: u32) {
171        if minutes != self.minutes && self.kind == Some(Kind::Measured) {
172            self.kind = Some(Kind::Corrected);
173        }
174        self.minutes = minutes;
175    }
176}
177
178/// Changes the sessions `keys` names with `change`, in their month files in
179/// `dir`; returns how many were changed.
180pub fn update_in(dir: &Path, keys: &[String], change: impl Fn(&mut Session)) -> Result<usize, String> {
181    let Ok(entries) = std::fs::read_dir(dir) else { return Ok(0) };
182    let mut changed = 0;
183    for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "toml") && p.file_name().is_some_and(|n| n != "running.toml")) {
184        // Each month read, changed and written under its lock.
185        changed += crate::filelock::with_lock(&path, || -> Result<usize, String> {
186            let Some(mut month) = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str::<Month>(&t).ok()) else { return Ok(0) };
187            let mut here = 0;
188            for session in month.sessions.iter_mut().filter(|s| keys.contains(&s.key())) {
189                change(session);
190                here += 1;
191            }
192            if here > 0 {
193                write_atomically(&path, &toml::to_string(&month).map_err(|e| e.to_string())?)?;
194            }
195            Ok(here)
196        })?;
197    }
198    Ok(changed)
199}
200
201/// The session `key` names, changed by `change`: in place within its month;
202/// moved to another month, written there first, then taken out of its old
203/// one, so that nothing is lost on the way. Returns whether it was found.
204pub fn change_in(dir: &Path, key: &str, change: impl Fn(&mut Session)) -> Result<bool, String> {
205    let Some(found) = sessions_in(dir).into_iter().find(|s| s.key() == key) else { return Ok(false) };
206    let path = month_file(dir, found.start);
207    // The session as its month holds it now, read and changed under the month's
208    // lock, which the sharing takes too: never a copy read before.
209    let new = crate::filelock::with_lock(&path, || {
210        let month: Month = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
211        month.sessions.into_iter().find(|s| s.key() == key).map(|mut session| {
212            change(&mut session);
213            session
214        })
215    });
216    let Some(new) = new else { return Ok(false) };
217    if month_file(dir, new.start) == path {
218        return update_in(dir, &[key.to_string()], &change).map(|n| n > 0);
219    }
220    record_in(dir, &new)?;
221    remove_in(dir, &[key.to_string()])?;
222    Ok(true)
223}
224
225/// Takes out the sessions `keys` names (time noted by mistake).
226pub fn remove_in(dir: &Path, keys: &[String]) -> Result<usize, String> {
227    let Ok(entries) = std::fs::read_dir(dir) else { return Ok(0) };
228    let mut removed = 0;
229    for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "toml") && p.file_name().is_some_and(|n| n != "running.toml")) {
230        removed += crate::filelock::with_lock(&path, || -> Result<usize, String> {
231            let Some(mut month) = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str::<Month>(&t).ok()) else { return Ok(0) };
232            let before = month.sessions.len();
233            month.sessions.retain(|s| !keys.contains(&s.key()));
234            if month.sessions.len() != before {
235                write_atomically(&path, &toml::to_string(&month).map_err(|e| e.to_string())?)?;
236            }
237            Ok(before - month.sessions.len())
238        })?;
239    }
240    Ok(removed)
241}
242
243/// Minutes spent on each task, by UID, between two instants (Unix seconds).
244pub fn spent(sessions: &[Session], from: i64, to: i64) -> BTreeMap<String, u32> {
245    let mut out: BTreeMap<String, u32> = BTreeMap::new();
246    for s in sessions.iter().filter(|s| s.start >= from && s.start < to) {
247        let minutes = out.entry(s.task.clone()).or_default();
248        *minutes = minutes.saturating_add(s.minutes);
249    }
250    out
251}
252
253/// The focus session running now.
254#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
255pub struct Running {
256    pub task: String,
257    /// When it began, Unix seconds.
258    pub start: i64,
259    /// The minutes you chose.
260    pub planned: u32,
261    /// When it was paused, if it is.
262    #[serde(default)]
263    pub paused_at: Option<i64>,
264    /// Seconds spent paused, before the current pause.
265    #[serde(default)]
266    pub paused: i64,
267}
268
269impl Running {
270    /// Seconds of focus so far, pauses left out.
271    pub fn elapsed(&self, now: i64) -> i64 {
272        let until = self.paused_at.unwrap_or(now);
273        (until - self.start - self.paused).max(0)
274    }
275
276    /// Seconds left of the time chosen; negative past it.
277    pub fn remaining(&self, now: i64) -> i64 {
278        i64::from(self.planned) * 60 - self.elapsed(now)
279    }
280
281    /// Paused, or going again: the time paused is set aside.
282    pub fn toggle_pause(&mut self, now: i64) {
283        match self.paused_at.take() {
284            Some(since) => self.paused += now - since,
285            None => self.paused_at = Some(now),
286        }
287    }
288
289    /// The session as recorded when it ends: whole minutes of focus, at least one.
290    pub fn finished(&self, now: i64, done: bool) -> Session {
291        let minutes = u32::try_from((self.elapsed(now) + 30) / 60).unwrap_or(u32::MAX).max(1);
292        Session { task: self.task.clone(), start: self.start, minutes, done, kind: Some(Kind::Measured), ..Session::default() }
293    }
294}
295
296fn running_path(dir: &Path) -> PathBuf {
297    dir.join("running.toml")
298}
299
300/// The session running now, if any.
301pub fn running_in(dir: &Path) -> Option<Running> {
302    std::fs::read_to_string(running_path(dir)).ok().and_then(|t| toml::from_str(&t).ok())
303}
304
305pub fn running() -> Option<Running> {
306    running_in(&folder())
307}
308
309/// Keeps the running session, or forgets it (None).
310pub fn keep_running_in(dir: &Path, running: Option<&Running>) -> Result<(), String> {
311    let path = running_path(dir);
312    crate::filelock::with_lock(&path, || match running {
313        Some(r) => write_atomically(&path, &toml::to_string(r).map_err(|e| e.to_string())?),
314        None => match std::fs::remove_file(&path) {
315            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
316            _ => Ok(()),
317        },
318    })
319}
320
321pub fn keep_running(running: Option<&Running>) -> Result<(), String> {
322    keep_running_in(&folder(), running)
323}
324
325/// The running session, as the file holds it now, changed by `change` and
326/// kept, all under the file's lock, which the sharing takes too: a press
327/// is set over the session as it is then. Whether one was running.
328pub fn change_running_in(dir: &Path, change: impl FnOnce(&mut Running)) -> Result<bool, String> {
329    let path = running_path(dir);
330    crate::filelock::with_lock(&path, || {
331        let Some(mut running) = running_in(dir) else { return Ok(false) };
332        change(&mut running);
333        keep_running_in(dir, Some(&running)).map(|()| true)
334    })
335}
336
337pub fn change_running(change: impl FnOnce(&mut Running)) -> Result<bool, String> {
338    change_running_in(&folder(), change)
339}
340
341/// Minutes an open-ended session counts at most: one left running all night is not a night's work.
342const OPEN_ENDED_CEILING: u32 = 180;
343
344/// Ends the running session: recorded, then forgotten. A session left running
345/// for hours (the window closed, a night passed) counts only the time chosen;
346/// an open-ended one (`planned` 0), three hours at most.
347pub fn finish_in(dir: &Path, now: i64, done: bool) -> Result<Option<Session>, String> {
348    finish_noted_in(dir, now, done, "")
349}
350
351/// Ends the running session kept in `dir`, with where you stopped.
352pub fn finish_noted_in(dir: &Path, now: i64, done: bool, note: &str) -> Result<Option<Session>, String> {
353    let Some(running) = running_in(dir) else { return Ok(None) };
354    let mut session = running.finished(now, done);
355    session.note = note.to_string();
356    let ceiling = if running.planned == 0 { OPEN_ENDED_CEILING } else { running.planned.saturating_mul(2).max(running.planned.saturating_add(30)) };
357    if session.minutes > ceiling {
358        session.minutes = if running.planned == 0 { OPEN_ENDED_CEILING } else { running.planned };
359        // Not what the timer measured any more: Sioul set it, as a hand would.
360        session.kind = Some(Kind::Corrected);
361    }
362    record_in(dir, &session)?;
363    keep_running_in(dir, None)?;
364    Ok(Some(session))
365}
366
367pub fn finish(now: i64, done: bool) -> Result<Option<Session>, String> {
368    finish_in(&folder(), now, done)
369}
370
371/// Ends the running session with where you stopped.
372pub fn finish_with_note(now: i64, done: bool, note: &str) -> Result<Option<Session>, String> {
373    finish_noted_in(&folder(), now, done, note)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn a_stretch_changed_in_place_or_moved_to_its_month() {
382        let dir = std::env::temp_dir().join(format!("sioul-timelog-change-{}", std::process::id()));
383        let _ = std::fs::remove_dir_all(&dir);
384        let at = |text: &str| -> i64 { format!("{text}[Europe/Paris]").parse::<jiff::Zoned>().unwrap().timestamp().as_second() };
385        let first = Session { task: "t1".into(), start: at("2026-10-05T09:00"), minutes: 120, ..Session::default() };
386        record_in(&dir, &first).unwrap();
387        // A break missed: the end set back to 10:15, a word, another task.
388        assert!(change_in(&dir, &first.key(), |s| {
389            s.minutes = 75;
390            s.note = "the draft, up to page 3".into();
391            s.task = "t2".into();
392        })
393        .unwrap());
394        let all = sessions_in(&dir);
395        assert_eq!((all.len(), all[0].minutes, all[0].task.as_str(), all[0].note.as_str()), (1, 75, "t2", "the draft, up to page 3"));
396        // Moved to the month before: written there, gone from here; one stretch still.
397        let key = all[0].key();
398        assert!(change_in(&dir, &key, |s| s.start = at("2026-09-30T16:00")).unwrap());
399        let all = sessions_in(&dir);
400        assert_eq!(all.len(), 1);
401        assert!(dir.join("2026-09.toml").exists());
402        assert!(!change_in(&dir, "nothing", |_| {}).unwrap(), "a stretch gone is said so");
403        let _ = std::fs::remove_dir_all(&dir);
404    }
405
406    #[test]
407    fn each_session_says_how_it_was_known() {
408        let dir = std::env::temp_dir().join(format!("sioul-timelog-kind-{}", std::process::id()));
409        let _ = std::fs::remove_dir_all(&dir);
410        let start = 1_791_100_000;
411        // The timer: measured.
412        keep_running_in(&dir, Some(&Running { task: "t".into(), start, planned: 25, ..Running::default() })).unwrap();
413        let timed = finish_in(&dir, start + 20 * 60, true).unwrap().unwrap();
414        assert_eq!(timed.kind, Some(Kind::Measured));
415        // Its minutes changed by hand: corrected.
416        assert!(change_in(&dir, &timed.key(), |s| s.set_minutes(15)).unwrap());
417        let read = sessions_in(&dir);
418        assert_eq!((read[0].minutes, read[0].kind), (15, Some(Kind::Corrected)));
419        // A note changed, not the minutes: still what it was.
420        assert!(change_in(&dir, &read[0].key(), |s| s.set_minutes(15)).unwrap());
421        assert_eq!(sessions_in(&dir)[0].kind, Some(Kind::Corrected));
422        // Typed by hand stays typed when changed.
423        let mut typed = Session { task: "u".into(), start: start + 7200, minutes: 30, kind: Some(Kind::Typed), ..Session::default() };
424        typed.set_minutes(45);
425        assert_eq!(typed.kind, Some(Kind::Typed));
426        // Forgotten overnight: Sioul cut it to the time chosen, so it is no longer measured.
427        keep_running_in(&dir, Some(&Running { task: "d".into(), start: start + 9000, planned: 25, ..Running::default() })).unwrap();
428        assert_eq!(finish_in(&dir, start + 9000 + 10 * 3600, false).unwrap().unwrap().kind, Some(Kind::Corrected));
429        // A session written before kinds were kept reads as unknown, and writes nothing new.
430        let month = month_file(&dir, start);
431        let text = std::fs::read_to_string(&month).unwrap();
432        std::fs::write(&month, format!("{text}\n[[session]]\ntask = \"old\"\nstart = {}\nminutes = 10\n", start + 20_000)).unwrap();
433        let old = sessions_in(&dir).into_iter().find(|s| s.task == "old").unwrap();
434        assert_eq!((old.kind, Kind::id(old.kind), Kind::timed(old.kind)), (None, "unknown", false));
435        assert!(toml::to_string(&Month { sessions: vec![old] }).unwrap().lines().all(|l| !l.starts_with("kind")));
436        assert!(Kind::timed(Some(Kind::Corrected)) && !Kind::timed(Some(Kind::Typed)));
437        let _ = std::fs::remove_dir_all(&dir);
438    }
439
440    #[test]
441    fn time_by_hand_and_billed_once() {
442        let dir = std::env::temp_dir().join(format!("sioul-time-hand-{}", std::process::id()));
443        let _ = std::fs::remove_dir_all(&dir);
444        let start = 1_791_000_000;
445        let meeting = Session { project: "lumen".into(), start, minutes: 90, note: "Kick-off meeting".into(), ..Session::default() };
446        let coffee = Session { project: "lumen".into(), start: start + 7200, minutes: 30, unbilled: true, ..Session::default() };
447        record_in(&dir, &meeting).unwrap();
448        record_in(&dir, &coffee).unwrap();
449        assert_eq!(update_in(&dir, &[meeting.key()], |s| s.invoice = "2026-001".into()).unwrap(), 1);
450        let all = sessions_in(&dir);
451        assert_eq!((all[0].invoice.as_str(), all[1].invoice.as_str()), ("2026-001", ""));
452        let text = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| std::fs::read_to_string(e.path()).unwrap()).collect::<String>();
453        assert!(!text.contains("task = \"\""), "a session for a project alone says so without an empty task: {text}");
454        assert_eq!(remove_in(&dir, &[coffee.key()]).unwrap(), 1);
455        assert_eq!(sessions_in(&dir).len(), 1);
456        // A month's file that cannot be read is never written over with one session.
457        let file = month_file(&dir, start);
458        std::fs::write(&file, "[[session]]\nstart = \"half written").unwrap();
459        assert!(record_in(&dir, &meeting).is_err());
460        assert_eq!(std::fs::read_to_string(&file).unwrap(), "[[session]]\nstart = \"half written");
461        let _ = std::fs::remove_dir_all(&dir);
462    }
463
464    #[test]
465    fn sessions_by_month_and_by_task() {
466        let dir = std::env::temp_dir().join(format!("sioul-time-{}", std::process::id()));
467        let _ = std::fs::remove_dir_all(&dir);
468        let start = 1_791_100_000; // 4 October 2026
469        record_in(&dir, &Session { task: "a".into(), start, minutes: 25, ..Session::default() }).unwrap();
470        record_in(&dir, &Session { task: "a".into(), start: start + 3600, minutes: 5, done: true, ..Session::default() }).unwrap();
471        record_in(&dir, &Session { task: "b".into(), start: start - 40 * 86_400, minutes: 15, ..Session::default() }).unwrap();
472        let all = sessions_in(&dir);
473        assert_eq!(all.len(), 3);
474        // Beside them, hidden, each month's lock (`filelock`).
475        assert_eq!(std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).filter(|e| !e.file_name().to_string_lossy().starts_with('.')).count(), 2, "two months, two files");
476        let spent = spent(&all, start - 86_400, start + 86_400);
477        assert_eq!(spent.get("a"), Some(&30));
478        assert_eq!(spent.get("b"), None);
479
480        let mut running = Running { task: "c".into(), start, planned: 15, ..Running::default() };
481        running.toggle_pause(start + 300);
482        running.toggle_pause(start + 900);
483        assert_eq!((running.elapsed(start + 1200), running.remaining(start + 1200)), (600, 300));
484        keep_running_in(&dir, Some(&running)).unwrap();
485        assert_eq!(running_in(&dir), Some(running));
486        let session = finish_in(&dir, start + 1200, true).unwrap().unwrap();
487        assert_eq!((session.minutes, session.done), (10, true));
488        assert_eq!(running_in(&dir), None);
489        // Forgotten overnight: the time chosen, not the night.
490        keep_running_in(&dir, Some(&Running { task: "d".into(), start, planned: 25, ..Running::default() })).unwrap();
491        assert_eq!(finish_in(&dir, start + 10 * 3600, false).unwrap().unwrap().minutes, 25);
492        std::fs::remove_dir_all(&dir).unwrap();
493    }
494}