Skip to main content

sioul_app/
capacity.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What a day holds, as the window uses it (docs/capacity.md): the record
5//! gathered for the plan each time it is made (`planned`) and kept for what
6//! the pages show beside it; today's slots of time for you, named, with a
7//! suggestion from your own well-rated items, and written to
8//! `state/slots.toml` for every process of this device to hold what waits in
9//! them (`sioul_core::attention::Slots`, `crate::hours::in_slot`); the day's
10//! balance, in words, for the evening's review (`day_balance`).
11
12use crate::backend::{load_config, say, tr};
13use jiff::Zoned;
14use jiff::civil::Date;
15use sioul_core::agenda::Occurrence;
16use sioul_core::capacity::{self, DayBalance, Record};
17use sioul_core::dayview::DayView;
18use sioul_core::plan::Settings;
19use sioul_core::tasks::Task;
20use sioul_core::timelog::Session;
21use std::collections::BTreeMap;
22use std::sync::{Arc, Mutex};
23
24/// Events between two instants, read again at most every five minutes for the
25/// same span: the plan is made often, the calendars change rarely.
26fn events(from: i64, to: i64) -> Vec<Occurrence> {
27    type Kept = BTreeMap<(i64, i64), (i64, Vec<Occurrence>)>;
28    static KEPT: Mutex<Kept> = Mutex::new(BTreeMap::new());
29    let now = jiff::Timestamp::now().as_second();
30    let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner());
31    kept.retain(|_, (at, _)| (0..300).contains(&(now - *at)));
32    if let Some((_, events)) = kept.get(&(from, to)) {
33        return events.clone();
34    }
35    let events = sioul_core::agenda::occurrences(from, to);
36    kept.insert((from, to), (now, events.clone()));
37    events
38}
39
40/// The record as last gathered.
41static LAST: Mutex<Option<Arc<Record>>> = Mutex::new(None);
42
43/// The plan's settings with what your record says: the ratings said after
44/// tasks, corrected lengths, what a day holds, the free time kept, the gain
45/// slot (`capacity::gather`). The record is kept for the pages.
46pub(crate) fn planned(mut settings: Settings, tasks: &[Task], sessions: &[Session]) -> Settings {
47    // A task's time block is the task's time: never an event of its own to the record (`blocks`).
48    let own = |from: i64, to: i64| sioul_core::blocks::without_blocks(events(from, to), tasks);
49    let record = capacity::gather(tasks, sessions, &settings, &load_config().planning, &own, &Zoned::now());
50    settings.capacity = record.planning.clone();
51    *LAST.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(record));
52    settings
53}
54
55/// The record as last gathered, if the plan was made.
56pub(crate) fn last() -> Option<Arc<Record>> {
57    LAST.lock().unwrap_or_else(|e| e.into_inner()).clone()
58}
59
60/// The line a task's panel shows on request: how tasks like it usually go
61/// against the first guess; "" when there is nothing worth saying.
62pub(crate) fn ratio_line(task: &Task) -> String {
63    last().map(|record| record.ratio_line(task, tr())).unwrap_or_default()
64}
65
66/// Today's layout in words: the slots of time for you named, one suggestion
67/// from your own items when there is one (G19), the free time kept named, and
68/// why today holds what it holds. Its slots are written down, when they
69/// changed, for every process of this device: what can wait, waits (G18b),
70/// whichever process decides it (the window, the listener of other apps, a
71/// phone's background step, `sioul remind --watch`, the calls' table).
72pub(crate) fn dress(day: &mut DayView, tasks: &[Task]) {
73    let now = Zoned::now();
74    let record = last();
75    let suggestion = record.as_ref().and_then(|r| capacity::suggestion(tasks, &r.index, now.date()));
76    for block in &mut day.blocks {
77        match block.kind {
78            "gain" => {
79                block.title = tr().text("capacity-gain-slot", None);
80                block.note = suggestion.as_ref().map(|title| say("capacity-gain-suggestion", &[("title", title.clone())])).unwrap_or_default();
81            }
82            "slack" => block.title = tr().text("capacity-slack", None),
83            _ => {}
84        }
85    }
86    day.said = record.map(|r| r.reasons(now.date(), tr())).unwrap_or_default();
87    let slots = sioul_core::attention::Slots { day: Some(now.date()), slots: day.blocks.iter().filter(|b| b.kind == "gain").map(|b| (b.start, b.end)).collect() };
88    match slots.save(&sioul_core::attention::Slots::default_path()) {
89        // Calls ring or go to voicemail in them too: the phone's table follows.
90        Ok(true) => {
91            std::thread::spawn(|| crate::calls::refresh(true));
92        }
93        Ok(false) => {}
94        Err(e) => eprintln!("sioul: {e}"),
95    }
96}
97
98/// A day's balance, in words, for the evening's review: each cost and the
99/// total against what a day holds for you (light, usual, heavy; "" when nothing
100/// is known), what gave back against its minimum (below, around, above; ""),
101/// and at most two lines on what the plan does with it. Never a number to show.
102pub(crate) fn day_balance(date: Date) -> DayBalance {
103    let zone = jiff::tz::TimeZone::system();
104    let tasks = sioul_core::tasks::all(&zone);
105    let sessions = sioul_core::timelog::sessions();
106    let record = last().unwrap_or_else(|| {
107        let config = load_config();
108        let settings = Settings::of_hours(&config.week_hours(), sioul_core::areas::TaskAreas::usual());
109        let own = |from: i64, to: i64| sioul_core::blocks::without_blocks(events(from, to), &tasks);
110        Arc::new(capacity::gather(&tasks, &sessions, &settings, &config.planning, &own, &Zoned::now()))
111    });
112    // The day's outcome as said now: the review may be newer than the record.
113    let mut record = (*record).clone();
114    record.outcomes.remove(&date);
115    record.outcomes.extend(capacity::outcomes_of(&sioul_core::reviews::Reviews::load_between(&sioul_core::reviews::Reviews::default_path(), date, date)));
116    let midnight = |d: Date| d.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second());
117    let next = date.tomorrow().map_or(midnight(date) + 86_400, midnight);
118    // A task's time block is the task's time, counted with the task (`blocks`).
119    let events = sioul_core::blocks::without_blocks(events(midnight(date), next), &tasks);
120    record.day_balance(&tasks, &sessions, &events, date, &zone, tr())
121}