Skip to main content

sioul_core/
demands.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What a task or an event asks of you, and what it gives back (docs/tasks.md,
5//! "What it asks, what it gives"; docs/capacity.md). Margins before and after
6//! it: getting there, getting ready, coming back; kept free in the plan, never
7//! counted as a pause. Rated by you from 0 to 10: what it costs, cognitively,
8//! emotionally, in anxiety, and to the body and senses (physical effort,
9//! standing, noise, crowds); and what it gives back to your well-being.
10//! Written in the task's or the event's own file (`X-SIOUL-BEFORE`,
11//! `X-SIOUL-AFTER`, `X-SIOUL-COST`, `X-SIOUL-GAIN`), so that it travels with
12//! it and other programs keep it.
13//!
14//! How it went, said after the task, is kept apart (`X-SIOUL-FELT-COST`,
15//! `X-SIOUL-FELT-GAIN`, each dated): ratings given after are more accurate
16//! than forecasts, anxiety above all is over-predicted beforehand (Rachman
17//! 1994; docs/research/capacity-budget.md, criteria 6–7), so the plan uses
18//! them when they exist (`capacity`).
19
20use jiff::civil::Date;
21use serde::{Deserialize, Serialize};
22
23pub const BEFORE: &str = "X-SIOUL-BEFORE";
24pub const AFTER: &str = "X-SIOUL-AFTER";
25pub const COST: &str = "X-SIOUL-COST";
26pub const GAIN: &str = "X-SIOUL-GAIN";
27/// The costs and the gain as felt, after the task; dated by `FELT_ON`.
28pub const FELT_COST: &str = "X-SIOUL-FELT-COST";
29pub const FELT_GAIN: &str = "X-SIOUL-FELT-GAIN";
30/// The parameter that dates a felt rating: `;X-SIOUL-ON=20261006`.
31pub const FELT_ON: &str = "X-SIOUL-ON";
32
33/// Minutes kept before and after: getting there and back, getting ready.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Margins {
36    #[serde(default)]
37    pub before: u32,
38    #[serde(default)]
39    pub after: u32,
40}
41
42impl Margins {
43    pub fn is_empty(self) -> bool {
44        self.before == 0 && self.after == 0
45    }
46}
47
48/// Minutes, as a property's value says them ("20"); at most a day.
49pub fn minutes_of(value: &str) -> u32 {
50    value.trim().parse::<u32>().unwrap_or(0).min(24 * 60)
51}
52
53/// How heavy something is, as one word: from its highest cost when any is
54/// rated (`Demands::level`), else as you chose it (`X-SIOUL-ENERGY`).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Level {
58    Light,
59    Usual,
60    Heavy,
61    /// It gives back: a gain of 5 or more, no cost above 3 (a walk, music).
62    Rest,
63}
64
65impl Level {
66    /// The word as `X-SIOUL-ENERGY` holds it: "light", "heavy", "rest"; the usual, "".
67    pub fn energy(self) -> &'static str {
68        match self {
69            Level::Light => "light",
70            Level::Usual => "",
71            Level::Heavy => "heavy",
72            Level::Rest => "rest",
73        }
74    }
75
76    /// As the pages say it: "light", "usual", "heavy", "rest".
77    pub fn id(self) -> &'static str {
78        match self {
79            Level::Light => "light",
80            Level::Usual => "usual",
81            Level::Heavy => "heavy",
82            Level::Rest => "rest",
83        }
84    }
85
86    /// The word of `X-SIOUL-ENERGY` ("light", "heavy", "rest", "" for the usual).
87    pub fn of_energy(word: &str) -> Level {
88        match word {
89            "light" => Level::Light,
90            "heavy" => Level::Heavy,
91            "rest" => Level::Rest,
92            _ => Level::Usual,
93        }
94    }
95
96    /// The rating an item counts at when it has none of its own: light 2,
97    /// usual 4, heavy 7; what gives back, 2 (nothing is free, little is).
98    /// A guess to tune (docs/capacity.md, "Load").
99    pub fn rating(self) -> f32 {
100        match self {
101            Level::Light | Level::Rest => 2.0,
102            Level::Usual => 4.0,
103            Level::Heavy => 7.0,
104        }
105    }
106
107    /// Heavy from this rating on; light up to `LIGHT_TOP`. Guesses to tune (docs/capacity.md).
108    pub const HEAVY_FROM: u8 = 7;
109    pub const LIGHT_TOP: u8 = 3;
110    /// A gain from which something with light costs gives back.
111    pub const GIVES_FROM: u8 = 5;
112
113    /// The level of a highest cost (0 to 10) with a gain.
114    pub fn of(highest: u8, gain: Option<u8>) -> Level {
115        if highest <= Level::LIGHT_TOP && gain.is_some_and(|g| g >= Level::GIVES_FROM) {
116            Level::Rest
117        } else if highest <= Level::LIGHT_TOP {
118            Level::Light
119        } else if highest >= Level::HEAVY_FROM {
120            Level::Heavy
121        } else {
122            Level::Usual
123        }
124    }
125}
126
127/// The four costs and the gain, each 0 to 10, as you rate them; none unsaid.
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Demands {
130    #[serde(default)]
131    pub cognitive: Option<u8>,
132    #[serde(default)]
133    pub emotional: Option<u8>,
134    #[serde(default)]
135    pub anxiety: Option<u8>,
136    /// Body and senses: physical effort, standing, sensory load, noise, crowds.
137    #[serde(default)]
138    pub body: Option<u8>,
139    #[serde(default)]
140    pub gain: Option<u8>,
141}
142
143/// The costs' names as written, in the order of `Demands::costs`.
144pub const COST_NAMES: [&str; 4] = ["COGNITIVE", "EMOTIONAL", "ANXIETY", "BODY"];
145
146impl Demands {
147    /// The four costs, in order: cognitive, emotional, anxiety, body.
148    pub fn costs(&self) -> [Option<u8>; 4] {
149        [self.cognitive, self.emotional, self.anxiety, self.body]
150    }
151
152    /// The cost at `index` of `costs`, changed.
153    pub fn set_cost(&mut self, index: usize, value: Option<u8>) {
154        let value = value.map(|v| v.min(10));
155        match index {
156            0 => self.cognitive = value,
157            1 => self.emotional = value,
158            2 => self.anxiety = value,
159            _ => self.body = value,
160        }
161    }
162
163    /// Whether any cost is rated.
164    pub fn has_cost(&self) -> bool {
165        self.costs().iter().any(Option::is_some)
166    }
167
168    /// Nothing said at all.
169    pub fn is_empty(&self) -> bool {
170        !self.has_cost() && self.gain.is_none()
171    }
172
173    /// The highest cost rated, or None.
174    pub fn highest(&self) -> Option<u8> {
175        self.costs().into_iter().flatten().max()
176    }
177
178    /// How heavy it is, from every cost rated: its highest. A task is as
179    /// heavy as its worst side (docs/research/capacity-budget.md, criterion 9).
180    /// None when no cost is rated: then the word chosen stands.
181    pub fn level(&self) -> Option<Level> {
182        self.highest().map(|h| Level::of(h, self.gain))
183    }
184
185    /// Each value 10 at most.
186    pub fn bounded(&self) -> Demands {
187        let b = |v: Option<u8>| v.map(|v| v.min(10));
188        Demands { cognitive: b(self.cognitive), emotional: b(self.emotional), anxiety: b(self.anxiety), body: b(self.body), gain: b(self.gain) }
189    }
190
191    /// The costs as written: "COGNITIVE=3;EMOTIONAL=5;ANXIETY=7;BODY=2", the rated ones only.
192    pub fn cost_value(&self) -> String {
193        COST_NAMES.iter().zip(self.costs()).filter_map(|(name, value)| value.map(|v| format!("{name}={}", v.min(10)))).collect::<Vec<_>>().join(";")
194    }
195
196    /// The costs read from what `cost_value` writes; names in any case, any order.
197    pub fn read_cost(&mut self, value: &str) {
198        for part in value.split(';') {
199            let Some((name, number)) = part.split_once('=') else { continue };
200            let number = number.trim().parse::<u8>().ok().map(|n| n.min(10));
201            if let Some(index) = COST_NAMES.iter().position(|n| name.trim().eq_ignore_ascii_case(n)) {
202                self.set_cost(index, number);
203            }
204        }
205    }
206
207    /// The gain read from its property's value.
208    pub fn read_gain(&mut self, value: &str) {
209        self.gain = value.trim().parse::<u8>().ok().map(|n| n.min(10));
210    }
211}
212
213/// A rating said after the task, on a day.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
215pub struct Felt {
216    /// The day it was said; None when its line had no date (another program's).
217    pub on: Option<Date>,
218    pub demands: Demands,
219}
220
221/// "20261006" → the date; anything else, None.
222pub fn felt_date(value: &str) -> Option<Date> {
223    let value = value.trim();
224    if value.len() != 8 {
225        return None;
226    }
227    Date::strptime("%Y%m%d", value).ok()
228}
229
230/// The felt ratings of a file's lines, as (name, date parameter, value):
231/// one per date, the costs and the gain of a date together; undated ones
232/// first, as the oldest, then by date.
233pub fn felt_of(lines: &[(String, Option<Date>, String)]) -> Vec<Felt> {
234    let mut out: Vec<Felt> = Vec::new();
235    for (name, on, value) in lines {
236        let index = match out.iter().position(|f| f.on == *on && on.is_some()) {
237            Some(i) => i,
238            None => {
239                out.push(Felt { on: *on, demands: Demands::default() });
240                out.len() - 1
241            }
242        };
243        if name.eq_ignore_ascii_case(FELT_COST) {
244            out[index].demands.read_cost(value);
245        } else if name.eq_ignore_ascii_case(FELT_GAIN) {
246            out[index].demands.read_gain(value);
247        }
248    }
249    out.sort_by_key(|f| f.on);
250    out
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn costs_written_read_and_weighed() {
259        let rated = Demands { cognitive: Some(2), emotional: Some(2), anxiety: Some(9), body: Some(4), gain: Some(1) };
260        assert_eq!(rated.cost_value(), "COGNITIVE=2;EMOTIONAL=2;ANXIETY=9;BODY=4");
261        let mut read = Demands::default();
262        read.read_cost("anxiety=9; cognitive=2 ;EMOTIONAL=2;body=4;OTHER=4");
263        read.read_gain(" 1 ");
264        assert_eq!(read, rated);
265        let mut big = Demands::default();
266        big.read_cost("COGNITIVE=40;BODY=12");
267        assert_eq!((big.cognitive, big.body), (Some(10), Some(10)), "never past 10");
268        assert_eq!(minutes_of(" 20 "), 20);
269        assert_eq!(minutes_of("3000"), 1440);
270        // Older files without BODY read as before; a body alone is written alone.
271        let mut old = Demands::default();
272        old.read_cost("COGNITIVE=3;EMOTIONAL=5;ANXIETY=7");
273        assert_eq!(old.body, None);
274        assert_eq!(Demands { body: Some(6), ..Demands::default() }.cost_value(), "BODY=6");
275        // JSON without "body" (an older form) reads it as unsaid.
276        let json: Demands = serde_json::from_str(r#"{"cognitive":3,"gain":5}"#).unwrap();
277        assert_eq!((json.cognitive, json.body, json.gain), (Some(3), None, Some(5)));
278    }
279
280    #[test]
281    fn heaviness_is_the_highest_cost() {
282        let d = |c: Option<u8>, e: Option<u8>, a: Option<u8>, b: Option<u8>, g: Option<u8>| Demands { cognitive: c, emotional: e, anxiety: a, body: b, gain: g };
283        // Thresholds: ≤3 light, 4–6 usual, ≥7 heavy.
284        assert_eq!(d(Some(3), None, None, None, None).level(), Some(Level::Light));
285        assert_eq!(d(Some(4), None, None, None, None).level(), Some(Level::Usual));
286        assert_eq!(d(Some(6), None, None, None, None).level(), Some(Level::Usual));
287        assert_eq!(d(Some(7), None, None, None, None).level(), Some(Level::Heavy));
288        assert_eq!(d(Some(0), None, None, None, None).level(), Some(Level::Light));
289        // Every cost counts, body too: one heavy side makes it heavy.
290        assert_eq!(d(Some(1), Some(1), Some(1), Some(8), None).level(), Some(Level::Heavy));
291        assert_eq!(d(Some(2), Some(2), Some(9), None, Some(8)).level(), Some(Level::Heavy), "a gain never lightens a heavy cost");
292        // Gives back: a gain of 5 or more, no cost above 3.
293        assert_eq!(d(Some(3), None, None, Some(2), Some(5)).level(), Some(Level::Rest));
294        assert_eq!(d(Some(3), None, None, None, Some(4)).level(), Some(Level::Light));
295        assert_eq!(d(Some(4), None, None, None, Some(9)).level(), Some(Level::Usual));
296        // No cost rated: the word stands.
297        assert_eq!(d(None, None, None, None, Some(9)).level(), None);
298        assert_eq!(Demands::default().level(), None);
299        assert_eq!((Level::Usual.energy(), Level::Rest.energy(), Level::of_energy("heavy"), Level::of_energy("")), ("", "rest", Level::Heavy, Level::Usual));
300    }
301
302    #[test]
303    fn felt_ratings_by_date() {
304        let on = felt_date;
305        let lines = vec![
306            (FELT_COST.to_string(), on("20261006"), "ANXIETY=3;BODY=1".to_string()),
307            (FELT_GAIN.to_string(), on("20261006"), "6".to_string()),
308            (FELT_COST.to_string(), on("20260929"), "ANXIETY=6".to_string()),
309            (FELT_GAIN.to_string(), None, "2".to_string()),
310        ];
311        let felt = felt_of(&lines);
312        assert_eq!(felt.len(), 3);
313        assert_eq!(felt[0], Felt { on: None, demands: Demands { gain: Some(2), ..Demands::default() } }, "undated first, as oldest");
314        assert_eq!(felt[1].demands.anxiety, Some(6));
315        assert_eq!(felt[2], Felt { on: on("20261006"), demands: Demands { anxiety: Some(3), body: Some(1), gain: Some(6), ..Demands::default() } });
316        assert_eq!(felt_date("2026-10-06"), None);
317    }
318}