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 if sioul_core::quiet::time_off_on(&config.time_off, now.date()).is_some() {
30 return None;
31 }
32 sioul_core::window::hours_on(&config.week_hours(), now.date(), now.time_zone()).map(|(_, closing)| closing)
33}
34
35fn night(now: &Zoned) -> Option<(i64, i64)> {
37 let stamp = now.timestamp().as_second();
38 crate::hours::blocks(now).kept.iter().filter(|k| k.kind == "sleep" && k.end > stamp).min_by_key(|k| k.start).map(|k| (k.start, k.at))
39}
40
41fn standing(now: &Zoned) -> (Option<Zoned>, Option<(i64, i64)>, bool, Reviews) {
44 let config = load_config();
45 let overrides = Overrides::load(&Overrides::default_path());
46 let today = now.date();
47 let reviews = Reviews::load_between(&Reviews::default_path(), today.yesterday().unwrap_or(today), today);
48 (work_end(&config, now), night(now), overrides.closed_today(now), reviews)
49}
50
51fn offer_at(now: &Zoned, mode: &Mode) -> Option<Offer> {
53 let (work_end, night, closed_today, reviews) = standing(now);
54 reviews::offer(&Moment { now, mode, work_end, night, closed_today }, &reviews)
55}
56
57fn notice_at(now: &Zoned, mode: &Mode) -> Option<Offer> {
61 if !crate::hours::comes(sioul_core::attention::Kind::WorkOver) {
62 return None;
63 }
64 let (work_end, night, closed_today, reviews) = standing(now);
65 reviews::offer_any_time(&Moment { now, mode, work_end, night, closed_today }, &reviews)
66}
67
68pub(crate) fn offer_json(now: &Zoned, mode: &Mode) -> serde_json::Value {
71 match offer_at(now, mode) {
72 Some(offer) => serde_json::json!({
73 "kind": offer.kind.id(),
74 "date": offer.date.to_string(),
75 "line": tr().text(&format!("review-offer-{}", offer.kind.id()), None),
76 "button": tr().text(&format!("review-close-{}", offer.kind.id()), None),
77 }),
78 None => serde_json::json!({ "kind": "", "date": "", "line": "", "button": "" }),
79 }
80}
81
82fn day_of(kind: Kind, now: &Zoned) -> Date {
85 let mode = crate::hours::mode_at(now);
86 let config = load_config();
87 let moment = Moment { now, mode: &mode, work_end: work_end(&config, now), night: night(now), closed_today: false };
88 match kind {
89 Kind::Night => moment.evening().1,
90 Kind::Work if now.hour() < reviews::DAY_TURNS => now.date().yesterday().unwrap_or(now.date()),
91 Kind::Work => now.date(),
92 }
93}
94
95fn capitalized(text: &str) -> String {
97 let mut chars = text.chars();
98 chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
99}
100
101fn day_name(day: Date, today: Date) -> String {
103 let name = if today.tomorrow().is_ok_and(|t| t == day) {
104 tr().text("day-tomorrow", None)
105 } else if day > today && day.since(today).is_ok_and(|s| s.get_days() < 7) {
106 tr().text(&format!("weekday-{}", day.weekday().to_monday_one_offset()), None)
107 } else {
108 tr().day(day)
109 };
110 capitalized(&name)
111}
112
113fn first_step_line(date: Date) -> Option<String> {
117 let overrides = Overrides::load(&Overrides::default_path());
118 let step = overrides.first_step.as_deref().map(str::trim).filter(|s| !s.is_empty())?;
119 let day = overrides.first_step_day.filter(|d| *d > date)?;
120 Some(say("review-first-step", &[("day", day_name(day, date)), ("step", step.to_string())]))
121}
122
123fn balance_lines(date: Date) -> Vec<String> {
128 balance_words(&crate::capacity::day_balance(date), tr())
130}
131
132fn balance_words(balance: &sioul_core::capacity::DayBalance, tr: &sioul_core::i18n::Translator) -> Vec<String> {
135 let mut lines: Vec<String> = balance
136 .costs
137 .iter()
138 .filter(|scale| !scale.level.is_empty() && scale.scale != "total")
139 .map(|scale| {
140 let mut args = sioul_core::i18n::args();
141 args.set("scale", tr.text(&format!("review-scale-{}", scale.scale), None));
142 args.set("level", tr.text(&format!("review-level-{}", scale.level), None));
143 tr.text("review-scale", Some(&args))
144 })
145 .collect();
146 match balance.gain.level {
147 "below" => lines.push(tr.text("review-gain-little", None)),
148 "around" | "above" => lines.push(tr.text("review-gain-some", None)),
149 _ => {}
150 }
151 lines.extend(balance.said.iter().cloned());
152 lines
153}
154
155fn memo_html(review: Option<&Review>) -> Option<String> {
157 review.map(|r| r.memo.trim()).filter(|m| !m.is_empty()).map(sioul_core::compose::markdown_html)
158}
159
160pub(crate) fn view(kind: &str) -> String {
165 let kind = Kind::parse(kind).unwrap_or(Kind::Work);
166 let now = Zoned::now();
167 let date = day_of(kind, &now);
168 let from = date.checked_sub(jiff::Span::new().days(BACK_DAYS)).unwrap_or(date);
169 let all = Reviews::load_between(&Reviews::default_path(), from, date);
170 let day = all.day(date);
171 let mut lines = reviews::morning_lines(day, tr());
172 if kind == Kind::Night {
173 if let Some(words) = day.and_then(|d| d.work.as_ref()).and_then(|r| reviews::said_words(r, tr())) {
174 lines.push(say("review-work-said", &[("words", words)]));
175 }
176 lines.extend(first_step_line(date));
177 }
178 let balance = if kind == Kind::Night { balance_lines(date) } else { Vec::new() };
179 let given = day.and_then(|d| d.review(kind));
180 let back: Vec<serde_json::Value> = all
181 .recent(date, BACK_DAYS)
182 .into_iter()
183 .map(|(date, day)| {
184 let memos: Vec<String> = [memo_html(day.work.as_ref()), memo_html(day.night.as_ref())].into_iter().flatten().collect();
185 serde_json::json!({ "day": capitalized(&tr().day_in(date, now.date())), "line": reviews::back_line(day, tr()), "memos": memos })
186 })
187 .collect();
188 serde_json::json!({
189 "kind": kind.id(),
190 "date": date.to_string(),
191 "title": tr().text(&format!("review-{}-title", kind.id()), None),
192 "lines": lines,
193 "balance": balance,
194 "felt": given.and_then(|r| r.felt).map_or("", Felt::id),
195 "mix": given.and_then(|r| r.mix).map_or("", Mix::id),
196 "memo": given.map(|r| r.memo.clone()).unwrap_or_default(),
197 "close": tr().text(&format!("review-close-{}", kind.id()), None),
198 "back": back,
199 })
200 .to_string()
201}
202
203pub(crate) fn close(mut sioul: Pin<&mut Sioul>, kind: &str, felt: &str, mix: &str, memo: &str) -> QString {
208 let Some(kind) = Kind::parse(kind) else { return QString::default() };
209 let now = Zoned::now();
210 let date = day_of(kind, &now);
211 let review = Review { at: now.timestamp().as_second(), felt: Felt::parse(felt), mix: Mix::parse(mix), memo: memo.trim().to_string() };
212 let kept = reviews::save(&Reviews::default_path(), date, kind, &review, now.time_zone());
213 let closing = match kind {
214 Kind::Work if !Overrides::load(&Overrides::default_path()).closed_today(&now) => sioul.as_mut().done_for_the_day(),
215 Kind::Work => {
216 sioul.as_mut().set_status(QString::from(&tr().text("review-kept", None)));
217 QString::default()
218 }
219 Kind::Night => {
220 sioul.as_mut().set_status(QString::from(&tr().text("review-closed-night", None)));
221 QString::default()
222 }
223 };
224 if let Err(e) = kept {
225 sioul.as_mut().set_status(QString::from(&e));
226 }
227 sioul.as_mut().set_mode(QString::from(&mode_json()));
229 closing
230}
231
232pub(crate) fn weather_said(weather: sioul_core::today::Weather) {
234 let now = Zoned::now();
235 if let Err(e) = reviews::note_weather(&Reviews::default_path(), now.date(), weather, now.timestamp().as_second(), now.time_zone()) {
236 eprintln!("{e}");
237 }
238}
239
240pub(crate) fn line(date: &str) -> String {
244 let now = Zoned::now();
245 let Ok(date) = date.parse::<Date>() else { return "null".into() };
246 let reviews = Reviews::load_between(&Reviews::default_path(), date, date);
247 let day = reviews.day(date);
248 let words = day.filter(|d| d.said()).map(|d| reviews::back_line(d, tr())).unwrap_or_default();
249 let memos: Vec<String> = day.map(|d| [memo_html(d.work.as_ref()), memo_html(d.night.as_ref())].into_iter().flatten().collect()).unwrap_or_default();
250 let mode = crate::hours::mode_at(&now);
251 let offered = offer_at(&now, &mode).is_some_and(|o| o.kind == Kind::Night && o.date == date);
252 let tonight = mode.sleeps() && night(&now).is_some_and(|(from, bed)| from <= now.timestamp().as_second() && reviews::night_day(bed, now.time_zone()) == Some(date));
254 let open = day.is_none_or(|d| d.night.is_none()) && (offered || tonight);
255 serde_json::json!({
256 "said": if words.is_empty() { String::new() } else { tr().text("review-line-said", None) },
257 "words": words,
258 "memos": memos,
259 "close": open,
260 "button": tr().text("review-close-night", None),
261 })
262 .to_string()
263}
264
265fn meeting(stamp: i64) -> bool {
267 sioul_core::agenda::occurrences(stamp - 86_400, stamp + 60).iter().any(|e| !e.cancelled && !e.all_day && e.start <= stamp && e.end > stamp)
268}
269
270fn told_path() -> std::path::PathBuf {
272 sioul_core::config::state_dir().join("review-told.toml")
273}
274
275fn told(date: Date) -> bool {
276 std::fs::read_to_string(told_path()).ok().and_then(|t| t.parse::<toml::Table>().ok()).and_then(|t| t.get("work").and_then(|v| v.as_str()).map(str::to_string)).is_some_and(|d| d == date.to_string())
277}
278
279fn mark_told(date: Date) {
280 let _ = std::fs::create_dir_all(sioul_core::config::state_dir());
281 if let Err(e) = std::fs::write(told_path(), format!("work = \"{date}\"\n")) {
282 eprintln!("{}: {e}", told_path().display());
283 }
284}
285
286pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
293 let (qt, shared) = (qt.clone(), Arc::clone(shared));
294 std::thread::spawn(move || {
295 static BUSY: std::sync::Mutex<()> = std::sync::Mutex::new(());
296 let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
297 let now = Zoned::now();
298 let stamp = now.timestamp().as_second();
299 let Some(end) = work_end(&load_config(), &now) else { return };
300 if !(0..NOTICE_GRACE).contains(&(stamp - end.timestamp().as_second())) || told(now.date()) {
301 return;
302 }
303 let mode = crate::hours::mode_at(&now);
304 let Some(Offer { kind: Kind::Work, date }) = notice_at(&now, &mode) else { return };
305 if meeting(stamp) {
307 mark_told(date);
308 return;
309 }
310 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
311 let (keeper, _) = crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, active, false);
312 if keeper.mine && !keeper.settled {
314 return;
315 }
316 mark_told(date);
317 if !keeper.mine {
318 return;
319 }
320 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
321 let _ = qt.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("review"), QString::default(), QString::from("work")));
322 });
323 if let Err(e) = sioul_sync::notify::remind(&tr().text("review-notice-title", None), &tr().text("review-notice-text", None), Some((tr().text("review-close-work", None), open))) {
324 eprintln!("{e}");
325 }
326 });
327}
328
329pub(crate) fn night_open(bed: i64, now: &Zoned) -> bool {
332 let Some(date) = reviews::night_day(bed, now.time_zone()) else { return false };
333 Reviews::load_between(&Reviews::default_path(), date, date).day(date).is_none_or(|d| d.night.is_none())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use sioul_core::capacity::{DayBalance, GainBalance, ScaleBalance};
340 use sioul_core::i18n::Translator;
341
342 fn scale(scale: &'static str, level: &'static str) -> ScaleBalance {
343 ScaleBalance { scale, level, load: 12.5, budget: 20.0 }
344 }
345
346 #[test]
347 fn the_days_balance_in_words_only() {
348 let en = Translator::new("en");
349 let balance = DayBalance {
350 date: "2026-10-06".into(),
351 costs: vec![scale("cognitive", "heavy"), scale("emotional", ""), scale("anxiety", "light"), scale("body", "usual"), scale("total", "heavy")],
352 gain: GainBalance { level: "below", good_hours: 0.4, minimum: 1.0 },
353 said: vec!["The plan keeps tomorrow lighter.".into()],
354 };
355 let words = balance_words(&balance, &en);
356 assert_eq!(words, ["Thinking: heavy for you.", "Anxiety: light for you.", "Body and senses: usual for you.", "Little time gave back.", "The plan keeps tomorrow lighter."]);
357 assert!(words.iter().all(|w| !w.chars().any(|c| c.is_ascii_digit() || c == '%')), "never a number: {words:?}");
358 let unknown = DayBalance { date: "2026-10-05".into(), costs: vec![scale("cognitive", ""), scale("total", "")], gain: GainBalance { level: "", good_hours: 0.0, minimum: 1.0 }, said: Vec::new() };
360 assert!(balance_words(&unknown, &en).is_empty());
361 let fr = Translator::new("fr");
362 let some = DayBalance { date: "2026-10-04".into(), costs: vec![scale("emotional", "heavy")], gain: GainBalance { level: "around", good_hours: 1.2, minimum: 1.0 }, said: Vec::new() };
363 assert_eq!(balance_words(&some, &fr), ["Émotions\u{202f}: charge lourde pour vous.", "Du temps vous a ressourcé."]);
364 }
365}