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    work_end_on(config, now.date(), now.time_zone())
30}
31
32/// `date`'s last hours of work or admin end then, in `zone`; none on a day
33/// without them, or in time off.
34fn work_end_on(config: &sioul_core::config::Config, date: Date, zone: &jiff::tz::TimeZone) -> Option<Zoned> {
35    if sioul_core::quiet::time_off_on(&config.time_off, date).is_some() {
36        return None;
37    }
38    sioul_core::window::hours_on(&config.week_hours(), date, zone).map(|(_, closing)| closing)
39}
40
41/// The night going on or coming next, as Health keeps it: (winding down from, bed at).
42fn night(now: &Zoned) -> Option<(i64, i64)> {
43    let stamp = now.timestamp().as_second();
44    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))
45}
46
47/// Where the day stands at `now`, as the offer reads it: today's end of work,
48/// the night, whether the work day was closed, and the reviews of yesterday and today.
49fn standing(now: &Zoned) -> (Option<Zoned>, Option<(i64, i64)>, bool, Reviews) {
50    let config = load_config();
51    let overrides = Overrides::load(&Overrides::default_path());
52    let today = now.date();
53    let reviews = Reviews::load_between(&Reviews::default_path(), today.yesterday().unwrap_or(today), today);
54    (work_end(&config, now), night(now), overrides.closed_today(now), reviews)
55}
56
57/// What the status line offers at `now`, in `mode`.
58fn offer_at(now: &Zoned, mode: &Mode) -> Option<Offer> {
59    let (work_end, night, closed_today, reviews) = standing(now);
60    reviews::offer(&Moment { now, mode, work_end, night, closed_today }, &reviews)
61}
62
63/// What the notice "Work hours are over" would offer at `now`, as the status
64/// line does, whatever the time now is for: the matrix of what reaches you
65/// says apart when it may come (`work_notice`).
66fn notice_offer(now: &Zoned, mode: &Mode) -> Option<Offer> {
67    let (work_end, night, closed_today, reviews) = standing(now);
68    reviews::offer_any_time(&Moment { now, mode, work_end, night, closed_today }, &reviews)
69}
70
71/// A sheet asked for: "work" or "night", and the day it is for when a
72/// notice's press carries one ("work:2026-10-14"); none for words that are
73/// neither.
74fn asked(what: &str) -> Option<(Kind, Option<Date>)> {
75    match what.split_once(':') {
76        Some((kind, date)) => Some((Kind::parse(kind)?, Some(date.parse().ok()?))),
77        None => Some((Kind::parse(what)?, None)),
78    }
79}
80
81/// For the status line (`mode_json`): {kind, date, line, button}; kind "" when
82/// nothing is offered: at work, while you sleep, once said.
83pub(crate) fn offer_json(now: &Zoned, mode: &Mode) -> serde_json::Value {
84    match offer_at(now, mode) {
85        Some(offer) => serde_json::json!({
86            "kind": offer.kind.id(),
87            "date": offer.date.to_string(),
88            "line": tr().text(&format!("review-offer-{}", offer.kind.id()), None),
89            "button": tr().text(&format!("review-close-{}", offer.kind.id()), None),
90        }),
91        None => serde_json::json!({ "kind": "", "date": "", "line": "", "button": "" }),
92    }
93}
94
95/// The day a review closes, given now: the night's day (after midnight, the
96/// evening before); the work day's, today (before 05:00, yesterday).
97fn day_of(kind: Kind, now: &Zoned) -> Date {
98    let mode = crate::hours::mode_at(now);
99    let config = load_config();
100    let moment = Moment { now, mode: &mode, work_end: work_end(&config, now), night: night(now), closed_today: false };
101    match kind {
102        Kind::Night => moment.evening().1,
103        Kind::Work => work_day(now),
104    }
105}
106
107/// The work day a sheet closes at `now`: today; before the turn of the day
108/// (05:00), yesterday. A notice's button carries it, so that hours ending
109/// after midnight open the sheet of the day they belong to.
110fn work_day(now: &Zoned) -> Date {
111    if now.hour() < reviews::DAY_TURNS { now.date().yesterday().unwrap_or(now.date()) } else { now.date() }
112}
113
114/// The first letter capitalized ("lundi" → "Lundi").
115fn capitalized(text: &str) -> String {
116    let mut chars = text.chars();
117    chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
118}
119
120/// "Tomorrow", "Monday", "Monday 19 October": the nearer, the shorter.
121fn day_name(day: Date, today: Date) -> String {
122    let name = if today.tomorrow().is_ok_and(|t| t == day) {
123        tr().text("day-tomorrow", None)
124    } else if day > today && day.since(today).is_ok_and(|s| s.get_days() < 7) {
125        tr().text(&format!("weekday-{}", day.weekday().to_monday_one_offset()), None)
126    } else {
127        tr().day(day)
128    };
129    capitalized(&name)
130}
131
132/// The first step named when the work day closed, for the day work comes back:
133/// "Tomorrow starts with: open the form (15 min)". A plan for what remains
134/// helps sleep come (Scullin et al. 2018); none when none was named.
135fn first_step_line(date: Date) -> Option<String> {
136    let overrides = Overrides::load(&Overrides::default_path());
137    let step = overrides.first_step.as_deref().map(str::trim).filter(|s| !s.is_empty())?;
138    let day = overrides.first_step_day.filter(|d| *d > date)?;
139    Some(say("review-first-step", &[("day", day_name(day, date)), ("step", step.to_string())]))
140}
141
142/// The day's costs and gains in words, for the night: each scale whose level is
143/// known that day ("Thinking: heavy for you."), the gain ("Some time gave
144/// back."), then the plan's own lines when the day changes tomorrow's. Never a
145/// number, a percentage or a gauge (criteria CB45–49, G20).
146fn balance_lines(date: Date) -> Vec<String> {
147    // The capacity's own reading of the day: its numbers (load, budget, good hours, minimum) stay there.
148    balance_words(&crate::capacity::day_balance(date), tr())
149}
150
151/// The balance in words, in `tr`'s language: a scale not known that day says
152/// nothing (CB12), the total is the scales' own; the gain in two words.
153fn balance_words(balance: &sioul_core::capacity::DayBalance, tr: &sioul_core::i18n::Translator) -> Vec<String> {
154    let mut lines: Vec<String> = balance
155        .costs
156        .iter()
157        .filter(|scale| !scale.level.is_empty() && scale.scale != "total")
158        .map(|scale| {
159            let mut args = sioul_core::i18n::args();
160            args.set("scale", tr.text(&format!("review-scale-{}", scale.scale), None));
161            args.set("level", tr.text(&format!("review-level-{}", scale.level), None));
162            tr.text("review-scale", Some(&args))
163        })
164        .collect();
165    match balance.gain.level {
166        "below" => lines.push(tr.text("review-gain-little", None)),
167        "around" | "above" => lines.push(tr.text("review-gain-some", None)),
168        _ => {}
169    }
170    lines.extend(balance.said.iter().cloned());
171    lines
172}
173
174/// A review's note as the sheet and the Health page show it: Markdown read as Notes reads it.
175fn memo_html(review: Option<&Review>) -> Option<String> {
176    review.map(|r| r.memo.trim()).filter(|m| !m.is_empty()).map(sioul_core::compose::markdown_html)
177}
178
179/// The sheet for `what` ("work", "night"), as JSON: its title and the day it
180/// closes; how the day started and went, in words; for the night the work's
181/// review, tomorrow's first step and the day's balance; the answers already
182/// given (closed once, the sheet opens on them); the notes of the last days.
183/// A notice's press carries its day ("work:2026-10-14"): pressed once the
184/// day turned (05:00 for the work day, the evening for the night), it opens
185/// nothing ("null"), rather than the sheet of a day not begun.
186pub(crate) fn view(what: &str) -> String {
187    let (kind, for_day) = match asked(what) {
188        Some(asked) => asked,
189        None if what.contains(':') => return "null".into(),
190        None => (Kind::Work, None),
191    };
192    let now = Zoned::now();
193    let date = day_of(kind, &now);
194    if for_day.is_some_and(|day| day != date) {
195        return "null".into();
196    }
197    let from = date.checked_sub(jiff::Span::new().days(BACK_DAYS)).unwrap_or(date);
198    let all = Reviews::load_between(&Reviews::default_path(), from, date);
199    let day = all.day(date);
200    let mut lines = reviews::morning_lines(day, tr());
201    if kind == Kind::Night {
202        if let Some(words) = day.and_then(|d| d.work.as_ref()).and_then(|r| reviews::said_words(r, tr())) {
203            lines.push(say("review-work-said", &[("words", words)]));
204        }
205        lines.extend(first_step_line(date));
206    }
207    let balance = if kind == Kind::Night { balance_lines(date) } else { Vec::new() };
208    let given = day.and_then(|d| d.review(kind));
209    let back: Vec<serde_json::Value> = all
210        .recent(date, BACK_DAYS)
211        .into_iter()
212        .map(|(date, day)| {
213            let memos: Vec<String> = [memo_html(day.work.as_ref()), memo_html(day.night.as_ref())].into_iter().flatten().collect();
214            serde_json::json!({ "day": capitalized(&tr().day_in(date, now.date())), "line": reviews::back_line(day, tr()), "memos": memos })
215        })
216        .collect();
217    serde_json::json!({
218        "kind": kind.id(),
219        "date": date.to_string(),
220        "title": tr().text(&format!("review-{}-title", kind.id()), None),
221        "lines": lines,
222        "balance": balance,
223        "felt": given.and_then(|r| r.felt).map_or("", Felt::id),
224        "mix": given.and_then(|r| r.mix).map_or("", Mix::id),
225        "memo": given.map(|r| r.memo.clone()).unwrap_or_default(),
226        "close": tr().text(&format!("review-close-{}", kind.id()), None),
227        "back": back,
228    })
229    .to_string()
230}
231
232/// The review kept for `date`, the day its sheet was opened for (`view`),
233/// though the day turned meanwhile (a sheet opened at 04:58 and closed at
234/// 05:01 closes the day it showed; none given, the day of `kind` now); then
235/// for the work day the day closed as "Done for today" always did, unless it
236/// was already today, or the sheet was an earlier day's: the closing
237/// screen's JSON, else "". The night: the status line says the day is
238/// closed. A review that cannot be kept is said; the work day closes all
239/// the same.
240pub(crate) fn close(mut sioul: Pin<&mut Sioul>, kind: &str, date: &str, felt: &str, mix: &str, memo: &str) -> QString {
241    let Some(kind) = Kind::parse(kind) else { return QString::default() };
242    let now = Zoned::now();
243    let today = day_of(kind, &now);
244    let date = date.parse::<Date>().unwrap_or(today);
245    let review = Review { at: now.timestamp().as_second(), felt: Felt::parse(felt), mix: Mix::parse(mix), memo: memo.trim().to_string() };
246    let kept = reviews::save(&Reviews::default_path(), date, kind, &review, now.time_zone());
247    let closing = match kind {
248        Kind::Work if closes_the_work_day(date, today, Overrides::load(&Overrides::default_path()).closed_today(&now)) => sioul.as_mut().done_for_the_day(),
249        Kind::Work => {
250            sioul.as_mut().set_status(QString::from(&tr().text("review-kept", None)));
251            QString::default()
252        }
253        Kind::Night => {
254            sioul.as_mut().set_status(QString::from(&tr().text("review-closed-night", None)));
255            QString::default()
256        }
257    };
258    if let Err(e) = kept {
259        sioul.as_mut().set_status(QString::from(&e));
260    }
261    // What the status line offers follows at once.
262    sioul.as_mut().set_mode(QString::from(&mode_json()));
263    closing
264}
265
266/// Whether closing a work day's sheet closes the work day itself, as "Done
267/// for today" always did: only today's, not closed yet; an earlier day's
268/// sheet (opened before the turn of the day, closed after it) keeps its
269/// review alone, and never closes a day not begun.
270fn closes_the_work_day(sheet: Date, today: Date, closed_today: bool) -> bool {
271    sheet == today && !closed_today
272}
273
274/// The weather said for today, copied as the morning's (`reviews::note_weather`).
275pub(crate) fn weather_said(weather: sioul_core::today::Weather) {
276    let now = Zoned::now();
277    if let Err(e) = reviews::note_weather(&Reviews::default_path(), now.date(), weather, now.timestamp().as_second(), now.time_zone()) {
278        eprintln!("{e}");
279    }
280}
281
282/// A day on the Health page, as JSON: its words when some were said ("How the
283/// day went: Morning: haze · End of work: heavy…") and its notes; today, the
284/// button to close it while the evening offers it, and during the night.
285pub(crate) fn line(date: &str) -> String {
286    let now = Zoned::now();
287    let Ok(date) = date.parse::<Date>() else { return "null".into() };
288    let reviews = Reviews::load_between(&Reviews::default_path(), date, date);
289    let day = reviews.day(date);
290    let words = day.filter(|d| d.said()).map(|d| reviews::back_line(d, tr())).unwrap_or_default();
291    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();
292    let mode = crate::hours::mode_at(&now);
293    let offered = offer_at(&now, &mode).is_some_and(|o| o.kind == Kind::Night && o.date == date);
294    // Winding down or asleep in this day's night, the page still offers it: you came to it.
295    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));
296    let open = day.is_none_or(|d| d.night.is_none()) && (offered || tonight);
297    serde_json::json!({
298        "said": if words.is_empty() { String::new() } else { tr().text("review-line-said", None) },
299        "words": words,
300        "memos": memos,
301        "close": open,
302        "button": tr().text("review-close-night", None),
303    })
304    .to_string()
305}
306
307/// Whether an event goes on now (not a whole day's, not cancelled).
308fn meeting(stamp: i64) -> bool {
309    sioul_core::agenda::occurrences(stamp - 86_400, stamp + 60).iter().any(|e| !e.cancelled && !e.all_day && e.start <= stamp && e.end > stamp)
310}
311
312/// The day the end-of-work notice was given, on this device.
313fn told_path() -> std::path::PathBuf {
314    sioul_core::config::state_dir().join("review-told.toml")
315}
316
317fn told(date: Date) -> bool {
318    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())
319}
320
321fn mark_told(date: Date) {
322    let _ = std::fs::create_dir_all(sioul_core::config::state_dir());
323    if let Err(e) = std::fs::write(told_path(), format!("work = \"{date}\"\n")) {
324        eprintln!("{}: {e}", told_path().display());
325    }
326}
327
328/// What the end-of-work notice does at a moment (`work_notice`).
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub(crate) enum WorkNotice {
331    /// It comes now, for that day.
332    Comes(Date),
333    /// Kept here only lately: asked again in a minute, once the others know.
334    Soon,
335    /// Held by the matrix of what reaches you now (a pause, do-not-disturb):
336    /// it may come once it lets it, while its ten minutes last.
337    Held,
338    /// Not now, or not from here.
339    No,
340}
341
342/// The end-of-work notice at `now`: at the end of the day's last work or
343/// admin hours, within ten minutes of it, once on this device (`told`); at
344/// the times the matrix of what reaches you lets it come (as usual, never
345/// while you sleep or work); never during a meeting, nor once the day was
346/// closed or its review given, nor when the evening's offer takes its place:
347/// then it is told for the day, never asked again; from the device you are
348/// at, as `keeper` says it (the "notices" lease), asked last. The window's
349/// minute and a phone's alarm both decide it here (`tick`, `decide_work`).
350pub(crate) fn work_notice(now: &Zoned, keeper: impl FnOnce() -> sioul_sync::lease::Keeper) -> WorkNotice {
351    let stamp = now.timestamp().as_second();
352    let Some(end) = work_end(&load_config(), now) else { return WorkNotice::No };
353    let due = still_due(stamp, end.timestamp().as_second()) && !told(now.date());
354    let offer = || notice_offer(now, &crate::hours::mode_at(now));
355    let comes = || crate::hours::comes(sioul_core::attention::Kind::WorkOver);
356    let (notice, told_for) = notice_decision(due, offer, comes, || meeting(stamp), keeper, now.date());
357    if let Some(day) = told_for {
358        mark_told(day);
359    }
360    notice
361}
362
363/// The end-of-work notice's decision (`work_notice`), each thing read only
364/// when those before let it on, in this order: whether it is `due` (its ten
365/// minutes, not told yet); what the day offers (`offer`): anything but the
366/// work day's (closed, its review given, the evening's offer instead), and it
367/// cannot come that day, told for `today` and never asked again; whether the
368/// matrix of what reaches you lets it come now (`comes`): held while it does
369/// not; a `meeting`: nothing said, told; who keeps the notices (`keeper`),
370/// asked last. Gives the decision, and the day to mark told, if any.
371fn notice_decision(
372    due: bool,
373    offer: impl FnOnce() -> Option<Offer>,
374    comes: impl FnOnce() -> bool,
375    meeting: impl FnOnce() -> bool,
376    keeper: impl FnOnce() -> sioul_sync::lease::Keeper,
377    today: Date,
378) -> (WorkNotice, Option<Date>) {
379    if !due {
380        return (WorkNotice::No, None);
381    }
382    let Some(Offer { kind: Kind::Work, date }) = offer() else { return (WorkNotice::No, Some(today)) };
383    if !comes() {
384        return (WorkNotice::Held, None);
385    }
386    // In a meeting, nothing is said: the status line still offers it.
387    if meeting() {
388        return (WorkNotice::No, Some(date));
389    }
390    let keeper = keeper();
391    // Kept here only lately: the next minute, once the others know.
392    if keeper.mine && !keeper.settled {
393        return (WorkNotice::Soon, None);
394    }
395    (if keeper.mine { WorkNotice::Comes(date) } else { WorkNotice::No }, Some(date))
396}
397
398/// Each minute: the end-of-work notice when it comes (`work_notice`), "Work
399/// hours are over", with "Close the work day". On a phone its own alarm says
400/// it, Sioul shown or not (`decide_work`). Off the window's thread.
401pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
402    if cfg!(target_os = "android") {
403        return;
404    }
405    let (qt, shared) = (qt.clone(), Arc::clone(shared));
406    std::thread::spawn(move || {
407        static BUSY: std::sync::Mutex<()> = std::sync::Mutex::new(());
408        let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
409        let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
410        let keeper = || crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, active, false).0;
411        let now = Zoned::now();
412        if let WorkNotice::Comes(_) = work_notice(&now, keeper) {
413            let close = Offered { label: tr().text("review-close-work", None), kind: "review", key: format!("work:{}", work_day(&now)) };
414            if let Err(e) = show_notice(&qt, &tr().text("review-notice-title", None), &tr().text("review-notice-text", None), vec![close]) {
415                eprintln!("{e}");
416            }
417        }
418    });
419}
420
421/// A button of a closing notice: its words, and what it opens in the window
422/// (`reminder_opened`: "review" with "work" or "night" and the day it is for,
423/// "work:2026-10-14", or "need" with a block's key).
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub(crate) struct Offered {
426    pub label: String,
427    pub kind: &'static str,
428    pub key: String,
429}
430
431/// A closing notice (the end of work's, the night's), as the system shows it
432/// (docs/reviews.md, "The notices"): on Linux and macOS with its buttons
433/// (`notify::remind_offering`), each opening what it offers; on Windows
434/// through Sioul's icon in the tray (main.qml's `onTrayNotice`, Tray.qml),
435/// whose message carries no button: a click opens what the first one offers.
436/// A phone's are its alarm's (`decide_work`, `health::decide_night`).
437pub(crate) fn show_notice(qt: &QtThread, title: &str, body: &str, offered: Vec<Offered>) -> Result<(), String> {
438    if cfg!(windows) {
439        let (kind, key) = offered.into_iter().next().map(|first| (first.kind, first.key)).unwrap_or(("", String::new()));
440        let (title, body) = (title.to_string(), body.to_string());
441        let _ = qt.queue(move |mut sioul| sioul.as_mut().tray_notice(QString::from(&title), QString::from(&body), QString::from(kind), QString::from(&key)));
442        return Ok(());
443    }
444    let choices = offered
445        .into_iter()
446        .map(|offer| {
447            let qt = qt.clone();
448            let open: Box<dyn FnOnce() + Send> = Box::new(move || {
449                let _ = qt.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from(offer.kind), QString::default(), QString::from(&offer.key)));
450            });
451            (offer.label, open)
452        })
453        .collect();
454    sioul_sync::notify::remind_offering(title, body, choices)
455}
456
457/// A phone's notice stays in the shade this long after it comes at most,
458/// then goes (the work's at the turn of the day, 05:00, if sooner; the
459/// night's at waking): its press carries its day, and opens nothing once the
460/// day turned (`view`).
461pub(crate) const PHONE_NOTICE_KEPT: i64 = 6 * 3600;
462
463/// The turn of the day (05:00) next after `end`, when the work day it ended
464/// is no longer the one the sheet closes (`day_of`), Unix seconds; six hours
465/// on where the zone has no such time.
466fn next_turn(end: &Zoned) -> i64 {
467    let at = |date: Date| date.at(reviews::DAY_TURNS, 0, 0, 0).to_zoned(end.time_zone().clone()).ok().map(|z| z.timestamp().as_second());
468    let stamp = end.timestamp().as_second();
469    at(end.date()).filter(|turn| *turn > stamp).or_else(|| end.date().tomorrow().ok().and_then(at)).unwrap_or(stamp + PHONE_NOTICE_KEPT)
470}
471
472/// Whether the end-of-work notice may still come at `stamp`, the hours
473/// having ended at `end` (Unix seconds): within its ten minutes, never before.
474fn still_due(stamp: i64, end: i64) -> bool {
475    (0..NOTICE_GRACE).contains(&(stamp - end))
476}
477
478/// When the end-of-work notice may come in the next days, for a phone's
479/// alarms (`eventalarms::coming`): at the end of each day's last work or
480/// admin hours, today's while it may still come; each (`day:work:<date>`,
481/// Unix seconds). A time just past stays as it was, so that the list stays
482/// the same from one minute to the next: Android rings it at once.
483pub(crate) fn coming_work(now: &Zoned) -> Vec<(String, i64)> {
484    let config = load_config();
485    let stamp = now.timestamp().as_second();
486    let mut out = Vec::new();
487    let mut date = now.date();
488    for _ in 0..3 {
489        if let Some(end) = work_end_on(&config, date, now.time_zone()) {
490            let at = end.timestamp().as_second();
491            if stamp - at < NOTICE_GRACE && !told(date) {
492                out.push((format!("day:work:{date}"), at));
493            }
494        }
495        let Ok(next) = date.tomorrow() else { break };
496        date = next;
497    }
498    out
499}
500
501/// A phone's alarm at the end of work (`eventalarms`, "day:work:…"): the
502/// notice as the window's minute decides it on a computer (`work_notice`),
503/// who keeps the notices read, never claimed, from there: Sioul may be put
504/// away, or not running (`share::looked`). Shown in Android's notifications
505/// with "Close the work day", which opens Sioul on the sheet. {"shown": true}
506/// once shown, its press carrying its day; {"again_at": ms} rung early, kept
507/// here only lately, or held by the matrix while its ten minutes last, asked
508/// each minute as a computer's minute asks it; {} when it does not come, nor
509/// asked again that day once it cannot (`work_notice`).
510pub(crate) fn decide_work(now: &Zoned) -> serde_json::Value {
511    let stamp = now.timestamp().as_second();
512    let end = work_end(&load_config(), now);
513    let end_at = end.as_ref().map(|end| end.timestamp().as_second());
514    if let Some(end) = end_at
515        && (1..3600).contains(&(end - stamp))
516    {
517        return serde_json::json!({ "again_at": end * 1000 });
518    }
519    match work_notice(now, || crate::share::looked("notices", sioul_sync::lease::Rule::FollowsYou)) {
520        WorkNotice::Comes(date) => {
521            let close = Offered { label: tr().text("review-close-work", None), kind: "review", key: format!("work:{}", work_day(now)) };
522            let until = end.as_ref().map_or(stamp + PHONE_NOTICE_KEPT, |end| next_turn(end).min(stamp + PHONE_NOTICE_KEPT));
523            crate::eventalarms::day_note(&format!("day:work:{date}"), &tr().text("review-notice-title", None), &tr().text("review-notice-text", None), &[close], until);
524            serde_json::json!({ "shown": true })
525        }
526        WorkNotice::Soon | WorkNotice::Held if end_at.is_some_and(|end| still_due(stamp + 60, end)) => serde_json::json!({ "again_at": (stamp + 60) * 1000 }),
527        WorkNotice::Soon | WorkNotice::Held | WorkNotice::No => serde_json::json!({}),
528    }
529}
530
531/// Whether the night's review of the night `bed` belongs to is still to give:
532/// its notice then offers "Close the day".
533pub(crate) fn night_open(bed: i64, now: &Zoned) -> bool {
534    let Some(date) = reviews::night_day(bed, now.time_zone()) else { return false };
535    Reviews::load_between(&Reviews::default_path(), date, date).day(date).is_none_or(|d| d.night.is_none())
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use sioul_core::capacity::{DayBalance, GainBalance, ScaleBalance};
542    use sioul_core::i18n::Translator;
543
544    fn scale(scale: &'static str, level: &'static str) -> ScaleBalance {
545        ScaleBalance { scale, level, load: 12.5, budget: 20.0 }
546    }
547
548    #[test]
549    fn the_days_balance_in_words_only() {
550        let en = Translator::new("en");
551        let balance = DayBalance {
552            date: "2026-10-06".into(),
553            costs: vec![scale("cognitive", "heavy"), scale("emotional", ""), scale("anxiety", "light"), scale("body", "usual"), scale("total", "heavy")],
554            gain: GainBalance { level: "below", good_hours: 0.4, minimum: 1.0 },
555            said: vec!["The plan keeps tomorrow lighter.".into()],
556        };
557        let words = balance_words(&balance, &en);
558        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."]);
559        assert!(words.iter().all(|w| !w.chars().any(|c| c.is_ascii_digit() || c == '%')), "never a number: {words:?}");
560        // Nothing known that day: nothing said, rather than "light".
561        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() };
562        assert!(balance_words(&unknown, &en).is_empty());
563        let fr = Translator::new("fr");
564        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() };
565        assert_eq!(balance_words(&some, &fr), ["Émotions\u{202f}: charge lourde pour vous.", "Du temps vous a ressourcé."]);
566    }
567
568    /// The end-of-work notice comes within ten minutes of the hours' end,
569    /// never before them: a phone's alarm rung hours early (an old day's,
570    /// after a restart) is not asked again each minute until then.
571    #[test]
572    fn the_end_of_work_notice_comes_within_its_ten_minutes_only() {
573        let end = 1_792_000_000;
574        assert!(still_due(end, end));
575        assert!(still_due(end + NOTICE_GRACE - 1, end));
576        assert!(!still_due(end + NOTICE_GRACE, end));
577        assert!(!still_due(end - 1, end));
578        assert!(!still_due(end - 8 * 3600, end), "hours before: not due, not asked again");
579    }
580
581    /// A notice's press carries its day: the sheet is asked for that day,
582    /// and only words that are a sheet and a day are read so.
583    #[test]
584    fn a_press_asks_for_its_own_days_sheet() {
585        let day: Date = "2026-10-14".parse().unwrap();
586        assert_eq!(asked("work"), Some((Kind::Work, None)));
587        assert_eq!(asked("night"), Some((Kind::Night, None)));
588        assert_eq!(asked("work:2026-10-14"), Some((Kind::Work, Some(day))));
589        assert_eq!(asked("night:2026-10-14"), Some((Kind::Night, Some(day))));
590        assert_eq!(asked("work:yesterday"), None);
591        assert_eq!(asked("settings:2026-10-14"), None);
592        // Words that are no day: nothing, read before anything is.
593        assert_eq!(view("work:yesterday"), "null");
594    }
595
596    /// The work notice on a phone goes at the turn of the day (05:00) after
597    /// the hours' end, or six hours after it came if sooner: work ending at
598    /// 23:30 goes at 05:00, not at 05:30.
599    #[test]
600    fn the_work_notice_goes_at_the_turn_of_the_day() {
601        let zone = jiff::tz::TimeZone::fixed(jiff::tz::offset(2));
602        let at = |text: &str| text.parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap();
603        assert_eq!(next_turn(&at("2026-10-14T17:30")), at("2026-10-15T05:00").timestamp().as_second());
604        assert_eq!(next_turn(&at("2026-10-14T23:30")), at("2026-10-15T05:00").timestamp().as_second());
605        assert_eq!(next_turn(&at("2026-10-15T01:00")), at("2026-10-15T05:00").timestamp().as_second());
606        assert_eq!(next_turn(&at("2026-10-15T05:00")), at("2026-10-16T05:00").timestamp().as_second());
607        let end = at("2026-10-14T23:30");
608        let shown = end.timestamp().as_second() + 60;
609        assert_eq!(next_turn(&end).min(shown + PHONE_NOTICE_KEPT), at("2026-10-15T05:00").timestamp().as_second());
610    }
611
612    /// A sheet opened before the turn of the day and closed after it closes
613    /// the day it showed: its review kept, the new day never closed.
614    #[test]
615    fn a_sheet_closed_after_the_turn_of_the_day_closes_its_own() {
616        let (yesterday, today): (Date, Date) = ("2026-10-13".parse().unwrap(), "2026-10-14".parse().unwrap());
617        assert!(closes_the_work_day(today, today, false));
618        assert!(!closes_the_work_day(today, today, true), "closed already");
619        assert!(!closes_the_work_day(yesterday, today, false), "opened at 04:58, closed at 05:01: its review alone");
620    }
621
622    /// What can still come is asked before what reaches you: a day closed,
623    /// its review given or the evening's offer instead is told at once,
624    /// what reaches you never asked, nor a meeting, nor who keeps the
625    /// notices; held by the matrix, asked again; then a meeting, the lease.
626    #[test]
627    fn the_end_of_work_notice_asks_first_whether_it_can_come() {
628        let today: Date = "2026-10-14".parse().unwrap();
629        let work = || Some(Offer { kind: Kind::Work, date: today });
630        let keeper = |mine: bool, settled: bool| sioul_sync::lease::Keeper { mine, settled, ..Default::default() };
631        let never = || -> bool { panic!("not asked when it cannot come") };
632        let no_keeper = || -> sioul_sync::lease::Keeper { panic!("not asked") };
633        // Not due: nothing read, nothing told.
634        assert_eq!(notice_decision(false, || panic!("not asked"), never, never, no_keeper, today), (WorkNotice::No, None));
635        // It cannot come today (closed, review given, the evening's instead): told, the matrix never asked.
636        assert_eq!(notice_decision(true, || None, never, never, no_keeper, today), (WorkNotice::No, Some(today)));
637        assert_eq!(notice_decision(true, || Some(Offer { kind: Kind::Night, date: today }), never, never, no_keeper, today), (WorkNotice::No, Some(today)));
638        // Held by the matrix while it could come: asked again, not told.
639        assert_eq!(notice_decision(true, work, || false, never, no_keeper, today), (WorkNotice::Held, None));
640        // A meeting: told, nothing said.
641        assert_eq!(notice_decision(true, work, || true, || true, no_keeper, today), (WorkNotice::No, Some(today)));
642        // The lease: kept here lately, soon; elsewhere, told; here, it comes.
643        assert_eq!(notice_decision(true, work, || true, || false, || keeper(true, false), today), (WorkNotice::Soon, None));
644        assert_eq!(notice_decision(true, work, || true, || false, || keeper(false, true), today), (WorkNotice::No, Some(today)));
645        assert_eq!(notice_decision(true, work, || true, || false, || keeper(true, true), today), (WorkNotice::Comes(today), Some(today)));
646    }
647
648    /// A notice's button carries the work day its sheet closes, as the sheet
649    /// counts it: hours ending after midnight belong to the day before,
650    /// until the turn of the day at 05:00.
651    #[test]
652    fn hours_ending_after_midnight_date_their_button_with_the_day_before() {
653        let zone = jiff::tz::TimeZone::fixed(jiff::tz::offset(2));
654        let at = |text: &str| text.parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone.clone()).unwrap();
655        let (day, next): (Date, Date) = ("2026-10-14".parse().unwrap(), "2026-10-15".parse().unwrap());
656        assert_eq!(work_day(&at("2026-10-14T17:30")), day);
657        assert_eq!(work_day(&at("2026-10-15T00:30")), day);
658        assert_eq!(work_day(&at("2026-10-15T04:59")), day);
659        assert_eq!(work_day(&at("2026-10-15T05:00")), next);
660    }
661}