1use 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
21const NOTICE_GRACE: i64 = 10 * 60;
23const BACK_DAYS: i64 = 14;
25
26fn work_end(config: &sioul_core::config::Config, now: &Zoned) -> Option<Zoned> {
29 work_end_on(config, now.date(), now.time_zone())
30}
31
32fn 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
41fn 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
47fn 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
57fn 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
63fn 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
71fn 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
81pub(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
95fn 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
107fn work_day(now: &Zoned) -> Date {
111 if now.hour() < reviews::DAY_TURNS { now.date().yesterday().unwrap_or(now.date()) } else { now.date() }
112}
113
114fn 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
120fn 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
132fn 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
142fn balance_lines(date: Date) -> Vec<String> {
147 balance_words(&crate::capacity::day_balance(date), tr())
149}
150
151fn 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
174fn 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
179pub(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
232pub(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 sioul.as_mut().set_mode(QString::from(&mode_json()));
263 closing
264}
265
266fn closes_the_work_day(sheet: Date, today: Date, closed_today: bool) -> bool {
271 sheet == today && !closed_today
272}
273
274pub(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
282pub(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 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
307fn 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
312fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub(crate) enum WorkNotice {
331 Comes(Date),
333 Soon,
335 Held,
338 No,
340}
341
342pub(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
363fn 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 if meeting() {
388 return (WorkNotice::No, Some(date));
389 }
390 let keeper = keeper();
391 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
398pub(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#[derive(Debug, Clone, PartialEq, Eq)]
425pub(crate) struct Offered {
426 pub label: String,
427 pub kind: &'static str,
428 pub key: String,
429}
430
431pub(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
457pub(crate) const PHONE_NOTICE_KEPT: i64 = 6 * 3600;
462
463fn 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
472fn still_due(stamp: i64, end: i64) -> bool {
475 (0..NOTICE_GRACE).contains(&(stamp - end))
476}
477
478pub(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
501pub(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
531pub(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 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 #[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 #[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 assert_eq!(view("work:yesterday"), "null");
594 }
595
596 #[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 #[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 #[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 assert_eq!(notice_decision(false, || panic!("not asked"), never, never, no_keeper, today), (WorkNotice::No, None));
635 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 assert_eq!(notice_decision(true, work, || false, never, no_keeper, today), (WorkNotice::Held, None));
640 assert_eq!(notice_decision(true, work, || true, || true, no_keeper, today), (WorkNotice::No, Some(today)));
642 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 #[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}