1use 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
23pub const CORRECTION: i64 = 30 * 60;
25pub const EVENING: i64 = 3 * 3600;
27pub const EVENING_FROM: i8 = 20;
29pub const EVENING_ENDS: i8 = 2;
30pub const DAY_TURNS: i8 = 5;
32
33#[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 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#[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 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 pub fn bad(self) -> bool {
97 self == Mix::TooMuch
98 }
99
100 pub fn fine_for_costs(self) -> bool {
102 matches!(self, Mix::AboutRight | Mix::TooEmpty)
103 }
104
105 pub fn good_for_gain(self) -> bool {
107 self == Mix::AboutRight
108 }
109}
110
111#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct Review {
139 pub at: i64,
141 pub felt: Option<Felt>,
142 pub mix: Option<Mix>,
143 pub memo: String,
145}
146
147impl Review {
148 pub fn said(&self) -> bool {
150 self.felt.is_some() || self.mix.is_some() || !self.memo.trim().is_empty()
151 }
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct Day {
157 pub weather: Option<Weather>,
160 pub weather_at: Option<i64>,
162 pub later: Option<Weather>,
164 pub work: Option<Review>,
165 pub night: Option<Review>,
166}
167
168impl Day {
169 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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct Reviews {
191 pub days: BTreeMap<Date, Day>,
192}
193
194impl Reviews {
195 pub fn default_path() -> PathBuf {
197 crate::config::data_dir().join("reviews")
198 }
199
200 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 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 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 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
252fn 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
260pub 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
282fn 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
294fn 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
315fn 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
326fn 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 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 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
359pub 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
378pub 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
406pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415pub struct Offer {
416 pub kind: Kind,
417 pub date: Date,
419}
420
421pub struct Moment<'a> {
423 pub now: &'a Zoned,
424 pub mode: &'a Mode,
425 pub work_end: Option<Zoned>,
428 pub night: Option<(i64, i64)>,
430 pub closed_today: bool,
432}
433
434impl Moment<'_> {
435 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 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
455pub fn offer(moment: &Moment, reviews: &Reviews) -> Option<Offer> {
461 if moment.mode.sleeps() || moment.mode.time.works() || moment.mode.free() {
463 return None;
464 }
465 offer_any_time(moment, reviews)
466}
467
468pub 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
485fn 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
499pub 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
523pub 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
536pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}