Skip to main content

sioul_app/
reviews.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The two rituals, for the window (docs/reviews.md): closing the work day
5//! ("Done for today") and closing the day before sleep, each with how the day
6//! felt, all of it optional. The sheet's view (`DayReview.qml`), the answers
7//! kept (`sioul_core::reviews`), the status line's offer, the one notice at the
8//! end of the day's last work or admin hours, and a day's words for the Health
9//! page. Words only: no count, no score, nothing about what was not done.
10
11use crate::backend::{QtThread, Shared, load_config, mode_json, say, tr};
12use crate::backend::qobject::Sioul;
13use cxx_qt_lib::QString;
14use jiff::Zoned;
15use jiff::civil::Date;
16use sioul_core::quiet::{Mode, Overrides};
17use sioul_core::reviews::{self, Felt, Kind, Mix, Moment, Offer, Review, Reviews};
18use std::pin::Pin;
19use std::sync::Arc;
20
21/// The end-of-work notice comes within this long of the hours' end, or not at all.
22const NOTICE_GRACE: i64 = 10 * 60;
23/// The notes of the last days, read back in the sheet.
24const BACK_DAYS: i64 = 14;
25
26/// Today's last hours of work or admin end then; none on a day without them,
27/// or in time off.
28fn work_end(config: &sioul_core::config::Config, now: &Zoned) -> Option<Zoned> {
29    if sioul_core::quiet::time_off_on(&config.time_off, now.date()).is_some() {
30        return None;
31    }
32    sioul_core::window::hours_on(&config.week_hours(), now.date(), now.time_zone()).map(|(_, closing)| closing)
33}
34
35/// The night going on or coming next, as Health keeps it: (winding down from, bed at).
36fn night(now: &Zoned) -> Option<(i64, i64)> {
37    let stamp = now.timestamp().as_second();
38    crate::hours::blocks(now).kept.iter().filter(|k| k.kind == "sleep" && k.end > stamp).min_by_key(|k| k.start).map(|k| (k.start, k.at))
39}
40
41/// Where the day stands at `now`, as the offer reads it: today's end of work,
42/// the night, whether the work day was closed, and the reviews of yesterday and today.
43fn standing(now: &Zoned) -> (Option<Zoned>, Option<(i64, i64)>, bool, Reviews) {
44    let config = load_config();
45    let overrides = Overrides::load(&Overrides::default_path());
46    let today = now.date();
47    let reviews = Reviews::load_between(&Reviews::default_path(), today.yesterday().unwrap_or(today), today);
48    (work_end(&config, now), night(now), overrides.closed_today(now), reviews)
49}
50
51/// What the status line offers at `now`, in `mode`.
52fn offer_at(now: &Zoned, mode: &Mode) -> Option<Offer> {
53    let (work_end, night, closed_today, reviews) = standing(now);
54    reviews::offer(&Moment { now, mode, work_end, night, closed_today }, &reviews)
55}
56
57/// What the notice "Work hours are over" offers at `now`: as the status
58/// line, at the times the matrix of what reaches you lets it come (as usual,
59/// the status line's own: never at work, asleep, paused or in Free time).
60fn notice_at(now: &Zoned, mode: &Mode) -> Option<Offer> {
61    if !crate::hours::comes(sioul_core::attention::Kind::WorkOver) {
62        return None;
63    }
64    let (work_end, night, closed_today, reviews) = standing(now);
65    reviews::offer_any_time(&Moment { now, mode, work_end, night, closed_today }, &reviews)
66}
67
68/// For the status line (`mode_json`): {kind, date, line, button}; kind "" when
69/// nothing is offered: at work, while you sleep, once said.
70pub(crate) fn offer_json(now: &Zoned, mode: &Mode) -> serde_json::Value {
71    match offer_at(now, mode) {
72        Some(offer) => serde_json::json!({
73            "kind": offer.kind.id(),
74            "date": offer.date.to_string(),
75            "line": tr().text(&format!("review-offer-{}", offer.kind.id()), None),
76            "button": tr().text(&format!("review-close-{}", offer.kind.id()), None),
77        }),
78        None => serde_json::json!({ "kind": "", "date": "", "line": "", "button": "" }),
79    }
80}
81
82/// The day a review closes, given now: the night's day (after midnight, the
83/// evening before); the work day's, today (before 05:00, yesterday).
84fn day_of(kind: Kind, now: &Zoned) -> Date {
85    let mode = crate::hours::mode_at(now);
86    let config = load_config();
87    let moment = Moment { now, mode: &mode, work_end: work_end(&config, now), night: night(now), closed_today: false };
88    match kind {
89        Kind::Night => moment.evening().1,
90        Kind::Work if now.hour() < reviews::DAY_TURNS => now.date().yesterday().unwrap_or(now.date()),
91        Kind::Work => now.date(),
92    }
93}
94
95/// The first letter capitalized ("lundi" → "Lundi").
96fn capitalized(text: &str) -> String {
97    let mut chars = text.chars();
98    chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
99}
100
101/// "Tomorrow", "Monday", "Monday 19 October": the nearer, the shorter.
102fn day_name(day: Date, today: Date) -> String {
103    let name = if today.tomorrow().is_ok_and(|t| t == day) {
104        tr().text("day-tomorrow", None)
105    } else if day > today && day.since(today).is_ok_and(|s| s.get_days() < 7) {
106        tr().text(&format!("weekday-{}", day.weekday().to_monday_one_offset()), None)
107    } else {
108        tr().day(day)
109    };
110    capitalized(&name)
111}
112
113/// The first step named when the work day closed, for the day work comes back:
114/// "Tomorrow starts with: open the form (15 min)". A plan for what remains
115/// helps sleep come (Scullin et al. 2018); none when none was named.
116fn first_step_line(date: Date) -> Option<String> {
117    let overrides = Overrides::load(&Overrides::default_path());
118    let step = overrides.first_step.as_deref().map(str::trim).filter(|s| !s.is_empty())?;
119    let day = overrides.first_step_day.filter(|d| *d > date)?;
120    Some(say("review-first-step", &[("day", day_name(day, date)), ("step", step.to_string())]))
121}
122
123/// The day's costs and gains in words, for the night: each scale whose level is
124/// known that day ("Thinking: heavy for you."), the gain ("Some time gave
125/// back."), then the plan's own lines when the day changes tomorrow's. Never a
126/// number, a percentage or a gauge (criteria CB45–49, G20).
127fn balance_lines(date: Date) -> Vec<String> {
128    // The capacity's own reading of the day: its numbers (load, budget, good hours, minimum) stay there.
129    balance_words(&crate::capacity::day_balance(date), tr())
130}
131
132/// The balance in words, in `tr`'s language: a scale not known that day says
133/// nothing (CB12), the total is the scales' own; the gain in two words.
134fn balance_words(balance: &sioul_core::capacity::DayBalance, tr: &sioul_core::i18n::Translator) -> Vec<String> {
135    let mut lines: Vec<String> = balance
136        .costs
137        .iter()
138        .filter(|scale| !scale.level.is_empty() && scale.scale != "total")
139        .map(|scale| {
140            let mut args = sioul_core::i18n::args();
141            args.set("scale", tr.text(&format!("review-scale-{}", scale.scale), None));
142            args.set("level", tr.text(&format!("review-level-{}", scale.level), None));
143            tr.text("review-scale", Some(&args))
144        })
145        .collect();
146    match balance.gain.level {
147        "below" => lines.push(tr.text("review-gain-little", None)),
148        "around" | "above" => lines.push(tr.text("review-gain-some", None)),
149        _ => {}
150    }
151    lines.extend(balance.said.iter().cloned());
152    lines
153}
154
155/// A review's note as the sheet and the Health page show it: Markdown read as Notes reads it.
156fn memo_html(review: Option<&Review>) -> Option<String> {
157    review.map(|r| r.memo.trim()).filter(|m| !m.is_empty()).map(sioul_core::compose::markdown_html)
158}
159
160/// The sheet for `kind` ("work", "night"), as JSON: its title and the day it
161/// closes; how the day started and went, in words; for the night the work's
162/// review, tomorrow's first step and the day's balance; the answers already
163/// given (closed once, the sheet opens on them); the notes of the last days.
164pub(crate) fn view(kind: &str) -> String {
165    let kind = Kind::parse(kind).unwrap_or(Kind::Work);
166    let now = Zoned::now();
167    let date = day_of(kind, &now);
168    let from = date.checked_sub(jiff::Span::new().days(BACK_DAYS)).unwrap_or(date);
169    let all = Reviews::load_between(&Reviews::default_path(), from, date);
170    let day = all.day(date);
171    let mut lines = reviews::morning_lines(day, tr());
172    if kind == Kind::Night {
173        if let Some(words) = day.and_then(|d| d.work.as_ref()).and_then(|r| reviews::said_words(r, tr())) {
174            lines.push(say("review-work-said", &[("words", words)]));
175        }
176        lines.extend(first_step_line(date));
177    }
178    let balance = if kind == Kind::Night { balance_lines(date) } else { Vec::new() };
179    let given = day.and_then(|d| d.review(kind));
180    let back: Vec<serde_json::Value> = all
181        .recent(date, BACK_DAYS)
182        .into_iter()
183        .map(|(date, day)| {
184            let memos: Vec<String> = [memo_html(day.work.as_ref()), memo_html(day.night.as_ref())].into_iter().flatten().collect();
185            serde_json::json!({ "day": capitalized(&tr().day_in(date, now.date())), "line": reviews::back_line(day, tr()), "memos": memos })
186        })
187        .collect();
188    serde_json::json!({
189        "kind": kind.id(),
190        "date": date.to_string(),
191        "title": tr().text(&format!("review-{}-title", kind.id()), None),
192        "lines": lines,
193        "balance": balance,
194        "felt": given.and_then(|r| r.felt).map_or("", Felt::id),
195        "mix": given.and_then(|r| r.mix).map_or("", Mix::id),
196        "memo": given.map(|r| r.memo.clone()).unwrap_or_default(),
197        "close": tr().text(&format!("review-close-{}", kind.id()), None),
198        "back": back,
199    })
200    .to_string()
201}
202
203/// The review kept, then for the work day the day closed as "Done for today"
204/// always did, unless it was already today: the closing screen's JSON, else
205/// "". The night: the status line says the day is closed. A review that
206/// cannot be kept is said; the work day closes all the same.
207pub(crate) fn close(mut sioul: Pin<&mut Sioul>, kind: &str, felt: &str, mix: &str, memo: &str) -> QString {
208    let Some(kind) = Kind::parse(kind) else { return QString::default() };
209    let now = Zoned::now();
210    let date = day_of(kind, &now);
211    let review = Review { at: now.timestamp().as_second(), felt: Felt::parse(felt), mix: Mix::parse(mix), memo: memo.trim().to_string() };
212    let kept = reviews::save(&Reviews::default_path(), date, kind, &review, now.time_zone());
213    let closing = match kind {
214        Kind::Work if !Overrides::load(&Overrides::default_path()).closed_today(&now) => sioul.as_mut().done_for_the_day(),
215        Kind::Work => {
216            sioul.as_mut().set_status(QString::from(&tr().text("review-kept", None)));
217            QString::default()
218        }
219        Kind::Night => {
220            sioul.as_mut().set_status(QString::from(&tr().text("review-closed-night", None)));
221            QString::default()
222        }
223    };
224    if let Err(e) = kept {
225        sioul.as_mut().set_status(QString::from(&e));
226    }
227    // What the status line offers follows at once.
228    sioul.as_mut().set_mode(QString::from(&mode_json()));
229    closing
230}
231
232/// The weather said for today, copied as the morning's (`reviews::note_weather`).
233pub(crate) fn weather_said(weather: sioul_core::today::Weather) {
234    let now = Zoned::now();
235    if let Err(e) = reviews::note_weather(&Reviews::default_path(), now.date(), weather, now.timestamp().as_second(), now.time_zone()) {
236        eprintln!("{e}");
237    }
238}
239
240/// A day on the Health page, as JSON: its words when some were said ("How the
241/// day went: Morning: haze · End of work: heavy…") and its notes; today, the
242/// button to close it while the evening offers it, and during the night.
243pub(crate) fn line(date: &str) -> String {
244    let now = Zoned::now();
245    let Ok(date) = date.parse::<Date>() else { return "null".into() };
246    let reviews = Reviews::load_between(&Reviews::default_path(), date, date);
247    let day = reviews.day(date);
248    let words = day.filter(|d| d.said()).map(|d| reviews::back_line(d, tr())).unwrap_or_default();
249    let memos: Vec<String> = day.map(|d| [memo_html(d.work.as_ref()), memo_html(d.night.as_ref())].into_iter().flatten().collect()).unwrap_or_default();
250    let mode = crate::hours::mode_at(&now);
251    let offered = offer_at(&now, &mode).is_some_and(|o| o.kind == Kind::Night && o.date == date);
252    // Winding down or asleep in this day's night, the page still offers it: you came to it.
253    let tonight = mode.sleeps() && night(&now).is_some_and(|(from, bed)| from <= now.timestamp().as_second() && reviews::night_day(bed, now.time_zone()) == Some(date));
254    let open = day.is_none_or(|d| d.night.is_none()) && (offered || tonight);
255    serde_json::json!({
256        "said": if words.is_empty() { String::new() } else { tr().text("review-line-said", None) },
257        "words": words,
258        "memos": memos,
259        "close": open,
260        "button": tr().text("review-close-night", None),
261    })
262    .to_string()
263}
264
265/// Whether an event goes on now (not a whole day's, not cancelled).
266fn meeting(stamp: i64) -> bool {
267    sioul_core::agenda::occurrences(stamp - 86_400, stamp + 60).iter().any(|e| !e.cancelled && !e.all_day && e.start <= stamp && e.end > stamp)
268}
269
270/// The day the end-of-work notice was given, on this device.
271fn told_path() -> std::path::PathBuf {
272    sioul_core::config::state_dir().join("review-told.toml")
273}
274
275fn told(date: Date) -> bool {
276    std::fs::read_to_string(told_path()).ok().and_then(|t| t.parse::<toml::Table>().ok()).and_then(|t| t.get("work").and_then(|v| v.as_str()).map(str::to_string)).is_some_and(|d| d == date.to_string())
277}
278
279fn mark_told(date: Date) {
280    let _ = std::fs::create_dir_all(sioul_core::config::state_dir());
281    if let Err(e) = std::fs::write(told_path(), format!("work = \"{date}\"\n")) {
282        eprintln!("{}: {e}", told_path().display());
283    }
284}
285
286/// Each minute: at the end of the day's last work or admin hours, one quiet
287/// notice, "Work hours are over", with "Close the work day"; once, from the
288/// device you are at, within ten minutes of the hours' end; at the times the
289/// matrix of what reaches you lets it come (as usual, never while you sleep or
290/// work), never during a meeting, nor once the day was closed or its review
291/// given. Off the window's thread.
292pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
293    let (qt, shared) = (qt.clone(), Arc::clone(shared));
294    std::thread::spawn(move || {
295        static BUSY: std::sync::Mutex<()> = std::sync::Mutex::new(());
296        let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
297        let now = Zoned::now();
298        let stamp = now.timestamp().as_second();
299        let Some(end) = work_end(&load_config(), &now) else { return };
300        if !(0..NOTICE_GRACE).contains(&(stamp - end.timestamp().as_second())) || told(now.date()) {
301            return;
302        }
303        let mode = crate::hours::mode_at(&now);
304        let Some(Offer { kind: Kind::Work, date }) = notice_at(&now, &mode) else { return };
305        // In a meeting, nothing is said: the status line still offers it.
306        if meeting(stamp) {
307            mark_told(date);
308            return;
309        }
310        let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
311        let (keeper, _) = crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, active, false);
312        // Kept here only lately: the next minute, once the others know.
313        if keeper.mine && !keeper.settled {
314            return;
315        }
316        mark_told(date);
317        if !keeper.mine {
318            return;
319        }
320        let open: Box<dyn FnOnce() + Send> = Box::new(move || {
321            let _ = qt.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("review"), QString::default(), QString::from("work")));
322        });
323        if let Err(e) = sioul_sync::notify::remind(&tr().text("review-notice-title", None), &tr().text("review-notice-text", None), Some((tr().text("review-close-work", None), open))) {
324            eprintln!("{e}");
325        }
326    });
327}
328
329/// Whether the night's review of the night `bed` belongs to is still to give:
330/// its notice then offers "Close the day".
331pub(crate) fn night_open(bed: i64, now: &Zoned) -> bool {
332    let Some(date) = reviews::night_day(bed, now.time_zone()) else { return false };
333    Reviews::load_between(&Reviews::default_path(), date, date).day(date).is_none_or(|d| d.night.is_none())
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use sioul_core::capacity::{DayBalance, GainBalance, ScaleBalance};
340    use sioul_core::i18n::Translator;
341
342    fn scale(scale: &'static str, level: &'static str) -> ScaleBalance {
343        ScaleBalance { scale, level, load: 12.5, budget: 20.0 }
344    }
345
346    #[test]
347    fn the_days_balance_in_words_only() {
348        let en = Translator::new("en");
349        let balance = DayBalance {
350            date: "2026-10-06".into(),
351            costs: vec![scale("cognitive", "heavy"), scale("emotional", ""), scale("anxiety", "light"), scale("body", "usual"), scale("total", "heavy")],
352            gain: GainBalance { level: "below", good_hours: 0.4, minimum: 1.0 },
353            said: vec!["The plan keeps tomorrow lighter.".into()],
354        };
355        let words = balance_words(&balance, &en);
356        assert_eq!(words, ["Thinking: heavy for you.", "Anxiety: light for you.", "Body and senses: usual for you.", "Little time gave back.", "The plan keeps tomorrow lighter."]);
357        assert!(words.iter().all(|w| !w.chars().any(|c| c.is_ascii_digit() || c == '%')), "never a number: {words:?}");
358        // Nothing known that day: nothing said, rather than "light".
359        let unknown = DayBalance { date: "2026-10-05".into(), costs: vec![scale("cognitive", ""), scale("total", "")], gain: GainBalance { level: "", good_hours: 0.0, minimum: 1.0 }, said: Vec::new() };
360        assert!(balance_words(&unknown, &en).is_empty());
361        let fr = Translator::new("fr");
362        let some = DayBalance { date: "2026-10-04".into(), costs: vec![scale("emotional", "heavy")], gain: GainBalance { level: "around", good_hours: 1.2, minimum: 1.0 }, said: Vec::new() };
363        assert_eq!(balance_words(&some, &fr), ["Émotions\u{202f}: charge lourde pour vous.", "Du temps vous a ressourcé."]);
364    }
365}