Skip to main content

sioul_core/
reviews.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! How each day went, as you said it (docs/reviews.md): the morning's
5//! weather, the review at the end of the work day ("Done for today") and the
6//! one before sleep, each with how the day felt, its mix and a note, every
7//! answer optional. Kept readable in `$XDG_DATA_HOME/sioul/reviews/YYYY-MM.toml`,
8//! one table per date, and shared with the sharing's "time" part, with the
9//! plan's other files: every device learns the same.
10//!
11//! The plan learns from the day's outcome (`Day::outcome`, criteria CB13–14
12//! and G7 of docs/research/): a day without one counts for nothing. Nothing
13//! here is counted, scored or compared: the words are shown as said.
14
15use crate::i18n::{Translator, args};
16use crate::quiet::Mode;
17use crate::today::Weather;
18use jiff::civil::Date;
19use jiff::{Timestamp, Zoned};
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23/// A weather said again within this long of the first is a correction, not a change.
24pub const CORRECTION: i64 = 30 * 60;
25/// The evening: this long before the night's wind-down (an informed guess).
26pub const EVENING: i64 = 3 * 3600;
27/// Without a night set, the evening runs from this hour to `EVENING_ENDS` (guesses).
28pub const EVENING_FROM: i8 = 20;
29pub const EVENING_ENDS: i8 = 2;
30/// Without a night set, a day closed before this hour is the day before.
31pub const DAY_TURNS: i8 = 5;
32
33/// How the day felt: the words a task has for what it takes.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum Felt {
36    Light,
37    Usual,
38    Heavy,
39    GaveBack,
40}
41
42impl Felt {
43    pub const ALL: [Felt; 4] = [Felt::Light, Felt::Usual, Felt::Heavy, Felt::GaveBack];
44
45    /// As files write it: "light", "usual", "heavy", "gave_back"; a word it does not know is none.
46    pub fn parse(text: &str) -> Option<Felt> {
47        match text.trim() {
48            "light" => Some(Felt::Light),
49            "usual" => Some(Felt::Usual),
50            "heavy" => Some(Felt::Heavy),
51            "gave_back" | "gave-back" => Some(Felt::GaveBack),
52            _ => None,
53        }
54    }
55
56    pub fn id(self) -> &'static str {
57        match self {
58            Felt::Light => "light",
59            Felt::Usual => "usual",
60            Felt::Heavy => "heavy",
61            Felt::GaveBack => "gave_back",
62        }
63    }
64}
65
66/// The day's mix of duty and what is yours (G7): too much, about right, too empty.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum Mix {
69    TooMuch,
70    AboutRight,
71    TooEmpty,
72}
73
74impl Mix {
75    pub const ALL: [Mix; 3] = [Mix::TooMuch, Mix::AboutRight, Mix::TooEmpty];
76
77    /// As files write it: "too_much", "about_right", "too_empty"; a word it does not know is none.
78    pub fn parse(text: &str) -> Option<Mix> {
79        match text.trim() {
80            "too_much" | "too-much" => Some(Mix::TooMuch),
81            "about_right" | "about-right" => Some(Mix::AboutRight),
82            "too_empty" | "too-empty" => Some(Mix::TooEmpty),
83            _ => None,
84        }
85    }
86
87    pub fn id(self) -> &'static str {
88        match self {
89            Mix::TooMuch => "too_much",
90            Mix::AboutRight => "about_right",
91            Mix::TooEmpty => "too_empty",
92        }
93    }
94
95    /// A day that was too much: a bad day for the budgets.
96    pub fn bad(self) -> bool {
97        self == Mix::TooMuch
98    }
99
100    /// About right or too empty: the costs went fine.
101    pub fn fine_for_costs(self) -> bool {
102        matches!(self, Mix::AboutRight | Mix::TooEmpty)
103    }
104
105    /// About right alone: a good day, for the gain's minimum.
106    pub fn good_for_gain(self) -> bool {
107        self == Mix::AboutRight
108    }
109}
110
111/// Which review: at the end of the work day, or before sleep (the whole day).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum Kind {
114    Work,
115    Night,
116}
117
118impl Kind {
119    pub fn parse(text: &str) -> Option<Kind> {
120        match text.trim() {
121            "work" => Some(Kind::Work),
122            "night" => Some(Kind::Night),
123            _ => None,
124        }
125    }
126
127    pub fn id(self) -> &'static str {
128        match self {
129            Kind::Work => "work",
130            Kind::Night => "night",
131        }
132    }
133}
134
135/// One review: when it was given, and what was said, each answer optional.
136/// Given with nothing said, it says the day was closed without a word.
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct Review {
139    /// When it was given (Unix seconds); written as RFC 3339 text.
140    pub at: i64,
141    pub felt: Option<Felt>,
142    pub mix: Option<Mix>,
143    /// Markdown; "" when none.
144    pub memo: String,
145}
146
147impl Review {
148    /// Something was said: a word or a note.
149    pub fn said(&self) -> bool {
150        self.felt.is_some() || self.mix.is_some() || !self.memo.trim().is_empty()
151    }
152}
153
154/// One day: the morning's weather, as said, and its two reviews.
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct Day {
157    /// The morning's weather: the first said that day (a change within
158    /// `CORRECTION` corrects it). None: never said; the plan's default is not a perception.
159    pub weather: Option<Weather>,
160    /// When it was first said (Unix seconds).
161    pub weather_at: Option<i64>,
162    /// Another weather said later that day, the last one.
163    pub later: Option<Weather>,
164    pub work: Option<Review>,
165    pub night: Option<Review>,
166}
167
168impl Day {
169    /// The day's outcome, for the plan to learn from: the night's mix if
170    /// given, else the work's; none, and the day counts for nothing.
171    pub fn outcome(&self) -> Option<Mix> {
172        self.night.as_ref().and_then(|r| r.mix).or_else(|| self.work.as_ref().and_then(|r| r.mix))
173    }
174
175    pub fn review(&self, kind: Kind) -> Option<&Review> {
176        match kind {
177            Kind::Work => self.work.as_ref(),
178            Kind::Night => self.night.as_ref(),
179        }
180    }
181
182    /// Words were said that day, in either review.
183    pub fn said(&self) -> bool {
184        self.work.as_ref().is_some_and(Review::said) || self.night.as_ref().is_some_and(Review::said)
185    }
186}
187
188/// Every day kept, by date.
189#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct Reviews {
191    pub days: BTreeMap<Date, Day>,
192}
193
194impl Reviews {
195    /// The folder of the month files, `$XDG_DATA_HOME/sioul/reviews`.
196    pub fn default_path() -> PathBuf {
197        crate::config::data_dir().join("reviews")
198    }
199
200    /// Every month kept in `dir`. A file that does not read is left out, and
201    /// said on the error output; it is never written over (`save`).
202    pub fn load(dir: &Path) -> Reviews {
203        let mut months: Vec<PathBuf> = std::fs::read_dir(dir)
204            .map(|entries| entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| month_of(p).is_some()).collect())
205            .unwrap_or_default();
206        months.sort();
207        let mut reviews = Reviews::default();
208        for path in months {
209            reviews.take(&path);
210        }
211        reviews
212    }
213
214    /// The months covering `from` to `to` only: what a few weeks need.
215    pub fn load_between(dir: &Path, from: Date, to: Date) -> Reviews {
216        let mut reviews = Reviews::default();
217        let (mut year, mut month) = (from.year(), from.month());
218        while (year, month) <= (to.year(), to.month()) {
219            reviews.take(&dir.join(format!("{year:04}-{month:02}.toml")));
220            (year, month) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
221        }
222        reviews.days.retain(|date, _| (from..=to).contains(date));
223        reviews
224    }
225
226    /// One month file's days added; a date in its own month's file comes first.
227    fn take(&mut self, path: &Path) {
228        match read_month(path) {
229            Ok(days) => {
230                let own = month_of(path);
231                for (date, day) in days {
232                    if own == Some((date.year(), date.month())) || !self.days.contains_key(&date) {
233                        self.days.insert(date, day);
234                    }
235                }
236            }
237            Err(e) => eprintln!("{e}"),
238        }
239    }
240
241    pub fn day(&self, date: Date) -> Option<&Day> {
242        self.days.get(&date)
243    }
244
245    /// The days with words before `before`, the newest first, at most `days` days back.
246    pub fn recent(&self, before: Date, days: i64) -> Vec<(Date, &Day)> {
247        let from = before.checked_sub(jiff::Span::new().days(days)).unwrap_or(before);
248        self.days.range(from..before).rev().filter(|(_, day)| day.said()).map(|(date, day)| (*date, day)).collect()
249    }
250}
251
252/// "2026-10.toml" → (2026, 10); hidden and other files are none.
253fn month_of(path: &Path) -> Option<(i16, i8)> {
254    let name = path.file_name()?.to_str()?.strip_suffix(".toml")?;
255    let (year, month) = name.split_once('-')?;
256    let (year, month): (i16, i8) = (year.parse().ok()?, month.parse().ok()?);
257    (name.len() == 7 && (1..=12).contains(&month)).then_some((year, month))
258}
259
260/// The file a date is kept in.
261pub fn month_path(dir: &Path, date: Date) -> PathBuf {
262    dir.join(format!("{:04}-{:02}.toml", date.year(), date.month()))
263}
264
265fn weather_of(text: &str) -> Option<Weather> {
266    match text.trim() {
267        "clear" => Some(Weather::Clear),
268        "haze" => Some(Weather::Haze),
269        "fog" => Some(Weather::Fog),
270        _ => None,
271    }
272}
273
274fn weather_id(weather: Weather) -> &'static str {
275    match weather {
276        Weather::Clear => "clear",
277        Weather::Haze => "haze",
278        Weather::Fog => "fog",
279    }
280}
281
282/// A time as files may hold it: RFC 3339 text (as written here), a TOML
283/// date-time written by hand, or Unix seconds.
284fn stamp_of(value: &toml::Value) -> Option<i64> {
285    let text = match value {
286        toml::Value::Integer(seconds) => return Some(*seconds),
287        toml::Value::String(text) => text.clone(),
288        toml::Value::Datetime(datetime) => datetime.to_string(),
289        _ => return None,
290    };
291    text.parse::<Timestamp>().ok().or_else(|| text.parse::<Zoned>().ok().map(|z| z.timestamp())).map(Timestamp::as_second)
292}
293
294/// A time as written here: "2026-10-06T17:04:12+02:00", in `zone`.
295fn stamp_text(at: i64, zone: &jiff::tz::TimeZone) -> String {
296    let stamp = Timestamp::from_second(at).unwrap_or(Timestamp::UNIX_EPOCH);
297    stamp.display_with_offset(zone.to_offset(stamp)).to_string()
298}
299
300fn review_of(table: &toml::Table) -> Review {
301    Review {
302        at: table.get("at").and_then(stamp_of).unwrap_or(0),
303        felt: table.get("felt").and_then(toml::Value::as_str).and_then(Felt::parse),
304        mix: table.get("mix").and_then(toml::Value::as_str).and_then(Mix::parse),
305        memo: table.get("memo").and_then(toml::Value::as_str).unwrap_or_default().to_string(),
306    }
307}
308
309fn day_of(table: &toml::Table) -> Day {
310    let weather = |name: &str| table.get(name).and_then(toml::Value::as_str).and_then(weather_of);
311    let review = |name: &str| table.get(name).and_then(toml::Value::as_table).map(review_of);
312    Day { weather: weather("weather"), weather_at: table.get("weather_at").and_then(stamp_of), later: weather("later"), work: review("work"), night: review("night") }
313}
314
315/// One month's days; none when the file is not there.
316fn read_month(path: &Path) -> Result<BTreeMap<Date, Day>, String> {
317    let text = match std::fs::read_to_string(path) {
318        Ok(text) => text,
319        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
320        Err(e) => return Err(format!("{}: {e}", path.display())),
321    };
322    let table: toml::Table = text.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
323    Ok(table.iter().filter_map(|(key, value)| Some((key.parse::<Date>().ok()?, day_of(value.as_table()?)))).collect())
324}
325
326/// Changes one date's table in its month file, the rest of the file kept as
327/// written (comments, what a newer Sioul adds); under the file's lock, as the
328/// sharing writes in it too. A file that does not read is never written over.
329fn change(dir: &Path, date: Date, edit: impl FnOnce(&mut toml_edit::Table)) -> Result<(), String> {
330    let path = month_path(dir, date);
331    crate::filelock::with_lock(&path, || {
332        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
333        let text = match std::fs::read_to_string(&path) {
334            Ok(text) => text,
335            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
336            Err(e) => return Err(fail(e)),
337        };
338        let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| format!("{}: {e}", path.display()))?;
339        let key = date.to_string();
340        let item = doc.entry(&key).or_insert_with(|| {
341            let mut table = toml_edit::Table::new();
342            // No empty "[2026-10-06]" above "[2026-10-06.work]".
343            table.set_implicit(true);
344            toml_edit::Item::Table(table)
345        });
346        if let Some(inline) = item.as_inline_table().cloned() {
347            *item = toml_edit::Item::Table(inline.into_table());
348        }
349        let table = item.as_table_mut().ok_or_else(|| format!("{}: {key} is not a table", path.display()))?;
350        edit(table);
351        std::fs::create_dir_all(dir).map_err(fail)?;
352        // Hidden while written: the sharing and sync apps leave it alone.
353        let temporary = dir.join(format!(".{}.new", path.file_name().and_then(|n| n.to_str()).unwrap_or("month.toml")));
354        std::fs::write(&temporary, doc.to_string()).map_err(fail)?;
355        std::fs::rename(&temporary, &path).map_err(fail)
356    })
357}
358
359/// Keeps a review of `date`, whole: given again, it replaces the one before.
360pub fn save(dir: &Path, date: Date, kind: Kind, review: &Review, zone: &jiff::tz::TimeZone) -> Result<(), String> {
361    let mut table = toml_edit::Table::new();
362    table.insert("at", toml_edit::value(stamp_text(review.at, zone)));
363    if let Some(felt) = review.felt {
364        table.insert("felt", toml_edit::value(felt.id()));
365    }
366    if let Some(mix) = review.mix {
367        table.insert("mix", toml_edit::value(mix.id()));
368    }
369    let memo = review.memo.trim_end();
370    if !memo.trim().is_empty() {
371        table.insert("memo", toml_edit::value(memo));
372    }
373    change(dir, date, |day| {
374        day.insert(kind.id(), toml_edit::Item::Table(table));
375    })
376}
377
378/// The weather said for `date` at `at` (Unix seconds): the morning's when it
379/// is the first, or a correction within `CORRECTION` of it; else a change
380/// later in the day, forgotten when it comes back to the morning's.
381pub fn note_weather(dir: &Path, date: Date, weather: Weather, at: i64, zone: &jiff::tz::TimeZone) -> Result<(), String> {
382    change(dir, date, |day| {
383        let first = day.get("weather").and_then(toml_edit::Item::as_str).and_then(weather_of);
384        let first_at = day.get("weather_at").and_then(toml_edit::Item::as_str).and_then(|t| stamp_of(&toml::Value::String(t.to_string())));
385        let correcting = first_at.is_some_and(|t| (0..=CORRECTION).contains(&(at - t)));
386        match first {
387            None => {
388                day.insert("weather", toml_edit::value(weather_id(weather)));
389                day.insert("weather_at", toml_edit::value(stamp_text(at, zone)));
390                day.remove("later");
391            }
392            Some(_) if correcting => {
393                day.insert("weather", toml_edit::value(weather_id(weather)));
394                day.remove("later");
395            }
396            Some(morning) if morning == weather => {
397                day.remove("later");
398            }
399            Some(_) => {
400                day.insert("later", toml_edit::value(weather_id(weather)));
401            }
402        }
403    })
404}
405
406/// The day a night belongs to: the evening it starts, or, its bedtime (`bed`,
407/// Unix seconds) before noon, the evening before (Health's `night_of`).
408pub fn night_day(bed: i64, zone: &jiff::tz::TimeZone) -> Option<Date> {
409    let bed = Timestamp::from_second(bed).ok()?.to_zoned(zone.clone());
410    if bed.hour() < 12 { bed.date().yesterday().ok() } else { Some(bed.date()) }
411}
412
413/// What the status line offers now (docs/reviews.md).
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub struct Offer {
416    pub kind: Kind,
417    /// The day it closes.
418    pub date: Date,
419}
420
421/// Where the day stands, for the offer.
422pub struct Moment<'a> {
423    pub now: &'a Zoned,
424    pub mode: &'a Mode,
425    /// When today's last hours of work or admin end; none on a day without
426    /// them (a day off, time off, no hours set).
427    pub work_end: Option<Zoned>,
428    /// The night now or next, as Health keeps it: (winding down from, bed at), Unix seconds.
429    pub night: Option<(i64, i64)>,
430    /// The work day closed today with "Done for today".
431    pub closed_today: bool,
432}
433
434impl Moment<'_> {
435    /// Whether it is the evening, and the day it closes: the last `EVENING`
436    /// before the night's wind-down; without a night set, from `EVENING_FROM`
437    /// to `EVENING_ENDS`, a day closed before `DAY_TURNS` being the day before.
438    pub fn evening(&self) -> (bool, Date) {
439        let stamp = self.now.timestamp().as_second();
440        let today = self.now.date();
441        match self.night {
442            Some((from, bed)) if from - EVENING <= stamp && stamp < from => (true, night_day(bed, self.now.time_zone()).unwrap_or(today)),
443            // In the night itself the day is the night's; after it, today.
444            Some((from, bed)) if from <= stamp => (false, night_day(bed, self.now.time_zone()).unwrap_or(today)),
445            Some(_) => (false, today),
446            None => {
447                let hour = self.now.hour();
448                let day = if hour < DAY_TURNS { today.yesterday().unwrap_or(today) } else { today };
449                (hour >= EVENING_FROM || hour < EVENING_ENDS, day)
450            }
451        }
452    }
453}
454
455/// What the status line offers now: closing the work day once today's last
456/// hours of work or admin are over, until the evening; then closing the day,
457/// once the work day is over. Nothing while you sleep (the wind-down
458/// included) or work, nor once that review is given; the next morning never
459/// opens on yesterday.
460pub fn offer(moment: &Moment, reviews: &Reviews) -> Option<Offer> {
461    // Nothing offered at work, asleep or paused, nor in Free time (docs/pauses.md).
462    if moment.mode.sleeps() || moment.mode.time.works() || moment.mode.free() {
463        return None;
464    }
465    offer_any_time(moment, reviews)
466}
467
468/// What `offer` would offer whatever the time now is for: for the notice
469/// "Work hours are over", whose times are the notification matrix's to say
470/// (`notify`; as usual, those of `offer`).
471pub fn offer_any_time(moment: &Moment, reviews: &Reviews) -> Option<Offer> {
472    let stamp = moment.now.timestamp().as_second();
473    let ended = moment.work_end.as_ref().is_some_and(|end| stamp >= end.timestamp().as_second());
474    let work_over = moment.closed_today || moment.work_end.is_none() || ended;
475    let (evening, day) = moment.evening();
476    if evening {
477        let given = reviews.day(day).is_some_and(|d| d.night.is_some());
478        return (work_over && !given).then_some(Offer { kind: Kind::Night, date: day });
479    }
480    let today = moment.now.date();
481    let given = reviews.day(today).is_some_and(|d| d.work.is_some());
482    (ended && !moment.closed_today && !given).then_some(Offer { kind: Kind::Work, date: today })
483}
484
485// ---------------------------------------------------------------- words
486
487fn say(tr: &Translator, id: &str, pairs: &[(&str, String)]) -> String {
488    let mut a = args();
489    for (name, value) in pairs {
490        a.set(*name, value.clone());
491    }
492    tr.text(id, Some(&a))
493}
494
495fn weather_word(tr: &Translator, weather: Weather) -> String {
496    tr.text(&format!("review-weather-{}", weather_id(weather)), None)
497}
498
499/// How the day started and what the plan did with it, in words, only what was
500/// said: "This morning: haze. The plan kept a lighter day." Nothing when the
501/// weather was never said.
502pub fn morning_lines(day: Option<&Day>, tr: &Translator) -> Vec<String> {
503    let Some(morning) = day.and_then(|d| d.weather) else { return Vec::new() };
504    let later = day.and_then(|d| d.later).filter(|l| *l != morning);
505    let mut line = match later {
506        None => say(tr, "review-morning", &[("weather", weather_word(tr, morning))]),
507        Some(later) => say(tr, "review-morning-later", &[("weather", weather_word(tr, morning)), ("later", weather_word(tr, later))]),
508    };
509    let plan = match (morning, later) {
510        (_, Some(later)) if later.room() < morning.room() => Some("review-plan-lighter"),
511        (_, Some(_)) => Some("review-plan-roomier"),
512        (Weather::Haze, None) => Some("review-plan-haze"),
513        (Weather::Fog, None) => Some("review-plan-fog"),
514        (Weather::Clear, None) => None,
515    };
516    if let Some(plan) = plan {
517        line.push(' ');
518        line.push_str(&tr.text(plan, None));
519    }
520    vec![line]
521}
522
523/// What a review said, in words: "heavy; the mix: too much"; "a note" when
524/// only a note was written; none when nothing was said.
525pub fn said_words(review: &Review, tr: &Translator) -> Option<String> {
526    let felt = review.felt.map(|f| tr.text(&format!("review-felt-word-{}", f.id().replace('_', "-")), None));
527    let mix = review.mix.map(|m| tr.text(&format!("review-mix-word-{}", m.id().replace('_', "-")), None));
528    match (felt, mix) {
529        (Some(felt), Some(mix)) => Some(say(tr, "review-said-both", &[("felt", felt), ("mix", mix)])),
530        (Some(felt), None) => Some(felt),
531        (None, Some(mix)) => Some(say(tr, "review-said-mix", &[("mix", mix)])),
532        (None, None) => (!review.memo.trim().is_empty()).then(|| tr.text("review-said-note", None)),
533    }
534}
535
536/// A past day in one line, as it was said: "Morning: haze · End of work:
537/// heavy; the mix: about right · The day: usual".
538pub fn back_line(day: &Day, tr: &Translator) -> String {
539    let mut parts = Vec::new();
540    if let Some(weather) = day.weather {
541        parts.push(say(tr, "review-back-morning", &[("weather", weather_word(tr, weather))]));
542    }
543    if let Some(words) = day.work.as_ref().and_then(|r| said_words(r, tr)) {
544        parts.push(say(tr, "review-back-work", &[("words", words)]));
545    }
546    if let Some(words) = day.night.as_ref().and_then(|r| said_words(r, tr)) {
547        parts.push(say(tr, "review-back-night", &[("words", words)]));
548    }
549    parts.join("  ·  ")
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::areas::{Time, Week};
556    use crate::quiet::Reason;
557
558    fn folder(name: &str) -> PathBuf {
559        let dir = std::env::temp_dir().join(format!("sioul-reviews-{name}-{}", std::process::id()));
560        let _ = std::fs::remove_dir_all(&dir);
561        dir
562    }
563
564    fn paris() -> jiff::tz::TimeZone {
565        jiff::tz::TimeZone::get("Europe/Paris").unwrap()
566    }
567
568    fn at(text: &str) -> Zoned {
569        text.parse().unwrap()
570    }
571
572    fn seconds(text: &str) -> i64 {
573        at(text).timestamp().as_second()
574    }
575
576    #[test]
577    fn a_review_kept_and_read_back() {
578        let dir = folder("round");
579        let date: Date = "2026-10-06".parse().unwrap();
580        let review = Review { at: seconds("2026-10-06T17:04:12[Europe/Paris]"), felt: Some(Felt::Heavy), mix: Some(Mix::AboutRight), memo: "The bank called back.\n\n- the form *sent*\n- \"quotes\" and \\ kept".into() };
581        save(&dir, date, Kind::Work, &review, &paris()).unwrap();
582        let text = std::fs::read_to_string(dir.join("2026-10.toml")).unwrap();
583        assert!(text.contains("[2026-10-06.work]") && text.contains("at = \"2026-10-06T17:04:12+02:00\"") && text.contains("felt = \"heavy\"") && text.contains("mix = \"about_right\""), "{text}");
584        assert!(!text.contains("[2026-10-06]\n"), "no empty table above: {text}");
585        assert!(text.contains("\"\"\"") || text.contains("'''"), "a note on several lines reads as lines: {text}");
586        let read = Reviews::load(&dir);
587        assert_eq!(read.day(date).unwrap().work.as_ref(), Some(&review));
588        assert_eq!(read.day(date).unwrap().outcome(), Some(Mix::AboutRight));
589        // Given again, it replaces the one before, whole.
590        let again = Review { at: review.at + 60, felt: Some(Felt::Usual), mix: None, memo: String::new() };
591        save(&dir, date, Kind::Work, &again, &paris()).unwrap();
592        assert_eq!(Reviews::load(&dir).day(date).unwrap().work.as_ref(), Some(&again));
593        // Closed without a word: kept as such, and counts for nothing.
594        let silent = Review { at: review.at, ..Review::default() };
595        save(&dir, "2026-10-07".parse().unwrap(), Kind::Work, &silent, &paris()).unwrap();
596        let read = Reviews::load(&dir);
597        let day = read.day("2026-10-07".parse().unwrap()).unwrap();
598        assert!(day.work.is_some() && !day.said() && day.outcome().is_none());
599        let _ = std::fs::remove_dir_all(&dir);
600    }
601
602    #[test]
603    fn both_reviews_of_one_day() {
604        let dir = folder("both");
605        let date: Date = "2026-10-06".parse().unwrap();
606        let work = Review { at: seconds("2026-10-06T17:00[Europe/Paris]"), felt: Some(Felt::Heavy), mix: Some(Mix::TooMuch), memo: String::new() };
607        let night = Review { at: seconds("2026-10-06T22:10[Europe/Paris]"), felt: Some(Felt::Usual), mix: Some(Mix::AboutRight), memo: "Dinner with friends.".into() };
608        save(&dir, date, Kind::Work, &work, &paris()).unwrap();
609        save(&dir, date, Kind::Night, &night, &paris()).unwrap();
610        let read = Reviews::load(&dir);
611        let day = read.day(date).unwrap();
612        assert_eq!((day.work.as_ref(), day.night.as_ref()), (Some(&work), Some(&night)));
613        // The night's mix first; without it, the work's.
614        assert_eq!(day.outcome(), Some(Mix::AboutRight));
615        let only_work = Day { night: None, ..day.clone() };
616        assert_eq!(only_work.outcome(), Some(Mix::TooMuch));
617        assert!(Mix::TooMuch.bad() && !Mix::TooMuch.fine_for_costs() && Mix::TooEmpty.fine_for_costs() && !Mix::TooEmpty.good_for_gain() && Mix::AboutRight.good_for_gain());
618        let without_mix = Day { work: Some(Review { mix: None, ..work.clone() }), night: Some(Review { mix: None, ..night }), ..Day::default() };
619        assert_eq!(without_mix.outcome(), None, "a day without a mix counts for nothing");
620        let _ = std::fs::remove_dir_all(&dir);
621    }
622
623    #[test]
624    fn older_and_hand_written_files() {
625        let dir = folder("old");
626        std::fs::create_dir_all(&dir).unwrap();
627        // Unix seconds, a TOML date-time typed by hand, a word from a newer
628        // Sioul, a field it adds, a comment: all read, and kept on writing.
629        let text = "# My days.\n[2026-09-30]\nweather = \"fog\"\nmood_tags = [\"rain\"]\n\n[2026-09-30.work]\nat = 1790000000\nfelt = \"electric\"\nmix = \"too_much\"\nkind = \"newer\"\n\n[2026-09-29.night]\nat = 2026-09-29T22:30:00+02:00\nfelt = \"gave_back\"\n";
630        std::fs::write(dir.join("2026-09.toml"), text).unwrap();
631        // Hidden and stray files are no months.
632        std::fs::write(dir.join(".2026-09.toml.new"), "broken [").unwrap();
633        std::fs::write(dir.join("notes.toml"), "x = 1").unwrap();
634        let read = Reviews::load(&dir);
635        let day = read.day("2026-09-30".parse().unwrap()).unwrap();
636        assert_eq!((day.weather, day.work.as_ref().map(|r| (r.at, r.felt, r.mix))), (Some(Weather::Fog), Some((1_790_000_000, None, Some(Mix::TooMuch)))));
637        let night = read.day("2026-09-29".parse().unwrap()).unwrap().night.clone().unwrap();
638        assert_eq!((night.at, night.felt), (seconds("2026-09-29T22:30[Europe/Paris]"), Some(Felt::GaveBack)));
639        // A night review added: the rest stays as written.
640        save(&dir, "2026-09-30".parse().unwrap(), Kind::Night, &Review { at: 1_790_010_000, felt: Some(Felt::Light), ..Review::default() }, &paris()).unwrap();
641        let after = std::fs::read_to_string(dir.join("2026-09.toml")).unwrap();
642        assert!(after.starts_with("# My days.") && after.contains("mood_tags = [\"rain\"]") && after.contains("kind = \"newer\"") && after.contains("felt = \"electric\""), "{after}");
643        // A month that does not read is left out, and never written over.
644        std::fs::write(dir.join("2026-08.toml"), "[2026-08-01\nbroken").unwrap();
645        assert!(Reviews::load(&dir).day("2026-09-30".parse().unwrap()).is_some());
646        assert!(save(&dir, "2026-08-02".parse().unwrap(), Kind::Work, &Review::default(), &paris()).is_err());
647        assert_eq!(std::fs::read_to_string(dir.join("2026-08.toml")).unwrap(), "[2026-08-01\nbroken");
648        // Only the months asked for.
649        let between = Reviews::load_between(&dir, "2026-09-30".parse().unwrap(), "2026-10-31".parse().unwrap());
650        assert_eq!(between.days.keys().map(Date::to_string).collect::<Vec<_>>(), ["2026-09-30"]);
651        let _ = std::fs::remove_dir_all(&dir);
652    }
653
654    #[test]
655    fn a_day_with_only_its_weather() {
656        let dir = folder("weather");
657        let date: Date = "2026-10-06".parse().unwrap();
658        let morning = seconds("2026-10-06T08:40[Europe/Paris]");
659        note_weather(&dir, date, Weather::Clear, morning, &paris()).unwrap();
660        // Said again ten minutes later: a correction.
661        note_weather(&dir, date, Weather::Haze, morning + 600, &paris()).unwrap();
662        let read = Reviews::load(&dir);
663        let day = read.day(date).unwrap();
664        assert_eq!((day.weather, day.later, day.work.as_ref(), day.outcome()), (Some(Weather::Haze), None, None, None));
665        assert!(!day.said());
666        let text = std::fs::read_to_string(dir.join("2026-10.toml")).unwrap();
667        assert!(text.contains("[2026-10-06]") && text.contains("weather = \"haze\"") && text.contains("weather_at = \"2026-10-06T08:40:00+02:00\""), "{text}");
668        // In the afternoon, fog: the morning stays haze; back to haze, the change is forgotten.
669        note_weather(&dir, date, Weather::Fog, morning + 6 * 3600, &paris()).unwrap();
670        assert_eq!(Reviews::load(&dir).day(date).map(|d| (d.weather, d.later)), Some((Some(Weather::Haze), Some(Weather::Fog))));
671        note_weather(&dir, date, Weather::Haze, morning + 7 * 3600, &paris()).unwrap();
672        assert_eq!(Reviews::load(&dir).day(date).map(|d| (d.weather, d.later)), Some((Some(Weather::Haze), None)));
673        // A review that day keeps the weather.
674        save(&dir, date, Kind::Night, &Review { at: morning + 14 * 3600, mix: Some(Mix::TooEmpty), ..Review::default() }, &paris()).unwrap();
675        let read = Reviews::load(&dir);
676        assert_eq!(read.day(date).map(|d| (d.weather, d.outcome())), Some((Some(Weather::Haze), Some(Mix::TooEmpty))));
677        assert_eq!(read.recent("2026-10-07".parse().unwrap(), 14).len(), 1);
678        assert!(read.recent(date, 14).is_empty(), "the day closed is not among the days before it");
679        let _ = std::fs::remove_dir_all(&dir);
680    }
681
682    #[test]
683    fn what_the_status_line_offers() {
684        let mode = |time: Time, reason: Reason| Mode { quiet: !time.works(), time, week: Week::default(), reason, until: None, back: None, label: String::new() };
685        let evening = mode(Time::Leisure, Reason::Evening);
686        let none = Reviews::default();
687        let friday = |time: &str| at(&format!("2026-10-02T{time}[Europe/Paris]"));
688        let end = Some(friday("17:00"));
689        // The night: winding down from 22:00, bed at 23:00.
690        let night = Some((seconds("2026-10-02T22:00[Europe/Paris]"), seconds("2026-10-02T23:00[Europe/Paris]")));
691        let offered = |now: &Zoned, mode: &Mode, reviews: &Reviews, closed: bool, night: Option<(i64, i64)>| offer(&Moment { now, mode, work_end: end.clone(), night, closed_today: closed }, reviews).map(|o| (o.kind, o.date.to_string()));
692        // At work: nothing; once the hours are over: the work day, until the evening.
693        assert_eq!(offered(&friday("16:30"), &mode(Time::Work, Reason::Working), &none, false, night), None);
694        assert_eq!(offered(&friday("17:05"), &evening, &none, false, night), Some((Kind::Work, "2026-10-02".into())));
695        assert_eq!(offered(&friday("18:30"), &mode(Time::Meals, Reason::Meal), &none, false, night), Some((Kind::Work, "2026-10-02".into())), "a meal is no sleep");
696        // Working late, or the day closed with "Done for today": no work review offered.
697        assert_eq!(offered(&friday("17:30"), &mode(Time::Work, Reason::WorkingLate), &none, false, night), None);
698        assert_eq!(offered(&friday("17:30"), &mode(Time::Leisure, Reason::DoneForTheDay), &none, true, night), None);
699        // Given: not offered again.
700        let mut given = Reviews::default();
701        given.days.insert("2026-10-02".parse().unwrap(), Day { work: Some(Review { at: 1, ..Review::default() }), ..Day::default() });
702        assert_eq!(offered(&friday("17:30"), &evening, &given, false, night), None);
703        // The evening, three hours before winding down: the day, whatever the work review.
704        assert_eq!(offered(&friday("19:00"), &evening, &none, false, night), Some((Kind::Night, "2026-10-02".into())));
705        assert_eq!(offered(&friday("21:59"), &evening, &given, false, night), Some((Kind::Night, "2026-10-02".into())));
706        // Winding down is sleep's: nothing; nor the next morning.
707        assert_eq!(offered(&friday("22:10"), &mode(Time::Sleep, Reason::WindingDown), &none, false, night), None);
708        let saturday = at("2026-10-03T08:00[Europe/Paris]");
709        let day_off = mode(Time::Leisure, Reason::DayOff);
710        assert_eq!(offer(&Moment { now: &saturday, mode: &day_off, work_end: None, night: None, closed_today: false }, &none), None);
711        // A night after midnight: the evening before is the day closed.
712        let late = Some((seconds("2026-10-03T00:30[Europe/Paris]"), seconds("2026-10-03T01:30[Europe/Paris]")));
713        assert_eq!(offered(&friday("23:45"), &evening, &none, false, late), Some((Kind::Night, "2026-10-02".into())));
714        // Without a night set: from 20:00 to 02:00; before 05:00, the day before.
715        assert_eq!(offered(&friday("19:30"), &evening, &given, false, None), None);
716        assert_eq!(offered(&friday("20:00"), &evening, &given, false, None), Some((Kind::Night, "2026-10-02".into())));
717        assert_eq!(offered(&at("2026-10-03T01:00[Europe/Paris]"), &evening, &none, false, None), Some((Kind::Night, "2026-10-02".into())));
718        // A day without hours: the evening only.
719        let day_off = |now: &Zoned| offer(&Moment { now, mode: &evening, work_end: None, night, closed_today: false }, &none).map(|o| o.kind);
720        assert_eq!((day_off(&friday("15:00")), day_off(&friday("20:00"))), (None, Some(Kind::Night)));
721        // Work until 21:00 in the evening: the day once work is over.
722        let long = |now: &Zoned| offer(&Moment { now, mode: &evening, work_end: Some(friday("21:00")), night, closed_today: false }, &none).map(|o| o.kind);
723        assert_eq!((long(&friday("20:30")), long(&friday("21:10"))), (None, Some(Kind::Night)));
724    }
725
726    #[test]
727    fn said_in_words() {
728        let tr = Translator::new("en");
729        let haze = Day { weather: Some(Weather::Haze), ..Day::default() };
730        assert_eq!(morning_lines(Some(&haze), &tr), ["This morning: haze. The plan kept a lighter day."]);
731        let changed = Day { weather: Some(Weather::Clear), later: Some(Weather::Fog), ..Day::default() };
732        assert_eq!(morning_lines(Some(&changed), &tr), ["This morning: clear; later, fog. The plan lightened the rest of the day."]);
733        assert!(morning_lines(Some(&Day::default()), &tr).is_empty() && morning_lines(None, &tr).is_empty(), "never said: nothing");
734        let work = Review { at: 1, felt: Some(Felt::Heavy), mix: Some(Mix::TooMuch), memo: String::new() };
735        assert_eq!(said_words(&work, &tr).as_deref(), Some("heavy; the mix: too much"));
736        assert_eq!(said_words(&Review { memo: "x".into(), ..Review::default() }, &tr).as_deref(), Some("a note"));
737        assert_eq!(said_words(&Review::default(), &tr), None);
738        let day = Day { weather: Some(Weather::Haze), work: Some(work), night: Some(Review { at: 2, felt: Some(Felt::GaveBack), ..Review::default() }), ..Day::default() };
739        assert_eq!(back_line(&day, &tr), "Morning: haze  ·  End of work: heavy; the mix: too much  ·  The day: gave back");
740        // French: the same words, its typography.
741        let fr = Translator::new("fr");
742        assert_eq!(morning_lines(Some(&haze), &fr), ["Ce matin\u{202f}: brume. Le plan a gardé une journée plus légère."]);
743    }
744}